신입 백엔드 개발자 혼돈의 파일럿 프로젝트 돌아보기 (feat.정산플랫폼팀)

Apr.25.2022 김윤정

Backend Culture Programming General WEB

안녕하세요.

이제 막 파일럿을 끝내고 정산플랫폼팀에 합류한 신입 개발자 김윤정입니다.

길고 고되지만 재밌었던 우아한테크코스 교육을 끝내고 마침내 우아한형제들에 입사하게 되면서 행복 시작인 줄 알았지만…😅 듣기만 하던 파일럿 프로젝트를 시작하니 정신없는 하루의 연속이었습니다. (물론 여전히 행복했습니다 ㅎㅎ)

정산플랫폼팀(전 정산시스템팀)의 파일럿은 입사하기 전부터 유명하여 우아한형제들 개발 블로그 파일럿 회고를 여러 차례 읽어보았습니다. 세희님태현님우빈님시영님의 파일럿 글을 읽으면서 만약 내가 파일럿을 하게 된다면 그쯤에는 글에 나와 있는 것들을 다 아는 상태일 줄 알았는데…. 막상 파일럿을 시작할 때가 되니 여전히 아무것도 모르는 상태에서 맨땅에 헤딩하는 기분이었습니다.

이 글은 정산플랫폼팀 파일럿 프로젝트를 끝내고 돌아보며 간략히 배운 내용을 공유하고 회고하기 위해서 작성한 글입니다.

많이 부족한 글이겠지만 그런 모습이 보일 때마다 ‘아.. 신입이지’라고 생각해 주시길 부탁드립니다. 🙏


데브 경수님의 인스타툰 @waterglasstoon

[1차 과제] 미니 정산 어드민 구현

과제 내용

1차 과제는 정산 시스템의 매우 간소화된 기능과 노출되는 화면까지 구현하는 것이었습니다. 약 2주 정도의 시간 동안 전체적인 기능과 화면을 구현해야 해서 가장 정신없고 바쁜 주간이었습니다.

이전에 작성된 파일럿 회고에도 잘 서술되어 있지만, 전체적인 이해를 돕기 위해 간단히 요구사항을 정리해보겠습니다.

[회원 관리]

  • 회원 가입 및 로그인 기능
  • 회원은 일반 < 운영 < 운영팀장 < 관리자로 구분됩니다.
  • 일반은 조회만, 관리자는 모든 데이터에 대한 CRUD가 가능합니다.

[업주 관리]

  • 관리자에 의한 생성/수정/삭제가 가능합니다.
  • 업주를 검색할 수 있습니다.

    • 업주 이름, 업주 번호, 가게 이름 검색

[주문 관리]

  • 관리자에 의한 생성/수정/삭제가 가능합니다.

  • 주문은 상태를 가집니다.

    • 진행 중/완료/취소
  • 한 업주당 여러 주문이 있고, 하나의 주문은 하나 이상의 주문 상세와 연결되어 있습니다.

  • 주문 상세는 해당 주문의 하나 이상의 결제수단 내용을 담고 있습니다.

    • 결제수단은 카드, 모바일, 포인트, 일반 쿠폰, 업주 쿠폰으로 나뉩니다.
  • 주문은 검색할 수 있습니다.

    • 업주 번호, 주문일시

[보상/보정 관리]

  • 관리자에 의한 생성/수정/삭제가 가능합니다.
  • 여러 때에 따라서 +/- 보상 및 보정금이 추가될 수 있으며 보상 내용에 관해서 확인할 수 있습니다.
  • 지급된 업주에 대한 정산 대상에 포함되는 데이터입니다.
  • 보상은 검색할 수 있습니다.

    • 업주번호, 보상일시

[지급금 관리]

  • 운영회원은 지급금을 요청할 수 있습니다.

    • 지급금 요청 시 지급 날짜를 지정합니다.
  • 운영팀장은 지급금을 승인할 수 있습니다.

    • 지급금이 생성된 상세 내역을 확인할 수 있습니다.
  • 지급금 상세 보기를 할 수 있습니다.

    • 지급금에 포함된 주문 및 보상 내역을 확인할 수 있습니다.
  • 지급금을 검색할 수 있습니다.

    • 업주번호, 지급 기준 연월

사용 기술

여러 필수 사용 기술이 있지만, 그 중 글에서 다룰 내용과 밀접한 것들을 한번 언급하고 넘어가려고 합니다.

  • 객체지향 코드 및 클린 코드
  • 단위 테스트 & 통합 테스트
  • JPA
  • SpringBoot 2.6.x
  • Gradle 7.x 이상
  • flyway Mysql
  • 모던 JS 환경 – react

무난하죠? 사실 처음 요구사항을 받았을 때는 지급금 관련 핵심 비즈니스 로직을 제외하고는 CRUD가 대부분이고, 필수 사용 기술도 JS와 리액트를 제외했을 때 초면인 것들이 없어 안일하게 생각했습니다.

그래서 얼른 끝내고 초면인 UI에 많은 시간을 투자하자고 마음먹고 시작하려고 했는데 .. 역시 개발 일정은 자신을 믿지 말고 3배 이상으로 잡으라고 하죠. (ㅠㅠ) 간단하게 생각했던 요구사항 분석부터 난항을 겪었습니다.

과제 구현하기 (feat. 산 넘어 더 거대한 산)

[요구사항 분석]

분명 요구사항을 읽을 때는 끄덕끄덕 읽었는데, 막상 구현하려고 보니 물음표 투성이었습니다.

지급금 생성은 어느 타이밍에 해야 할까? 지급금이 생성된 다음에 속한 주문이 취소되면 어떻게 해야 할까? 지급금을 중복으로 생성하면 어떻게 구분해야 할까? 등등등 …

야심 차게 스프링부트 프로젝트를 생성했지만 그대로 요구사항만 분석하다가 3일을 흘려보냈습니다. 😭 정산 도메인은 생각보다 더 낯설었고 구현을 하기 위해서는 도메인 흐름을 잘 파악하고 정리하는 시간이 필요하겠다는 생각이 들었습니다.

(짧은) 경험상 명확한 용어 정의와 제약사항만 정리해도 프로젝트의 방향이 제대로 잡히곤 했습니다. 또한 나중에 이게 왜 이렇게 구현이 되었는지 유추할 수 있는 틀이 되기도 합니다.


데브 경수님의 인스타툰 @waterglasstoon

아래 그림은 도메인에서 헷갈리는 용어와 제약사항을 정리하여 README에 추가한 부분입니다. 특히 지급금 도메인을 구현하기 위해서는 정산 기준 일자, 지급 일자, 정산 주기 등등을 정의해야 했습니다.


[회원 권한 분리]

정산 어드민 회원은 4개의 권한 중 하나를 가입 시 부여받습니다.

저는 구현의 복잡도를 낮추기 위해 모든 회원은 가입 시 권한 중 1개를 선택하여 가입하도록 했고, 한번 부여받은 권한은 (우선) 변경되지 않는다는 제약사항을 설정했습니다. (복잡한 경우를 피하고 스스로 합리화 할 수 있는 장치를 마련한 셈입니다. ㅎㅎ )

권한을 4가지로 분리하다 보니 동일 URL에 대해서 HTTP 메서드마다 접근 제한을 해야 했습니다. 아래 그림과 같이 업주를 조회하는 API는 일반회원과 관리자 회원 모두가 가능하지만, 업주를 추가하는 API에 대해서는 관리자만 가능하기 때문입니다.

여기까지 보신 분들은 Spring Security를 사용하면 간단하게 해결이 되지 않나? 생각하실 수도 있습니다. 이때는 Spring Security를 경험해 본 적이 없고 선택기술 중 하나였기에 적용하지 않았습니다. (사실 .. 짧은시간 안에 빠르게 학습하고 적용할 자신이 없었습니다. Spring Security를 사용하는 게 좋지 않느냐는 리뷰 받은 부분 중 하나입니다. 😅)

이 권한 문제를 해결하기 위해서 처음에는 인터셉터에서 HTTP 메서드와 URL을 저장하는 자료구조를 만들어서 분기하도록 구현했습니다. 하지만 그 결과 인터셉터와 인터셉터를 등록하는 Configuration 파일이 거대해졌고, HTTP 메서드와 URL을 등록하는 과정에서 휴먼 에러가 자주 발생했습니다. (실제로 관련 버그로 인해 4시간을 소요하기도 했습니다. ㅠㅠ)

AS-IS

/* Configuration 코드 */ 

@Configuration
public class AuthenticationConfig implements WebMvcConfigurer {

    private final JwtProvider jwtProvider;

    public AuthenticationConfig(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Bean
    public RoleHandlerInterceptor basicHandlerInterceptor() {
        return new RoleHandlerInterceptor(jwtProvider, Role.BASIC)
            .addPathPatterns("/api/**", PathMethod.GET);
    }

    @Bean
    public RoleHandlerInterceptor memberHandlerInterceptor() {
        return new RoleHandlerInterceptor(jwtProvider, Role.MEMBER)
            .addPathPatterns("/api/settlement/*/date", PathMethod.PUT)
            .addPathPatterns("/api/settlement/*/cancel-status", PathMethod.PUT);
    }

    @Bean
    public RoleHandlerInterceptor managerHandlerInterceptor() {
        return new RoleHandlerInterceptor(jwtProvider, Role.MANAGER)
            .addPathPatterns("/api/settlement/*/complete-status", PathMethod.PUT);
    }

    @Bean
    public RoleHandlerInterceptor adminHandlerInterceptor() {
        return new RoleHandlerInterceptor(jwtProvider, Role.ADMIN)
            .addPathPatterns("/api/owner/**", PathMethod.ANY)
            .addPathPatterns("/api/order/**", PathMethod.ANY)
            .addPathPatterns("/api/compensation/**", PathMethod.ANY)
            .excludePathPatterns("/api/owner/all", PathMethod.GET, PathMethod.OPTIONS)
            .excludePathPatterns("/api/owner/**", PathMethod.GET, PathMethod.OPTIONS)
            .excludePathPatterns("/api/order/**", PathMethod.GET, PathMethod.OPTIONS)
            .excludePathPatterns("/api/compensation/**", PathMethod.GET, PathMethod.OPTIONS);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
      //.. 약 20줄 생략
    }
}
/* 인터셉터  코드 */ 

public class AuthenticationHandlerInterceptor implements HandlerInterceptor {

  //... URL 패턴 및 HTTP 메서드 등록 과정 생략

    @Override /* 여러 책임이 함께 섞여있습니다. */ 
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws ServletException, IOException {
        if(request.getMethod().equals("OPTIONS")) {
            return true;
        }

        String accessToken = AuthorizationExtractor.extract(request);
        if (!jwtProvider.isValidToken(accessToken)) {
            handleNotAuthorized(request, response, handler);
            return false;
        }

        String uri = request.getRequestURI();
        String httpMethod = request.getMethod();
        if (!pathRegistry.matchPathPattern(uri, PathMethod.valueOf(httpMethod))) {
            return true;
        }

        String currentRole = jwtProvider.getPayload(accessToken, "role");
        if (role.isAppropriateAuthority(Role.valueOf(currentRole))) {
            return true;
        }

        handleNotAuthorized(request, response, handler);
        return false;
    }
}

이런 비효율적인 코딩을 하다가 전에 진행한 프로젝트에서 비슷한 경우에 팀원이 AOP로 처리했던 것을 언급해주었습니다. 즉, 인터셉터에서는 토큰 검증만 거치고 권한 체크는 횡단 관심으로 분리하는 것입니다.

TO-BE

/* Configuration 코드 */ 

@Configuration
public class AuthenticationConfig implements WebMvcConfigurer {

    private final JwtProvider jwtProvider;

    public AuthenticationConfig(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Bean
    public AuthenticationHandlerInterceptor authenticationHandlerInterceptor() {
        return new AuthenticationHandlerInterceptor(jwtProvider);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationHandlerInterceptor())
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/registration")
            .excludePathPatterns("/api/login")
            .excludePathPatterns("/api/user/*");
    }
}
/* 인터셉터  코드 */ 

public class AuthenticationHandlerInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;

    public AuthenticationHandlerInterceptor(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws ServletException, IOException {
        if(request.getMethod().equals("OPTIONS")) {
            return true;
        }

        String accessToken = AuthorizationExtractor.extract(request);
        if (!jwtProvider.isValidToken(accessToken)) {
            handleNotAuthorized(request, response, handler);
            return false;
        }

        return true;
    }

    private void handleNotAuthorized(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws ServletException, IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

각각 코드가 1/3씩 줄어든 것을 확인할 수 있습니다. 더불어 이전에 인터셉터의 preHandle() 부분에 토큰 유효성 검사 + 토큰에 담긴 권한 추출 + 권한 체크에 대한 책임이 함께 얽혀있었던 것이 이제는 토큰 유효성 검사에 대한 책임만 가지게 되었습니다.

권한 체크는 아래와 같이 AOP로 처리합니다.

@RequiredArgsConstructor
@Aspect
@Component
public class AdminRoleAspect {

    private JwtProvider jwtProvider;

    @Before("@annotation(com.example.pilot.presentation.auth.annotation.AdminUser)")
    public void checkAdminRole() {
        HttpServletRequest request =
            ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        String accessToken = AuthorizationExtractor.extract(request);
        String currentRole = jwtProvider.getPayload(accessToken, "role");
        if (!Role.ADMIN.isAppropriateAuthority(Role.valueOf(currentRole))) {
            throw new InvalidRoleAccessException();
        }
    }
}

어노테이션이 부여된 메서드를 기준으로 메서드 이전에 권한 체크를 미리 수행합니다. 적용된 모습은 다음과 같습니다.

[요약정리]

그래서 뭐가 좋아졌는데? 궁금하실 수 있으니 요약정리를 한번 해보겠습니다.

  • 인터셉터와 Configuration에 거대한 코드 → 코드량이 줄고 하나의 책임만 가지게 되었습니다.
  • 복잡한 인터셉터 등록 과정 (메서드와 URL을 수동으로 입력)에서 발생하는 휴먼 에러 → 어노테이션만 붙이면 되기 때문에 에러가 날 일이 거의 없습니다.
  • 특정 권한이 가능한 기능이 무엇인지 확인하기 어려움 → 컨트롤러 메서드에 부착된 어노테이션만 확인하면 바로 알 수 있습니다.


[메서드 Naming]

개발에서 네이밍은 경력 0년 차부터 10년 차 이상까지 모두 어려워하는 것이라고 합니다. 좋은 게 좋은 거기 때문에 무엇이 맞다 틀리다고 이야기할 수 없지만, 팀에서 정하는 컨벤션을 잘 파악하고 적용하는 것이 중요하다고 생각합니다.

이전에 저는 메서드명이 아무리 길어지더라도 모든 정보를 다 담아야 한다고 생각하는 편이었습니다.

예를 들어, 다음과 같이 업주의 정보와 보상 기간을 통해서 보상금을 검색하는 메서드가 있다면 상당히 긴 메서드 탄생하곤 했습니다.

AS-IS

List<Compensation> findCompensationByOwnerAndPeriod(@Param("serialnumber") String serialNumber,
                                            @Param("starttime") LocalDateTime startTime,
                                            @Param("endtime") LocalDateTime endTime);

하지만 리뷰 중 메서드 명뿐 아니라 파라미터를 통해서도 해당 메서드가 맡은 책임의 정보를 유추할 수 있다는 것을 언급해주셨습니다.

TO-BE

List<Compensation> findCompensations(@Param("serialnumber") String serialNumber,
                                @Param("starttime") LocalDateTime startTime,
                                @Param("endtime") LocalDateTime endTime);

훨씬 짧은 네이밍에 파라미터 정보를 더하면 업주 번호와 보상 기간을 기준으로 보상금을 검색하는 메서드인 것이 드러납니다. 추가로 저는 너무 긴 네이밍은 IDE에서 메서드 자동완성 시 실제로 해당 메서드가 어떤 메서드인지 읽고 판단하는 데 시간이 더 오래 걸리곤 했기 때문에 리팩터링 후 메서드 명이 훨씬 더 좋다고 생각합니다.


데브 경수님의 인스타툰 @waterglasstoon

물론 네이밍은 팀 by 팀, 사람 by 사람입니다. ㅎㅎ 이런 방법으로도 네이밍이 가능하구나 가볍게 보고 넘어가시는 것을 추천해 드립니다.


[해당 메서드의 사용처를 확인했나?]

1차 리뷰에서 가장 식은땀을 많이 흘린 질문이기도 합니다. 아주 아주 기본적인 것을 확인했나? 라는 질문으로 들렸습니다.

상황은 다음과 같습니다.

저는 주문이나 보상금에 대해서 검색을 할 때 총 3가지 조건으로 검색할 수 있도록 했습니다.

  1. 업주 번호
  2. 주문이나 보상금 생성 시작 일시
  3. 주문이나 보상금 생성 종료 일시

위 모든 조건은 선택이며 입력이 되지 않았을 때 기본값으로 검색을 수행합니다. 예를 들어 종료 일시가 주어지지 않는다면 시작일 기준으로 현재 시각 이전까지의 데이터를 모두 조회하는 식입니다.

이때, 저는 아래와 같이 Objects.isNull() 을 통해 해당 조건이 입력되었는지 아닌지를 판단했습니다. (혹시 여기서 무엇이 어색한지 눈치채셨나요? 그렇다면 대단하십니다. 👍)

private LocalDateTime resolveEndTime(LocalDate endDate) {
        if (Objects.isNull(endDate)) {
            return LocalDateTime.now();
        }
}

Objects.isNull()을 사용한 이유가 무엇이냐고요? 별다른 이유가 없었습니다. 그저 등호와 같은 기호를 쓰는 것보다 기본으로 자바에서 제공해주는 메서드를 사용하는 것이 더 좋을 것이라고 막연하게 생각했기 때문이었습니다.

리뷰를 하며 이 부분에 대해서 Objects.isNull() 선언 부로 가서 docs에 쓰여 있는 부분을 읽어보라고 하셨습니다.

이제는 모두 눈치채셨나요? Objects.isNull() 은 Predicate에서 사용하기 위한 메서드 입니다. docs에는 매우 친절하게 예시도 써주었는데 filter(Objects::isNull) 과 같은 경우에 사용하도록 가이드하고 있습니다.

물론 오류를 발생시키는 부분은 아니기 때문에 사람마다 활용처에 대한 생각이 다를 수 있습니다. 팀이나 개인의 취향에 맞춰 가독성이 더 좋은 것을 선택하면 됩니다.

하지만 코드에서 특정 메서드를 사용할 때 이것이 어떨 때 쓰는 것이고 어떤 역할을 책임지는지에 대한 기본적인 것을 확인하지 못한 것을 매우 반성할 수 있는 리뷰였습니다.


[생성자의 유효성 검사 책임]

생성자에서 유효성 검사를 해도 될까? 라는 고민은 이전부터 항상 헷갈리고 어려웠던 부분이었습니다.

생성자의 역할은 객체를 생성하는 것이고, 과한 로직이 있으면 생성자가 무거워지니 지양하라는 것과 유효성 검사를 생성자에서 하는 것이 상충하는 것처럼 보이기 때문입니다. 하지만 예외가 발생할 객체를 만드는 것은 아무래도 동의가 되지 않는 것도 사실입니다.

공교롭게도 이 시기에 제가 속한 대화방에서도 비슷한 주제로 이야기가 나왔습니다.

그렇다면 생성자의 유효성 검사가 위의 규칙을 위반하는 것일까요? 여기에 대해 ‘객체 상태’를 훼손하고 있는지 아니면 작동 중에 발생할 수 있는 오류에 대비하고 있는지를 구별해야 합니다.

더 자세한 내용을 위해서 다음 링크를 적극 추천해 드립니다. (우주 최고 갓제이슨의 어메이징한 설명 👍)

그렇다면 다시 정산 어드민으로 돌아와서 어느 상황에서 위 고민이 필요했는지 한번 살펴보려고 합니다.

정산 도메인의 핵심 객체는 지급금(Settlement) 입니다.

지급금은 한 달 단위로 생성이 됩니다. 그렇기 때문에 2022년 3월 지급금을 생성하기 위해서 2022년 3월에 발생한 모든 주문과 보상금을 가지고 와서 해당 연월의 지급금을 생성하게 됩니다.

이때 지급금에 속한 주문과 보상금은 모두 완료 및 유효한 상태이며, 주문 중에서도 업주 쿠폰으로 결제된 결제금액은 제외하고 지급금을 생성해야 합니다.

처음에는 이 모든 유효성을 검사하고 지급금을 계산하는 로직이 생성자에 있는 것이 적합하지 않다고 생각해 따로 서비스에서 호출하는 형태로 구현했습니다.

그런데 리뷰 중 이 부분을 생성자에서 수행하기를 추천하시고 나서 위에서 언급된 것처럼 해당 로직 및 유효성 검사가 과연 ‘객체 상태’를 훼손하는 것인지 작동 중 발생하는 오류에 대비하는 것인지를 고민했습니다.

설명이 길었지만, 결론적으로 유효하지 않은 주문과 보상금으로 지급금 객체를 생성하는 것은 ‘지급금 객체에 대한 상태 훼손’이라고 해석하여 해당 로직을 생성자에서 수행하도록 수정했습니다.

물론 사람마다 객체 상태에 대한 훼손의 기준이 다를 수 있습니다. 또한 생성자에서 유효성 검사가 성능에 영향을 끼치는 정도를 고려하여 유연하게 적용하시는 것을 추천드립니다.


[Entity 연관 관계 끊어내기]

1차 리뷰에서 가장 많이 언급된 부분은 바로 엔티티 간 강하게 결합되어 있는 연관 관계 입니다.

초반에 구현할 때는 모든 객체가 양방향 연관 관계를 맺고 있어서 객체 참조로 매우 강하게 결합되어 있었습니다. (더 복잡하게 얽힐 수 없을 정도로 말이죠..ㅎㅎ)

AS-IS

여기서 잠깐 위 도메인 구조에 대해 간단한 설명을 하고 넘어가면 좋을 것 같습니다.

  • Owner는 업주입니다.
  • Order는 특정 업주에게 속한 주문입니다. 한 업주와 다대일 관계입니다.
  • Compensations는 특정 업주에게 속한 보상 및 보정금액입니다. 한 업주와 다대일 관계입니다.
  • Settlement는 지급금으로 특정 업주에게 속합니다. 이때 해당 업주의 지급금 생성 기준 연월의 주문과 보상 및 보정금액을 포함합니다.

여기서 업주, 주문, 보상 및 보정금, 지급금을 모두 같은 도메인으로 볼 수 있을까요? 저는 각자가 같은 도메인인지 판단을 할 때는 도메인의 생성이나 변경 주기가 같은지 확인해보려고 합니다.

예를 들어 업주의 가게명이 변경되었을 때 그 영향을 지급금 도메인이 알 필요가 없는 경우에는 강한 결합 형태인 엔티티 참조를 하지 않는 것을 리뷰해주셨습니다. 도메인은 언제든 분리될 가능성이 있기 때문입니다.

TO-BE

리뷰를 받고 위와 같이 강한 결합 형태를 없애고 ID를 참조하는 형태로 대공사를 했습니다.

  • 업주 입장에서 주문, 보상/보정금, 지급금을 알 필요가 없다고 해서 참조를 제거했습니다.
  • 주문과 보상/보정금, 지급금에서 업주를 ID로 참조하도록 했습니다.
  • 주문과 보상/보정금이 지급금을 ID로 참조하도록 했습니다.

객체를 분리하고 나니 객체 참조가 필요한 비즈니스 로직에 에러가 생겼습니다.

  • 업주 삭제 시 주문, 보상/보정금, 지급금 삭제 → 기존에 JPA orphanRemoval로 처리
  • 주문, 보상/보정금 상태 업데이트, 삭제 시 유효한 지급금에 속한 것인지 체크 → 기존에 주문과 보상/보정금에서 참조하고 있던 지급금 엔티티의 상태로 유효성 검사

에러가 생긴 위와 같은 로직을 별도의 클래스 파일로 분리하였고 의존 방향이 단방향으로 흐르도록 DIP를 적용했습니다.

변경된 의존관계와 엔티티 간 구조가 잘 설계된 것인지는 잘 모르겠습니다.. ㅎㅎ 아무래도 보는 사람마다 다른 의견을 가질 수도 있다고 생각합니다. 다만 리팩터링을 하면서 느낀 장점들은 몇 가지 있습니다.

  1. 도메인이 완전히 분리될 때 이전 구조보다 안정적으로 분리될 수 있을 것 같다.
  2. 단방향 의존관계 흐름을 유지하려고 하면서 비즈니스 로직의 책임이 자연스럽게 적합한 도메인에 부여되는 효과가 있었다.
  3. 객체 참조로 인한 예상하기 어려운 변경 유효 범위를 줄일 수 있었다.

아무래도 가장 쉽지 않은 부분이었는데요, 많은 분의 의견이 궁금합니다. 🙌

[2차 과제] 멀티 모듈 + 리팩터링

과제 내용

2차 과제 내용은 1차에서 진행한 과제를 Gradle Multi Module로 전환하는 것입니다.

추가 요구사항으로는 admin 모듈을 빌드하여 jar 실행 시 프론트 영역을 함께 실행하는 것과 1차 코드리뷰 반영이 있습니다.

과제 구현하기

[지저분한 조회 로직 개선하기]

기존의 프로젝트에 멀티 모듈을 적용하면서 도메인 간 의존 방향을 최대한 깔끔하게 정리해야 했습니다. 저는 그 작업을 위해서 엔티티 간 객체 참조를 최대한 제거하고 단방향으로 의존 방향이 흐르도록 리팩터링 작업을 했습니다.

요구사항도 실제 현업에서 주어지는 요구사항보다 훨씬 간단한 만큼 비즈니스 로직에서는 깔끔하게 의존 관계를 정리할 수 있었습니다. 하지만 조회 시 화면에서 필요로 하는 데이터를 노출하기 위해서는 의존 관계가 다시 꼬여버리는 문제가 발생했습니다. 이외에도 여러 가지 문제가 있었습니다.

  1. Service 코드 클래스 상단에 트랜잭션 처리를 위해 @Transactional(readOnly = true) 를 추가해두었습니다. 하지만 해당 클래스에서 readOnly가 아닌 메서드가 반을 차지하여 일일이 @Transactional 어노테이션을 추가해주는 비효율이 발생했습니다.
  2. 조회 로직은 별다른 비즈니스 로직 없이 화면에서 요구하는 데이터를 조회하여 그대로 반환하는 메서드이기 때문에 의미 없는 엔티티 → Service용 DTO → Presentation용 DTO 포장 작업이 반복되었습니다. (관련 코드의 양도 매우 많아서 지저분했습니다.)

해결을 위해 비즈니스 로직을 담당하는 서비스와 조회 로직을 담당하는 서비스를 분리하였습니다.

각 서비스에 알맞게 @Transactional 어노테이션을 추가하고 엔티티를 조회할 필요 없이 QueryDsl를 사용하여 화면에 필요한 데이터만 바로 DTO로 조회했습니다.

결과적으로 굉장히 길었던 DTO 포장 작업 관련 코드가 종합 300줄 이상 제거되었고, 조회 시 필요한 데이터가 변경되더라도 비즈니스 로직이 있는 코드를 건드리지 않을 수 있었습니다.


[데이터 레이어에서의 강 결합은 괜찮은가?]

앞서 도메인 간 결합도를 낮추기 위해서 대규모 공사를 했었습니다. 조회 시 일어나는 결합을 해결하기 위해서 조회용 서비스를 나누기도 했었죠. 그런데 어플리케이션 레벨에서의 결합도에 지나치게 신경을 쓴 나머지 데이터 레이어에서의 강 결합은 간과한 부분에 대해서 리뷰를 해주셨습니다.

상황은 다음과 같습니다. 기존에 주문과 업주가 다른 도메인이며 분리될 가능성이 높기 때문에 업주에서 주문을 제거하고 주문도 업주의 id를 참조하는 형태로 리팩터링 했습니다.

그런데 지급금 생성 시 특정 업주 번호에 속한 주문을 필터링하여 반환해야하는 로직이 있습니다. 이때는 결합도를 생각하지 않고 주문과 업주 테이블을 join하는 쿼리를 날리고 있었습니다.

이런 경우에는 필요한 결합인지, 혹은 함께 가야하는 도메인인지 고민을 해보고 그에 따라 해결 방법이 모두 다를 것입니다. 저의 경우에는 매우 간단하게 서비스에서 이미 가지고 있던 업주의 정보를 활용하여 join 없이 쿼리를 수행할 수 있었습니다. 쿼리를 작성하기 이전 이미 있는 정보를 적극적으로 활용하여 비효율성을 낮추는 것도 매우 중요하다는 것을 알게 되었습니다.


[멀티모듈에서 profile 나누기]

처음 멀티 모듈을 나누었을 때는 endpoint 모듈에서 어플리케이션 전체에 대한 profile 설정을 가지고 있었습니다. (멀티모듈이 무엇인지 제대로 이해하지 못하고 모듈을 나누기만 한 것이 티가 나죠 😅)

하지만 (당연하게도) 특정 모듈과 관련된 profile 설정은 해당 모듈에 두는 것이 좋습니다. 저는 이전에 항상 하나의 모듈만 사용해보았기 때문에 application-dev, application-test 식으로 profile 환경을 분리하곤 했습니다.

각 모듈에 profile 설정을 두기 위해 각 모듈 이름을 suffix로 붙인 application-core 와 같은 profile 설정 파일을 생성했습니다. 그리고 해당 설정 파일 내부에서 환경마다 설정을 분리했습니다.

예를 들어, flyway 설정을 생각해볼 수 있습니다. 저는 초반에는 flyway sql 파일과 관련 설정이 모두 endpoint 모듈인 admin 모듈에 있었습니다. 하지만 해당 설정은 엔티티와 관련이 있는 설정이므로 엔티티가 있는 domain 모듈에 두는 것이 좋다는 리뷰를 받았습니다.

결과적으로 모든 flyway sql 파일과 profile을 도메인 모듈로 옮겼고 admin 모듈의 profile에서는 domain의 profile을 include 하는 형태로 변경시켰습니다.

/* application-core.yml */
/* 공통 설정 */
spring:
  jackson:
    time-zone: "Asia/Seoul"

  jpa:
    properties:
      hibernate:
        dialects: org.hibernate.dialect.MySQL57Dialect
        format_sql: true
        default_batch_fetch_size: 20
    generate-ddl: true

---
/* dev 환경 */
spring:
  config:
    activate:
      on-profile: dev

  flyway:
    baseline-on-migrate: true
    enabled: true
    locations: classpath:/db/migration

  jpa:
    hibernate:
      ddl-auto: validate

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: // 생략
    username: // 생략
    password: // 생략

---
/* test 환경 */
spring:
  config:
    activate:
      on-profile: test

  flyway:
    enabled: false

  jpa:
    show-sql: true
    hibernate:
      ddl-auto: none

  h2:
    console:
      enabled: true
      path: /h2-console

  datasource:
    driver-class-name: org.h2.Driver
    url: // 생략
    username: // 생략
    password: // 생략

  logging:
    level:
      org.hibernate.SQL: DEBUG
      org.hibernate.type: trace

[3차 과제] 스프링 배치

과제 내용

마지막 과제는 스프링 배치를 사용하여 배치 시스템을 구축하는 것입니다.

  • 결제수단별 집계 배치

    • 결제수단별로 해당 일자의 주문 금액이 얼마인지 확인할 수 있는 배치를 만듭니다.

      • 지급금 생성 배치
      • 지급금 생성로직을 배치로 구현합니다.

과제 구현하기

[유지보수를 고려하며 구현하기]

배치를 구현하거나 조회 로직을 구현할 때 DTO로 바로 조회해야 하는 경우가 있습니다. 이럴 때는 주로 Projection을 사용합니다.

Projection을 사용하는 경우 여러 종류를 사용할 수가 있는데 저는 다음과 같이 생성자 기반 projection을 사용하여 DTO로 매핑하였습니다. (Projections에는 생성자 기반, setter 기반, field 기반 등등이 있습니다.)

@Override
public Optional<OwnerResponseDto> findById(Long id) {
    OwnerResponseDto result = jpaQueryFactory
        .select(Projections.constructor(OwnerResponseDto.class,
            owner.id,
            owner.name,
            owner.serialNumber,
            owner.shopName
        ))
        .from(owner)
        .where(owner.id.eq(id))
        .fetchOne();

    return Optional.ofNullable(result);
}

하지만 위와 같이 생성자 기반 Projection을 사용하면 생성자의 순서와 projection 하는 데이터의 순서가 일치해야 합니다. 즉, 데이터 타입이 같은 상황에서 순서가 바뀌더라도 에러가 나지 않음으로 문제가 발생했을 시 추적하기가 매우 어려워집니다. 반면 다른 방식의 projection은 명칭이 일치해야 하므로 순서 불일치에 대한 오류를 미리 발견하여 방지할 수 있습니다. (물론 가장 많이 쓰이는 @QueryProjection은 생성자 기반입니다. 하지만 현재 글에서는 Projections.constructor()의 유지보수 측면에서 주의할 점 위주로 보시면 좋을 것 같습니다 ㅎㅎ)

같은 맥락으로 저는 결제수단별 집계를 수행하는 로직의 ItemReader에서 다음과 같이 데이터를 읽어오고 있습니다. 여기서는 어떤 부분이 유지보수에 문제가 될 수 있을까요?

@Bean
@StepScope
public RepositoryItemReader<PaymentAggregationDto> paymentAggregationReader() {
    RepositoryItemReader<PaymentAggregationDto> reader = new RepositoryItemReader<>();

    reader.setRepository(orderRepository);
    reader.setMethodName("groupByPayment");
    reader.setPageSize(CHUNK_SIZE);
    reader.setArguments(List.of(paymentAggregationJobParameters.getStartDateTime(), paymentAggregationJobParameters.getEndDateTime()));
    reader.setSort(Collections.singletonMap("od.payment", Sort.Direction.ASC));

    return reader;
}

바로 메서드명을 호출하는 setMethodName() 부분입니다. 해당 메서드는 다른 모듈에 있는 repository의 메서드 입니다. 그러므로 다 음에메드서 명이 변경이 된다고 하더라도 바로 캐치하고 수정하기 어려운 형태입니다.

이렇게 지금 당장은 프로그램에 에러를 발생시키지 않지만, 유지보수 측면에서 보았을 때 에러가 발생할 가능성이 있는 부분을 고려하여 구현하는 것이 굉장히 중요합니다. 그리고 위 리뷰를 통해 제가 아직 그런 부분을 미리 고려하는 시각이 많이 부족하다는 것을 깨달을 수 있었습니다.


[배치 수행 중 배치 어플리케이션이 재배포 된다면?]

배치 무중단 배포와 관련된 이야기입니다. 사실 ‘배치 무중단 배포’라는 키워드로 검색하면 readlink 라는 키워드와 함께 변경되지 않는 원본 jar로 배치를 수행하는 것에 대한 많은 내용이 나와 있습니다.

그렇다면 만일 readlink와 같은 방법을 사용하지 않고 배치가 수행되고 있는 중간에 jar가 재배포 되어 변경된다면 어떻게 될까요? 실행 도중 에러가 날까요?

답은 yes일 수도 no일 수도 있습니다. 그 이유는 JVM이 모든 클래스를 한 번에 로드하여 실행하는 것이 아니라 일부만 로드하기 때문입니다. 그래서 만일 배치 작업을 완료할 수 있는 모든 클래스가 JVM에 모두 로드되어 있다면 무사히 배치가 완료되겠지만 기존 jar가 대치된 이후에 실행 중이던 배치에서 필요한 클래스를 로드하려고 한다면 아래와 같이 NoClassDefFoundError 예외가 발생합니다.

마무리

현재 글을 마무리하고 있는 이 시점에서야 드디어 길고 고된 파일럿이 끝나가고 있습니다. (감격….🤭 ㅠㅠ)

처음 파일럿을 시작하면서 지금까지 우아한테크코스에서 배운 것들을 잘 녹여서 보여주고 또 새로운 도전을 많이 해보자! 라는 다짐을 하고 있었는데요..ㅎㅎ (제 기준) 많은 양을 정해진 일정에 맞춰서 개발하는 것이 생각보다 녹록하지 않았고 금방 기존의 방식대로 무지성 코딩을 하는 저 자신을 발견할 수 있었습니다.

파일럿을 하면서 배운 것들을 몇 가지 정리해보면 다음과 같습니다.

  • 시간에 쫓겨 개발을 하다 보니 본래 하던 대로 돌아가기만 하는 코드를 짜기 쉬웠습니다. 실제 업무를 하면서 코딩을 많이 하는 것이 현재 실력을 키우는 것과는 매우 별개일 수 있다는 생각이 들었습니다.
  • 바쁘게 몰아치며 코딩을 하다 보니 가장 먼저 포기하게 되는 것이 짧은 호흡의 커밋과 문서화였습니다. 혼자 하는 파일럿에서는 이 부분이 그렇게 중요하지 않을 수 있지만 앞으로 팀에서 일하게 될 때는 무엇보다 중요한 것이니 특히 신경 써야 한다고 생각했습니다.
  • 개발 일정은 자신을 절대 믿으면 안 된다고 생각합니다.


데브 경수님의 인스타툰 @waterglasstoon

  • 개발 …. 절대 혼자는 못 한다…. 옆 동료를 금보다 귀하게 여기자 ‼️

위에 서술한 것 말고도 파일럿을 하는 동안 (*고생한 것 100000)** 만큼 많이 배웠다고 생각합니다.

무엇보다 무사히 파일럿을 마무리 할 수 있었다는 것이 가장 감격스럽습니다.

혼자 파일럿을 구현하고 있을 때와 매 리뷰 후 정산플랫폼 팀원분들이 한 분도 빠짐없이 안부를 물어주시고 잘하고 있다고 응원을 해주셨습니다. (특히, 리뷰 후 스스로에 대한 의구심이 가장 많이 들 때 저를 일으켜 준 소중한 한마디였습니다. 😭)

아니었다면 정산플랫폼팀 사상 최초 파일럿 중 탈주한 신입 역사를 쓰게 되었을지도 모릅니다. ✨ 갓 정산 ✨


데브 경수님의 인스타툰 @waterglasstoon

아무튼 ‼️ 이제 정말 정말 끝입니다. (앞으로 끝인지 시작인지는 모르겠지만 😅)

긴 글 읽어주셔서 감사합니다.

위 내용의 모든 출처

  • 무슨 질문이든 1초 만에 답해주는 우테코 3기 백엔드 크루들 및 코치
  • 초면인 리액트로 고생할 때 도움을 준 우주 최고 실력자 우테코 3기 프론트엔드 크루들
  • 수많은 리뷰와 응원으로 함께 해주신 🙌갓 정산플랫폼팀🙌
  • 위 모든 짤은 인스타툰 데브 경수님의 개발 만화입니다. 😊 – @waterglasstoon