본문 바로가기
회고

넥스트스텝 ATDD, 클린 코드 with Spring 8기 수료 회고

by Renechoi 2024. 3. 15.

소개

소프트웨어 생태계에 선한 영향력을

이라는 모토를 가진 넥스트스텝에서 약 5주간 수행한 교육 과정 회고 글입니다. 자바, 스프링을 사용해 백엔드 어플리케이션을 구현하되, 테스트주도개발(TDD) 방법론을 훈련하는 과정이었습니다. 새로운 지식을 습득하며 익힌 코딩과 리뷰 사이클, 고민하고 풀어냈던 경험을 공유합니다.

 

어쩌다?

어떻게 알게되었는지 모르겠는데 회사를 다니게 되면 넥스트스텝에서 하는 교육을 들어야지, 하고 막연히 생각하고 있었던 것 같다. 어떻게 알게 되었는지...는 정말 모르겠다 (이것이 무의식 마케팅의 힘인가). 아마 우아한테크코스와의 접점 때문이 아닐까 싶다.

 

자바 개발을 시작하면서 우아한테크코스 프리코스에 참여했었고 그때 임팩트가 컸던 것 같다. 개발자로서는 처음 걸음마를 시작하는 시점이었지만 소프트웨어 장인이 되고 싶다는 강렬한 열망을 심어주었었다. 처음 개발을 시작하는 입장에서 어떤 개발을 추구해야 할지에 대한 관점과 안목을 가질 수 있었다. 그래서 학원에서나 혹은 현업에서나, 개발 방법론 혹은 비스무리한 관련된 주제가 나오면 내가 지금 하고 있는 개발의 방향성이 그때 뿌리를 내렸던 것 같다고 자랑스럽게 얘기하곤 했고, 지금도 그러하다.

 

넥스트스텝은 우아한테크코스는 아니지만 관련이 깊다. 대표자님께서 우아한테크코스도 운영하시고, 강사님 역시 우테코에서 교육을 하시는 것으로 알고 있다. 그래서 커리큘럼에 따라 차이는 있겠지만 결이 비슷한 학습을 기대할 수 있다. 주니어 6개월 차에 참여하게 되었다.

교육 과정에 대해서

ATDD, 클린 코드 with Spring

ATDD, 클린 코드 with Spring 8기는 제목에서 알 수 있듯이, Java와 스프링 기반의 백엔드 환경에서 테스트코드 중심의 미션을 수행하는 과정이다. 물론 클린 코드도 빼놓을 수 없다! 스포를 하자면 초반에는 테스트 코드에 대한 기반 다지기부터 활용까지 다양하게 스펙트럼을 가져가고, 중반부에서 후반부로 넘어갈 수록 클린코드에 대한 비중이 높아진다. 그래서 결론적으로 테스트코드 작성 역량과 클린 코드 작성 역량을 모두 강화한다.

 

대상은 과정 소개에서 소개하듯 자바 기본기와 JPA를 사용한 ORM 데이터베이스 사용 역량을 어느 정도 갖춘 개발자들을 대상이다.

주차별 학습 내용 및 회고

1주차

🚀 0단계 - 리뷰 사이클 연습 간략화

  • 핵심 미션: https://google.com으로 HTTP 요청을 보내고, 200 응답 코드 검증

🚀 1단계 - 지하철역 인수 테스트 작성

  • 핵심 미션: 지하철역 목록 조회 및 삭제 기능의 인수 테스트 작성과 리팩터링

🚀 2단계 - 지하철 노선 관리

  • 핵심 미션: 지하철 노선에 대한 생성, 조회, 수정, 삭제 기능의 인수 테스트 작성 및 기능 구현

🚀 3단계 - 지하철 구간 관리

  • 핵심 미션: 지하철 노선 구간의 등록 및 제거 기능의 인수 테스트 작성, 예외 케이스 검증 포함한 구현

1주차 미션은 리뷰 사이클에 대한 이해와 인수 테스트의 작성 및 리팩터링, 그리고 실제 기능 구현을 통해 테스트 주도 개발(TDD) 방법론의 첫 걸음을 떼는 단계다.

 

깃 사용법을 익히고, fork 부터 commit, push, 리뷰 요청과 merge 그리고 rebase 까지의 과정을 자연스럽게 학습할 수 있었다. (못하면 진행이 안되니까..😅)

 

통합 테스트, 단위 테스트, E2E 테스트 등 다양한 테스트를 다루면서 우리 과정이 다루고 목표로 하는 인수 테스트(Acceptance Test)에 대해서 배운다.

 

인수테스트는 사용자 스토리를 검증하는 기능 테스트이다. 명세나 계약의 요구 사항이 충족되는지 확인하기 위해 수행되는 테스트.

인수 테스트가 통과되면, 기능 구현은 끝이다.

 

사실 첫 주차는 맛보기이다. 지하철 노선도 구현도 CRUD 수준으로 복잡하지 않았다. 하지만 1주차의 구현이 마지막까지 주욱 이어지기 때문에 초반에 뼈대를 잡는 느낌으로 패키지 구성이나 아키텍처, 레이어 등에 대한 고민을 최대한 하면 좋은 것 같다. 그래야 나중에 편하다. 강사님도 소개할 때 이야기해주시만, 난의도가 2주차부터 급상승(롤러코스터)한다! 

 

 

1주차 때 객체 지향과 아키텍처에 관해서 리뷰어님과 많은 의견을 나누었던 게 도움이 되었다. 

 

 

2주차

2주차 미션은 단위 테스트의 중요성을 강조하며, 테스트 주도 개발(TDD)을 더 깊이 있게 경험하는 단계이다.

🚀 실습 - 단위 테스트 작성

  • 핵심 미션: 지하철 구간 관련 단위 테스트 작성을 통해, 실제 비즈니스 로직에 대한 이해도를 높이고 리팩터링 기술을 연습한다.

🚀 1단계 - 구간 추가 요구사항 반영

  • 핵심 미션: 노선 중간에 역을 추가하는 기능을 구현하기 전, 사용자 스토리와 완료 조건을 바탕으로 한 인수 조건을 도출하고, 이를 검증하는 인수 테스트를 작성한다. 이를 통해 TDD 사이클을 체험하며, 기능을 완벽하게 구현하기보다는 테스트를 통한 개발 프로세스를 경험하는 것을 목표로 한다.

🚀 2단계 - 구간 제거 요구사항 반영

  • 핵심 미션: 위치에 상관없이 지하철 노선에서 역을 제거할 수 있는 기능에 대한 요구사항을 정의하고, 인수 테스트를 작성하여 기능 구현을 한다. 기능 구현 전에 테스트를 먼저 작성하는 인수 테스트 주도 개발의 접근 방식을 본격적으로 실습한다.

🚀 3단계 - 경로 조회 기능

  • 핵심 미션: 추가된 경로 조회 기능에 대한 요구사항을 바탕으로 인수 조건을 도출하고, 해당 조건을 검증하는 인수 테스트를 작성한다. 경로 조회 기능 구현은 TDD로 진행되며, 인수 테스트 이후에는 도메인 레이어와 서비스 레이어에 대한 단위 테스트를 통해 기능의 정확성을 확보하는 것이 목표이다.

2주차 미션은 테스트 작성의 중요성과 함께, 실제 비즈니스 로직 구현 전에 테스트를 먼저 작성하는 접근 방식을 통해 개발 프로세스의 효율성과 코드의 신뢰성을 높이는 경험을 목표로 했다.

 

비즈니스 구현의 난이도가 높아진다. 지하철 노선에 새로운 구산을 추가하거나 기존 구간을 제거하는 기능에서 다양한 케이스들을 고려해야 한다. 이런 점에서 충분히 실무에서의 업무 흐름과 유사한 요구사항이 제시된다고 생각했다.

 

또 어려운 포인트는 아이러니 한 부분일 수 있는데, 요구 사항이 클리어하지 않다는 점이다! 요구사항이 명확하지 않게 주어지고, 그렇기 때문에 비즈니스를 스스로 이해하기 위한 노력도 요구되었다. 이 역시 마찬가지로 실제 업무와 유사한 환경에서 요구되는 문제 해결 능력이라고 생각했다.

 

객체가 복잡해진다. 기존의 station, Line, Station 과 같이 간단한 로직에서 Section이 추가되는데, 이를 위한 추상화, 비즈니스 로직의 역할과 책임, 객체의 바운더리 등 고려할 것이 많아진다.

 

이 부분에서 객체 지향 코딩의 역량을 정말 많이 기를 수 있는 포인트들이 있다고 생각한다. 개인적으로 도움을 받았던 부분은 일급컬렉션의 사용이었다. 2주차에 작성된 Line, Sections Section의 모델링은 다음과 같다.

 

@Entity
public class Line {
    @Embedded
    private Sections sectionCollection = new Sections();
}
/**
 * Sections 일급 컬렉션으로 리팩토링하여 Line 엔티티와의 관계를 관리합니다.
 * Sections 내에서 Section 엔티티들의 생명주기를 관리하며,
 * Line 엔티티와의 연관관계를 효율적으로 관리하기 위해 사용됩니다.
 *
 * @author : Rene Choi
 * @since : 2024/02/02
 */
@Embeddable
public class Sections implements Iterable<Section> {

    /**
     * 현실 세계의 도메인 컨텍스트를 반영할 때 지하철 노선도에서 '라인' 부분이 중복될 수는 없음을 고려하여
     * Set 자료구조로 선택.
     * 또한 향후 정렬 요구 사항의 필요성 대응을 고려하여 SortedSet으로 선택.
     * <p>
     * Cascade Option 적용 이슈에 대해서
     * - Line과 Section 사이 -> 지하철 노선이 없어진다면, 그 노선에 속한 구건(Section)도 더 이상 존재할 이유가 없으므로 이 경우 CascadeType.ALL은 적절한 옵션이다.
     * - Line과 Station 사이 -> 노선이 삭제된다고 하더라도 연관된 지하철 역이 사라져서는 안된다. 따라서 Section 와 Station 사이에서는 Cascade 옵션을 적용하면 안 된다.
     * => 결론적으로 현재 필드에 CascadeType.ALL 옵션 적용은 불필요하다.
     */
    // @OneToMany(fetch = FetchType.LAZY)
    @OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE}, orphanRemoval = true)
    @JoinColumn(name = "line_id")
    @SortNatural
    private SortedSet<Section> sections = new TreeSet<>();
