리뷰프로덕트팀 신입 개발자의 파일럿 프로젝트

Mar.02.2023 유재서

Backend Culture

안녕하세요!

우아한테크코스 교육을 수료하고 리뷰프로덕트팀에 입사한 신입 개발자 유재서입니다. 😃

교육받을 때 우아한형제들 기술 블로그에서 많은 도움을 받았었는데, 블로그에 글을 기재할 수 있게 되어서 너무 영광입니다. ㅎㅎ

새로운 지식과 방법들을 학습하며 진행한 프로젝트이다 보니 부족한 부분이 많은데, 성장 일기로 봐주시면 감사하겠습니다! 🙂


미니 리뷰 프로젝트 구현하기

파일럿의 주제는 미니 리뷰 프로젝트를 구현해 보는 것이었는데요.

요구사항을 간략하게 정리해 보았습니다.

  1. 리뷰 등록, 수정, 삭제

    • 리뷰는 배달 리뷰, 메뉴 리뷰, 리뷰 이미지, 리뷰 내용, 별점으로 구성됩니다.
    • 리뷰 C/U/D가 발생하면 리뷰 변경 이벤트를 발행합니다.
    • 모든 리뷰 데이터는 히스토리가 관리되어야 합니다.
  2. 리뷰 조회

    • 정렬 조건(최신, 좋아요순)
    • 필터 조건(사진이 있는 리뷰, 좋아요가 있는 리뷰)
  3. 리뷰 좋아요 등록/ 취소

  4. 가게 리뷰 통계

    • 리뷰 C/U/D로 이벤트가 발생했을 때, 이를 수신하여 가게 리뷰 통계를 집계합니다.
  5. 모든 기능 화면 제공

  6. CI, 로깅 설정, 배포

사용한 기술 스택은 다음과 같습니다.

  • Spring Boot 2.x, Gradle
  • Spring Data JPA
  • QueryDSL
  • Aurora(MySQL), H2
  • Vue.js
  • amazon S3, amazon SNS, amazon SQS

엔티티 관계도

본격적인 개발을 진행하기 앞서 설계를 먼저 진행하였는데요. 그 과정에서 고민했던 내용들을 공유하고자 합니다.

설계 과정에서 신경 썼던 부분은 엔티티 간의 연관관계였습니다. JPA를 사용하면서 겪는 대표적인 문제 중 하나는 N+1문제인데요.
이전의 개발 경험을 비추었을 때 객체 간의 연관이 있다고 모든 관계를 객체 참조로 설정하다 보면, 특정 엔티티를 조회했을 때 어디까지 조회되는지에 대한 경계가 없어 N+1문제를 비롯해 모든 연관된 엔티티가 조회되는 성능적인 이슈들을 마주쳤었습니다.

이번 파일럿에서는 해당 부분을 더욱 신경 써서 고민했었습니다. 그 과정에서 세운 규칙은 생성, 수정, 삭제를 함께 하는 같은 라이프사이클을 갖는 엔티티들끼리는 객체 참조를 하고, 이외의 다른 엔티티들 간의 관계는 Id로 간접 참조를 거는 것이었습니다.

위 그림에서 같은 패키지에 있는 엔티티들은 같은 라이프사이클을 가지고 있다고봐주시면 될 것 같습니다. 같은 패키지안에서는 객체 참조를 하고 있고, 다른 패키지 간에는 Id로 참조하고 있습니다.

이렇게 구성하게 되면 리뷰를 조회할 때는 배달 리뷰, 리뷰 이미지, 리뷰 메뉴들은 함께 조회되지만, Id로 참조하고 있는 가게 엔티티는 조회되지 않고 경계가 끊기게 됩니다.

더불어 패키지 간의 순환 참조는 없는지 확인하며, 설계에서 어색한 부분이 없는지 검증했습니다.

해당 내용을 의식하며 코드를 작성한 후, 코드 리뷰를 요청드렸습니다.

@Service
@Transactional
@RequiredArgsConstructor
public class ReviewRegisterService {

    private final ReviewRepository reviewRepository;
    private final ReviewDeliveryRepository reviewDeliveryRepository;
    private final ReviewMenuRepository reviewMenuRepository;
    private final ReviewImageRepository reviewImageRepository;

    ...

    public void register(ReviewRegisterCommand registerCommand) {
        Order order = orderRepository.findById(registerCommand.getOrderId())
                .orElseThrow(() -> new NoSuchElementException("주문이 존재하지 않습니다."));
        ...

        Review review = new Review(registerCommand.getMemberNumber(), order, registerCommand.getShopId(),
                registerCommand.getContent(), registerCommand.getRating());

        Review savedReview = reviewRepository.save(review);
        saveDeliveryReview(registerCommand, savedReview);
        saveReviewMenus(registerCommand, savedReview);
        saveReviewImages(registerCommand, savedReview);
    }

    private void saveDeliveryReview(ReviewRegisterCommand registerCommand, Review savedReview) {
        ...
        reviewDeliveryRepository.save(reviewDelivery);
    }

    private void saveReviewMenus(ReviewRegisterCommand registerCommand, Review savedReview) {
        ...
        for (ReviewMenuCommand reviewMenuCommand : registerCommand.getReviewMenus()) {
            ...
            ReviewMenu reviewMenu = new ReviewMenu(reviewMenuCommand.getMenuName(), reviewMenuCommand.getMenuReviewType(), reviewMenuCommand.getContent());
            reviewMenuRepository.save(reviewMenu);
        }
    }

    private void saveReviewImages(ReviewRegisterCommand registerCommand, Review savedReview) {
        if (registerCommand.getReviewImages() != null) {
            ...
            saveReviewImage(savedReview, imageInfos);
        }
    }

    private void saveReviewImage(Review savedReview, Map<String, String> imageInfos) {
        for (String name : imageInfos.keySet()) {
            reviewImageRepository.save(new ReviewImage(savedReview.getId(), imageInfos.get(name)));
        }
    }
}

위 코드에서 애그리거트 루트에 대한 리뷰를 남겨주셨습니다.

Review 1) reviewMenu나 Delivery도 동일할 것 같은데 같은 라이프사이클을 갖는 애그리거트안에서는 루트 애그리거트를 통해서만 변경이나 조회가 가능하도록 수정하면 좋을 것 같아요.

Review 2) 같이 저장된다는 건 같은 Aggregate 내에 있다고 생각해도 좋을 것 같아요.

그동안 조회의 관점에서만 고민하고 있었다는 것을 깨닫는 순간이었습니다.

배달의민족 앱에서 리뷰를 작성하실 때를 떠올리시면, 배달 리뷰와 별점을 기록하고, 리뷰 이미지와 추천 메뉴를 작성하는 하나의 플로우가 떠오르실 겁니다. 리뷰를 수정할 때도 이미지와 메뉴에 대한 기록을 함께 수정하고 저장됩니다.

즉, 같이 생성되고 같이 수정, 삭제되는 사이클을 가지고 있는데요. 같은 라이프사이클을 갖는 엔티티를 각각의 repository(reviewRepository, reviewDeliveryRepository, reviewMenuRepository, reviewImageRepository)로 관리하게 되면, 외부에서 엔티티에 직접 접근하여 데이터를 변경할 수 있게 되고, 데이터 일관성이 깨질 수 있다는 것을 알게 되었습니다. 각각의 repository를 통해 데이터가 어디서 변경될지 모르니 프로젝트가 커질수록 불안한 마음이 커질 것이라고 생각했습니다.

따라서 Review를 애그리거트 루트로 설정하여 전체 애그리거트를 관리할 수 있도록 개선했습니다.

@Service
@RequiredArgsConstructor
public class ReviewRegisterService {

    private final ReviewRepository reviewRepository;

    ...

    @Transactional
    public void register(ReviewRegisterCommand registerCommand) {
        ...

        try {
            Order order = orderRepository.findById(registerCommand.getOrderId())
                    .orElseThrow(() -> new NoSuchElementException("주문이 존재하지 않습니다."));

            ...

            Review review = Review.builder()
                    .memberNumber(registerCommand.getMemberNumber())
                    .reviewDelivery(registerCommand.toReviewDelivery())
                    .order(order)
                    .menus(saveReviewMenus(registerCommand))
                    .images(saveReviewImages(registerCommand))
                    .shopId(registerCommand.getShopId())
                    .content(registerCommand.getContent())
                    .rating(registerCommand.getRating())
                    .build();

            reviewRepository.save(review);

            ...
        } catch (Exception exception) {
            ReviewExceptionHandler.handleException(exception);
            throw exception;
        }
    }
}

위 코드에서 아실 수 있듯이 리뷰와 관련된 엔티티들은 애그리거트 루트인 리뷰를 통해서만 변경할 수 있습니다.
해당 리뷰를 남겨 주셔서 더 넒은 시야로 유지 보수의 관점에서도 고민해 볼 수 있었습니다. 😃

멀티 모듈 적용

사용자가 리뷰를 등록, 수정, 삭제했을 때 Amazon SNS에 리뷰 변경에 대한 이벤트를 발송해야 했습니다.

이 과정에서 멀티 모듈을 적용해야겠다는 생각을 했는데요.
리뷰 변경 이벤트를 수신하여, 가게별로 리뷰 통계 값을 집계해야 했기 때문입니다. 따라서 해당 기능은 리뷰 API와 독립된 별도의 기능이라 생각했습니다.

처음에는 review-api와 review-core라는 두 개의 모듈로 분리했었습니다. 멀티 모듈을 처음 적용해 보다 보니 기준도 모호했고, yml에 선언한 환경 변수들 또한 중복되고 있었습니다.

이후 기술 블로그에 기고된 멀티모듈 글을 참고하여 아래와 같은 기준으로 다시 나눌 수 있었는데요.

review-api

  • 다른 모듈들과 협력하여 애플리케이션 비즈니스 로직들을 처리하는 모듈
    예) service, controller

review-core

  • 도메인을 관리하는 모듈
    예) domain, repository

review-worker

  • 이벤트가 발생했을 때, 이벤트를 발신하고 수신하는 모듈
  • 리뷰 변경 이벤트 발송(피드백 이후 해당 로직 review-api로 이동)
  • 리뷰 변경 이벤트 수신 및 통계 업데이트

review-client

  • 외부 시스템과 통신하는 모듈
    예) 회원 API 통신

review-common

  • 모든 모듈에서 필요한 클래스
    예) custom exception

해당 작업을 통해 review-api와 review-worker에서 review-core 도메인 모듈을 사용할 수 있게 되었고, 각각의 역할에 맡게 나누어 build.gradle 의존성들과 yml에 선언한 환경 변수들의 중복 또한 없앨 수 있었습니다.

초기에 설계한 멀티 모듈 간의 의존성인데요.

여기서 이상한 부분은 review-api와 review-worker의 관계였습니다. review-api와 review-worker의 모듈을 나누었지만 review-worker에서 이벤트를 발송하고 수신하는 모든 로직이 있었기 때문에, review-api에서 review-worker를 의존하고 있었습니다.

즉, review-worker가 별도의 애플리케이션으로 분리되지 않고 모듈만 나누어진 구조였습니다.

해당 설계 내용을 공유드렸더니 팀원분께서 review-api와 review-worker를 같이 두게 되면, api 기능과 통계를 집계하는 로직이 같이 처리되다 보니 애플리케이션에 부하가 많이 올 수 있다. review-api는 api 기능에만 집중해야 한다. 고 말씀 해 주셨습니다.
추후 생각해 보니 당연한 부분이었는데, 역할을 분리하는 것에만 집중하고 궁극적으로 모듈을 왜 분리해야 했는지에 대한 고민이 부족했다는 것을 깨달을 수 있었습니다.

팀원분들이 주신 피드백을 통해 위와 같이 모듈 분리를 개선할 수 있었습니다.

review-worker에서는 이벤트를 수신하여 통계를 업데이트하는 기능만 수행할 수 있도록 하였고, review-worker와 review-api의 관계를 끊어내서 각각의 애플리케이션으로 동작하도록 수정하여 배포를 진행했습니다.

가게 리뷰 통계 집계

리뷰 생성, 수정, 삭제 작업을 발생하면 SNS에 리뷰가 변경되었다는 이벤트가 발행됩니다. 그러면 해당 SNS를 구독하고 있던 SQS 큐에서 이벤트를 Pull 하고, 해당 이벤트를 애플리케이션에서 수신하여 가게 리뷰 통계를 업데이트하는 흐름으로 구성했습니다.

현재는 하나의 SQS에서만 SNS를 구독하고 있지만, 리뷰 변경 이벤트가 필요한 여러 곳에서 구독할 수도 있습니다.

메시지 큐를 사용하는 아키텍처는 이번 파일럿에서 처음 경험해 볼 수 있었는데요.
메시지 큐를 사용하면 안정적인 서비스를 구성할 수 있습니다. review-worker에 문제가 생기더라도 review-api에서는 리뷰 변경 이벤트를 발행할 수 있고, review-worker에서는 review-api에서 문제가 생기더라도 큐에서 이벤트를 수신할 수 있게 됩니다. 만약 API를 통해 통신한다면, 둘 중 한 곳에서라도 문제가 발생하더라도 이벤트가 유실될 것입니다.

@Component
@RequiredArgsConstructor
public class ReviewUpdatingSQSMessagingConsumer {

    ...

    @Async
    @SqsListener(value = "${client.event.sqs-queue-name}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
    public void subscribe(SQSResponseMessage sqsResponseMessage) throws JsonProcessingException {
        ...
    }
}

실제로 테스트하는 과정에서도 애플리케이션에 문제가 생겨서 뜨지 못하는 경우가 많았는데, 큐에 이벤트가 보관되어 있어서 추후 문제가 해결되었을 때 통계 집계 작업을 정상적으로 처리할 수 있습니다.

위 코드처럼 deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS 옵션으로 설정하면, 리스너 메서드가 성공적으로 수행됐을 경우(예외 없이) 큐에서 이벤트가 삭제됩니다.

가게별 리뷰 통계 집계 로직의 문제점 및 개선 방법

리뷰 변경 이벤트를 수신하여 통계 값을 업데이트하는 로직은 다음과 같았습니다.

리뷰 변경 타입에 따라서 다르게 동작하도록 로직을 구성했었는데요.

그림에서도 느껴지시겠지만 분기 처리도 많고 필요 이상으로 복잡하게 구현된 로직이었습니다.

팀원분들께서 업데이트 타입을 나누지 않고 통계 값을 집계할 수 있었으면 좋겠다.는 피드백을 주셔서, 로직을 간단하게 수행할 수 있는 방향으로 개선해야 했습니다.

더불어 Spring Cloud AWS docs를 읽던 중 해당 글을 보게 됩니다.

https://docs.awspring.io/spring-cloud-aws/docs/current/reference/html/index.html#messaging

😱 .. 위에서 알 수 있듯이 현재 로직은 멱등성이 보장되고 있지 않았습니다. 같은 이벤트가 여러 번 오면 검증 없이 데이터를 증가시키거나 감소시킵니다. 같은 이벤트를 여러 번 받더라도 통계 집계에는 이상이 없도록 개선해야 했습니다.

따라서 리뷰 변경 이벤트가 발행되면, 리뷰 변경이 발생한 가게에 작성된 리뷰들 중 리뷰의 상태가 노출 상태인 리뷰들을 별점별로 그룹핑해서 가게 리뷰 통계를 업데이트하도록 개선했습니다.
추가적인 연산 없이 리뷰 테이블에서 직접 조회하여 업데이트하기 때문에 멱등성도 보장할 수 있었고, 분기 처리도 모두 제거할 수 있어서 더 이해하기 좋은 코드로 개선할 수 있었습니다.

로직을 작성할 때 현재의 방법이 적절한 방법인지, 더 효율적이고 쉬운 방법은 없는지에 대해 더 고민하는 습관을 가져야 한다는 것을 깨달을 수 있었습니다.

히스토리 쉽게 관리하기

히스토리와 관련된 요구사항은 1) 리뷰 데이터는 히스토리가 관리되어야 한다.2) 어떤 필드의 값이 변경되었는지 알 수 있어야 한다.는 두 가지가 있었습니다.

따라서 각각의 1) History(ReviewHistory, ReviewImageHistory, ReviewMenuHistory, ReviewDeliveryHistory, LikeReviewHistory) 엔티티를 새롭게 생성하고,
2) 현재 엔티티의 값과 요청이 온 값(변경하려는 값)을 비교해서 이전과 데이터가 변경되었는지를 나타내는 boolean 타입 필드를 추가하여 해결해야겠다고 생각했습니다.

하지만 해당 방법은 5개의 새로운 엔티티를 일일이 생성해야 하는 비슷한 반복 작업을 해야 했습니다. 지금은 파일럿이기 때문에 5개의 엔티티지만, 실제 프로젝트의 모든 엔티티 변경 이력을 관리해야 한다면 많은 비용이 드는 작업일 것입니다.

고민하던 중 팀원분께서 Spring Data Envers라는 키워드를 알려주셨습니다.

Envers는 엔티티에 감사(auditing) 기능을 추가할 수 있는 하이버네이트 모듈인데요. 엔티티의 변경 이력을 자동으로 관리해 줍니다. Spring Data Envers는 하이버네이트 Envers를 편리하게 사용할 수 있는 Spring Data JPA의 확장 모듈입니다.

자세한 내용은 Review 엔티티를 보며 설명드리겠습니다.

@Entity
@Table(name = "review", indexes = {
        @Index(name = "id", columnList = "id")
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Review {

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

    @Column(nullable = false)
    private String memberNumber;

    @OneToOne(mappedBy = "review", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private ReviewDelivery reviewDelivery;

    @Column(nullable = false, unique = true)
    private Long orderId;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "review")
    List<ReviewImage> images = new ArrayList<>();

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "review")
    List<ReviewMenu> menus = new ArrayList<>();

    @Column(nullable = false)
    private Long shopId;

    @Audited(withModifiedFlag = true)
    private String content;

    @Column(nullable = false)
    @Audited(withModifiedFlag = true)
    private Integer rating;

    @Enumerated(EnumType.STRING)
    @Audited(withModifiedFlag = true)
    private ExposureStatus exposureStatus;

    @CreatedDate
    @Column(nullable = false)
    protected LocalDateTime createdDate;

    @LastModifiedDate
    @Column(nullable = false)
    protected LocalDateTime lastModifiedDate;

    ...

}

여기서 주의 깊게 보셔할 부분은 @Audited(withModifiedFlag = true)입니다. @Audited를 선언하면 자동으로 변경 이력이 관리되는데요. 테이블 상단에 선언하면 모든 필드에 대한 변경 이력을 관리하게 됩니다. 관리하고 싶은 대상을 특정하고 싶으시면, 위의 예시처럼 각각의 필드에 별도로 선언하시면 됩니다.

또한 withModifiedFlag를 true로 주면 어떤 필드가 수정되었는지 데이터베이스에 boolean 값으로 기록됩니다.

그럼 실제 데이터베이스 테이블에는 어떻게 저장되는지 확인해 보겠습니다.

revision_type에서 0은 등록, 1은 수정, 2는 삭제를 나타냅니다. xxx_mod 칼럼은 값이 수정되었는지를 boolean 값으로 나타냅니다.

또한 revision_id를 통해서는 같은 트랜잭션에서 함께 변경된 모든 이력들을 알 수 있고, revision_id는 revinfo라는 테이블에서 관리됩니다.
위 테이블에서 review_history와 review_menu_history 테이블에서 같은 revision_id를 가지고 있는 것을 확인해 볼 수 있는데요. 이는 같은 트랜잭션에서 함께 저장되었다는 것을 나타냅니다.

해당 기능을 통해 리뷰 히스토리를 관리하는 기능을 빠르게 구현할 수 있었고, 구현하는 과정에서 히스토리를 통해 많은 도움을 받을 수 있었습니다.

참고로 위의 예시에서는 기본으로 설정되는 이름들을 변경했습니다. 밑의 속성들을 찾아보시면 도움이 될 것 같습니다.

spring:
  jpa:
    properties:
      org:
        hibernate:
          envers:
            audit_table_suffix: _history
            revision_field_name: revision_id
            revision_type_field_name: revision_type
    ...

더 자세한 내용을 알고 싶으시면 영한님 발표가 많은 도움이 되실 것 같습니다.

외부 시스템과 연동하는 기능 – 테스트 코드 작성의 어려움

테스트 코드에 대한 고민도 많았는데요. 진행한 테스트 전략은 다음과 같았습니다.

 Acceptance Test - API 접점을 검증하는 E2E 테스트
 Service Test - 통합 테스트 
 Domain Test - 단위 테스트
 Controller Test - MockMvc를 활용한 컨트롤러 요청 DTO 검증 테스트 (컨트롤러 레이어 슬라이스 테스트)

해당 테스트 전략 덕분에 사전에 많은 오류를 발견하여 해결할 수 있었고, 적절한 범위에서 테스트를 진행할 수 있었습니다.

테스트 코드 작성에서 가장 어려웠고 아직도 적절한 방법이었는지 의문이 드는 부분은, 외부 API나 인프라에 의존하고 있는 부분을 테스트하는 방법이었는데요. 실제와 조금이라도 더 유사하게 동작할 수 있도록 다양한 자료를 찾아보았지만 결국 Mock 대역을 사용하는 방식을 사용했습니다.

example 1)
class ReviewQueryServiceTest extends IntegrationTest {

    //S3에 이미지 업로드
    @Autowired
    private ImageManager imageManager;

        ...

    @BeforeEach
    void setUpMockImage() {
        image = new MockMultipartFile("files", "image-file.jpeg", "image/jpeg", "<<jpeg data>>".getBytes());

        when(imageManager.upload(List.of(image))).thenReturn(Map.of("image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg",
                "https://bucketname.s3.ap-northeast-2.amazonaws.com/image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg"));
    }
    ...

example.2)
@Component
public class MemberMockServer {

    // 회원 API 통신
    @Mock
    protected final MemberClient memberClient;

    private MultipartFile image;

    ...

    public void findMemberNumbersByCI(String memberNumber)
    {
        when(memberClient.findMemberNumbersByCI(memberNumber)).thenReturn(
                List.of(
                        new MemberNumberQuery(BETA_MEMBER_NUMBER),
                        new MemberNumberQuery(FAKE_MEMBER_NUMBER)
                )
        );
    }

    ...
}

아무래도 Mock은 제가 기대하는 값을 직접 세팅을 해주다 보니, ‘실제 프로덕션 환경에서 잘 동작하지 않으면 어쩌지..?‘라는 불안감이 있었고, 실제로 애플리케이션을 구동했을 때도 외부와 관련되어 있는 부분에서 문제가 많이 발생했습니다.

이 부분은 앞으로도 계속 고민해 나아가며, 최대한 프로덕션 환경과 가깝게 테스트하는 방법을 학습할 필요가 있다고 생각합니다.

다시 생각해 보는 테스트 코드의 목적

처음에 작성했던 리뷰 조회 테스트인데요. 리뷰는 주문이 존재해야 작성할 수 있습니다. 따라서 사용자 등록과 주문 생성 등은 리뷰 작성에 초점을 맞췄을 때는 부수적인 대상이라고 생각했고, 테스트 가독성을 위해 초기 세팅 부분들은 모두 @BeforEach로 넣어 중복을 줄이고자 했습니다.

class ReviewQueryServiceTest extends IntegrationTest {

    ...

    //s3 이미지 업로더
    @Autowired
    private ImageManager imageManager;

        ...

    private Member meiBaedari;

    private Shop bunsik;

    private Order bunsikOrder;
    private Order hotdogOrder;
    private Order cokeOrder;

    private MultipartFile image;

    @BeforeEach
    void setUp() {
        meiBaedari = memberRepository.save(new Member(BETA_MEMBER_NUMBER));

        bunsik = shopRepository.save(new Shop("357910", "맛있는 떡볶이"));

        bunsikOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(bunsikOrder, "떡볶이"));

        hotdogOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(hotdogOrder, "핫도그"));

        cokeOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(cokeOrder, "콜라"));

        reviewRegisterService = new ReviewRegisterService(reviewRepository, orderRepository, orderMenuRepository, imageManager, memberMockServer.getMemberClient());
    }

    @BeforeEach
    void setUpMockImage() {
        image = new MockMultipartFile("files", "image-file.jpeg", "image/jpeg", "<<jpeg data>>".getBytes());

        when(imageManager.upload(List.of(image))).thenReturn(Map.of("image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg",
                "https://bucketname.s3.ap-northeast-2.amazonaws.com/image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg"));
                ...
    }

    @DisplayName("리뷰를 조회한다.")
    @Test
    void findReview() {
        //given
        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.DIFFERENT_ADDRESS, "배달이 늦었어요."), bunsikOrder, List.of(new ReviewMenu("떡볶이", MenuReviewType.RECOMMEND, "맛있어요")), image, bunsik.getId(), "맛있어요 ~", 5));
        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.LIKE, ""), hotdogOrder, List.of(new ReviewMenu("핫도그", MenuReviewType.RECOMMEND, "굿!")), null, bunsik.getId(), "핫도그 너무 맛있어요. 추천해요!", 5));
        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.LIKE, ""), cokeOrder, List.of(new ReviewMenu("콜라", MenuReviewType.RECOMMEND, "")), null, bunsik.getId(), "맛있어요.", 4));

        ...
    }
}

Review) ... 재서님은 given 을 BeforeEach 로 테스트 반복을 피해주신 것처럼 보여요!! 그런데 새로운 테스트를 추가할 때마다 given 이 달라질 수 있고 그렇게 되면 beforeEach 에 값이 바뀌어야 되는데 기존 테스트까지 영향이 갈 수 있을 것 같다고 생각했어요! 또한 각 테스트(유즈케이스)마다 필요한 최소의 given 값이 있을 텐데 공통으로 세팅을 해주게 되면 해당 메서드 파악이 힘들 것 같아 보이긴 해요! (테스트 목적은 테스트도 있지만 해당 로직 파악도 저는 있다고 생각해요!) 관련해서 재서님은 어떻게 생각하시는지 궁금합니다~

해당 리뷰를 받고 모든 중복을 무조건 없애려 했던 생각을 되돌아볼 수 있었고, 테스트 코드의 목적에 대해 다시 생각해 볼 수 있었습니다.

저 또한 코드 리뷰를 할 때 테스트를 통해 로직의 흐름을 파악할 때가 많았고, 그럴 때마다 @BeforEach로 초기화하는 부분을 보기 위해 위로 올라가야 하는 것에 불편함을 느끼고 있었습니다. 또한 초깃값 세팅을 변경하거나 잘못 설정하면 해당 클래스의 모든 테스트에 문제가 생겨, @BeforEach로 초기화된 값을 사용하는 테스트가 늘어날수록 부담이 되었습니다.

class ReviewQueryServiceTest extends IntegrationTest {

    ...

    @BeforeEach
    void setUp() {
        reviewRegisterService = new ReviewRegisterService(reviewRepository, orderRepository, orderMenuRepository, imageManager, memberMockServer.getMemberClient());
    }

    @BeforeEach
    void setUpMockImage() {
        image = new MockMultipartFile("files", "image-file.jpeg", "image/jpeg", "<<jpeg data>>".getBytes());

        when(imageManager.upload(List.of(image))).thenReturn(Map.of("image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg",
                "https://bucketname.s3.ap-northeast-2.amazonaws.com/image/9cbc1ee6-bfe1-40c6-840a-2fa2f804a9df.jpg"));
    }

    @DisplayName("리뷰를 조회한다.")
    @Test
    void findReview() {
        //given
        Member meiBaedari = memberRepository.save(new Member(BETA_MEMBER_NUMBER));

        Shop bunsik = shopRepository.save(new Shop("357910", "맛있는 떡볶이"));

        Order bunsikOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(bunsikOrder, "떡볶이"));

