티스토리 뷰

마지막 미션인 레거시 리팩토링 미션은 중간에 제작근로 배포, 데모데이, 취업준비 등으로 우선되는 일들을 처리하고 고민하느라 거의 1달 넘게 진행했던 것 같다. 4단계까지 마무리했다. 끝까지 마무리 된 크루들이 많이 없을만큼 집중해서 하기 쉽지 않은 환경이었는데 마무리해낸 크루 중 한명이라 뿌듯하다.✌️

 

레거시 리팩터링을 진행하면서 귀찮은 점도 엄청 많았고, 느끼는 점도 많았다. 무엇보다 이 미션을 집중해서 해봐야겠다는 생각을 한 것은 JPA에 대해 좀 더 공부해보고 싶었기 때문이었다. 아직도 스프링도 마찬가지이지만 JPA의 내부과정에 대해 온전하게 이해된 느낌은 아니다. 좀 더 깊게 이해해 나가고 있는 과정이라고 생각한다.

 

리팩터링 미션을 진행하면서 우아한테크코스 과정을 종합하는 미션이라는 생각이 들었다. 각 단계별로 느낀 점이 다르고 실제 진행한 단계와는 다소 차이가 있지만, 기억을 떠올려 주어진 단계에 해당하는 대로 느낀점과 배운점을 재구성 해보겠다!


1단계

문서의 역할을 하는 테스트코드

첫번째 단계는 레거시 코드를 이해하고 리팩터링하기 위한 테스트코드를 작성하는 것이다. 기능하고 있는 코드를 리팩터링하기 위해서는 테스트 코드가 뒷받침 되어야 한다. 사실 테스트코드가 다 통과된다고 해서 기능이 온전하다고 보장할 수는 없다고 생각한다. 하지만, 적어도 테스트를 작성한 코드만큼은 동작한다는 것을 보장하게 되므로 리팩터링에 있어 테스트코드는 필수라고 생각한다.

 

1단계에서 가장 크게 느낀 점은 문서로 역할을 하는 테스트코드이다. 사실 제작근로를 하면서 기존 프로덕션 코드를 테스트 네이밍을 보며 정책을 이해하기도 했었다. 당시에는 문서로써의 테스트를 크게 못 느꼈다. 이번 미션을 하면서 기존 프로덕션에서 정책을 뽑아내서 README로 만들고, 이 항목대로 서비스 테스트코드를 작성하면서 문서의 역할을 하는 테스트코드의 의미를 느끼게 되었다.


2단계

  • 엔티티를 뷰까지 주고 받는 것을 dto로 분리
  • builder 패턴을 사용하여 setter 제거
  • 비즈니스 로직을 도메인으로 이동(일급컬렉션, VO 사용)
  • JdbcTemplate을 사용하여 dao로 되어있던 부분을 JPA로 변경
  • 객체간의 연관관계 수정

비즈니스 로직을 도메인으로

"응용 애플리케이션을 개발할 때 TDD, OOP를 적용하려면 핵심 비즈니스 로직을 도메인 객체가 담당하도록 구현하는 것이다." 라고 한다. 비즈니스 로직은 도메인 안쪽으로 넣고, 서비스 레이어에서는 도메인들을 조합해서 사용하는 로직을 작성하는 것이 좋다고 생각한다. 도메인 안에 비즈니스 로직이 들어가야 해당 객체로 온전하게 동작할 수 있고, 다른 서비스에서 사용하더라도 해당 도메인에 관련된 로직은 그대로 재사용이 가능하다.

 

서비스에 있던 로직을 도메인으로 넣으려고 하다보니 VO 객체, 일급컬렉션 등이 필요해지게 되었다. 우아한테크코스 레벨1에서 배웠던 VO, 일급컬렉션이 이래서 필요하구나를 이제와서 느끼는 순간이었다. 좀 의외였던 부분은 VO객체는 매개변수 없는 생성자가 필요 없을 것이라고 생각했는데 3단계를 하며 실행하다 보니 엔티티와 마찬가지로 매개변수 없는 생성자가 필요했다.

JPA 연관관계

사실 JPA 연관관계를 수정하는 내용 3단계 내용을 의도했던 듯 하나, 우아한객체지향 세미나 영상을 보고 JPA 연관관계까지 적용해보았다. 팀 프로젝트를 하면서 JPA 연관관계를 다르게 해보고 싶었는데, 나도 잘 모르는 상태에서 팀원들을 설득해서 다른 방식으로 하는 것은 쉽지 않았다. 그때 못해본 내용을 이번 미션을 통해서 연관관계에 대해 고민해 보고 싶었다. 

 

예전에도 그랬지만 가장 인상깊었던 내용은 객체를 사용하는 연관관계는 결합도가 높은 의존관계 라는 것이다. 이것을 id를 사용해서 repository라는 인터페이스를 통해서 결합도를 낮춰줄 수 있다. 어떤 것을 id로 하고 어떤 것을 객체로 연관관계를 설정할 것인가에 대한 문제가 남는다. 이 부분은 사실 예전에 팀플에서 id 연관관계 주장을 했을 때, 내가 명확하게 대답하지 못했던 부분이다. (그 당시에도 우아한객체지향 영상을 봤지만 그 대답을 할 만큼 이해되지 않았던 듯 하다.) 지금 대답하면, 생명주기와 관련해서 객체 연관관계를 설정하는 것이다. Menu와 Product 사이에는 MenuProduct라는 중간 테이블이 생긴다. Product가 생성되고 소멸되는 주기는 MenuProduct의 생명주기와 다르다. 따라서 이 둘은 id로 연관을 맺는다. Menu와 MenuProduct의 생명주기는 동일하다. Menu는 이미 존재하는 Product를 조합한 MenuProduct 정보로 생성되고 소멸될 때 같이 소멸된다. 따라서 이 부분은 객체로 연관관계를 맺을 수 있다.

 


3단계

3단계의 요구사항인 "메뉴의 이름과 가격이 변경되면 주문 항목도 함께 변경된다. 메뉴 정보가 변경되더라도 주문 항목이 변경되지 않게 구현한다." 부분은 아직도 의문점이 있다. 처음에는 요구사항 자체를 이해하지 못했다. 의미하는 바는 처음 주문시 메뉴는 "똠냥꿍 세트", "24000" 이었는데 나중에 "찐~한 태국 세트", "30000"으로 바뀐다고 하더라도 과거주문은 여전히 예전 이름으로 나와야 한다는 것이다.

 

3단계를 하던 당시에는 이해하지 못했고, 4단계에서 다시 고민해보았다. OrderLineItem에 대한 복사본의 엔티티를 이벤트 방식으로 하나 더 만들어서 Order측에서 관리해야하는 건가 싶었다. 이런 방식으로 하다보니 데이터의 중복만 발생하게 되었던 것 같아서 결국 컬럼에 menuName, menuPrice를 추가해주는 방식으로 해결했다. 이벤트로 시도해보았는데, Menu~Product 의 가격 부분을 이벤트로 같이 수정되게 설정해두었기 때문에 이 부분까지 개입되니 Product의 가격이 변경되도, Menu의 가격이 변경되도 해당 부분을 관리할 수 있어야 했다. 개념적으로 복사본을 만들어서 관리하는게 맞는 것 같은데 다른 부분의 이벤트까지 엮여있어 아직 기술적인 부족함으로 그것에 대한 관리까지는 실패했다.  이것은 이벤트를 좀 더 자유롭게 사용할 수 있게 되고, 시간이 좀 더 지나면 이해할 수 있는 부분일 것 같다.

mock 테스트는 믿을 수 있는가?

2단계에서는 기존 mock 테스트에 통과되면 동작한다고 믿고 테스트가 통과되는 것만 확인하고 미션을 제출했다. 하지만, 3단계를 진행하면서 보니 실제 애플리케이션이 뜨지 않거나 api 콜을 보내니 예외가 발생했다. mock 테스트는 비즈니스 로직에 대한 행위의 결과값을 stubbing 하기 때문에 테스트는 통과하지만 실제는 그렇게 동작하지 않을 수 있다는 점을 크게 느꼈다. 

 

그렇다고 mock 테스트가 과연 의미가 없고 통합 테스트만을 진행해야 할까? mock 테스트는 layer 단위로 나눠서 테스트할 때 필요하다고 생각한다. 이런 경우 각 레이어가 잘 동작함을 보장할 때 동작한다고 생각한다. 온전한 동작을 보장할 수 없을 때, mock 테스트는 과연 믿을 수 있는가 라는 의심이 강하게 들었다.

이벤트 사용하기

요구사항에는 없었지만, Menu~Product 사이에서도 서로 데이터가 일치되어야 한다고 생각했다. 이럴 경우 가격에 대한 양방향 의존성이 생겨 이 부분에 이벤트를 적용하였다. Product의 가격이 변경되면 Product.price * quantity 로 메뉴 가격이 변경되게 하는 것이다. 

// ProductService
    @Transactional
    public Product update(final Long productId, final ProductInformationRequest request) {
        final Product product = productRepository.findById(productId)
                .orElseThrow(IllegalArgumentException::new);
        product.updatePrice(request.getPrice());
        eventPublisher.publishEvent(new MenuProductEvent(product.getId(), product.getPrice()));
        productRepository.save(product);
        return product;
    }
    

// EventHandler
@Component
public class MenuProductEventHandler {
    private final MenuProductRepository menuProductRepository;
    private final MenuRepository menuRepository;

    public MenuProductEventHandler(MenuProductRepository menuProductRepository,
                                   MenuRepository menuRepository) {
        this.menuProductRepository = menuProductRepository;
        this.menuRepository = menuRepository;
    }

    @EventListener
    public void handle(MenuProductEvent event) {
        final MenuProduct menuProduct = menuProductRepository.findByProductId(event.getProductId())
                .orElseThrow(IllegalArgumentException::new);
        final Menu menu = menuRepository.findById(menuProduct.getMenuId())
                .orElseThrow(IllegalArgumentException::new);
        final Price productPrice = new Price(event.getProductPrice());
        menu.updatePrice(productPrice.multiply(menuProduct.getQuantity()));
    }
}

이벤트를 발행하면, 해당 처리는 도메인의 EventHandler를 만들어 처리하는 방식이다. 아직 이벤트에 대한 이해도가 높지는 않지만 사용해보면서 이벤트 사용을 시작해 보았다는 것에 의미가 있다고 생각한다.


4단계

3단계에서 부족했던 부분까지 4단계에서는 채워 작성해보려고 노력했다. 크루들(아마찌, 손너잘)과 박재성님의 도메인 원정대, 조영호님의 우아한 객체지향 영상을 회의실에서 같이 보면서 의문이 생기는 점마다 동영상을 멈추고 의미 하나하나를 이해해보는 활동을 했다. 평소 DDD에 관심이 있던 크루(손너잘)에게서 많이 배웠고, 그동안 우아한테크코스의 미션의 의미를 이해할 수 있는 시간이었다. 마치, 그동안의 10개월을 정리해보는 시간이었다고 해야할까? 

 

[하루짜리 DDD]

DDD에 대해 잘 모르지만 그때 했던 이야기들을 정리해보겠다. DDD는 문제해결 영역을 먼저 정의 한다고 한다. 이것을 bounded context라고 하고 그것의 진입점을 AggregateRoot Entity라고 한다.(global ID로 구분된다.) 그 내부에서는 지역 ID로 구분되고 이것은 root 엔티티를 통해서 접근하게 된다. (JPA 가 연관관계 매핑을 통해 기술적으로 이렇게 할 수 있도록 지원하는 듯하다.) 엔티티마다 repository가 만들어지는 것이 아니라 aggregate 엔티티 단위로 repository가 만들어진다. 패키지 또한 aggregate 단위로 구분될 수 있다. 

 

[Layer Architecture에 대한 이해]

 

https://www.researchgate.net/figure/Representation-of-Multitier-Architecture-used-by-Spring-Boot-7_fig4_341956559

Layered Architecture에서 단방향의 의존관계를 생각하면 어디에 레이어에 위치해야하는지 스스로 답을 내릴 수 있는 듯 싶다. repository는 도메인과 같은 패키지에 속해서 service는 repository, 도메인을 참조하는 것이다. 도메인은 service 단까지만 노출할 것이기 때문에 컨트롤러에 넘기는 dto또한 service 단에 위치해야 한다고 생각한다. 

 

dto를 controller 단에서 만들 것인가? service 단에서 만들어서 보내줄 것인가에 대한 고민도 들었다. 하지만 layered architecture 관점에서 살펴보면 service를 통해서 도메인에 접근하기 때문에 controller까지 도메인이 노출되는 것은 layered architecture에 어긋난다. 따라서 service 단에서 엔티티를 dto로 변환하여 controller에게 보내주어야 한다. 서비스 단까지만 도메인을 노출하는데 중요한 이유 중 하나는 OSIV 설정에도 있다고 생각했다. 기본적으로 OSIV 설정이 true라면 뷰까지 영속성 컨텍스트가 이어지고 엔티티가 노출되어 보호받지 못하기 때문에도 dto로 변환하여 그 관계를 끊어주는 것이 필요하다고 생각했다.

 

[우아한테크코스 미션에 대한 의미]

aggregate 는 문제해결의 범위이다. 따라서 aggregate를 잘 나누기 위해서는 해결하고자 하는 문제가 중요하고 요구사항이 잘 정리되어 있어야 한다. 우아한테크코스의 프리코스에서부터 요구사항을 작성을 강조했던 이유를 알게 되었다. DDD에서 객체지향은 기본이라고 생각한다. 각각의 역할을 잘 나누고 동작하게 하는 것이 중요하기 때문이다. 패키지를 나누면 그 단위로 멀티모듈의 단위가 될 수 있고, 이것은 MSA(나는 아직 MSA에 대해 모른다;;)로 갈 수 있는 초석이라고 생각한다. 단방향 연관관계, 패키지 간의 사이클에 대해 걱정하는 것 또한 의존성을 분리하고 MSA로 갈 수있는 단계가 아닐까라는 생각이 들었다. 

 

교육과정 동안 DDD는 처음 시작하는 크루들에게 어려운 개념이라 코치분들이 몰라도 된다고 해서 살펴보지 않았던 부분이다. 이제는 DDD에 대해서 조금씩 공부해보고 싶은 생각이 들었다. 기술과 엮어 객체를 더 객체답게 사용할 수 있는 방법이라는 인상을 받았다.

 

패키지, 클래스 간 단방향 의존성 파악하기

하루동안의 DDD 수련을 하여 얻은 인사이트로 3단계까지 진행했던 부분에 대해 의존관계 그래프를 작성해보았다.

클래스간, 패키지간, 레이어간의 단방향의 의존성을 가지는지 확인해보았다. (사실 DB를 넘어선 애플리케이션 단의 의존성 그래프를 어떻게 그려야하는지 잘 몰랐는데 이제 그릴 수 있게 되었다!) 몇몇 군데 사이클이 돌고 있었고 이것을 분리하는 작업을 진행했다. 자세한 내용은 리팩터링 미션 브랜치 docs 부분에 정리해두었다.

단방향 의존성 인터페이스로 구현하기

다른 패키지에서 도메인을 참조하지만 단방향으로 설정해주고 싶다면 인터페이스를 사용하여 단방향 설정을 해줄 수 있다. 코드 예시에서 table과 order의 패키지를 분리하였으나 유효성 검증 때문에 양방향의 사이클이 돌았다. 이 경우 table 패키지에 인터페이스를 두고, 이것을 order에서 구현하여 사용하여 table <- order 방향의 단방향 관계를 유지할 수 있었다. 이런 방식으로 하니 서비스에 남아있던 비즈니스 로직이 도메인에 들어오는 또 놀라운 경험을 하게 되었다.

 

// package kitchenpos.table.domain;

public interface OrderValidator {
    void validate(Long orderTableId);
}


// package kitchenpos.order.domain;

@Component
public class TableValidator implements OrderValidator {
    private final OrderRepository orderRepository;

    public TableValidator(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public void validate(Long orderTableId) {
        if (orderRepository.existsByOrderTableIdAndOrderStatusIn(
                orderTableId, Arrays.asList(OrderStatus.COOKING.name(), OrderStatus.MEAL.name()))) {
            throw new IllegalArgumentException();
        }
    }
}

멀티모듈

멀티모듈을 처음 적용해보는 것이라 설정이 쉽지 않았다. 가장 의존성이 적은 product 부터 패키지 분리를 시작하며 4개의 패키지로 분리했다.(6개의 패키지 모두 단방향으로 분리하는 작업을 해서 6개로도 나눌 수 있을 것 같지만, 멀티모듈 적용에 의의를 두고 다른 일로 시간에 밀려 4개로만 작업했다.) 아래는 각 패키지 간의 의존관계이다.

TableGroup <- Table <- (Order, OrderLineItem)
MenuGroup <-Menu <- (Order, OrderLineItem)
Product <- Menu

각 모듈마다 build.gradle을 설정해주었고, common으로 공통 모듈을 만들지는 않았다. order 모듈을 기준으로 application의 메인 문을 실행시켰다. 모듈을 분리할 때, 다른 모듈에 대한 dependency를 설정해주어야 해당 모듈에 대한 내용을 가져올 수 있었다. 멀티모듈을 도메인 패키지 단위로 적용하려면 각각의 단방향 연관관계 설정은 필수라고 생각했다. (모듈 사이에 사이클이 돌면 실행되지 않기 때문에...) 

// menu package의 build.gradle
dependencies {
    implementation project(':product')
    // ...기타 설정들...
}

전체 프로젝트에 build.gradle 전체 관리를 해서 subprojects{ } 로 관리하는 방식도 있는 듯하다. 하지만 멀티모듈을 각 패키지 단위로 설정해서 나눴고 이런 경우 아예 모듈을 분리해서 따로 서버를 띄울 수도 있을 듯 하다.


아쉬운점

JPA의 N+1 문제를 일부만 해결하고 이후 리팩터링 하면서 추가적으로 체크해보지 못한 것이 지금 아쉽게 느껴진다. 클래스 사이의 양방향 연관관계를 수정하였는데, 기존 양방향이었던 Menu~MenuProduct와 Order~OrderLineItem를 OneToMany로 변경해주었다. 처음에는 OneToMany 관계만 단방향 설정할 때는 insert 이후 update 쿼리가 나가기 때문에 null 인 경우 예외가 발생하였다.(DB설정에 null이 불가능하게 되어 있었기 때문이다.) 이후 양방향으로 변경했을 때에는 로직상에서 update 설정을 해주고 save를 하는 로직을 추가해주었기 때문에 다시 단방향으로 변경했을 때에는 문제가 되지 않았다. 이 부분은 이렇게 하지 않고 OneToMany에서 cascade 타입을 PERSIST로 하여 영속화 하는 방식으로도 설정할 수 있다고 한다.뿐만 아니라, 생명주기가 같은 경우 객체 참조를 하였기에 orphan removal 등을 통해서 생명주기를 동일하게 가져가는 것이 좋았을 것이라고 생각한다. 그동안 이론으로 알고 있던 JPA의 N+1 문제 등에 대해 더 깊은 고민을 해봤으면 좋았을 것 같다는 생각이 든다. 사실 연관관계와 단방향 의존관계, 멀티모듈을 하느라 정신 팔려서 이 부분까지는 많이 고민해보지 못했던 것 같아 아쉽다.

 

repository를 어그리거트 단위로 만든다고 하는데 아직 거기까지 생각해보긴 어려웠다. 모든 엔티티에 대해 repository를 만들었지만 추후 DDD 개념이나 설계에 더 공부해본다면 이런 부분도 개선할 수 있는 점에 포함될 듯 싶다. 또한, 다른 프로젝트를 더 경험해보면서 이벤트와 gradle에 대해 좀 더 익숙하고 자유롭게 사용할 수 있게 학습해나가고 싶다. 

회고

코드에 대한 깊이가 있는 사람이 리팩터링 요소를 넣어 일부러 레거시를 만들어 놓은 미션 느낌이었다. 예전에 도메인도 없던 쇼핑카드 api를 생각하면..ㅎㅎㅎㅎ 애그리거트 단위로 서비스가 나뉘어져 있었고 DB 정규화도 되어 있었다. 리팩터링 미션을 하면서 신경쓰지 못하고 지나간 부분도 있어 아쉽기도 하다. 이제 하면 되지 않나 싶지만 미션은 끝을 내지 않으면 끝이 없기 때문에 마무리할 것은 마무리하고 지나가야 하지 않을까?! 그런 아쉬운 부분은 다음번에 다른데서 적용할 때 더 신경써 볼 수 있지 않을까 싶다.

 

우아한객체지향 영상은 지금까지 5번은 본 듯 싶다.

  1. 자바에 입문하던 때, 처음 객체지향이란 걸 듣고나서 보게되었다. 이때는 진짜 하나도 이해할 수 없었다.
  2. 팀 프로젝트를 시작할 때, JPA 연관관계에 고민하면서 봤다. JPA 기술에 대해 아는 것이 훨씬 적었고, 실제로 사용해본 적도 없었기 때문에 그때도 이해할 수 있는 부분은 적었다. 그래도 객체가 뭔지, 각 레이어가 의미하는 바가 무엇인지를 알 수 있었던 것 같다.
  3. 레벨4에서 팀 프로젝트 트랜잭션 단위에 대해 리팩터링을 크루가 제안했을 때 봤다. 이때는 그래도 조금 더 JPA 관계에 대해 이해할 수 있었다. 다만 validator, event 등에 대한 내용은 온전히 이해하기 어려웠다.
  4. 리팩터링 미션에서 연관관계에 대해 고민할 때 봤다. 이때는 id와 객체로 연관관계를 맺는 기준에 대해 집중해서 봤고, 이벤트를 사용해서 단방향을 설정하는 것에 초점을 두었다.
  5. 리팩터링 미션을 3단계까지하고 다른 크루들과 하루동안 회의실에서 봤다. DDD 개념과 단방향이 의존성관리의 측면에서 봤다. 이때 같이 스터디를 하면서 하루동안이지만 그동안 테크코스 미션과정에 담긴 의미를 이해하고, 시야가 좀 더 트이는 경험을 했다. 인터페이스를 정의하고 다른 패키지에서 구현하는 방식으로 단방향 설정을 하는 것도 구현해 볼 수 있었다.

이 영상은 여러번 볼 때마다 느끼는 점이 다르고 같은 내용이지만 좀 더 이해할 수 있는 범위가 많아지고 있다는 것이 느껴진다. 실무를 하고 3년차쯤보면 더 재밌다고 한다. 더 복잡한 도메인을 다루는 실무를 하고 나서 다시보고 싶다.🙂

 

이것 저것 할 일이 많아서 미션만 온전히 집중할 수는 없어지만 끝까지 마무리한 것 같아 뿌듯하다. 미션을 끝까지 해보면서 우아한테크코스 과정과 미션에 대한 생각도 해볼 수 있어 좋았다. 지금까지 흐릿하게 있던 부분들에 대해 시야가 좀 트인 것 같아서 앞으로 DDD나 설계 철학에 대해서도 공부해나가고 싶다.

참고

- 조영호님의 우아한 객체지향 세미나 동영상

- 나의 리팩터링 미션 브랜치

- 우아콘 - 박재성님의 도메인 원정대

- 4단계 PR

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함