@Entity
public class Section implements Comparable<Section> {

    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "up_station_id")
    private Station upStation;

    @ManyToOne
    @JoinColumn(name = "down_station_id")
    private Station downStation;
}

 

이 시점부터 배울 수 있는 점은 객체 디자인은 뿌린대로 거둔다는 표현이 딱 어울린다는 사실 같다. 무슨 말이냐면, 처음부터 디자인을 잘 해놓으면 나중 갈 수록 편하다. 처음에 조금 꼬이면 나중에 가도 꼬인다. 이건 실무에서도 마찬가지고 어느 개발에서나 마찬가지일 것이다.

 

2주차부터 급격히 많아지는 요구사항이 어려웠던 점은, '요구 사항을 한번에 주면 좋았잖아!'라는 생각이 들기 때문이다. 만들 때 고려하고 만들었더라면 꼬이지 않을 문제들이, 다 만들어놓은 상태에서 변경 혹은 추가 하려고 할 때 문제가 생기기 때문이다. 그런데 그런 생각이 든다면 디자인이 잘못된 것이 아닌가 의심해야 한다. 만약에 처음부터 OCP 원칙을 준수하도록 구현을 했다면 추가나 수정 요구사항에 대응하는 것이 수월할 것이다. 교육 과정은 의도적으로 이러한 과제를 수행하도록 설계되어 있다.

 

나중 가서 깨달은 것이지만 이때 일급 컬렉션으로 Sections를 디자인해서 후에 정말 많은 이득을 보았다고 생각했다.

 

앗. 그런데 교육 과정은 인수 테스트 아니었나?! 맞다. 🤣 사실 인수테스트에 더 많은 신경을 쓰는 것이 교육 목표였을 텐데, 개인적으로는 실제 구현과 테스트를 거의 반반 정도로 투자한 것이 현실이었던 것 같다.

 

테스트 관련 해서는 2주차에는 Mock의 개념과 활용, 테스트 격리, @SpringBootTest 활용법에 대해서 다뤘다.

 

3주차

3주차 미션에서는 인증 기반 인수 테스트와 즐겨찾기 기능의 구현 및 개선에 초점을 맞추었다.

🚀 실습 - 인증 기반 인수 테스트

  • 핵심 미션: 인증 토큰 발급 과정을 이해하고 인증 기반의 인수 테스트를 작성한다.

🚀 1단계 - 즐겨찾기 기능 완성

  • 핵심 미션: 인증 기반으로 즐겨찾기 기능을 완성한다. 사용자별 즐겨찾기 관리가 가능하도록 개선하고, 즐겨찾기 생성, 조회, 삭제 기능에 대한 인수 테스트를 작성한다. 예외 케이스에 대한 검증도 포함하여 안정성 있는 기능 구현을 목표로 한다.

🚀 2단계 - 깃헙 로그인 구현

  • 핵심 미션: GitHub을 이용한 로그인 기능을 구현하고, 가입되지 않은 사용자에 대해 회원 가입 후 토큰을 발행하는 과정을 포함한다. 이 과정에서 GitHub 로그인을 검증할 수 있는 인수 테스트를 구현하여, 외부 인증 방식을 애플리케이션에 통합하는 방법을 실습한다.

🚀 3단계 - 패키지 리팩터링

  • 핵심 미션: 작성한 인수 테스트를 기반으로 리팩터링을 진행하며, 인증 관련 코드를 다른 프로젝트에서 재사용할 수 있는 수준으로 개선한다. 로그인 로직의 추상화, 중복 제거, 패키지 간 상호 참조 최소화를 목표로 한다.

3주차 미션에서는 인증과 추가 기능 구현이 중심이었다. 잠시 지하철이라는 메인 도메인에서 빗겨난 주차였다.

 

먼저 즐겨찾기 기능 구현에서는, 사용자별로 즐겨찾기를 관리할 수 있도록 Favorite 도메인을 설계하고, 관련 API를 구현했다. 사용자는 즐겨찾기를 생성, 조회, 삭제할 수 있는데, 이 과정에서 사용자 인증 정보를 기반으로 한 권한 검증이 이루어진다.

 

특히, 즐겨찾기 삭제 시에는 사용자가 자신의 즐겨찾기만 삭제할 수 있도록 비즈니스 로직을 구현해야 한다.

 

인증은 SpringSecurity와 Spring이 구현한 미들웨어 방식을 함께 고민해볼 수 있는 과제로 유도되었던 것 같다. 개인적으로 이 주차에 시도해보았던 방식은 테스트 실행시 특정 사용자를 인증 모의하는 방식에서 시큐리티가 제공하는 어노테이션을 모사하는 방식을 구현해보는 시도였다.

 

public class WithMockCustomUserContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
        TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
            configurableApplicationContext,
            "test.execution.listener=nextstep.utils.securityutils.WithMockCustomUserTestExecutionListener"
        );
    }
}

public class WithMockCustomUserTestExecutionListener extends DependencyInjectionTestExecutionListener implements TestExecutionListener {