        Order hotdogOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(hotdogOrder, "핫도그"));

        Order cokeOrder = orderRepository.save(new Order(meiBaedari.getMemberNumber(), bunsik.getId(), OrderStatus.COMPLETED_DELIVERY));
        orderMenuRepository.save(new OrderMenu(cokeOrder, "콜라"));

        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.DIFFERENT_ADDRESS, "배달이 늦었어요."), bunsikOrder, List.of(new ReviewMenu("떡볶이", MenuReviewType.RECOMMEND, "맛있어요")), image, bunsik.getId(), "맛있어요 ~", 5));
        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.LIKE, ""), hotdogOrder, List.of(new ReviewMenu("핫도그", MenuReviewType.RECOMMEND, "굿")), null, bunsik.getId(), "핫도그 너무 맛있어요. 추천해요!", 5));
        reviewRepository.save(new Review(meiBaedari.getMemberNumber(), new ReviewDelivery(DeliveryReviewType.LIKE, ""), cokeOrder, List.of(new ReviewMenu("콜라", MenuReviewType.RECOMMEND, "")), null, bunsik.getId(), "맛있어요.", 4));

        ...
    }
}

따라서 각각의 테스트마다 given 값을 설정하는 방식으로 개선하였는데요. 예전부터 고민해 왔던 주제인데, 해당 방법을 적용하니 오히려 가독성을 해치지 않고 로직의 의도를 명확하게 이해하는데 많은 도움이 된다는 것을 깨달을 수 있었습니다.


파일럿을 마치며

처음 입사했을 때는 팀 프로젝트의 도메인과 코드 모두 이해하기 어려웠는데, 지금은 하나씩 찾아가다 보면 이해할 수 있을 정도로(아직 많이 부족하지만..) 파일럿이 많은 도움이 되었습니다.

또한 사내의 여러 플랫폼들도 사용해 볼 수 있었는데요. AWS 놀이터를 사용하며 AWS에서 여러 테스트를 시도해 볼 수 있어서 정말 좋았습니다.

파일럿 프로젝트를 구성해 주시고 팀에 적응할 수 있도록 배려해 주신 모든 팀원분들께 정말 감사드립니다. 🥺

파일럿을 진행하며 느낀 점과 깨달은 점이 많은데요.

  • 일정 관리는 정말x100 중요하다.

사실 처음 구성해 주신 파일럿 프로젝트는 글에 언급된 요구사항보다 기능이 많았습니다. 진행하는 도중 몇 개의 기능을 제거하게 되었는데요. 프로젝트를 진행하며 공유드렸던 계획대로 진행이 되지 않아 일정이 밀리게 되었습니다. 처음 접하게 된 기술과 방법들이 꽤 있었는데 이를 학습하고 테스트하는 일정을 잘 고려하지 못했었습니다. 더불어 이슈를 겪고 있는 부분이나 현재 진행하고 있는 부분에 대해서 지속적으로 공유드리며 중간 조정을 했어야 했는데, 이 부분을 잘 못한 것 같아 스스로 아쉬움이 남습니다. 이번 일을 통해 일정을 관리하는 것이 정말 중요하다는 것을 깨달을 수 있었고, 지속적으로 일정을 확인하며 팀에 공유해야 하는 것의 중요성을 깨달을 수 있었습니다.

  • 시간에 쫓기며 개발하다 막바지에 급하게 배포를 하는 과정에서 화면단과 인프라 에러를 만나게 되었습니다. 개발을 진행하면서 조금 더 신경 쓰고 꼼꼼했더라면 막을 수 있었던 에러였다고 생각했습니다. 앞의 이야기와 연결되는 내용인 것 같지만, 일정을 잘 관리하면서 더x100 꼼꼼하게 개발해야겠다고 다짐했습니다.

파일럿을 진행하면서 라이브 리뷰, 코드 리뷰를 요청드릴 때마다 많은 질문과 피드백을 주셨는데요. 제가 생각하지 못했던 부분에 대해 많이 알려주셔서, 다양한 관점에서 고민할 수 있었던 정말 소중한x100 시간이었습니다. 기획자분들께서 배민 리뷰 프로젝트의 전반적인 부분에 대한 교육을 해주셔서 도메인에 대한 이해를 높일 수 있었습니다.

파일럿 기간 동안 어려움도 많았고, 미숙한 부분 또한 정말 많았는데 팀원분들이 도와주셔서 잘 마칠 수 있었습니다. 우아한테크코스 1년간의 과정도 정말 많은 도움이 되었습니다. 감사한 분들이 너무 많네요!! 🥺

긴 글 읽어주셔서 감사드리며, 팀에 기여할 수 있는 구성원으로 열심히 성장하겠습니다! 🙂