오늘은 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);
}
}
무엇이 문제일까?
- Service가 Entity의 필드를 너무 많이 알고 있다
- rating, imageUrl, content 등 세부 필드를 직접 매핑
- Entity 필드가 변경되면 Service도 수정해야 함
- 가독성이 떨어진다
- Builder 코드가 6-7줄을 차지
- 핵심 비즈니스 로직(리뷰 생성)이 매핑 로직에 묻힘
- 재사용이 어렵다
- 다른 곳에서도 같은 매핑이 필요하면 코드 중복 발생
해결책: 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()를 선호할까?
- Java 컨벤션: LocalDate.of(), List.of() 등 Java 표준 라이브러리에서 사용
- 간결함: toEntity()보다 짧고 간결
- 정적 팩토리 메서드 패턴: 객체 생성의 표준 패턴
// 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 호출
- ❌ 데이터베이스 조회
오늘의 깨달음
간단한 변화지만 큰 효과를 가져온다:
- Service 코드가 깔끔해진다 - 비즈니스 로직에만 집중
- DTO가 변환 책임을 가진다 - 각 계층이 자기 역할에 집중
- 유지보수가 쉬워진다 - 변경 지점이 명확함
작은 리팩토링이지만, 코드의 가독성과 유지보수성을 크게 높일 수 있다! 🚀
'👍코드 회고' 카테고리의 다른 글
| 리팩토링 vs 오버 엔지니어링 (10.16) (0) | 2025.10.16 |
|---|---|
| 장바구니 코드 속 store_id는 어디에 둘까? (10.15) (0) | 2025.10.15 |
| 스프링 개발 회고 (10.01) (0) | 2025.10.01 |
| 스프링 개발 회고 (09.30) (0) | 2025.10.01 |
| 프로젝트 설계 회고 (09.29) (0) | 2025.09.29 |