    @Override
    public void beforeTestExecution(TestContext testContext) throws Exception {
        WithMockCustomUser withMockCustomUser = testContext.getTestMethod().getAnnotation(WithMockCustomUser.class);
        if (withMockCustomUser != null) {
            setToken(testContext, fetchTokenWithLoginExecution(withMockCustomUser));
        }
        super.beforeTestExecution(testContext);
    }

    private static String fetchTokenWithLoginExecution(WithMockCustomUser withMockCustomUser) {
        String email = withMockCustomUser.email();
        String password = withMockCustomUser.password();
        createMember(createMemberRequest(email, password, 20));
        return parseAsAccessToken(loginAndCreateAuthorizationToken(createTokenRequest(email, password)));
    }

    private static void setToken(TestContext testContext, String authorizationToken) {
        if (testContext.getTestClass().isAssignableFrom(FavoriteAcceptanceTest.class)) {
            ((FavoriteAcceptanceTest)testContext.getTestInstance()).setAuthorizationToken(authorizationToken);
        }
    }
}

 

그러면서 TestExecutionListener를 처음으로 사용해보았다.

 

ArgumentResolver의 구현은 이전에 해본 몇 번의 삽질 덕분에 편하게 구현할 수 있었다.

 

@RequiredArgsConstructor
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String token = parseToken(webRequest);

        if (!jwtTokenProvider.validateToken(token)) {
            throw new AuthenticationException("Invalid or expired JWT token");
        }

        return userDetailsService.loadUserByEmail(jwtTokenProvider.getPrincipal(token));
    }
}

 

3주차의 핵심은 뭐니뭐니 해도 의존성에 대한 고민 챕터가 아니었나 싶다! 3주차 마지막 스텝의 요구사항에 다음과 같은 내용이 있었다.

auth 패키지와 member 패키지에 대한 의존 제거
현재 auth 패키지와 member 패키지는 서로 의존하고 있는 경우
UserDetailsService를 추상화 하여 auth -> member 의존을 제거하기

 

즉, 인증과 인가에 관련된 기능의 추상화를 통해, Auth와 Member 간의 의존성을 명확히 분리하고, 재사용성을 높일 수 있는 구조를 만들어야 했다.

 

사실 처음부터 Auth와 Member 패키지를 분리하는 것이 맞다고 생각했었고, 실제로 구현을 했었기 때문에 특별히 문제가 안될 것 같았다. 그런데 막상 몇 개를 고치려고 하다보니 생각보다 잘 풀리지가 않았고 결국 PR 빠꾸 😭

 

 

결국 몇 번의 시도 끝에 Auth에서 Member의 의존성을 분리하고 인증 유저를 추상화를 통해 해결할 수 있었다. 이 과정에서 UserPrincipalUserDetails 인터페이스를 중심으로 한 구조 개선을 시도했는데, 예를 들면,

 

변경 전: Member 의존성과 직접적인 객체 참조

기존 코드에서는 Member 도메인 객체를 직접 참조하여 인증 및 인가 로직을 구현하고 있었다. 이로 인해 auth 패키지와 member 패키지 간에 강한 의존성이 발생했던 것이 문제였다.

 

// 기존: Member 도메인 객체를 직접 참조
public static UserPrincipal from(Member member) {
    return ModelMapperBasedObjectMapper.convert(member, UserPrincipal.class);
}

변경 후: UserDetails 인터페이스를 통한 의존성 분리

UserDetails 인터페이스를 통해 Member 도메인과 auth 패키지 간의 의존성을 분리한다. UserPrincipal 클래스는 UserDetails 인터페이스를 구현하며, Member 도메인 객체 대신 UserDetails를 통해 필요한 사용자 정보를 처리한다. 이렇게 하면 auth 패키지의 코드를 다른 프로젝트에서도 재사용할 수 있게 된다.

 

// 개선: UserDetails 인터페이스를 통한 의존성 관리
public static UserPrincipal from(UserDetails userDetails) {
    return ModelMapperBasedObjectMapper.convert(userDetails, UserPrincipal.class);
}

인증 로직의 추상화

githubLogin 메서드와 createToken 메서드에서 Member 객체 대신 UserDetails 인터페이스를 사용함으로써, 인증 프로세스의 추상화 수준을 높인다.

// 개선: 인증 로직에서 UserDetails 인터페이스 사용
public TokenResponse githubLogin(GithubLoginRequest loginRequest) {
    UserDetails oauthUser = authService.authenticateWithGithub(loginRequest.getCode());
    UserDetails userPrincipal = userDetailsService.loadUserByEmailOptional(oauthUser.getEmail())
        .orElseGet(() -> UserPrincipal.from(oAuthUserRegistrationService.registerOAuthUser(OAuthUserRegistrationRequest.of(oauthUser.getEmail()))));
    return tokenService.createToken(userPrincipal.getEmail());
}

 

결론적으로 핵심은 다음과 같다고 생각한다.

1) Auth Package와 Member Package 간의 의존성이 인터페이스를 통해 관리되어야 한다는 것

2) 의존성은 한 방향으로 흘러야 한다는 것

 

UserDetails 인터페이스는 이 두 패키지 간의 계약으로 작동하며, 구체적인 구현 세부 사항을 추상화한다. 이를 통해 Auth Package는 Member Package의 구현 변경에 영향을 받지 않고, 필요한 인증 및 인가 로직을 수행할 수 있다.

 

