서비스단 깔끔하게 하기 (10.02)
2025. 10. 3. 17:27

오늘은 Service 계층의 코드를 더 깔끔하게 만드는 간단하지만 효과적인 패턴을 배웠다. DTO에서 Entity로 변환하는 로직을 어디에 두어야 할까?

문제 상황: Service가 너무 복잡해진다

Before: Service에 Builder 로직이 그대로 노출

@Service
@RequiredArgsConstructor
public class ReviewService {
    
    @Transactional
    public ReviewResponseDto createReview(Long userId, UUID orderId, ReviewRequestDto request) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
        
        Store store = storeRepository.findById(request.storeId())
            .orElseThrow(() -> new EntityNotFoundException("가게를 찾을 수 없습니다"));
        
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
        
        // 😵 Builder 로직이 Service에 그대로 노출됨
        Review review = Review.builder()
            .user(user)
            .store(store)
            .order(order)
            .rating(request.rating())
            .imageUrl(request.photos())
            .content(request.content())
            .build();
        
        Review savedReview = reviewRepository.save(review);
        return ReviewResponseDto.from(savedReview);
    }
}

무엇이 문제일까?

  1. Service가 Entity의 필드를 너무 많이 알고 있다
    • rating, imageUrl, content 등 세부 필드를 직접 매핑
    • Entity 필드가 변경되면 Service도 수정해야 함
  2. 가독성이 떨어진다
    • Builder 코드가 6-7줄을 차지
    • 핵심 비즈니스 로직(리뷰 생성)이 매핑 로직에 묻힘
  3. 재사용이 어렵다
    • 다른 곳에서도 같은 매핑이 필요하면 코드 중복 발생

해결책: DTO에 of()메서드 추가

DTO에 변환 로직 캡슐화

public record ReviewRequestDto(
    Long storeId,
    Integer rating,
    String content,
    String photos
) {
    // DTO가 Entity 변환 책임을 가짐
    // of() 메서드: 정적 팩토리 메서드 패턴
    public Review of(User user, Store store, Order order) {
        return Review.builder()
            .user(user)
            .store(store)
            .order(order)
            .rating(this.rating)
            .imageUrl(this.photos)
            .content(this.content)
            .build();
    }
}

After: 깔끔해진 Service

@Service
@RequiredArgsConstructor
public class ReviewService {
    
    @Transactional
    public ReviewResponseDto createReview(Long userId, UUID orderId, ReviewRequestDto request) {
        User user = findUserById(userId);
        Store store = findStoreById(request.storeId());
        Order order = findOrderById(orderId);
        
        // 😊 한 줄로 깔끔하게!
        Review review = request.of(user, store, order);
        
        Review savedReview = reviewRepository.save(review);
        return ReviewResponseDto.from(savedReview);
    }
    
    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
    }
    
    private Store findStoreById(Long storeId) {
        return storeRepository.findById(storeId)
            .orElseThrow(() -> new EntityNotFoundException("가게를 찾을 수 없습니다"));
    }
    
    private Order findOrderById(UUID orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
    }
}

장점 비교

1. 가독성 향상

Before:

// 무슨 일이 일어나는지 파악하려면 6줄을 읽어야 함
Review review = Review.builder()
    .user(user)
    .store(store)
    .order(order)
    .rating(request.rating())
    .imageUrl(request.photos())
    .content(request.content())
    .build();

After:

// 한눈에 의도 파악 가능!
Review review = request.of(user, store, order);

2. 책임 분리

계층 Before After

Service 비즈니스 로직 + 매핑 로직 비즈니스 로직만
DTO 데이터 전달만 데이터 전달 + Entity 변환

3. 유지보수성

Entity에 필드가 추가되거나 변경되면?

Before: Service의 모든 Builder 코드를 찾아서 수정해야 함 😱

After: DTO의 of() 메서드만 수정하면 끝! 😊


비슷한 패턴: from() 메서드

Entity를 DTO로 변환할 때도 비슷한 패턴을 사용할 수 있다.

Response DTO에 from() 메서드

public record ReviewResponseDto(
    Long reviewId,
    Long storeId,
    String content,
    Integer rating,
    String photos,
    LocalDateTime createdAt
) {
    // Entity -> DTO 변환
    public static ReviewResponseDto from(Review review) {
        return new ReviewResponseDto(
            review.getId(),
            review.getStore().getId(),
            review.getContent(),
            review.getRating(),
            review.getImageUrl(),
            review.getCreatedAt()
        );
    }
}

Service에서 사용

@Transactional(readOnly = true)
public ReviewResponseDto getReview(Long reviewId) {
    Review review = reviewRepository.findById(reviewId)
        .orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다"));
    
    // 깔끔한 변환!
    return ReviewResponseDto.from(review);
}

실전 예시: 전체 흐름

1. Request DTO (요청 데이터 → Entity)

public record ReviewRequestDto(
    Long storeId,
    Integer rating,
    String content,
    String photos
) {
    public Review of(User user, Store store, Order order) {
        return Review.builder()
            .user(user)
            .store(store)
            .order(order)
            .rating(this.rating)
            .imageUrl(this.photos)
            .content(this.content)
            .build();
    }
}

2. Response DTO (Entity → 응답 데이터)

public record ReviewResponseDto(
    Long reviewId,
    Long storeId,
    String storeName,
    String content,
    Integer rating,
    String photos,
    LocalDateTime createdAt
) {
    public static ReviewResponseDto from(Review review) {
        return new ReviewResponseDto(
            review.getId(),
            review.getStore().getId(),
            review.getStore().getName(),
            review.getContent(),
            review.getRating(),
            review.getImageUrl(),
            review.getCreatedAt()
        );
    }
}

3. Service (비즈니스 로직에만 집중)

@Service
@RequiredArgsConstructor
public class ReviewService {
    
    private final ReviewRepository reviewRepository;
    private final UserRepository userRepository;
    private final StoreRepository storeRepository;
    private final OrderRepository orderRepository;
    
    @Transactional
    public ReviewResponseDto createReview(Long userId, UUID orderId, ReviewRequestDto request) {
        // 1. 필요한 엔티티 조회
        User user = findUserById(userId);
        Store store = findStoreById(request.storeId());
        Order order = findOrderById(orderId);
        
        // 2. DTO를 Entity로 변환
        Review review = request.of(user, store, order);
        
        // 3. 저장
        Review savedReview = reviewRepository.save(review);
        
        // 4. Entity를 Response DTO로 변환
        return ReviewResponseDto.from(savedReview);
    }
    
    @Transactional(readOnly = true)
    public ReviewResponseDto getReview(Long reviewId) {
        Review review = reviewRepository.findById(reviewId)
            .orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다"));
        
        return ReviewResponseDto.from(review);
    }
    
    // 조회 로직들을 private 메서드로 분리
    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
    }
    
    private Store findStoreById(Long storeId) {
        return storeRepository.findById(storeId)
            .orElseThrow(() -> new EntityNotFoundException("가게를 찾을 수 없습니다"));
    }
    
    private Order findOrderById(UUID orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
    }
}

패턴 정리

변환 방향 메서드 이름 위치 사용 예시

DTO → Entity of() Request DTO request.of(user, store)
Entity → DTO from() Response DTO ResponseDto.from(entity)

네이밍 컨벤션

실무에서 가장 많이 사용하는 패턴:

  • of() - DTO에서 Entity로 변환할 때 (가장 일반적!)
  • from() - Entity에서 DTO로 변환할 때 (static 메서드)
  • toEntity() - of()의 대안 (더 명시적이지만 덜 사용됨)

왜 of()를 선호할까?

  1. Java 컨벤션: LocalDate.of(), List.of() 등 Java 표준 라이브러리에서 사용
  2. 간결함: toEntity()보다 짧고 간결
  3. 정적 팩토리 메서드 패턴: 객체 생성의 표준 패턴
// Java 표준 라이브러리의 of() 예시
LocalDate date = LocalDate.of(2025, 10, 1);
List<String> list = List.of("a", "b", "c");

// 우리 코드에서도 동일한 패턴
Review review = request.of(user, store, order);

메서드 이름 비교

// 1. of() - 가장 일반적 ✅
Review review = request.of(user, store, order);

// 2. toEntity() - 더 명시적이지만 장황함
Review review = request.toEntity(user, store, order);

// 3. from() - 보통 static 메서드로 사용
ReviewResponseDto dto = ReviewResponseDto.from(review);

주의사항

1. 복잡한 비즈니스 로직은 Service에

// ❌ DTO에 비즈니스 로직 넣지 말기
public Review of(User user, Store store, Order order) {
    // 리뷰 작성 가능 여부 검증 (비즈니스 로직)
    if (!order.isDelivered()) {
        throw new IllegalStateException("배달 완료된 주문만 리뷰를 작성할 수 있습니다");
    }
    return Review.builder()...
}

// ✅ Service에서 비즈니스 로직 처리
@Transactional
public ReviewResponseDto createReview(...) {
    Order order = findOrderById(orderId);
    
    // Service에서 검증
    validateOrderForReview(order);
    
    Review review = request.of(user, store, order);
    ...
}

2. of()는 순수한 변환만

DTO의 of()는 데이터 변환만 담당해야 한다:

  • ✅ 필드 매핑
  • ✅ 기본값 설정
  • ❌ 유효성 검증 (Controller나 Service에서)
  • ❌ 외부 API 호출
  • ❌ 데이터베이스 조회

오늘의 깨달음

간단한 변화지만 큰 효과를 가져온다:

  1. Service 코드가 깔끔해진다 - 비즈니스 로직에만 집중
  2. DTO가 변환 책임을 가진다 - 각 계층이 자기 역할에 집중
  3. 유지보수가 쉬워진다 - 변경 지점이 명확함

작은 리팩토링이지만, 코드의 가독성과 유지보수성을 크게 높일 수 있다! 🚀