우당탕탕 정산어드민 시스템 파일럿 프로젝트 도전기(feat. 정산플랫폼팀)

Jun.30.2022 최영진

Backend Culture Programming General WEB

들어가기 전에

안녕하세요🙂 우아한테크캠프pro(3기)를 수료하고 얼마 전 정산플랫폼팀에 합류하게 된 최영진입니다.
우아한형제들 기술블로그를 통해 자주 도움을 얻고 긍정적인 영향을 많이 받아왔었는데, 제가 정산플랫폼팀의 일원으로서 이곳에 글을 쓰고 있다니 정말 꿈만 같습니다!


이미지 출처: 영화 “나의 결혼 원정기”

팀에 합류한 후 약 4주간 소문으로만 듣던 정산 파일럿 프로젝트를 진행하였는데요, 소문(?)대로 쉽지 않은 과정이었지만 그만큼 많이 배우고 저의 개발 습관이나 부족한 부분들을 점검하고 채울 수 있는 의미 있는 시간이었습니다.

기술블로그에는 이미 저희 팀 개발자분들(세희 님, 태현 님, 우빈 님, 시영 님, 윤정 님)께서 좋은 내용을 많이 다뤄 주셔서 어떤 내용을 담아야 하나 고민을 많이 했는데, 저는 파일럿을 진행하면서 받은 피드백 중 인상 깊었던 내용들 위주로 정리하였습니다.(실제로 받은 피드백은 훠어어어얼씬 많습니다 ㅎㅎ)


이미지 출처: 만화 “내일의 죠”

제가 작성한 내용이 꼭 정답은 아니기에 어떤 부분을 고민하였고, 어떻게 해결을 하였는지, 나라면 어떻게 할 수 있을지를 함께 고민하면서 봐 주시면 더욱 좋을 것 같습니다.😋

[1-2주 차] 간단한 정산 어드민 시스템 만들기

1-2주차 미션은 간단한 정산 어드민 시스템을 만드는 것이었습니다. 요구사항은 아래와 같습니다.

[기능 요구사항]

  • 어드민 회원(Member) 기능

    • 회원가입, 로그인, 권한관리
    • 권한 : 일반 < 운영 일반 < 운영 팀장 < 관리자
      • 일반 : 조회(R)만 가능
      • 관리자 : CRUD 모두 가능
  • 업주(StoreOwner) 관리 기능

    • 관리자에 의해 등록, 수정, 삭제 가능
    • 업주(1)는 여러 주문(N)을 가짐
    • 업주는 검색할 수 있어야 함
  • 주문(Order) 관리 기능

    • 관리자에 의해 등록, 수정, 삭제 가능
    • 주문(1)은 일시(일자, 시간) 및 주문상세(N)를 가짐
    • 주문상세는 결제수단과 결제금액을 가짐
    • 주문은 업주 정보 및 주문 발생 일시로 검색가능
  • 보상금액(CompensationAmount) 기능

    • 관리자에 의해 등록,수정,삭제 가능
    • 보상금액은 대상 업주, 보상사유, 보상날짜, 보상금액을 가짐
  • 지급(Payment) 관리 기능

    • 주문상세 및 보상금액으로 전체 업주에 대해 지급되는 금액을 계산
    • 운영 일반 권한을 가진 어드민 회원이 지급금을 요청
    • 운영 팀장 권한을 가진 어드민 회원이 지급금 요청 승인
    • 지급금 요청,승인 시, 상세내역(주문상세, 보상금액 내역) 조회 가능

[기술 요구사항]

  • OOP (객체지향) 코드 & 클린 코드
  • (JUnit5) 단위 테스트 & 통합 테스트
  • SQL 인잭션, 스크립팅 공격을 비롯한 기본적인 보안
  • HTTP API
  • Spring Boot 2.6.x
  • JPA
  • Gradle 7.x 이상
  • Git & GitLab
  • JIRA
  • flyway Mysql
  • (React) 모던 JS 환경

피드백 1 – 이것은 도메인 코드인가? 검증 코드인가?

아래 코드는 제가 초기에 작성한 어드민 회원 엔티티(이하 Member)입니다. 이번 피드백은 메서드에 집중해서 살펴볼 것이기 때문에, 메서드 중심으로 코드를 살펴보면 좋습니다.

  • AS-IS
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    private String department;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private MemberRole role;

    private Member(String email, String password, String name, String department, MemberRole role) {
        validateFieldValues(email, password, name, department);
        ...
    }

    public static Member createUser(String email, String password, String name, String department) {
        return new Member(email, password, name, department, MemberRole.ROLE_USER);
    }

    public static Member createManager(String email, String password, String name, String department) {
        return new Member(email, password, name, department, MemberRole.ROLE_MANAGER);
    }

    public void change(String name, String department, MemberRole role) {
        validateName(name);
        validateDepartment(department);
        validateRole(role);

        this.name = name;
        this.department = department;
        this.role = role;
    }
    ...
    // 유효성 검증 메서드 총 6개!
    private static void validateFieldValues(String email, String password, String name, String department) {
        validateEmail(email);
        validatePassword(password);
        validateName(name);
        validateDepartment(department);
    }

    private static void validateEmail(String email) { ... }

    private static void validatePassword(String password) { ... }

    private static void validateName(String name) { ... }

    private static void validateDepartment(String department) { ... }

    private void validateRole(MemberRole role) { ... }
}
  • 혹시 이 코드를 처음 봤을 때 Member가 어떤 역할과 책임이 있는지 한눈에 파악 가능하신가요? 너무 많은 유효성 검증 코드가 있어 쉽지 않았을 것 같습니다.

    • 단순히 구성만 살펴봐도 생성자 1개, 정적 팩토리 메서드 2개, 수정 메서드 1개, 유효성 검증 메서드 6개로 Member 안에 유효성 검증 메서드가 절반 이상을 차지하고 있습니다.
    • 처음엔 Member 클래스 안에서 모든 유효성 검증 로직을 가지고 있으면 Member 객체 생성 시, 좀 더 안전한 객체를 생성할 수 있겠다고 생각해 위와 같은 형태로 코드를 작성하였습니다.
  • 팀 피드백 중 너무 많은 유효성 검증 메서드들로 인해 정작 중요한 도메인 로직을 파악하는 데 어려움이 있고 가독성이 떨어진다는 피드백을 받았습니다. 피드백 내용을 요약해 보면 다음과 같습니다.

    • 도메인은 도메인 로직에 좀 더 집중할 수 있게 작성하면 좋겠다.
    • 유효성 검증 로직들을 전부 제거하라는 말이 아니라, 도메인 로직이 더 잘 드러나는 것이 좋아 보인다.
    • 유효성 검증 책임들로 이뤄진 별도 클래스 또는 Wrapper 클래스로 분리하는 방법도 고려해 보자.
  • TO-BE (1) : MemberValidator

@UtilClass
public class MemberValidator { // (1)
    ...

    public static void validateCreation(String email, String password, String name, String department) {
        validateEmail(email);
        validatePassword(password);
        validateName(name);
        validateDepartment(department);
    }

    public static void validateChange(String name, String department, MemberRole role) {
        validateName(name);
        validateDepartment(department);
        validateRole(role);
    }

    private static void validateEmail(String email) { ... }

    private static void validatePassword(String password) { ... }

    private static void validateName(String name) { ... }

    private static void validateDepartment(String department) { ... }

    private static void validateRole(MemberRole role) { ... }
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    private String department;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private MemberRole role;

    private Member(String email, String password, String name, String department, MemberRole role) {
        MemberValidator.validateCreation(email, password, name, department); // (2) 

        ...
    }

    public static Member createUser(String email, String password, String name, String department) {
        return new Member(email, password, name, department, MemberRole.ROLE_USER);
    }

    public static Member createManager(String email, String password, String name, String department) {
        return new Member(email, password, name, department, MemberRole.ROLE_MANAGER);
    }

    public void change(String name, String department, MemberRole role) {
        // (2) 
        MemberValidator.validateChange(name, department, role);

        this.name = name;
        this.department = department;
        this.role = role;
    }

    ...
}
  • (1) 우선 Member에 대한 유효성 검증 책임을 가진 MemberValidator 클래스를 만들었습니다.
    • 앞선 유효성 검증 메서드들을 모두 이곳에서 관리하고 Member는 MemberValidator의 메서드를 호출하면 되는 방식으로 변경하였습니다. 즉, 도메인 코드와 유효성 검증 코드를 분리한 것입니다.
  • (2) Member가 MemberValidator에게 유효성 검증을 요청하고 있습니다.
  • 이 방법에는 각 역할과 책임에 맞게 코드를 분리했다는 장점과 반대로 MemberValidator에 유효성 로직이 점점 더 많아지면 오히려 클래스가 거대해지고 다시 가독성이 떨어진다는 단점이 존재했습니다.
  • 그래서 두 번째 방법으로 Wrapper 클래스를 활용하는 방법을 적용해 보았습니다.
  • TO-BE (2): Wrapper 클래스
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private Email email; // (1)

    @Embedded
    private Password password; // (1)

    @Embedded
    private Name name; // (1)

    @Embedded
    private Department department; // (1)

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private MemberRole role;

    public Member(Email email, Password password, Name name, Department department, MemberRole role) {
        this.email = email;
        this.password = password;
        this.name = name;
        this.department = department;
        this.role = role;
    }

    public static Member createUser(String email, String password, String name, String department) {
        return new Member(Email.from(email), 
                          Password.from(password), 
                          Name.from(name), 
                          Department.from(department), 
                          MemberRole.ROLE_USER);
    }

    public static Member createManager(String email, String password, String name, String department) {
        return new Member(Email.from(email), 
                          Password.from(password), 
                          Name.from(name), 
                          Department.from(department), 
                          MemberRole.ROLE_MANAGER);
    }

    public void change(String name, String department, MemberRole role) {
        this.name = Name.from(name);
        this.department = Department.from(department);
        this.role = role;
    }
}
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Email { // (2)
    private static final String EMAIL_FORMAT_REGEX = "^[_a-z0-9-]+(.[_a-z0-9-]+)*@(?:\\w+\\.)+\\w+

quot;; private static final Pattern EMAIL_FORMAT_PATTERN = Pattern.compile(EMAIL_FORMAT_REGEX); private static final int MAX_EMAIL_LENGTH = 50; private static final String EMAIL_AT_SIGN = "@"; @Column(unique = true, nullable = false) private String email; private Email(String email) { this.email = email; } public static Email from(String email) { validateEmail(email); return new Email(email); } private static void validateEmail(String email) { ... } public String extractEmailId() { int atSignIndex = email.indexOf(EMAIL_AT_SIGN); return email.substring(0, atSignIndex); } public String extractEmailDomain() { int atSignIndex = email.indexOf(EMAIL_AT_SIGN); return email.substring(atSignIndex + 1); } }
  • (1) Member의 각 필드(email, password, name, department)를 Wrapper 클래스(Email, Password, Name, Department)로 만들었습니다.
    • 이번 파일럿에서는 JPA를 사용하기 때문에, Wrapper 클래스에 @Embeddable을 선언하여 DB 매핑도 같이 처리했습니다.
  • (2) Wrapper 클래스로 만드는 과정에서 자연스럽게 각 필드와 관련된 유효성 검증 로직이 Wrapper 클래스 내부로 들어가게 되고 Member 클래스에는 Member와 관련한 로직들만 남게 되었습니다.
  • 다만, 단점으로는 무분별하게 Wrapper 클래스를 만들면 너무 많은 클래스들이 생겨나 관리의 어려움이 발생할 수 있기 때문에 적절한 역할과 책임을 고려하여 Wrapper 클래스를 만들어야 합니다.
  • 참고로, 여기서 주의해야 할 점은 단순히 유효성 검증 로직을 외부로 분리하거나 숨기기 위해 Wrapper 클래스를 사용한 것이 아니라는 것입니다.
    • Wrapper 클래스를 만드는 과정에서 관련 코드들을 한데 모았고, 자연스럽게 유효성 검증 로직이 포함된 것입니다. (결론적으로는 Member에서 유효성 검증 로직이 제거되었습니다.)
    • 오히려 유효성 검증 로직만 분리하겠다는 의도라면 앞서 설명한 MemberValidator 방법을 고려하는 게 더 적절합니다.

추가 의견

은 탄환은 없다

Validator 클래스를 사용하는 방법, Wrapper 클래스를 사용하는 방법 둘 다 좋은 방법이라고 생각합니다.

2가지 방법은 서로 목적도 다르고 장단점도 명확하기 때문에 문제 해결뿐만 아니라 각각의 방법을 목적에 맞게 잘 사용하였는지도 같이 살펴봐야 할 것 같습니다. 다만 모든 상황에 일관된 방법을 적용하기보다 문제상황이나 팀 내 컨벤션에 따라 어떤 가치에 더 무게를 둘지 생각해 보고 적절한 방법을 적용하면 좋을 것 같습니다.

피드백 2 – QuerydslRepositorySupport 없이 Querydsl Repository를 만들 수 있다.

이번 파일럿 프로젝트를 진행하면서 Querydsl을 사용하였습니다. 흔히 알려진 방식으로 Querydsl용 커스텀 interface를 만들고, 해당 interface를 구현한 xxxRepositoryImpl을 만들어 Querydsl Repository를 만들었습니다.

  • AS-IS
public interface OrderRepository extends JpaRepository<Order, Long>, OrderQueryRepository {
    ...
}
public interface OrderQueryRepository {

    Order findOrderWithDetails(Long id);

    ...
}
@Repository
public class OrderQueryRepositoryImpl extends QuerydslRepositorySupport implements OrderQueryRepository {

    public OrderQueryRepositoryImpl() {
        super(Order.class);
    }

    @Override
    public Order findOrderWithDetails(Long id) {
        return from(QOrder.order).join(QOrder.order.orderDetails, QOrderDetail.orderDetail).fetchJoin()
                                 .where(QOrder.order.id.eq(id))
                                 .fetchOne();
    }

    ...
}
  • 여기서 주목할 점은 QuerydslRepositorySupport를 상속하여 Querydsl Repository를 만들었다는 점입니다.
  • Querydsl을 사용하여 Repository를 만들 때 QuerydslRepositorySupport의 기능을 사용하지 않더라도 큰 고민 없이 일단 QuerydslRepositorySupport를 상속하였는데요, 팀에서는 이 부분에 대해서 다음과 같은 피드백을 주었습니다.
    • QuerydslRepositorySupport를 매번 상속받아 구현하였는데, 만약 다른 외부 모듈에서 특정 도메인 엔티티에 대해 커스텀 한 Querydsl Repository가 필요하면 어떻게 만들 수 있을까?
    • QuerydslRepositorySupport의 기능을 직접 사용하는 쿼리는 없는데, QuerydslRepositorySupport를 상속받지 않고 Querydsl Repository를 만드는 방법을 고민해 보면 좋을 것 같다.
    • Querydsl Repository 관련하여 Spring Boot Data Jpa 프로젝트에 Querydsl 적용하기를 참고하면 도움이 될 것 같다.
  • TO-BE
@Configuration
@EnableJpaAuditing
public class QueryDslConfig { 

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() { // (1)
        return new JPAQueryFactory(entityManager);
    }
}
@RequiredArgsConstructor
@Repository
public class OrderQueryRepository { // (2)
    private final JPAQueryFactory jpaQueryFactory; // (3)

    public Order findOrderWithDetails(Long id) {
        return jpaQueryFactory.selectDistinct(QOrder.order)
                              .join(QOrder.order.orderDetails, QOrderDetail.orderDetail).fetchJoin()
                              .where(QOrder.order.id.eq(id))
                              .fetchOne();
    }

    ...
}

추천해 주신 Spring Boot Data Jpa 프로젝트에 Querydsl 적용하기를 참고하여 JPAQueryFactory를 빈으로 등록하고, Querydsl Repository에서 주입(여기서는 생성자 주입)받아 사용하면 QuerydslRepositorySupport 상속이나 별도의 인터페이스 없이도 독립적으로 Querydsl Repository를 만들 수 있다는 것을 알게 되었습니다.

(1) 먼저 @Configuration 선언을 통해 등록한 QueryDslConfig 설정 파일에서 JPAQueryFactory를 빈으로 등록하였습니다.

(2) 기존의 OrderQueryRepositoryImpl에서 QuerydslRepositorySupport 상속, OrderQueryRepository 인터페이스 구현 부분을 제거하였습니다.

참고: 지금 구조는 단일 프로젝트로 OrderRepository 인터페이스가 OrderQueryRepository 인터페이스를 상속하는 구조를 유지해도 되지만, 멀티 모듈 환경을 고려해 기존의 인터페이스 상속 구조를 풀고, OrderQueryRepositoryImpl가 독립적으로 존재할 수 있도록 하였습니다. 따라서 기존의 OrderQueryRepository 인터페이스를 제거하였고, 이 과정에서 이름도 OrderQueryRepositoryImpl → OrderQueryRepository로 변경하였습니다.

(3) OrderQueryRepository는 이제 JPAQueryFactory 빈을 주입받아 특정 도메인에 종속되지 않고, 원하는 쿼리를 자유롭게 작성할 수 있게 하였습니다.

OrderQueryRepository뿐만 아니라 이제 Querydsl이 필요한 모든 Repository에 JPAQueryFactory만 주입받으면 자유롭게 Querydsl을 사용할 수 있습니다.

추가 의견

Querydsl Repository가 꼭 필요한지 먼저 생각해 보자

JPA, Querydsl을 접한 이후부터는 쿼리 작성의 편리함 때문에 굳이 Querydsl Repository가 필요한 상황이 아니어도 습관적으로 JpaRepository를 상속한 인터페이스를 만들고, Querydsl Repository 인터페이스를 만들고, Querydsl Repository 인터페이스 구현체를 만들고… 이런 식으로 개발을 해왔습니다.

이번 피드백을 통해 JPAQueryFactory 빈을 이용하면 좀 더 간편하게 Querydsl Repository를 만들어 사용할 수 있다는 것을 알게 되었는데요, 그렇다고 해서 Querydsl Repository를 무조건 적극적으로 사용하자!라는 건 아닙니다.

오히려 간편하게 Querydsl Repository를 사용할 수 있게 된 만큼 내가 전달하고자 하는 쿼리가 꼭 Querydsl Repository를 사용해서 구성해야 하는지, 프로젝트 구조(단일 모듈, 멀티모듈 등)에 맞게 Repository를 어떻게 관리할 수 있을지 등등 고민해 본 뒤에 필요한 경우에 알맞게 Querydsl Repository를 만들어 사용하면 좋겠습니다.

[3주 차] 모듈 분리하기

3주 차에서는 1-2주 차에서 만든 단일 프로젝트를 Gradle Multi Module로 전환하였습니다. 멀티 모듈로 전환하는 과정에서 gradle 위주로 피드백 받았던 내용을 공유하겠습니다.

[기능 요구사항]

  • 최소 3개 Module 로 분리

    • 필수 모듈: core
    • 필수 모듈: admin(어드민을 담당)
    • 필수 모듈: admin-front(React 프런트 담당)
  • admin 모듈은 core 모듈을 의존 하도록 구현

    • admin 모듈을 빌드하면 core 모듈이 자동으로 Import되도록 구성
  • admin 모듈을 빌드해서 jar로 실행 가능

    • admin 모듈을 빌드했을 때, 프런트 영역까지 잘 노출되는지 확인

피드백 3 – subprojects 말고 configure로 원하는 모듈만 따로 설정해 보자!

Gradle Multi Module로 프로젝트를 분리하면서 settings.gradle에 하위 모듈을 추가하고, 루트 프로젝트의 gradle.build에서 subprojects를 이용해서 모든 하위 모듈의 공통 설정을 관리하도록 하였습니다. 그리고 각 모듈의 gradle.build에서 필요한 설정을 작성했습니다.

  • AS-IS
/** 
 * settings.gradle (pilot-settle-admin)
 **/

rootProject.name = 'pilot-settle-admin'
include 'settle-admin', 'settle-core', 'settle-admin-front' // (1)
/**
 * build.gradle (pilot-settle-admin)
 **/

buildscript {
    ...
}

// (1)
subprojects { 
    group = 'com.pilot'
    version = '0.0.1-SNAPSHOT'

    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    sourceCompatibility = 11

    repositories {
        mavenCentral()
    }

    dependencies {
        // Test
        implementation 'org.springframework.boot:spring-boot-starter-test'

        // Lombok
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
    }

    test {
        useJUnitPlatform()
    }
}

  • (1) settlings.gradle의 settle-admin-front 모듈과 subprojects를 같이 생각해 보면 한 가지 이상한 점이 눈에 띕니다.

    • settle-admin-front는 settings.gradle에 하위 모듈로 추가가 되었고, subprojects는 settings.gradle에 include된 프로젝트 전부를 관리합니다.
    • settle-admin-front는 Java 관련 코드나 의존성이 전혀 필요 없는데도 build.gradle의 subprojects에 의해 불필요하게 java 관련 의존성이 주입되어 관리되는 문제가 있었습니다.
  • 팀에서는 configure를 이용하여 특정 모듈들을 따로 관리할 수 있는 방법에 대해 피드백을 주었습니다.

    • subprojects는 모든 하위 모듈을 관리하는 반면에, configure는 내가 원하는 특정 모듈(정확히는 프로젝트 단위)만 따로 관리할 수 있다.
    • Java 프로젝트들만 따로 변수에 저장한 후에 configure를 이용해 설정을 관리하고, front 모듈은 별도로 관리하는 것을 생각해 보면 좋을 것 같다.
  • TO-BE

/**
 * build.gradle (pilot-settle-admin)
 **/

buildscript {
    ext {
        springBootVersion = '2.6.6'
        dependencyManagementVersion = '1.0.11.RELEASE'
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}"
        classpath "io.spring.gradle:dependency-management-plugin:${dependencyManagementVersion}"
    }
}

// (2) 
def javaProjects = [
        project(':settle-admin'),
        project(':settle-core')
]

// (3)
configure(javaProjects) {
    group = 'com.pilot'
    version = '0.0.1-SNAPSHOT'

    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    sourceCompatibility = 11

    repositories {
        mavenCentral()
    }

    dependencies {
        // Test
        implementation 'org.springframework.boot:spring-boot-starter-test'

        // Lombok
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
    }

    test {
        useJUnitPlatform()
    }

  ...

}

  • (2) 피드백을 참고해 java 프로젝트들(settle-admin, settle-core)을 변수로 할당하였습니다.
  • (3) configure(…)를 이용하여 java 프로젝트들에만 java 관련 설정을 했습니다. 그 결과 위 이미지처럼 java 프로젝트에는 configure(…)에 작성한 설정이 반영된 반면에 settle-admin-front에는 java 관련 의존성이 없는 것을 확인할 수 있습니다.

피드백 4 – npm install과 build는 어느 모듈이 담당해야 할까요?

gradle 설정과 관련해서 한 가지 더 살펴볼 내용이 있습니다. 요구사항 중 admin 모듈을 빌드했을때, 프런트 영역까지 잘 노출되는지 확인해야 하는 내용이 있었는데요, 이와 관련한 피드백 내용입니다.

  • AS-IS
/**
 * build.gradle (settle-admin)
 **/

plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

dependencies {
    // settle-admin 모듈에서 필요한 의존성 설정들...

        ... 
}

// (1)
def adminFrontDir = project(":settle-admin-front").projectDir.path;

sourceSets {
    main {
        resources {
            srcDirs = ["$projectDir/src/main/resources"]
        }
    }
}

processResources {
    dependsOn "copyReactBuildFiles"
}

task installReact(type: Exec) {
    workingDir "$adminFrontDir"
    inputs.dir "$adminFrontDir"
    group = BasePlugin.BUILD_GROUP

    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "audit", "fix"
        commandLine 'npm.cmd', 'install'
    } else {
        commandLine "npm", "audit", "fix"
        commandLine 'npm', 'install'
    }
}

task buildReact(type: Exec) {
    dependsOn "installReact"
    workingDir "$adminFrontDir"
    inputs.dir "$adminFrontDir"
    group = BasePlugin.BUILD_GROUP

    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "run-script", "build"
    } else {
        commandLine "npm", "run-script", "build"
    }
}

task copyReactBuildFiles(type: Copy) {
    dependsOn "buildReact"

    from "$adminFrontDir/build"
    into "$projectDir/src/main/resources/static"
}

(1) settle-admin 모듈을 빌드할 때 settle-admin-front 모듈도 같이 npm install 및 build를 한 후 settle-admin의 jar 파일에 포함되도록 gradle task를 작성하였습니다.
하지만 이렇게 했을 경우 여러 문제점들이 있는데요, 이와 관련해서 제가 받은 피드백은 다음과 같습니다.

  • settle-admin 모듈은 front 관련 코드가 전혀 없는데도 npm 과 관련한 gradle 설정을 가지게 되는데 이 부분을 개선해 볼 수 있을까?
  • settle-admin-front를 다른 외부 모듈에서 사용한다고 했을 때, settle-admin-front에는 npm과 관련한 task가 존재하지 않아 외부 모듈에서 npm과 관련한 처리를 해야 하는 문제점도 있다.
  • npm install 및 build는 어느 모듈에서 담당해야 할까?
  • TO-BE
/**
 * build.gradle (settle-admin-front)
 **/
def adminFrontDir = "$projectDir";

// (2)
task installReact(type: Exec) {
    workingDir "$adminFrontDir"
    inputs.dir "$adminFrontDir"
    group = BasePlugin.BUILD_GROUP

    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "audit", "fix"
        commandLine 'npm.cmd', 'install'
    } else {
        commandLine "npm", "audit", "fix"
        commandLine 'npm', 'install'
    }
}

// (2)
task buildReact(type: Exec) {
    dependsOn "installReact"
    workingDir "$adminFrontDir"
    inputs.dir "$adminFrontDir"
    group = BasePlugin.BUILD_GROUP

    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "run-script", "build"
    } else {
        commandLine "npm", "run-script", "build"
    }
}
/**
 * build.gradle (settle-admin)
 **/
plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

dependencies {
    // settle-admin 모듈에서 필요한 의존성 설정들...

        ... 
}

// Front build
sourceSets {
    main {
        resources {
            srcDirs = ["$projectDir/src/main/resources"]
        }
    }
}

processResources {
    dependsOn "copyReactBuildFiles"
}

// (3)
task copyReactBuildFiles(type: Copy) {
    def adminFrontDir = project(":settle-admin-front").projectDir.path;
    dependsOn ':settle-admin-front:buildReact'

    from "$adminFrontDir/build"
    into "$projectDir/src/main/resources/static"
}

...
  • (2) 피드백을 참고하여 npm install, build task를 settle-admin에서 settle-admin-front의 build.gradle로 옮겼습니다.
  • (3) 그리고 settle-admin-front 모듈을 사용하는 주체인 settle-admin의 build.gradle에서 settle-admin-front의 buildReact에 Task 의존성을 갖도록 하였습니다.
    • settle-admin-front의 build.gradle에 작성된 buildReact Task를 사용하면 어떤 모듈이든 settle-admin-front 모듈을 빌드 할 수 있게 됩니다.
    • 이로써, settle-admin 모듈은 settle-admin-front 모듈 build에 대한 처리를 담당하지 않게 되었고, settle-admin-front의 buildReact Task에 dependsOn 설정을 통해 프런트 모듈을 빌드할 수 있게 되었습니다.

추가 의견

모듈도 각자의 역할과 책임이 있다.

이전에는 클래스 또는 패키지 단위의 관리에 대해 많이 접하고 이를 신경 쓰며 개발 해왔는데, 이번 과제를 통해 모듈 단위로 의존관계를 맺거나 필요한 설정들을 처음부터 끝까지 적용해 봤습니다. 그 과정에서 모듈도 마치 객체처럼 각자의 역할과 책임이 있다는 생각이 들었습니다.

모듈을 나눌 때 어떤 기준으로 나눌지, 특정 로직이 해당 모듈에서 담당하는 것이 맞는지, 모듈 간의 의존관계를 어떻게 맺고 있는지 등 모듈도 마치 객체를 구성할 때처럼 접근한다면 좀 더 명확하게 모듈을 관리할 수 있습니다.

[4주 차] 배치 적용하기

4주 차 과제는 지금까지 만든 프로젝트에 batch 모듈을 추가하여, 배치 기능을 만드는 것이었습니다. 그리고 이렇게 만든 배치 작업들을 Jenkins를 이용하여 실행할 수 있도록 실행 환경을 구축해야 했습니다.

[기능 요구사항]

  • 주문금액 집계 배치

  • 주문일자의 결제수단별 주문금액을 확인

  • 지급금 생성 배치

    • 1주 차에 만든 지급금 생성 로직을 Spring Batch로 구현
    • 기간을 배치 파라미터로 결정

**[기술 요구사항]**

  • Spring Profile을 이용하여 H2와 MySQL를 선택해서 사용
  • MySQL(운영), H2(테스트)
    • Spring Batch Test Code
    • Jenkins를 이용한 배치 실행 환경 구축

피드백 5 – 배치 작업을 쉽게 파악하기 위해 주석을 활용해 보자!

먼저 Spring Batch를 사용하여 배치 작업을 개발하였습니다. 아래 코드는 특정 주문 일자에 발생한 주문들을 대상으로 각 결제수단별 주문금액을 집계하는 배치 작업을 구현한 것입니다.

주문 일자(orderDate)를 JobParameter로 받아, Job → Step(Reader → Processor → Writer)의 구조로 배치 작업이 진행되게 됩니다.

  • AS-IS
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig { // (1)

    ...

    @Bean(BEAN_PREFIX + "jobParameter")
    @StepScope
    public OrderAmountSumJobParameter jobParameter() {
        return new OrderAmountSumJobParameter();
    }

    @Bean(JOB_NAME)
    public Job job() {
        return jobBuilderFactory.get(JOB_NAME)
                                .start(step())
                                .build();
    }

    @Bean(BEAN_PREFIX + "step")
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(CHUNK_SIZE)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    ...
}
@Getter
@NoArgsConstructor
public class OrderAmountSumJobParameter {
    private LocalDate orderDate;

    @Value("#{jobParameters[orderDate]}")
    public void setOrderDate(String orderDate) {
        this.orderDate = DateUtils.toLocalDate(orderDate);
    }

}
  • (1) 가장 먼저 받았던 피드백은 해당 배치 작업이 어떤 작업인지 파악하기 어렵다는 것이었습니다. 배치 작업을 파악하기 위해 다음과 같은 피드백을 받았습니다.
    • 배치 작업의 특성상 Step이 여러 개로 늘어나거나 다소 복잡한 배치 로직이 들어갔을 때 클래스 또는 메서드 이름만으로 해당 작업을 모두 나타내기 어렵다.
    • 클래스 또는 메서드 이름을 활용해도 좋지만 어느 정도 한계가 있기 때문에, 주석을 활용해서 해당 배치 작업에 대한 설명을 작성해도 좋다.
  • TO-BE
/** (2)
 * 주문일자를 기준으로 결제수단별 주문금액을 집계하는 배치
 * - JobParameter : orderDate(주문일자)
 * - Step : 주문일자를 기준으로 결제수단별 주문금액을 집계
 *   - Reader : 주문일자에 해당하는 전체 주문의 결제수단별 주문금액 조회(결과 : OrderAmountSumDto)
 *   - Processor : OrderAmountSumDto -> OrderAmountSum 로 전환
 *   - Writer : OrderAmountSum 저장
 **/
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig {

    ...

    @Bean(BEAN_PREFIX + "jobParameter")
    @StepScope
    public OrderAmountSumJobParameter jobParameter() {
        return new OrderAmountSumJobParameter();
    }

    @Bean(JOB_NAME)
    public Job job() {
        return jobBuilderFactory.get(JOB_NAME)
                                .start(step())
                                .build();
    }

    @Bean(BEAN_PREFIX + "step")
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(CHUNK_SIZE)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    ...
}
  • (2) 주석을 활용하여 해당 배치가 어떤 작업을 수행하는지 정리하였습니다.
    • JobParameter로 어떤 값을 받고, Step(Reader, Processor, Writer)이 어떤 작업을 하는지 작성하였습니다. 코드만 있을 때보다 배치 작업에 대해 더 쉽게 파악할 수 있습니다.

추가 의견

적절한 주석은 코드 품질 향상에 도움이 된다.

그동안의 경험을 살펴보면 주석을 작성할 때 주절주절 작성하게 되는 주석이 있는 반면에 간단 명료하게 잘 작성되는 주석도 있었습니다. 보통 코드 품질이 좋지 못할 때 주석도 이와 마찬가지로 내용이 좋지 못했던 것 같습니다.

코드를 깔끔하게 잘 짜면 주석도 필요 없겠지만 배치같이 하나의 작업이 여러 세부 단계들로 나눠져 있어 작업 내용을 한 번에 파악하기 힘든 경우나 도메인을 쉽게 파악하기 어려울 때는 오히려 적절한 주석은 코드를 파악하는데 많은 도움을 준다고 생각합니다.(물론 주석을 작성하면 주석 역시 코드와 함께 지속적인 관리의 대상이 됩니다.)

만약 작성한 코드를 주석으로 옮긴다고 할 때 내용이 쉽게 정리되지 않는다면 코드 품질이 좋지 못하다는 신호로 받아들이고 개선할 수 있을 것 같습니다.

피드백 6 – ChunkSize는 상황에 따라 바뀔 수 있다.

Spring Batch의 가장 큰 장점 중 하나는 Chunk 지향 처리입니다. 하나의 Chunk는 ChunkSize 만큼의 Item(Chunk 구성요소)으로 구성되는데요, 그렇다면 ChunkSize 값은 어디서 결정을 해주어야 할까요? 배치 작업 내부 코드? 배치를 실행하는 외부 주체? 아래 내용에서 같이 확인해 보겠습니다.

  • AS-IS
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig {

    private static final String JOB_NAME = "OrderAmountSumByPaymentTypeBatch";
    private static final String BEAN_PREFIX = JOB_NAME + "_";
    private static final int CHUNK_SIZE = 20; // (1) 

    ...

    @Bean(BEAN_PREFIX + "step")
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(CHUNK_SIZE) // (1)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    ...
}
  • (1) ChunkSize는 다른 곳에서 재사용할 것이 아니고 해당 배치 작업 안에서만 사용할 것이기 때문에 배치 클래스 내부에 정의하였습니다. 하지만 이럴 경우 치명적인 단점이 존재하게 되는데, 바로 ChunkSize의 변경이 자유롭지 못하다는 것입니다. 관련 피드백은 다음과 같습니다.
    • 상황에 따라 ChunkSize는 얼마든지 변경이 가능한데, 지금 상황에서는 ChunkSize 변경이 일어날 때마다 매번 코드를 수정하고 재배포 해야 하는 문제가 발생하게 된다.
    • 클래스 내부에 ChunkSize를 정의하기보다 외부(Jenkins Parameter 등)에서 받아서 사용하는 방법을 권장한다.
  • TO-BE
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig {

    ...

    // (2)
    @Value("${chunkSize:20}")
    private int chunkSize;

    ...

    @Bean(BEAN_PREFIX + "step")
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(chunkSize) // (2)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    ...
}
  • (2) ChunkSize는 배치 작업을 실행하는 외부 주체에서 주입받을 수 있도록 했습니다.
    • ChunkSize가 변경되더라도 배치 작업 실행 시점에 변경된 ChunkSize를 파라미터로 넘기면 되기 때문에 불필요한 코드 수정이나 배포가 필요하지 않습니다.
    • 만약 외부에서 주입받은 값이 없다면 기본값(여기서는 20)을 갖도록 하였습니다.

피드백 7 – Scope 이대로 사용하다간 우리 다 큰일 나아~

Spring Batch에서는 Job, Step, Step Component(Tasklet, Reader, Processor, Writer 등) 빈들이 조금 특별한 Scope를 가지고 있습니다. 바로 JobScope, StepScope인데요, 이번에는 이 Scope와 관련한 피드백을 살펴보겠습니다.

  • AS-IS
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig {

    ...

    @Bean(BEAN_PREFIX + "jobParameter")
    @StepScope // (1) 
    public OrderAmountSumJobParameter jobParameter() {
        return new OrderAmountSumJobParameter();
    }

    @Bean(JOB_NAME)
    public Job job() {
        return jobBuilderFactory.get(JOB_NAME)
                                .start(step())
                                .build();
    }

    // (2)
    @Bean(BEAN_PREFIX + "step")
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(chunkSize)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    @Bean(BEAN_PREFIX + "reader")
    @StepScope
    public JpaCursorItemReader<OrderAmountSumDto> reader() { ... }

    @Bean(BEAN_PREFIX + "processor")
    @StepScope
    public ItemProcessor<OrderAmountSumDto, OrderAmountSum> processor() { ... }

    @Bean(BEAN_PREFIX + "writer")
    @StepScope
    public ItemWriter<OrderAmountSum> writer() { ... }
}
  • (1) 주문 날짜(orderDate) 정보를 가진 JobParameter를 @StepScope 빈으로 등록해서 사용했습니다. orderDate는 Reader, Processor, Writer에서만 사용할 것이기 때문에 큰 고민 없이 @JobScope보다 @StepScope로 Scope 범위를 제한했습니다.

    • (2) 위 (1)에 이어서 Step에서도 따로 @JobScope를 갖는 JobParameter가 없기 때문에 별도의 Scope를 설정하지 않았습니다. 결국 Scope에 대한 정확한 이해 없이 단순히 JobParameter를 기준으로 Scope를 부여하는 식으로 잘못 사용하였고, 아래의 피드백을 받았습니다.
  • Step에 Scope가 적용되지 않았는데, Spring Batch 가이드 – Spring Batch Scope & Job Parameter에 나와있는 내용을 참고해서 JobParameter, Scope에 대해 다시 확인을 해보면 좋을 것 같다.

    • 상황에 따라 JobParameter를 @StepScope로 적용해서 사용할 수도 있지만, 그렇게 되면 JobParameter에 대한 활용도가 낮아질 수 있다. 만약 Job 전반에 걸쳐 사용해야 될 JobParameter를 추가해야 되는 상황이 발생하면 Scope도 변경해야 하고 그로 인해 발생하는 다른 부분에 대한 영향도 추가로 확인해야 한다.
  • TO-BE
@RequiredArgsConstructor
@Configuration
public class OrderAmountSumByPaymentTypeJobConfig {

    ...

    @Bean(BEAN_PREFIX + "jobParameter")
    @JobScope // (3)
    public OrderAmountSumJobParameter jobParameter() {
        return new OrderAmountSumJobParameter();
    }

    @Bean(JOB_NAME)
    public Job job() {
        return jobBuilderFactory.get(JOB_NAME)
                                .start(step())
                                .build();
    }

    @Bean(BEAN_PREFIX + "step")
    @JobScope // (4)
    public Step step() {
        return stepBuilderFactory.get(BEAN_PREFIX + "step")
                                 .<OrderAmountSumDto, OrderAmountSum>chunk(chunkSize)
                                 .reader(reader())
                                 .processor(processor())
                                 .writer(writer())
                                 .build();
    }

    @Bean(BEAN_PREFIX + "reader")
    @StepScope
    public JpaCursorItemReader<OrderAmountSumDto> reader() { ... }

    @Bean(BEAN_PREFIX + "processor")
    @StepScope
    public ItemProcessor<OrderAmountSumDto, OrderAmountSum> processor() { ... }

    @Bean(BEAN_PREFIX + "writer")
    @StepScope
    public ItemWriter<OrderAmountSum> writer() { ... }
}
  • (3) JobParameter의 Scope을 @StepScope@JobScope로 변경하였습니다.
    • (4) Step 역시 @JobScope를 적용했습니다.
  • 참고로, @JobScope 또는 @StepScope가 선언된 빈은 애플리케이션 구동 시점에 빈이 생성되는 것이 아니라 지정된 Scope가 실행되는 지점에 빈이 생성됩니다.
    • JobParameter 역시 아무 때나 사용할 수 있는 것이 아니라 @JobScope 또는 @StepScope가 선언된 빈이 생성되는 시점에 JobParameter가 생성되어 사용할 수 있습니다.

피드백 8 – Jenkins에서 Batch Job 실행을 좀더 효율적으로 바꿔볼까?

배치 작업을 완료한 후 다음으로 Jenkins에 배치 실행 환경을 구축하였습니다. settle-admin-batch의 결제수단별 주문금액 집계, 지급금 생성 배치 총 2개의 배치 실행 환경을 Jenkins로 구성하였습니다. 피드백 내용은 Jenkins 설정과 관련된 내용이므로 결제수단별 주문금액 집계 기준으로 설명하겠습니다.

  • AS-IS

/**
 * Build Script
 */

./gradlew clean :settle-admin-batch:bootJar \ // (1)

java -jar ${PFORILES_ACTIVE} ${BATCH_JAR} --chunkSize=${chunkSize} \
--job.name=OrderAmountSumByPaymentTypeBatch orderDate=${orderDate} version=${BUILD_NUMBER}
  • Jenkins에 git repository와 credentials 그리고 빌드할 branch를 작성하였습니다.
    • 결제수단별 주문금액 집계 배치에 사용될 ChunkSize와 JobParameter(orderDate)는 배치 실행 시입력받아 사용할 수 있도록 하였습니다.
    • (1) 문제가 되는 부분은 바로 이 부분인데요, 혹시 눈치채셨나요? 위 스크립트를 이용하면 매번 배치를 실행할 때마다 새로 gradle build를 실행하게 됩니다. 이와 관련하여 다음과 같은 피드백을 받을 수 있었습니다.
  • 실제로는 2개 배치 작업(결제수단별 주문금액 배치, 지급금 생성 배치)이 같은 jar에 들어 있는데 각 배치를 실행할 때 매번 따로 소스를 받아서 빌드하는 것은 비효율적인 면이 있어 보인다.
    • 타깃 브랜치에 Push가 일어나는 경우에만 build를 진행하고, 배치 실행 자체는 가장 최신의 build 결과로 산출되는 jar를 이용하는 방법으로 개선할 수 있을 것 같다. 즉, 가장 최신 버전의 jar가 존재한다면 해당 jar를 이용해서 별도의 build 과정 없이 바로 배치 작업을 실행할 수 있다.
  • TO-BE

// (2)
java -jar ${PFORILES_ACTIVE} ${BATCH_JAR} --chunkSize=${chunkSize} \
--job.name=OrderAmountSumByPaymentTypeBatch orderDate=${orderDate} version=${BUILD_NUMBER}

※ 주의!

제가 파일럿을 진행한 개발 환경은 Jenkins(젠킨스)는 Local 환경, GitLab은 VPN이 필요한 Private 환경이므로 일반적인 개발 환경과 다소 차이가 있습니다. 위 Webhook 이미지는 참고만 해 주시길 바랍니다!

  • Jenkins와 GitLab의 Webhook을 통해 develop 브랜치에 Push가 일어나면 자동으로 빌드가 되도록 구성하였습니다.
    • (2) gradle build 스크립트를 제거하고, 배치 실행 시 기존에 존재하는 jar 파일을 이용하여 바로 배치 작업을 수행하도록 수정하였습니다.
  • 그 결과 develop 브랜치에 별도의 Push가 없다면, 배치 작업 실행 시 빌드 과정 없이 바로 기존에 존재하는 jar 파일을 이용하여 배치를 수행할 수 있게 되었습니다.

맺으며


이미지 출처: “하이큐 애니 2기”

이렇게 4주간의 정산 파일럿 과정은 모두 마무리되었습니다. 지난 파일럿 기간 동안 때로는 벽에 부딪혀 막막하기도 하고 좌절도 많이 했었는데요, 그럴 때마다 팀원분들의 관심과 응원 덕에 잘 헤쳐나갈 수 있었던 것 같습니다.

파일럿을 진행하면서 이전에 사용해 봤던 기술이라도 어떤 관점과 기준을 가지느냐에 따라 그 활용도와 깊이가 다양해지고, 새로 사용해 본 기술들은 단순히 사용에서 그치는 것이 아니라 어떻게 지속적으로 개선하고 발전시켜나갈 것인가에 대해 많은 생각을 할 수 있었습니다.

그리고 크게는 설계에서부터 작게는 변수 이름까지 정말 다양한 관점의 많은 피드백을 받으면서 그동안 제가 가지고 있던 개발 습관이나 태도에 대해 다시 한번 돌아보고 부족한 점들을 온전히 마주할 수 있는 시간이었습니다.

이 시간들을 바탕으로 제가 갖추고 있는 것들은 녹슬지 않게, 앞으로 갖춰야 할 것 들은 더욱 빛날 수 있게 계속해서 갈고닦으며 성장하는 개발자가 되겠습니다.🙂


이미지 출처: 웹툰 “쌉니다 천리마 마트”

파일럿 기간 동안 많은 응원과 격려, 그리고 좋은 피드백을 아낌없이 주신 우리 정산플랫폼팀 팀원분들에게 무한한 신뢰와 감사를 드리며 이만 마치겠습니다.🙂