최종적으로 구현한 패키지 구조를 다이어그램으로 정리해보면 다음과 같았다.

 

4주차

4주차 미션은 요금 계산 기능을 개선하여 다양한 요금 정책을 적용하는 단계이다. BDD 테스트 방법론의 프레임워크인 Cucumber를 도입했다.

🚀 실습 - 고급 테스트 작성 기법

  • 핵심 미션: Cucumber를 활용한 인수 테스트 전환, DataTable과 파라미터 사용, 공유 객체 활용을 통해 보다 구체적이고 다양한 시나리오를 검증한다.

🚀 1단계 - 경로 조회 타입 추가

  • 핵심 미션: 최소 시간 경로 타입을 추가하여, 사용자가 최단 거리뿐만 아니라 최소 시간 기준으로도 경로를 조회할 수 있도록 한다. 노선과 구간 추가 시 거리와 함께 소요 시간 정보도 포함하도록 한다.

🚀 2단계 - 요금 조회 기능 개선

  • 핵심 미션: 경로 조회 결과에 요금 정보를 포함시킨다. 기본운임과 거리에 따른 추가운임 부과 로직을 구현하며, 요금 계산 방법에 대한 인수 테스트를 작성하여 기능을 검증한다.

🚀 3단계 - 요금 정책 추가

  • 핵심 미션: 노선별 추가 요금과 연령별 할인 정책을 반영한다. 가장 높은 금액의 추가 요금만 적용하는 로직과 청소년 및 어린이 할인 로직을 구현하며, 이에 대한 인수 테스트를 통해 정확성을 검증한다.

4주차 미션은 두 가지 축으로 요약해볼 수 있을 것 같다. 첫 번째는 요금 계산 비즈니스 로직이고, 두 번째는 Cucumber의 활용이었다.

 

요금 계산 비즈니스 로직에 대한 회고

4주차 미션을 통해 요금 계산 비즈니스 로직을 개선하고 다양한 요금 정책을 적용하는 과정은 실제 서비스를 개발하며 마주칠 수 있는 복잡한 요구 사항을 처리하는 능력을 키울 수 있는 좋은 기회였던 것 같다. 특히, 노선별 추가 요금과 연령별 할인 정책을 반영하는 과정에서, 기존의 단순한 요금 계산 로직을 어떻게 확장하여 다양한 조건을 만족시킬 수 있는지에 대한 깊은 고민이 필요했다. 이 과정에서 클래스를 나누어 책임을 분리하는 전략 패턴과 책임 연쇄 패턴에 대해 고민해보았는데, 결론적으로는 이보다는 보다 단순화된 방식으로 코드의 코드의 복잡도를 낮추면서도 요구 사항을 만족시키는 방법으로 구현했다.

 

@Component
public class FareCalculator {

    private static final int BASIC_FARE = 1250;

    /**
     * 노선별 추가 요금을 관리하는 맵 -> 하드코딩으로 구현
     */
    private static final Map<Long, Integer> LINE_ADDITIONAL_FARES = new HashMap<>();

    static {
        LINE_ADDITIONAL_FARES.put(1L, 200);
    }

    public int calculateFareWithLineChargesWithAuthUser(long distance, List<Long> lineIds, UserPrincipal userPrincipal) {
        int fare = calculateFare(distance);
        int highestAdditionalFare = findHighestAdditionalFare(lineIds);
        fare += highestAdditionalFare;
        return applyAgeDiscount(fare, userPrincipal.getAge());
    }

    public int calculateFareWithLineCharges(long distance, List<Long> lineIds) {
        int fare = calculateFare(distance);
        int highestAdditionalFare = findHighestAdditionalFare(lineIds);
        return fare + highestAdditionalFare;
    }
}

 

지하철 노선도 비즈니스의 컨텍스트에서 요금 계산이 제한적이라고 생각해서 클래스 별로 책임을 나누기보다는 하나의 클래스에서 처리하도록 결정을 했던 것 같다.

 

분명, 전략 패턴이나 책임 연쇄 패턴이 '객체지향 관점'에서 보면 더 '좋은' 설계가 아닐까 싶다. 그런데 그렇게 구현한 프로젝트에서 오히려 복잡성이 더 증가하는 경험을 했던 것 같다. 그래서 때로는 '의도적인' 절차 지향이 좀 더 나을 때가 있기도 한 것 같다는 생각을 한다.

수업에서 레퍼런스로 제시된 요 영상에서도 그런 내용이 나오는데 이 영상을 보면서 내심 놀라우면서도 끄덕 끄덕했다.

 

https://www.youtube.com/watch?v=dJ5C4qRqAgA&t=2941s

 

 

그렇다면 전략 패턴이나 책임 연쇄 패턴이 빛을 발할 때가 언제일까?

 

다음과 같이 간단하게 정리해보고 싶다.

  1. 비즈니스 로직이 사용자의 선택이나 설정에 따라 다양하게 변할 수 있는 경우
  2. 분리된 테스트가 반드시 필요한 경우
  3. 요청을 단계적으로 처리하거나, 요구사항에 따라 처리 객체가 동적으로 변경되어야 하는 경우

따지고 보면 요금 계산 요구사항도 이에 부합하긴 한다! 정책적인 측면에서 보자면 청소년 요금제, 어린이 요금제와 같이 나뉠 수도 있고, 구간 별로, 즉 단계별로 요청을 처리해야 하는 케이스이기도 하기 때문이다. 개인적으로는 많이 사용되는 예시를 생각해 보면 결제 시스템에서 다양한 결제 방식(신용카드, 페이팔, 암호화폐 등)을 지원해야 할 때이다. 각 결제 방식에 대해 전략 패턴을 적용하여, 결제 방식을 추가하거나 변경하는 경우, 기존 코드를 수정하지 않고도 새로운 결제 전략 클래스를 추가하기만 하면 될 것이다. 또 다른 대표적인 예시 중 하나는 스프링의 인증 처리 전략이다. 스프링 시큐리티에서는 다양한 인증 메커니즘을 처리하기 위해 AuthenticationManager가 AuthenticationProvider들을 관리하는 방식으로 전략 패턴을 활용한다. 사용자 정의 인증 과정이 필요할 때, 개발자는 AuthenticationProvider 인터페이스를 구현하여 새로운 인증 전략을 손쉽게 추가할 수 있다.

 

Cucumber의 활용에 대한 회고

Cucumber를 도입하여 BDD 방법론에 기반한 인수 테스트를 작성하는 과정은 초기에는 다소 낯설고 어려웠다.

 

사실 정말 어색했고 '굳이...?' 라는 생각이 정말 강력하게 들었었다.

 

그런데 사용하면서 Cucumber의 매력에 좀 빠져버린 것 같다.

 

리뷰에서도 그런 내용으로 남겼었다.

 

 

점차 시나리오 기반의 테스트 작성의 장점을 체감할 수 있었다. 특히, DataTable과 파라미터를 활용하여 보다 다양하고 구체적인 테스트 시나리오를 작성할 수 있었던 점, 그리고 공유 객체를 활용해 테스트 간의 데이터 공유와 관리를 효율적으로 할 수 있었던 점은 Cucumber를 활용한 인수 테스트의 큰 장점으로 다가왔다.

 

뒤에서 밝히겠지만, 이제는 실무에서 인수 테스트 코드를 짤 때 전부 Cucumber로만 짜게 되었다... 😅

 

Cucumber 예시

  Scenario: 50km를 초과하는 거리에 대한 지하철 이용 요금 계산
    Given 지하철역이 등록되어있음
      | name |
      | 역A   |
      | 역B   |
      | 역C   |
      | 역D   |
    And 지하철 노선이 등록되어있음
      | line | upStationId | downStationId | distance | duration |
      | 1호선  | 1           | 2             | 51       | 25       |
      | 2호선  | 2           | 3             | 7        | 30       |
      | 3호선  | 2           | 4             | 8        | 35       |
    When 역ID 1에서 역ID 2까지의 최단 거리 경로 요금을 조회하면
    Then 요금은 2350원이다
    When 역ID 1에서 역ID 3까지의 최단 거리 경로 요금을 조회하면
    Then 요금은 2650원이다
    When 역ID 1에서 역ID 4까지의 최단 거리 경로 요금을 조회하면
    Then 요금은 3150원이다

  Scenario: 로그인한 청소년 사용자가 최단 경로 조회
    Given 청소년 사용자가 로그인되어 있음
    And 지하철역이 등록되어있음
      | name |
      | 역A   |
      | 역B   |
    And 지하철 노선이 등록되어있음
      | line  | upStationId | downStationId | distance | duration |
      | Line1 | 1           | 2             | 10       | 10       |
    When 로그인 사용자가 역ID 1에서 역ID 2까지의 최단 거리 경로 요금을 조회하면
    Then 최단 거리 기준 경로를 응답
      | stationNames |
      | 역A, 역B       |
    And 총 거리와 소요 시간을 함께 응답함
      | distance | duration |
      | 10       | 10       |
    And 지하철 이용 요금도 함께 응답함
      | fareAmount |
      | 880        |
public class PathStepDef implements En {

    private ExtractableResponse<Response> response;
    private String authorizationToken;

    public PathStepDef() {
        Given("청소년 사용자가 로그인되어 있음", this::setUpYouthUserLogin);

        When("역ID {long}에서 역ID {long}까지의 최소 시간 경로를 조회하면", (Long sourceId, Long targetId) -> response = executeFindPathRequest(sourceId, targetId, "DURATION"));
        Then("요금은 {int}원이다", this::verifyFareAmountOnly);

        And("지하철 이용 요금도 함께 응답함", this::verifyFareAmount);
    }

        private void setUpChildUserLogin() {
        String email = "user@example.com";
        String password = "password";
        createMember(createMemberRequest(email, password, createChildUserRandomAge()));
        authorizationToken = parseAsAccessTokenWithBearer(loginAndCreateAuthorizationToken(createTokenRequest(email, password)));
    }

    private void verifyFareAmountOnly(Integer expectedFare) {
        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
        assertThat(parseFare(response)).isEqualTo(expectedFare);
    }

        private void verifyFareAmount(DataTable expectedFareTable) {
        List<Map<String, String>> expectedFare = expectedFareTable.asMaps(String.class, String.class);
        int expectedFareAmount = Integer.parseInt(expectedFare.get(0).get("fareAmount"));

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
        assertThat(parseFare(response)).isEqualTo(expectedFareAmount);
    }

이슈 슈팅

위에서 정리한 내용 외에도 더 많은 포인트들이 있었다. 그 사실이 새삼 놀라운 것 같다. 생각해 보면 길지 않은 시간이었는데 그만큼 밀도 있는 시간이었던 것 같다!

 

각 주차마다 다양한 측면에서 생각할 거리들이 있었고 이슈와 트러블 슈팅 플로우들을 글로 남겨두었다. 그 내용을 여기 다 옮기기는 힘들어서 링크로 걸어두려고 한다.

1주차

3주차

4주차

그래서 현재는?

즐거운 리뷰 사이클의 경험

리뷰 과정은 단순히 코드의 오류를 찾아내고 개선점을 제안하는 기술적인 활동을 넘어서, 서로의 생각과 경험을 공유하고 함께 성장하는 과정이라고 생각한다. 매 주차마다 다른 리뷰어 분들을 만났다. 스타일이 각기 다르시기 때문에 더 다양하고 즐거운 경험을 할 수 있었던 것 같다.

 

현업에서도 실무를 하면서도 리뷰를 안 하지는 않는데 아무래도 비즈니스 중심의 업무를 하다 보니 코드 리뷰보다는 업무 일정과 기능 구현과 검증 위주에 중점을 두는 것이 사실이다. 또, 기술과 코드는 조금 다른 결의 영역인 것 같다. 회사에서는 기술적인 세미나나 논점은 많이 다루지만 코드 자체에 대해서는 자율성이 높은 편이다. 그러다 보니 코드 자체를 놓고 왈가왈부하는 시간에 대한 갈증이 있었던 것 같다. 넥스트스텝 리뷰를 하면서 그런 갈증을 좀 해소할 수 있었다. 1:1 소통의 창구도 있어서 실시간 질문과 답변을 주고 받을 수 있어서 리뷰에 대해 좀 더 친근하게 접근할 수도 있었던 것 같다.

 

쓸만한 테스트 코드를 작성한다는 자신감

 

현업에서 테스트 코드를 작성하는 일은 매우 흔하다. 그 중요성을 잘 알고 있기에, 어떻게 하면 자동화되고 안정적인 코드를 작성할 수 있을지에 대해 깊이 고민해왔다. 이 과정은 단지 회사만을 위한 것이 아니라, 결국 나 자신을 위한 것이기도 하다. 테스트 코드는 수정 시 부작용이 발생하지 않음을 보장하며 유지보수성을 높여주는 심리적 안정감을 제공한다. QA 과정에서 이슈가 발견되어 수정이 필요할 때, 테스트 코드가 없다면 수정에 따른 다른 부분의 영향을 예측하기 어렵고, 이는 추가적인 시간 소모로 이어질 수 있다.

테스트 코드 작성이 시간을 많이 소모한다고 여겨질 수 있지만, 개발 전 과정을 고려했을 때 테스트 코드는 오히려 시간을 절약해 준다. 최근 회사에서 우리가 넥스트 스텝으로 가기 위해서 버전 관리를 어떻게 할 것인가에 대한 이슈가 제기되었다. 이때 테스트 코드가 한 가지 필수 요소라고 제시했다. 테스트 코드가 없는 프로젝트에서 버전 관리를 할 수 있을까?

지금까지 테스트 코드를 열심히 짜긴 했었다. 그런데 단위 테스트 위주로 작성하는 습관을 가졌었다. @SpringBootTest를 사용한 테스트는 상대적으로 지양해왔던 것 같다. 서비스 간 의존성을 최소화하는 극단적인 런던파 스타일의 테스트 코드 작성 방식을 선호했었다. 하지만 이번 교육 과정을 통해 생각이 크게 변화하였다. 이제 인수 테스트를 우선시하고, 그 다음으로 단위 테스트를 고려하는 방식으로 접근한다. 

 

인수 테스트가 가진 큰 장점은 블랙 박스 테스트라고 생각하는데 블랙 박스 테스트가 가진 장점은 어찌되었든 요청과 응답을 보장한다는 것. 그게 주는 심리적 안정감이 크다.

 

또 하나는 Cucumber를 사용할 줄 알게 된 것이 큰 자산이 되었다고 생각한다. 테스트 코드에서도 가독성이 중요하다. 손이 가면 저절로 입이 움직이는 간식처럼, 테스트 코드도 읽기 쉬우면 자연스럽게 좋은 품질의 코드를 짜게 만든다. 반대로 복잡하고 어렵게 적힌 코드는 마치 영화관에서 팝콘을 먹다가 맨 밑에 남은 짠 알갱이들을 씹는 것처럼 읽는 사람을 힘들게 할 것이다. 그래서 모듈화를 잘 시켜서 재활용하는 것도 중요하다. Cucumber는 행동 위주로 테스트를 정의하고 재활용할 수 있게 해주어서 테스트 코드의 모듈화 복잡성 문제를 해결해준다고 생각한다. 테스트 코드가 상당히 부드러워지고 청량해지는 느낌이다. 

 

최근에 짜는 프로젝트는 Cucumber로 전부 작성하고 있다. 지금 프로젝트가 어느 정도 정리 되면 Cucumber를 꼭 공유해야겠다는 생각을 하고 있다! 

 

다시, 또, 좋은 코드

그래서, 결국, 다시, 또 좋은 코드다.

 

개발자로서의 여정을 시작하면 내게도 역시 '좋은 코드'란 단어는 늘 화두였던 것 같다. '좋은 코드'에 대한 정의부터, 그게 무엇인지에 대한 탐색은 끊임없는 과제이다. 좋은 코드란 무엇일까? 다시 잠깐 정의를 해보자면, 좋은 코드란 음... 다양하게 정의를 내릴 수 있겠지만 이렇게 말하고 싶다. 협업하기 좋은 코드!

 

이 말이 많은 의미를 내포하고 있는 것 같으니까. ㅎㅎ

 

즉, 기계가 이해할 수 있는 코드가 아니라, 인간이 읽고 이해할 수 있는 코드이다.

 

교육 과정의 핵심은 ATDD로 테스트 개발 방법론에 대한 숙달이 목표였지만 '클린 코드'를 빼놓을 수 없었던 이유가 바로 이때문일 것이라 생각한다. 왜냐하면 테스트하기 좋은 코드는 곧 읽기 좋은 코드일 것이고 그것은 좋은 코드여야 함을 의미하니까. 이것을 메커니즘으로 풀면 SOLID와 같은 원칙에 따라 파생되는, 객체의 책임, 분리, 그리고 그로부터 얻는 확장성과 유지보수성일 것이다.

 

실무를 하면서 조금 아쉬운(?) 것은 모두가 좋은 코드의 필요성에 공감하지만, 실제로 좋은 코드를 지향하는 것 같지는 않다는 점이다. 물론 그런 생각도 든다. 꼭 그래야만 하는 것일까? 너무 교조적인 것은 아닐까? 좋은 코드를 강조하는 것이 소프트웨어 생태계가 가진 유연성에 반하는 어떤 경직된 정답 같은 것을 강요하는 것은 아닐까? 그 경계선의 줄타기가 참 어려운 것 같다.

 

좋은 코드를 그다지 지향하지 않는 편의 입장에서는 무엇보다 개발자의 덕목(?)은 비즈니스를 이뤄주는 사람이라는 것이다. 그리고 그 비즈니스란 대개 회사가 즉각적으로 요구하는 시급하고 어떤면에서는 신상 위협적이기까지 한 일들이 많다. 무슨 말이냐면, 이게 터지는 순간 우리는 망한다, 그러니까 이건 좋은 코드고 뭐고 그냥 기술 부채를 다 끌어다 써서 어떻게든 막아야 되는 것이다, 같은 일들. 실제 업무를 하다보면 바쁜 일정에 쫓기면서 코드 리뷰가 뒷전으로 밀리기도 하고, 책임과 자율성이 너무 극대화되어 누가 무엇을 하든지 어쨌든 가져만 와라는 식으로 일이 진행되기도 한다. 또 회사 대내외적 상황과 인프라 환경, 기획의 변화 등 다양한 요소에 따라 개발자의 포지션이 위협받는 경우는 얼마든지 존재하는 것 같다. 그런 와중에 꿋꿋이 '나는 (내가 원하는, 시간이 얼마가 걸리든 )좋은 코드만 쓴다. 누.가.뭐.래.도!' 이런 태도는 좋지 못하다. 개발자가 아무렴 예술가는 아니니까.

 

그럼에도, 작은 지향이 결국에는 큰 차이를 만들어내지 않을까? 무슨 일이든 그렇듯이. 그래서 결국 좋은 코드가 좋은 서비스를 만든다는 믿음, 그게 장기적으로 그리고 궁극적으로 성공을 견인하는 코어 엔진이 되는 것이 아닐까? 하는 생각을 한다. 마치 MSA 아키텍처의 최종 정합성 정책 같이?!

next step

흔히 그런 말을 한다. 개발자는 평생 공부해야 한다고. 사실 나는 이 말에 끌려서 개발 일에 더 매력을 느꼈던 것 같다.

 

개발이라는 분야는 끊임없이 변화하고 새로운 기술이 쏟아지는 곳이다. 혼자서 모든 것을 해결하려 하면 그 과정에서 발생하는 시행착오는 막대한 시간과 노력을 소모한다. 그래서 이미 그 길을 걸어간 이들의 지식과 경험을 훔쳐서 지름길로 가는 것, 그것이 공부의 이유라고 생각한다. 마치 거인의 어깨에 올라서 세상을 바라보는 것과 같다. 내 눈에 닿지 않는 세계까지 볼 수 있게 되고, 그렇게 얻은 통찰로 내 앞에 놓인 문제를 해결할 수 있게 된다.

 

물론 문제는 이제 다른 데 있는데... 그건 어떤 공부를 할 거냐, 가 아닐까 싶다. 생성형 AI의 강력함을 이제 모두 체감하고 있다. 이런 시대에 어떤 공부를 해야할까? 아니 학습이라는 건 어떤 의미가 있을까? 애초에 어떤 질문을 던져야 할지부터 다시 점검해야 하는 시기에서 사실은 이래저래 고민은 쌓여간다.

 

한 스텝 한 스텝 그래도 가본다! 수료증을 받으면 기분이 좋다! 우리에겐 성취감이 있다.

반응형