Entity를 API 응답으로 직접 반환하면 생기는 일
Controller에서 Entity를 그대로 반환한 적 있는가?
@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
return memberRepository.findById(id).orElseThrow();
}
"잘 되는데? DTO 만들기 귀찮은데 이대로 쓰면 안 돼?"
안 된다. 5가지 이유로 반드시 터진다.
1. 택배 상자로 이해하기
Entity = 회사 내부 서류 원본
DTO = 외부 전달용 복사본
Entity 직접 반환 = 내부 서류 원본을 고객에게 보내는 것
→ 주민번호가 적혀 있음 (민감 정보 노출)
→ 서류 양식 바꾸면 고객이 읽을 수 없음 (API 스펙 깨짐)
→ 서류에 다른 서류가 첨부돼 있고, 그 서류에 또 첨부가... (무한 재귀)
DTO 반환 = 필요한 내용만 복사해서 전달
→ 이름, 이메일만 적어서 보냄
→ 원본 양식이 바뀌어도 복사본은 그대로
→ 안전
2. 문제 1 — 민감 정보 노출
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
private String password; // ← 비밀번호!
private String socialNumber; // ← 주민번호!
private int failCount; // ← 로그인 실패 횟수
}
@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
return memberRepository.findById(id).orElseThrow();
}
// API 응답
{
"id": 1,
"name": "홍길동",
"email": "hong@example.com",
"password": "$2a$10$xyz...", // 해시값이라도 노출됨
"socialNumber": "900101-1234567", // 주민번호 그대로 노출!
"failCount": 3 // 내부 정보 노출
}
"@JsonIgnore 붙이면 되잖아?"
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
@JsonIgnore
private String password;
@JsonIgnore
private String socialNumber;
@JsonIgnore
private int failCount;
}
되긴 된다. 근데 Entity가 Jackson(JSON 직렬화)에 종속된다. 새 필드를 추가할 때마다 "이거 노출해도 되나?" 확인해야 한다. 까먹으면 민감 정보가 그대로 나간다.
3. 문제 2 — 양방향 관계 무한 재귀
이게 가장 많이 터지는 문제다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members;
}
@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
return memberRepository.findById(id).orElseThrow();
}
Jackson이 Member를 JSON으로 변환할 때:
Member → team 필드 직렬화
→ Team → members 필드 직렬화
→ Member → team 필드 직렬화
→ Team → members 필드 직렬화
→ Member → team 필드 직렬화
→ ...
→ StackOverflowError!
에러 로그:
com.fasterxml.jackson.databind.JsonMappingException:
Infinite recursion (StackOverflowError)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(...)
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(...)
...
"@JsonIgnore나 @JsonManagedReference 붙이면 되잖아?"
@Entity
public class Team {
@JsonManagedReference
@OneToMany(mappedBy = "team")
private List<Member> members;
}
@Entity
public class Member {
@JsonBackReference
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
해결은 되지만, Entity에 JSON 관련 어노테이션이 쌓인다. Entity는 DB 매핑용이지, API 응답용이 아니다.
4. 문제 3 — Entity 변경 = API 스펙 변경
// 처음 설계
@Entity
public class Member {
private String name; // API 응답: { "name": "홍길동" }
}
// 3개월 후 요구사항 변경: 이름을 성/이름으로 분리
@Entity
public class Member {
private String firstName; // name 삭제!
private String lastName; // 새 필드!
}
// 기존 API 응답 (클라이언트가 쓰고 있던 것)
{ "name": "홍길동" }
// 변경 후 API 응답 (클라이언트 깨짐!)
{ "firstName": "길동", "lastName": "홍" }
Entity를 직접 반환하면 DB 스키마 변경이 곧 API 스펙 변경이 된다.
프론트엔드, 모바일 앱, 외부 연동 — 전부 깨진다.
DTO를 쓰면 Entity가 바뀌어도 API 스펙은 유지할 수 있다.
// Entity는 바뀌었지만
@Entity
public class Member {
private String firstName;
private String lastName;
}
// DTO는 기존 스펙 유지
public class MemberResponse {
private String name; // firstName + lastName 합쳐서 반환
public MemberResponse(Member member) {
this.name = member.getLastName() + member.getFirstName();
}
}
5. 문제 4 — @JsonIgnore 떡칠
Entity에 @JsonIgnore가 3~4개 붙기 시작하면 이미 잘못된 거다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
@JsonIgnore
private String password;
@JsonIgnore
private String socialNumber;
@JsonIgnore
private int failCount;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders;
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDateTime createdAt;
}
문제점:
1. Entity가 JSON 직렬화에 종속됨
2. Entity의 역할이 불명확해짐 (DB 매핑? API 응답?)
3. API마다 숨겨야 할 필드가 다를 수 있음
→ 목록 API: name, email만
→ 상세 API: name, email, createdAt
→ 관리자 API: 전부 다
4. @JsonIgnore 하나로는 이런 케이스별 대응 불가
6. 문제 5 — LazyInitializationException
@GetMapping("/members/{id}")
public Member getMember(@PathVariable Long id) {
Member member = memberService.findById(id);
return member; // Controller에서 반환 → JSON 변환 시도
}
@Service
@Transactional(readOnly = true)
public class MemberService {
public Member findById(Long id) {
return memberRepository.findById(id).orElseThrow();
// team은 LAZY → 프록시 상태로 반환
}
// 여기서 트랜잭션 종료 → 영속성 컨텍스트 닫힘
}
Controller에서 JSON 변환 시:
Jackson이 member.getTeam()에 접근
→ LAZY 프록시 초기화 시도
→ 영속성 컨텍스트 이미 닫힘
→ LazyInitializationException!
org.hibernate.LazyInitializationException:
could not initialize proxy - no Session
"OSIV(Open Session In View) 켜면 되잖아?"
spring:
jpa:
open-in-view: true # 기본값이 true
OSIV를 켜면 Controller까지 영속성 컨텍스트가 살아있어서 LazyInitializationException은 안 터진다. 근데 그러면 Controller에서 LAZY 로딩이 발생 → 예측 불가능한 쿼리가 나감 → 성능 문제.
OSIV = true (기본값):
✅ LazyInitializationException 안 터짐
❌ Controller에서 쿼리 나감 → 성능 예측 불가
❌ DB 커넥션을 오래 잡고 있음
OSIV = false (권장):
✅ 트랜잭션 범위 명확
✅ DB 커넥션 빨리 반환
❌ LazyInitializationException 주의 필요
→ 해결: DTO로 변환해서 반환
7. 해결 — Entity를 DTO로 변환
방법 1: 수동 변환
// Response DTO
public class MemberResponse {
private Long id;
private String name;
private String email;
private String teamName;
public MemberResponse(Member member) {
this.id = member.getId();
this.name = member.getName();
this.email = member.getEmail();
this.teamName = member.getTeam().getName();
}
}
// Controller
@GetMapping("/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
Member member = memberService.findById(id);
return new MemberResponse(member);
}
// Service — 트랜잭션 안에서 DTO 변환 (LAZY 로딩 안전)
@Service
@Transactional(readOnly = true)
public class MemberService {
public MemberResponse findById(Long id) {
Member member = memberRepository.findById(id).orElseThrow();
return new MemberResponse(member); // 여기서 team.getName() 호출 → 안전
}
}
방법 2: Java Record (Java 16+)
public record MemberResponse(
Long id,
String name,
String email,
String teamName
) {
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getId(),
member.getName(),
member.getEmail(),
member.getTeam().getName()
);
}
}
// 사용
MemberResponse response = MemberResponse.from(member);
방법 3: MapStruct (자동 매핑)
@Mapper(componentModel = "spring")
public interface MemberMapper {
@Mapping(source = "team.name", target = "teamName")
MemberResponse toResponse(Member member);
}
// 사용
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberMapper memberMapper;
public MemberResponse findById(Long id) {
Member member = memberRepository.findById(id).orElseThrow();
return memberMapper.toResponse(member);
}
}
수동 변환: 간단, 완전 제어, 매핑 코드 많아짐
Record: 간결, 불변, Java 16+ 필요
MapStruct: 자동 매핑, 컴파일 타임 검증, 설정 필요
8. 요청도 DTO — Entity를 RequestBody로 쓰지 말 것
// 나쁜 예: Entity를 Request로 사용
@PostMapping("/members")
public void create(@RequestBody Member member) {
memberRepository.save(member);
// 클라이언트가 id, role, status 같은 필드도 보내면?
// → 의도치 않은 값이 저장될 수 있음
}
// 좋은 예: Request DTO 사용
public class CreateMemberRequest {
@NotBlank
private String name;
@Email
private String email;
@Size(min = 8)
private String password;
public Member toEntity() {
return Member.builder()
.name(name)
.email(email)
.password(passwordEncoder.encode(password))
.role(Role.USER) // 서버에서 설정
.status(Status.ACTIVE) // 서버에서 설정
.build();
}
}
@PostMapping("/members")
public MemberResponse create(@RequestBody @Valid CreateMemberRequest request) {
Member member = request.toEntity();
memberRepository.save(member);
return new MemberResponse(member);
}
9. 계층별 정리
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Controller │ ←→ │ Service │ ←→ │ Repository │
│ │ │ │ │ │
│ Request DTO │ │ Entity │ │ Entity │
│ Response DTO│ │ │ │ (Query DTO) │
└─────────────┘ └──────────────┘ └──────────────┘
Controller:
- Request DTO 받기 (검증 포함)
- Response DTO 반환
Service:
- Entity로 비즈니스 로직 처리
- DTO ↔ Entity 변환
Repository:
- Entity 조회/저장
- 성능 필요 시 Query DTO 직접 조회
10. "Entity에 @Setter 열면 안 되는 이유"
같은 맥락이다.
// 나쁜 예
@Entity
@Getter @Setter // Setter 전체 오픈
public class Member {
private String name;
private String email;
private Role role;
private Status status;
}
// 아무 데서나 이렇게 할 수 있다
member.setRole(Role.ADMIN); // 누가 어디서든 관리자로 변경 가능
member.setStatus(Status.DELETED); // 누가 어디서든 삭제 가능
// 좋은 예
@Entity
@Getter // Setter 없음
public class Member {
private String name;
private String email;
private Role role;
private Status status;
// 의미 있는 메서드로 상태 변경
public void changeName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("이름은 필수입니다");
}
this.name = name;
}
public void promote() {
this.role = Role.ADMIN;
}
public void deactivate() {
this.status = Status.INACTIVE;
}
}
@Setter 전체 오픈:
❌ 누가, 어디서, 왜 바꿨는지 추적 불가
❌ 유효성 검증 없이 아무 값이나 세팅 가능
❌ Entity의 일관성 보장 불가
의미 있는 메서드:
✅ 변경 의도가 명확 (changeName, promote, deactivate)
✅ 유효성 검증 가능
✅ 비즈니스 로직 캡슐화
11. 정리
Entity 직접 반환의 문제 5가지:
1. 민감 정보 노출 (password, socialNumber)
2. 양방향 관계 → 무한 재귀 (StackOverflow)
3. Entity 변경 → API 스펙 변경 (클라이언트 깨짐)
4. @JsonIgnore 떡칠 → Entity가 API에 종속
5. LazyInitializationException
해결:
✅ Entity → Response DTO 변환
✅ RequestBody → Request DTO 사용
✅ Entity에 @Setter 대신 의미 있는 메서드
✅ Service 레이어에서 DTO 변환 (트랜잭션 안에서)
핵심 한 줄
Entity는 DB와의 계약이고, DTO는 클라이언트와의 계약이다. 두 계약을 하나로 합치면 양쪽 다 깨진다.
다음 편 예고: 실무에서 자주 터지는 Spring 버그 모음 — 면접에도 나오는 것들
순환 참조, equals/hashCode 미구현, LocalDateTime 직렬화, 동시성 문제, 스프링 프로필 실수... 실무에서 한 번씩은 꼭 만나는 버그들을 정리한다. 면접에서도 단골 주제다.
Entity, DTO, API 응답, Jackson, JsonIgnore, 무한재귀, StackOverflow, LazyInitializationException, OSIV, MapStruct, Record, Setter, 캡슐화, Spring, JPA
'Java' 카테고리의 다른 글
| 캐시 한 줄이 DB 부하를 90% 줄인다 — Redis 실전 적용 (0) | 2026.04.20 |
|---|---|
| 실무에서 자주 터지는 Spring 버그 모음 — 면접에도 나오는 것들 (0) | 2026.04.20 |
| JPA 더티 체킹 — save() 안 해도 되는데 왜? 그리고 언제 안 될까? (0) | 2026.04.20 |
| @Transactional의 함정 — 붙였는데 왜 안 돼? (1) | 2026.04.20 |
| N+1 문제 총정리 — EAGER만 문제가 아니다 (1) | 2026.04.20 |