어제 오늘 내일

[Spring Boot] 컨트롤러가 너무 뚱뚱해요! DTO 변환 위치와 Service 계층의 역할 분리 본문

IT/SpringBoot

[Spring Boot] 컨트롤러가 너무 뚱뚱해요! DTO 변환 위치와 Service 계층의 역할 분리

hi.anna 2026. 3. 22. 01:47

 
개발을 하다 보면 이런 고민에 빠집니다.
"DB 테이블이랑 똑같은 클래스(Entity)가 있는데, 굳이 또 DTO(Data Transfer Object)를 만들어야 하나요?"
귀찮아서 그냥 Entity를 반환해 버리면 당장은 편하지만, 나중에 치명적인 보안 문제무한 루프(Circular Reference)에 빠지게 됩니다. 오늘은 왜 DTO를 써야 하는지, 그리고 변환은 어디서 하는 게 정석인지 명쾌하게 정리해 드립니다.


1. Entity를 절대 밖으로 꺼내지 마세요!

@Entity가 붙은 클래스는 데이터베이스 그 자체입니다. 이걸 컨트롤러에서 그대로 반환(return entity)하면 안 되는 3가지 이유가 있습니다.

  1. API 스펙이 DB에 종속됨: DB 컬럼명을 바꾸면 API 스펙도 강제로 바뀝니다. (프론트엔드 개발자가 화냅니다.)
  2. 보안 문제: 사용자 비번이나 주민번호 같은 민감한 정보까지 다 노출됩니다.
  3. 무한 루프: 양방향 연관관계가 걸려있다면 JSON 변환 과정에서 서로를 계속 참조하다가 서버가 죽습니다. (StackOverflowError)

그래서 우리는 필요한 데이터만 딱 담은 가방(DTO)을 따로 만들어서 주고받아야 합니다.


2. 역할 분담: Controller vs Service

코드를 짜기 전에 각 계층의 역할을 확실히 정해야 합니다.

  • Controller (웨이터): 손님의 주문(Request)을 받고, 주방(Service)에 전달하고, 완성된 요리(Response)를 서빙합니다. (요리하지 않음!)
  • Service (요리사): 재료(Repository)를 다듬고 지지고 볶아서 비즈니스 로직을 수행합니다. (트랜잭션 관리)
  • Repository (창고지기): DB에서 데이터를 꺼내오거나 저장합니다.

❌ 나쁜 예 (Fat Controller)

컨트롤러가 요리까지 다 하는 경우입니다.

@RestController
public class OrderController {

    @PostMapping("/orders")
    public Order createOrder(@RequestBody OrderDto dto) {
        // 컨트롤러가 엔티티 변환도 하고, 저장도 하고, 로직도 수행함 (최악!)
        Order order = new Order();
        order.setProduct(dto.getProduct());
        return orderRepository.save(order);
    }
}

✅ 좋은 예 (위임)

@RestController
public class OrderController {

    @PostMapping("/orders")
    public ResponseEntity<OrderResponseDto> createOrder(@RequestBody OrderRequestDto request) {
        // "주방장님, 주문 들어왔어요!" 하고 넘기기만 함
        return ResponseEntity.ok(orderService.create(request));
    }
}

3. 뜨거운 감자: DTO 변환은 어디서 하나요?

"Service에서 DTO를 받아서 Entity로 바꿔야 하나요? 아니면 Controller에서 바꿔서 줘야 하나요?"
이건 개발자들 사이에서도 의견이 갈리지만, 실무에서는 보통 Service 계층에서 처리하는 것을 선호합니다.

추천 전략: Service가 DTO를 받고, DTO를 리턴한다.

@Service
@Transactional
public class OrderService {

    public OrderResponseDto create(OrderRequestDto request) {
        // 1. DTO -> Entity 변환
        Order order = request.toEntity(); 

        // 2. 비즈니스 로직 수행 (저장, 계산 등)
        orderRepository.save(order);

        // 3. Entity -> DTO 변환 (트랜잭션 안에서 수행!)
        return new OrderResponseDto(order);
    }
}

이유:

  1. 트랜잭션 범위: Entity는 트랜잭션 안(@Transactional)에서만 살아 숨 쉽니다. 컨트롤러로 나가면 지연 로딩(Lazy Loading) 이슈가 발생할 수 있습니다.
  2. 재사용성: 나중에 이 로직을 웹이 아니라 앱이나 다른 곳에서 호출할 때도 DTO만 넘겨주면 되므로 깔끔합니다.

4. 귀찮은 변환 작업 줄이기 (MapStruct)

entity.setName(dto.getName())... 이거 치다가 손목 나가겠죠?
이럴 때 쓰는 라이브러리가 MapStruct입니다. 인터페이스만 정의하면 구현체를 자동으로 만들어줍니다.

@Mapper
public interface MemberMapper {
    MemberMapper INSTANCE = Mappers.getMapper(MemberMapper.class);

    @Mapping(target = "id", ignore = true) // id는 무시해라
    Member toEntity(MemberDto dto);

    MemberDto toDto(Member entity);
}

이제 MemberMapper.INSTANCE.toDto(member) 한 줄이면 변환 끝입니다!


마치며

오늘의 결론입니다.

  1. Entity는 절대로 API 응답으로 직접 내보내지 않는다. (DTO 사용 필수)
  2. Controller는 요청을 받고 응답을 주는 역할만 한다. (비즈니스 로직 금지)
  3. DTO 변환은 트랜잭션이 있는 Service 계층에서 하는 것이 안전하다.
  4. 변환 코드가 귀찮다면 MapStruct 같은 도구를 활용하자.

이 원칙만 지켜도 여러분의 코드는 "스파게티"가 아니라 잘 정리된 "서랍장"처럼 깔끔해질 것입니다.
다음 포스팅에서는 "외부 API가 10초 동안 응답이 없으면 내 서버도 죽나요?" 비동기 처리(@Async)와 이벤트(Event) 기반 설계에 대해 알아보겠습니다. (MSA로 가는 첫걸음이죠!)
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments