Spring Boot
강의

#11 - Service가 반드시 해야 할 일

중년개발자
중년개발자

@loxo

4일 전

7

Service 실무 가이드: Service가 반드시 해야 할 일 (Standard Responsibilities)

Spring Boot 4 Service Layer Standard Guide
"Service에만 넣어야 하는 로직과 그 이유"


0. Service의 정의: 비즈니스 규칙의 최종 심판

Service는 단순히 Repository를 호출하는 패스스루(Pass-through)가 아닙니다.
Service의 존재 이유는 **"상태를 변경하기 전, 모든 규칙을 검사하고 확정 짓는 것"**입니다.


1. 트랜잭션의 시작과 끝을 책임진다 (@Transactional)

Spring Boot에서 트랜잭션 경계는 Service 메서드입니다.
Controller나 Repository가 아닙니다.

✅ 표준 패턴: 읽기 전용과 쓰기 분리

java
@Service @RequiredArgsConstructor @Transactional(readOnly = true) // 기본은 읽기 전용 (성능 최적화) public class OrderService { // 데이터 변경이 필요한 메서드에만 @Transactional 붙임 @Transactional public Long placeOrder(OrderCommand command) { // ... } // 조회 메서드는 클래스 레벨의 readOnly = true를 따름 public OrderInfo getOrder(Long id) { // ... } } ### ⚠️ 주의: External API와 트랜잭션 `readOnly = true`라도 **트랜잭션이 시작되면 DB Connection을 점유**합니다. 외부 API 호출처(결제 조회, 알림 등)가 포함된 조회 로직이라면, 트랜잭션을 메서드 전체에 걸지 말고 **DB 조회 부분에만 한정**해야 합니다. ```java // Bad: 외부 API 응답이 늦으면 DB Connection도 같이 고갈됨 @Transactional(readOnly = true) public OrderInfo getOrderWithPayment(Long id) { Order order = orderRepository.findById(id); PaymentInfo payment = paymentApi.getPayment(order.getPayId()); // ⏰ Long Time return ... } // Good: 트랜잭션 없음 (필요한 곳만 Repository가 알아서 수행) public OrderInfo getOrderWithPayment(Long id) { Order order = orderService.getOrder(id); // 내부에서 짧게 트랜잭션 사용 PaymentInfo payment = paymentApi.getPayment(order.getPayId()); return ... }
  • Why?
    • readOnly = true: Hibernate의 Flush 모드를 MANUAL로 설정하여 성능 향상 (Dirty Checking 생략)
    • Master/Slave DB 라우팅의 기준점이 됨

2. Entity의 생명주기를 관리한다 (JPA & Repository)

Repository는 단순히 SQL을 실행하는 도구입니다.
어떤 시점에 저장하고, 어떤 시점에 수정할지는 Service가 결정합니다.

✅ 표준 패턴: 조회 -> 로직 -> 저장

java
@Transactional public void shipOrder(Long orderId) { // 1. 조회 (Entity 가져오기) Order order = orderRepository.findById(orderId) .orElseThrow(() -> new NotFoundException("주문 없음")); // 2. 로직 수행 (Entity에게 위임) order.ship(); // 3. 저장 (JPA Dirty Checking으로 자동 반영되지만, 명시적 save도 허용됨) // orderRepository.save(order); } ### 💡 Why? (Rich Domain Model vs Anemic Domain Model) * **Anemic Domain Model (빈약한 도메인 모델 - 지양):** * EntityGetter/Setter만 가짐 * 모든 로직이 Service에 있음 (`if (order.getStatus() == ...)`) * 객체지향이 아니라 **절차지향적 코드**가 됨 (유지보수 어려움) * **Rich Domain Model (풍부한 도메인 모델 - 권장):** * Entity**스스로의 상태를 변경하는 로직**을 가짐 (`order.ship()`) * Service**"무엇을 할지"**만 결정하고, **"어떻게 할지"**Entity에 위임 * **객체지향(OOP)의 핵심인 캡슐화**를 지키는 표준 패턴입니다.

3. DTO 변환의 중심 (MapStruct 활용)

데이터가 나갈 때(Response) 어떤 모양으로 나갈지 결정하는 것은 Service의 역할이 아닙니다(Controller 역할).
하지만 Service 내부 로직 처리를 위한 객체 변환이나, 외부 시스템 연동을 위한 변환은 Service 영역입니다.

📍 Mapper는 어디서 쓰는가? (사용 범위)

기술적으로는 Spring Bean이므로 어디서든 주입받아 사용 가능합니다.
하지만 아키텍처 관점에서의 권장 사용처는 다음과 같습니다.

  • Controller (O): Request DTOCommand, InfoResponse DTO 변환
  • Service (O): EntityInfo DTO 변환
  • Repository (X): Repository는 Entity 또는 Projection을 반환해야 합니다. Mapper를 Repository 안에서 쓰는 것은 안티 패턴입니다.

✅ 표준 패턴: MapStruct Mapper 사용

java
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface OrderMapper { OrderInfo toInfo(Order order); }
java
@Service public class OrderService { private final OrderMapper orderMapper; public OrderInfo getOrder(Long id) { Order order = findOrder(id); return orderMapper.toInfo(order); // Entity -> Info DTO 변환 } } ### 📍 Mapper 위치 가이드 MapStruct 인터페이스는 **변환 대상이 있는 패키지 근처**에 위치하는 것이 좋습니다. * **추천 위치:** `domain` 패키지 내부의 `mapper` 패키지 ```text com.example.project └─ order ├─ controller ├─ service ├─ domain │ ├─ Order.java │ └─ mapper │ └─ OrderMapper.java <-- 여기 └─ dto └─ OrderInfo.java
  • Why? Getter/Setter 노가다를 없애고, 컴파일 타임에 타입 안정성을 보장합니다.

4. 결과 반환 (Response)

Service는 Web Response (ResponseEntity)를 절대 반환하지 않습니다.
반환 타입은 프로젝트의 성격(OSIV 여부, 복잡도)에 따라 다음 두 가지 중 하나를 선택합니다.

✅ Option 1. Entity 반환 (Pragmatic & Command)

  • 상황: 단건 조회, 수정 후 반환, 도메인 로직이 Controller에서도 필요한 경우
  • 장점: DTO 변환 로직을 Controller(또는 Mapper)로 위임하여 Service가 순수해짐
  • 주의: OSIV(Open Session In View)가 꺼져있다면 LazyInitializationException 발생 가능
java
// 표준 패턴: Service는 Entity를 던지고, Controller가 DTO로 변환 public Order getOrder(Long id) { return orderRepository.findById(id).orElseThrow(...); }

⚠️ 주의: Controller는 절대 Entity를 반환하면 안 됩니다!

Service가 Entity를 반환하는 것과, Controller가 Entity를 API 응답으로 내보내는 것은 다릅니다.
Controller는 반드시 Response DTO로 변환해서 내보내야 합니다.

  • 이유 1 (무한 루프): 양방향 연관관계가 있는 Entity를 JSON으로 변환하면 StackOverflowError가 발생합니다.
  • 이유 2 (스펙 변경): DB 스키마가 변경되면 API 스펙도 같이 변해버려 클라이언트가 깨집니다.
  • 이유 3 (보안): 비밀번호나 내부 정보가 의도치 않게 노출될 수 있습니다.
java
// Controller (O) @GetMapping("/{id}") public OrderResponse get(@PathVariable Long id) { Order order = orderService.getOrder(id); return orderMapper.toResponse(order); // ⭕ 반드시 DTO로 변환! } // Controller (X) @GetMapping("/{id}") public Order get(@PathVariable Long id) { return orderService.getOrder(id); // ❌ 절대 금지 (Entity 직접 노출) }

✅ Option 2. Domain DTO (Info) 반환 (Strict & Query)

  • 상황: 목록 조회(N+1 방지), OSIV Off 환경, 필요한 데이터만 전송해야 할 때
  • 장점: Entity가 영속성 컨텍스트에서 분리되어 안전함, 네트워크 전송량 감소
  • 방법: JPA Projection 또는 QueryDSL 결과를 DTO로 바로 반환
java
// 최적화 패턴: 필요한 데이터만 DTO로 조회 (Entity를 거치지 않음) public OrderInfo getOrderInfo(Long id) { return orderRepository.findInfoById(id); }

Spring Boot 표준 가이드:

  • 기본적으로 **Option 1(Entity)**을 사용하여 생산성을 높이고,
  • 성능 이슈나 순환 참조 문제가 발생할 때 **Option 2(DTO/Projection)**로 최적화하는 것이 **가장 실용적인 접근(Best Practice)**입니다.

💡 OSIV(Open Session In View)란?

  • 설정 위치: application.yml
    yaml
    spring: jpa: open-in-view: true # default (false로 설정 시 꺼짐)
  • 켜져 있음 (Default): DB 커넥션을 API 응답이 끝날 때까지 붙잡고 있습니다.
    • 장점: Controller에서도 Entity의 지연 로딩(order.getItems())이 가능합니다. (편리함)
    • 단점: 커넥션을 너무 오래 잡고 있어 트래픽이 몰리면 DB가 죽을 수 있습니다.
  • 꺼져 있음 (false): 트랜잭션이 끝나면(Service 종료) DB 커넥션을 바로 반납합니다.
    • 장점: DB 커넥션 효율이 극대화되어 성능에 유리합니다.
    • 단점: Controller에서 지연 로딩을 시도하면 LazyInitializationException 에러가 발생합니다. (Service 안에서 다 조회해서 나가야 함)

5. 예외 처리 (Exception Translation)

Service는 기술 예외를 비즈니스 예외로 변환해야 합니다.
Controller는 DB가 터졌는지, 네트워크가 끊겼는지 알 필요가 없습니다.

✅ 표준 패턴: Custom Exception 던지기

java
// Bad: IllegalArgumentException (너무 포괄적임) throw new IllegalArgumentException("재고 없음"); // Good: 구체적인 의미가 담긴 예외 throw new OutOfStockException("상품 ID " + id + " 재고 부족");

6. 요약: Service가 해야 할 일 CheckList

  1. 트랜잭션 관리: @Transactional(readOnly=true)를 기본으로 하고, 쓰기 작업만 @Transactional을 붙였는가?
  2. 비즈니스 로직: if 문을 통한 판단(검증) 로직이 Service에 있는가?
  3. Entity 보호: Entity의 상태 변경을 Setter가 아닌 비즈니스 메서드 (order.cancel())로 했는가?
  4. 예외 변환: NoSuchElementException 같은 기술 예외를 OrderNotFoundException 같은 비즈니스 예외로 변환했는가?
  5. 순수성 유지: HttpServletRequest, ResponseEntity 같은 Web 객체가 없는가?

이 기준을 지키면, 여러분의 Service는 어떤 프레임워크나 인터페이스(Web, Batch, App) 가 붙어도 흔들리지 않는 단단한 코어가 됩니다.

#Service Layer#비즈니스 로직#트랜잭션 관리#`@Transactional`#Spring Boot 4

댓글 0

Ctrl + Enter를 눌러 등록할 수 있습니다
※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.