| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 자바문법
- Array
- 문자열
- 자바스크립트
- Eclipse
- 단위테스트
- 배열
- 정규식
- SpringBoot
- 스프링부트
- junit
- 인텔리제이
- Visual Studio Code
- vscode
- javascript
- js
- HashMap
- string
- input
- 자바
- html
- Java
- math
- java테스트
- IntelliJ
- list
- ArrayList
- junit5
- CSS
- 테스트자동화
- Today
- Total
어제 오늘 내일
[Spring Boot] 컨트롤러가 너무 뚱뚱해요! DTO 변환 위치와 Service 계층의 역할 분리 본문
개발을 하다 보면 이런 고민에 빠집니다.
"DB 테이블이랑 똑같은 클래스(Entity)가 있는데, 굳이 또 DTO(Data Transfer Object)를 만들어야 하나요?"
귀찮아서 그냥 Entity를 반환해 버리면 당장은 편하지만, 나중에 치명적인 보안 문제와 무한 루프(Circular Reference)에 빠지게 됩니다. 오늘은 왜 DTO를 써야 하는지, 그리고 변환은 어디서 하는 게 정석인지 명쾌하게 정리해 드립니다.
1. Entity를 절대 밖으로 꺼내지 마세요!
@Entity가 붙은 클래스는 데이터베이스 그 자체입니다. 이걸 컨트롤러에서 그대로 반환(return entity)하면 안 되는 3가지 이유가 있습니다.
- API 스펙이 DB에 종속됨: DB 컬럼명을 바꾸면 API 스펙도 강제로 바뀝니다. (프론트엔드 개발자가 화냅니다.)
- 보안 문제: 사용자 비번이나 주민번호 같은 민감한 정보까지 다 노출됩니다.
- 무한 루프: 양방향 연관관계가 걸려있다면 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);
}
}
이유:
- 트랜잭션 범위: Entity는 트랜잭션 안(
@Transactional)에서만 살아 숨 쉽니다. 컨트롤러로 나가면 지연 로딩(Lazy Loading) 이슈가 발생할 수 있습니다. - 재사용성: 나중에 이 로직을 웹이 아니라 앱이나 다른 곳에서 호출할 때도 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) 한 줄이면 변환 끝입니다!
마치며
오늘의 결론입니다.
- Entity는 절대로 API 응답으로 직접 내보내지 않는다. (DTO 사용 필수)
- Controller는 요청을 받고 응답을 주는 역할만 한다. (비즈니스 로직 금지)
- DTO 변환은 트랜잭션이 있는 Service 계층에서 하는 것이 안전하다.
- 변환 코드가 귀찮다면 MapStruct 같은 도구를 활용하자.
이 원칙만 지켜도 여러분의 코드는 "스파게티"가 아니라 잘 정리된 "서랍장"처럼 깔끔해질 것입니다.
다음 포스팅에서는 "외부 API가 10초 동안 응답이 없으면 내 서버도 죽나요?" 비동기 처리(@Async)와 이벤트(Event) 기반 설계에 대해 알아보겠습니다. (MSA로 가는 첫걸음이죠!)
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊
