스프링 초급 (6) - DTO: 엔티티를 그대로 내보내지 않는 이유
엔티티를 그대로 응답했을 때 터지는 무한 참조를 재현해 보고 요청·응답 DTO를 분리하는 이유를 다룹니다.
스프링 초급 시리즈의 6편입니다. 전체 목차는 0편에 있습니다.
지금 컨트롤러는 엔티티를 그대로 돌려준다
5편까지 만든 컨트롤러입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/{id}")
public Member find(@PathVariable Long id) {
return memberService.find(id); // 엔티티를 그대로 반환
}
}
Member 엔티티를 그대로 반환하고 있습니다. 호출해 보면 잘 됩니다.
1
curl localhost:8080/members/1
1
{"id":1,"name":"민아","email":"abcd1234@gmail.com"}
잘 되니까 문제를 느끼기 어렵습니다. 그래서 이번 편은 먼저 터뜨려 보는 것부터 시작합니다.
주문 기능을 추가하면
회원이 주문을 하는 기능을 붙입니다. 주문 엔티티를 만듭니다.
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
31
32
33
package com.example.demo.order;
import jakarta.persistence.*;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
private String itemName;
private int price;
protected Order() {
}
public Order(Member member, String itemName, int price) {
this.member = member;
this.itemName = itemName;
this.price = price;
}
public Long getId() { return id; }
public Member getMember() { return member; }
public String getItemName() { return itemName; }
public int getPrice() { return price; }
}
클래스 이름이
Order인데@Table(name = "orders")를 붙인 이유가 있습니다.ORDER는 SQL 예약어(ORDER BY)라서 테이블 이름으로 그대로 쓰면 문법 오류가 납니다. 클래스 이름을 그대로 쓰기 어려운 몇 안 되는 경우입니다.
@ManyToOne은 “주문 여러 개가 회원 하나에 붙는다”는 뜻입니다. 이렇게 하면 주문에서 회원을 꺼낼 수 있습니다.
1
order.getMember().getName();
그런데 반대 방향도 필요해집니다. 회원의 주문 목록을 보고 싶습니다. Member에 이걸 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
// 생성자, getter 생략
public List<Order> getOrders() { return orders; }
}
이제 회원에서 주문으로, 주문에서 회원으로 양쪽 다 갈 수 있습니다. 이걸 양방향 연관관계라고 부릅니다. 자바 코드 안에서는 편합니다.
1
2
member.getOrders().get(0).getItemName(); // 회원 → 주문
order.getMember().getName(); // 주문 → 회원
확인 1: 무한 참조 터뜨려보기
회원 한 명에게 주문 하나를 만들어 두고, 아까 그 API를 그대로 호출합니다. 코드는 한 줄도 안 바꿨습니다.
1
curl localhost:8080/members/1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet] :
Servlet.service() for servlet [dispatcherServlet] in context with path []
threw exception [Request processing failed] with root cause
org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: Document nesting depth (1001) exceeds the maximum allowed
(1000, from `StreamWriteConstraints.getMaxNestingDepth()`)
(through reference chain:
com.example.demo.member.Member["orders"]
->org.hibernate.collection.spi.PersistentBag[0]
->com.example.demo.order.Order["member"]
->com.example.demo.member.Member["orders"]
->org.hibernate.collection.spi.PersistentBag[0]
->com.example.demo.order.Order["member"]
->...)
reference chain을 천천히 읽으면 원인이 그대로 보입니다.
1
Member의 orders → 그 안의 Order → 그 Order의 member → 다시 orders → ...
끝나지 않습니다. 스프링은 객체를 JSON으로 바꿀 때 Jackson이라는 라이브러리를 씁니다. Jackson은 객체의 getter를 하나씩 불러가며 값을 채우는데, Member의 getOrders()를 부르면 Order가 나오고, Order의 getMember()를 부르면 다시 Member가 나옵니다. 서로가 서로를 들고 있으니 빠져나갈 방법이 없습니다.
중첩 깊이가 1000을 넘는 순간 Jackson이 멈추고 예외를 던집니다. 클라이언트에는 잘린 JSON이나 500 응답이 갑니다.
여기서 짚을 게 있습니다. 자바 코드 안에서는 아무 문제 없던 구조입니다. member.getOrders()도 order.getMember()도 잘 동작했습니다. 문제는 이 객체를 JSON으로 바꿔 밖으로 내보내는 순간에 생겼습니다.
시작 로그에
spring.jpa.open-in-view is enabled by default경고가 같이 보일 겁니다. JSON을 만드는 시점까지 DB 연결이 열려 있다는 뜻인데, 지금은 몰라도 됩니다. JPA 시리즈에서 다룹니다.
@JsonIgnore로 막으면 끝인가
검색하면 바로 나오는 해법이 있습니다. 한쪽 방향을 JSON에서 빼는 것입니다.
1
2
3
4
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
@JsonIgnore // 이 필드는 JSON으로 만들지 마
private Member member;
@JsonIgnore의 import는com.fasterxml.jackson.annotation.JsonIgnore입니다. Spring Boot 4는 Jackson 3을 쓰면서 대부분의 패키지가com.fasterxml.jackson에서tools.jackson으로 바뀌었는데, 애노테이션만 예외로com.fasterxml.jackson.annotation그대로입니다.@JsonProperty,@JsonIgnore같은 것들은 import를 안 고쳐도 됩니다. IDE 자동완성에tools.jackson.annotation이 안 뜬다고 당황하지 않아도 됩니다.
다시 호출하면 정상입니다.
1
2
3
4
5
6
7
8
{
"id": 1,
"name": "민아",
"email": "abcd1234@gmail.com",
"orders": [
{ "id": 1, "itemName": "키보드", "price": 50000 }
]
}
에러는 사라졌습니다. 그런데 이건 터진 곳을 막은 것이지 문제를 푼 게 아닙니다. 세 가지가 남습니다.
1. 응답 형태가 엔티티 구조에 묶인다
주문 목록 API에서 “주문한 사람 이름”을 같이 보내달라는 요구가 들어왔다고 해봅시다. Order에서 member를 꺼내야 하는데, 방금 @JsonIgnore로 막아놨습니다.
풀면 무한 참조가 다시 터지고, 두면 이름을 못 보냅니다. 응답에 무엇을 담을지가 엔티티끼리의 관계에 발이 묶였습니다.
2. 필요 없는 필드가 새어나간다
나중에 로그인 기능이 생겨 Member에 필드를 추가합니다.
1
2
3
4
5
6
@Entity
public class Member {
// ...
private String password; // 필드 하나 추가
private String phoneNumber;
}
컨트롤러는 안 건드렸는데 응답이 이렇게 나갑니다.
1
2
3
4
5
6
7
8
{
"id": 1,
"name": "민아",
"email": "abcd1234@gmail.com",
"password": "$2a$10$N9qo8uLOickgx2ZMRZo...",
"phoneNumber": "010-1234-5678",
"orders": [ ... ]
}
엔티티에 필드를 추가한 것만으로 API 응답에 자동으로 포함됐습니다. 아무도 “이걸 내보내자”고 결정하지 않았는데 나갔습니다.
3. 엔티티를 고치면 API가 조용히 바뀐다
name 필드가 헷갈려서 memberName으로 리팩터링했다고 해봅시다. IDE가 자동으로 다 바꿔주고, 컴파일도 통과하고, 테스트도 통과합니다.
그런데 응답 JSON의 키가 "name"에서 "memberName"으로 바뀝니다. 프론트엔드가 깨집니다. 컴파일 에러도 안 나고 아무도 모릅니다.
세 번째가 가장 위험합니다. 이유는 이렇습니다.
- 엔티티는 DB 구조를 따라 바뀝니다. 컬럼을 추가하거나 이름을 정리하는 이유로 바뀝니다
- API 응답은 클라이언트 요구를 따라 바뀝니다. 화면에 뭘 보여주느냐로 바뀝니다
서로 다른 이유로 바뀌는 두 가지가 하나로 묶여 있으면, 한쪽 변경이 다른 쪽을 강제합니다. 이게 엔티티를 그대로 내보내면 안 되는 진짜 이유입니다.
DTO로 분리하기
DTO(Data Transfer Object)는 계층 사이를 오갈 때 데이터만 담는 객체입니다. 이름은 거창한데 하는 일은 단순합니다. 들어올 때 받을 그릇과 나갈 때 담을 그릇을 따로 만드는 것입니다.
자바 16부터 record를 쓸 수 있습니다. 값만 담는 클래스를 짧게 쓰는 문법입니다.
1
public record MemberCreateRequest(String name, String email) {}
이 한 줄이 생성자, getter, equals, hashCode, toString을 다 만들어 줍니다. 다만 getter 이름이 getName()이 아니라 name() 입니다. 값이 한 번 정해지면 바뀌지 않는다는 점도 DTO에 잘 맞습니다.
응답 DTO도 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record MemberResponse(
Long id,
String name,
String email,
int orderCount
) {
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getId(),
member.getName(),
member.getEmail(),
member.getOrders().size()
);
}
}
from()은 엔티티를 받아 DTO를 만들어 주는 메서드입니다. new MemberResponse(...)를 컨트롤러마다 쓰면 필드가 늘어날 때마다 전부 고쳐야 하니, 변환 코드를 DTO 안에 한 번만 둡니다. 이런 걸 정적 팩토리 메서드라고 부릅니다.
orderCount를 보세요. Member 엔티티에 없는 필드입니다. 주문 개수만 필요한 화면이라면 주문 목록 전체를 내보낼 이유가 없습니다. 응답 모양을 화면 요구에 맞춰 정할 수 있게 됐습니다.
주문 응답도 만들어 봅니다. 아까 @JsonIgnore 때문에 못 넣던 회원 이름을 이제 넣을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record OrderResponse(
Long id,
String itemName,
int price,
String memberName
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getItemName(),
order.getPrice(),
order.getMember().getName() // 필요한 것만 꺼내 담는다
);
}
}
엔티티끼리 서로를 참조하고 있어도, DTO는 String memberName 하나만 들고 있습니다. 되돌아갈 길이 없으니 무한 참조가 구조적으로 불가능합니다.
컨트롤러를 고칩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping
public MemberResponse join(@RequestBody MemberCreateRequest request) {
Member member = memberService.join(request.name(), request.email());
return MemberResponse.from(member);
}
@GetMapping("/{id}")
public MemberResponse find(@PathVariable Long id) {
Member member = memberService.find(id);
return MemberResponse.from(member);
}
}
확인 2: 적용 전후 응답 비교
같은 요청을 DTO 적용 전후로 비교합니다.
1
curl localhost:8080/members/1
적용 전 (엔티티 반환 + @JsonIgnore)
1
2
3
4
5
6
7
8
9
10
{
"id": 1,
"name": "민아",
"email": "abcd1234@gmail.com",
"password": "$2a$10$N9qo8uLOickgx2ZMRZo...",
"phoneNumber": "010-1234-5678",
"orders": [
{ "id": 1, "itemName": "키보드", "price": 50000 }
]
}
적용 후 (MemberResponse 반환)
1
2
3
4
5
6
{
"id": 1,
"name": "민아",
"email": "abcd1234@gmail.com",
"orderCount": 1
}
password와 phoneNumber가 사라졌습니다. @JsonIgnore를 붙여서가 아니라 MemberResponse에 안 적어서 안 나갑니다.
이 차이가 핵심입니다.
- 엔티티를 반환하면 빼는 걸 깜빡하면 샙니다 (기본값이 노출)
- DTO를 반환하면 적어야 나갑니다 (기본값이 비노출)
앞으로 Member에 어떤 필드를 몇 개 추가하든 이 API 응답은 그대로입니다. 엔티티 변경이 API로 새어나가지 않게 됐습니다.
주문 조회도 비교해 봅니다.
1
curl localhost:8080/orders/1
적용 전 — 회원 정보를 넣으면 무한 참조, 빼면 이름을 못 보냄
1
{ "id": 1, "itemName": "키보드", "price": 50000 }
적용 후
1
{ "id": 1, "itemName": "키보드", "price": 50000, "memberName": "민아" }
변환은 어디서 하나
MemberResponse.from(member)를 부르는 위치는 컨트롤러입니다. 서비스가 아닙니다.
이유는 3편에서 나눈 계층 책임과 이어집니다.
- 서비스는 비즈니스 규칙을 담당합니다. “이미 가입된 이메일인가”를 판단합니다. HTTP를 몰라도 됩니다
- 컨트롤러는 HTTP 입출력을 담당합니다. 요청 JSON을 객체로 받고, 결과를 응답 형태로 바꿉니다
응답 JSON의 모양은 HTTP 세계의 관심사입니다. 서비스가 MemberResponse를 반환하기 시작하면, 나중에 같은 로직을 배치나 스케줄러에서 쓸 때 웹 응답용 객체를 억지로 받게 됩니다.
정리하면 이 방향입니다.
1
2
3
4
5
요청 JSON → MemberCreateRequest → (컨트롤러가 값을 풀어서 전달)
→ MemberService.join(name, email)
→ Member 엔티티
→ (컨트롤러가 MemberResponse.from)
→ 응답 JSON
엔티티는 서비스와 리포지토리 안쪽에만 머무르고, 밖으로 나가는 건 항상 DTO입니다.
변환 로직이 복잡해지거나 여러 엔티티를 합쳐야 하면 별도 Mapper 클래스로 옮기면 됩니다. 처음에는 DTO의
from()하나로 시작하는 걸 권합니다.
정리
- 엔티티를 그대로 반환하면 처음엔 잘 되지만, 양방향 연관관계가 생기는 순간 Jackson이 서로를 무한히 따라가다 터진다
@JsonIgnore는 에러만 막는다. 응답이 엔티티 구조에 묶이고, 필드가 새고, 엔티티 수정이 API를 조용히 바꾸는 문제는 남는다- 엔티티는 DB 구조를 따라, API 응답은 클라이언트 요구를 따라 바뀐다. 바뀌는 이유가 다르면 분리한다
- DTO는
record로 짧게 만들고, 엔티티 → DTO 변환은from()정적 팩토리 메서드에 모은다 - DTO는 적어야 나가는 구조다. 빼는 걸 깜빡해서 새지 않는다
- 변환은 컨트롤러에서 한다. 엔티티는 서비스 안쪽에 머무른다
다음 7편에서는 잘못된 요청이 들어왔을 때 왜 실패했는지를 응답에 담는 방법을 봅니다.