지난 글에서 회원가입할 때 웰컴 포인트를 같이 적립하도록 만들면서 PointHistory에 memberId라는 Long 컬럼을 하나 넣었다. 트랜잭션 경계를 확인하는 게 목적이라 이 정도로도 충분했는데, 막상 "이 포인트 이력이 누구 건지" 화면에 보여주려고 하니 문제가 생겼다. PointHistory 하나 가지고는 회원 이름도 이메일도 알 수 없고, 매번 memberId로 Member를 따로 조회해야 했다.
결론만 이야기하면 이렇다. Long memberId는 그냥 숫자일 뿐이라 자바 코드 안에서 회원 객체로 바로 못 넘어간다. JPA의 @OneToMany/@ManyToOne으로 진짜 연관관계를 맺어주면 pointHistory.getMember().getName()처럼 객체 그래프를 그대로 타고 들어갈 수 있다. 그런데 이 연관관계를 양방향으로 만들어두고 엔티티를 API 응답으로 그대로 내보내면, 회원이 포인트 이력을 물고 포인트 이력이 다시 회원을 무는 식으로 직렬화가 끝없이 돈다. 본 글에서는 이 연관관계를 실제로 매핑하고 그 함정에 직접 빠졌다가 빠져나오는 과정을 그대로 보여주도록 하겠다.
준비물
| 항목 | 버전 | 비고 |
|---|---|---|
| JDK | 21 LTS | 지난 글과 동일 |
| Spring Boot | 3.3.4 | Spring Web + Validation + Data JPA |
| MariaDB | 10.11 | Ubuntu 24.04 apt 기본 패키지 버전 |
| MariaDB Connector/J | 3.4.1 | JDBC 드라이버 |
| Jackson | 2.17.2 | Spring Boot 3.3.4가 물고 오는 기본 버전 |
| 이전 프로젝트 | demo 프로젝트 | Member, PointHistory(memberId 컬럼), MemberService가 있는 상태 |
지난 글까지 PointHistory는 이렇게 생겼었다.
@Entity
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long memberId; // 그냥 숫자다. Member 객체가 아니다.
@Column(nullable = false)
private Integer amount;
@Column(nullable = false, length = 50)
private String reason;
// ...
}
이번 글은 memberId(Long)를 실제 Member 참조로 바꾸는 게 목표다.
따라 하기
1. 연관관계 매핑하기
PointHistory의 Long memberId를 지우고 Member를 직접 참조하게 바꾼다.
package com.example.demo;
import jakarta.persistence.*;
@Entity
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@Column(nullable = false)
private Integer amount;
@Column(nullable = false, length = 50)
private String reason;
protected PointHistory() {
}
public PointHistory(Member member, Integer amount, String reason) {
this.member = member;
this.amount = amount;
this.reason = reason;
}
public Long getId() { return id; }
public Member getMember() { return member; }
public Integer getAmount() { return amount; }
public String getReason() { return reason; }
}
@ManyToOne은 "포인트 이력 여러 개가 회원 하나를 가리킨다"는 뜻이다. @JoinColumn(name = "member_id")가 실제 FK 컬럼 이름을 지정한다. 여기서 fetch = FetchType.LAZY를 일부러 명시했는데, @ManyToOne은 기본값이 EAGER라서 아무것도 안 적으면 회원을 조회할 때마다 관련 포인트 이력이 없어도 매번 즉시 로딩된다. 반대로 컬렉션 쪽(@OneToMany)은 기본값이 LAZY라서 이 부분은 그냥 둬도 된다.
이제 반대쪽 Member에 컬렉션을 추가한다.
@Entity
public class Member {
// id, name, email, age는 그대로
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PointHistory> pointHistories = new ArrayList<>();
public void addPointHistory(int amount, String reason) {
PointHistory history = new PointHistory(this, amount, reason);
pointHistories.add(history);
}
public List<PointHistory> getPointHistories() { return pointHistories; }
}
mappedBy = "member"는 "이 연관관계의 주인은 내가 아니라 PointHistory.member 필드다"라는 뜻이다. 실제 FK 컬럼은 point_history.member_id에만 있고 member 테이블에는 아무 컬럼도 추가되지 않는다. cascade = CascadeType.ALL과 orphanRemoval = true가 이번 글의 핵심인데, 뒤에서 직접 결과로 확인해보자.
이 상태로 애플리케이션을 재시작하면(ddl-auto: update) Hibernate가 FK 제약을 직접 만들어주는 것을 로그로 확인할 수 있다.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e "SHOW CREATE TABLE point_history\G"
CREATE TABLE `point_history` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`amount` int(11) NOT NULL,
`reason` varchar(50) NOT NULL,
`member_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `FKn7yuk3drkv7qwgq0op21olmwy` (`member_id`),
CONSTRAINT `FKn7yuk3drkv7qwgq0op21olmwy` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`)
) ENGINE=InnoDB
Long memberId로 컬럼만 넣었을 때는 없던 외래키 제약(FOREIGN KEY)이 자동으로 생긴 것을 확인할 수 있다.
2. cascade로 저장 한 번에 두 테이블 다 채우기
MemberService.register()도 정리한다. 지난 글까지는 memberRepository.save()와 pointHistoryRepository.save()를 각각 호출했는데, 이제는 회원 저장 한 번이면 된다.
@Transactional
public Member register(MemberRequest request) {
Member member = new Member(request.name(), request.email(), request.age());
member.addPointHistory(1000, "웰컴포인트 지급");
return memberRepository.save(member);
}
회원가입을 요청해보자.
curl -X POST http://localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"홍길동","email":"hong@example.com","age":25}'
{"id":1,"name":"홍길동","email":"hong@example.com","age":25}
응답은 지난 글과 똑같아 보이는데, 이번엔 서버 로그(show-sql: true)를 보자.
Hibernate:
insert into member (age, email, name) values (?, ?, ?) returning id
Hibernate:
insert into point_history (amount, member_id, reason) values (?, ?, ?) returning id
pointHistoryRepository.save()를 코드 어디에도 안 적었는데 point_history에 insert가 자동으로 나갔다. member.addPointHistory(...)로 컬렉션에 추가만 해두면, memberRepository.save(member) 한 번에 cascade = CascadeType.ALL이 자식까지 알아서 저장해주는 것을 확인할 수 있다.
3. 엔티티를 그대로 응답으로 내려줬다가 벌어진 일

이제 회원 상세 조회 API를 하나 만든다. 여기서 흔히 하는 실수를 그대로 재현해보자. 서비스에서 가져온 Member 엔티티를 DTO로 바꾸지 않고 컨트롤러가 그냥 반환하면 어떻게 될까.
@GetMapping("/members/{id}")
public Member detail(@PathVariable Long id) {
return memberService.findById(id);
}
@Transactional(readOnly = true)
public Member findById(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다: " + id));
}
방금 만든 회원을 조회해보자.
curl -s -o resp.json -w "HTTP_STATUS:%{http_code}\n" http://localhost:8080/members/1
HTTP_STATUS:200
상태 코드가 200이라 성공한 줄 알았다. resp.json 크기를 확인해보니 33,715바이트나 됐고 내용을 열어보니 이런 식으로 끝도 없이 반복되고 있었다.
{"id":1,"name":"홍길동", ... ,"pointHistories":[{"id":1, ... ,"member":{"id":1,"name":"홍길동", ... ,"pointHistories":[{"id":1, ... ,"member":{"id":1, ...
Member를 직렬화하면 pointHistories 리스트를 도는데, 그 안의 PointHistory는 다시 member 필드를 갖고 있고, 그 member는 또 pointHistories를 갖고 있어서 Jackson이 이 순환을 끝없이 따라갔기 때문이다. 서버 로그를 보니 진짜 원인이 찍혀 있었다.
WARN DefaultHandlerExceptionResolver : Ignoring exception, response committed already:
org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON:
Document nesting depth (1001) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`)
StackOverflowError가 날 줄 알았는데 실제로는 HttpMessageNotWritableException이었다. Jackson 2.15부터 StreamWriteConstraints라는 안전장치가 생겨서 중첩 깊이가 1000을 넘으면 예외를 던지도록 바뀌었다(여기서는 Jackson 2.17.2). 문제는 타이밍이다. 이 예외는 응답을 이미 스트리밍하기 시작한 뒤에 터져서 HTTP 상태 코드는 200으로 이미 클라이언트에 나가버린 뒤라는 것을 로그의 "response committed already"에서 확인할 수 있다. 클라이언트 입장에서는 200을 받았는데 JSON이 중간에 뚝 끊겨 파싱이 실패하는, 원인을 짐작하기 어려운 상황을 겪게 된다.
4. DTO로 응답 분리해서 해결하기
이 시리즈는 처음부터 MemberResponse처럼 응답 전용 DTO를 써왔다. 이번에도 같은 방식으로 푼다.
public record PointHistoryResponse(Long id, Integer amount, String reason) {
public static PointHistoryResponse from(PointHistory history) {
return new PointHistoryResponse(history.getId(), history.getAmount(), history.getReason());
}
}
public record MemberDetailResponse(
Long id, String name, String email, Integer age,
List<PointHistoryResponse> pointHistories
) {
public static MemberDetailResponse from(Member member) {
List<PointHistoryResponse> histories = member.getPointHistories().stream()
.map(PointHistoryResponse::from)
.toList();
return new MemberDetailResponse(
member.getId(), member.getName(), member.getEmail(), member.getAge(), histories);
}
}
PointHistoryResponse에는 member 필드 자체가 없어서 순환의 고리가 끊긴다. 컨트롤러도 DTO를 반환하도록 고친다.
@GetMapping("/members/{id}")
public MemberDetailResponse detail(@PathVariable Long id) {
Member member = memberService.findById(id);
return MemberDetailResponse.from(member);
}
다시 요청해보자.
curl http://localhost:8080/members/1
{"id":1,"name":"홍길동","email":"hong@example.com","age":25,"pointHistories":[{"id":1,"amount":1000,"reason":"웰컴포인트 지급"}]}
이번엔 한 번에 깔끔하게 끝나는 것을 확인할 수 있다. 서버 로그를 보면 쿼리가 두 번 나간다.
Hibernate: select m1_0.id, m1_0.age, m1_0.email, m1_0.name from member m1_0 where m1_0.id=?
Hibernate: select ph1_0.member_id, ph1_0.id, ph1_0.amount, ph1_0.reason from point_history ph1_0 where ph1_0.member_id=?
findById()는 @Transactional(readOnly = true)로 끝나 있고 실제로 pointHistories를 꺼내 쓰는 코드(MemberDetailResponse.from)는 컨트롤러, 즉 트랜잭션이 끝난 뒤에서 실행된다. 그런데도 LazyInitializationException 없이 두 번째 쿼리가 잘 나가는데, 이건 Spring Boot가 기본값으로 켜두는 spring.jpa.open-in-view 덕분이다. 요청이 끝날 때까지 Hibernate 세션을 열어둬서 컨트롤러 안에서도 지연 로딩된 컬렉션에 접근할 수 있다. (이 옵션이 요청 스레드 내내 DB 커넥션을 물고 있다는 트레이드오프는 이전에 커넥션 풀 글에서 다룬 내용과 이어진다.)
5. orphanRemoval 확인하기
cascade = CascadeType.ALL은 저장뿐 아니라 삭제에도 적용된다. orphanRemoval = true까지 있으면 컬렉션에서 자식을 빼기만 해도 DB에서 지워지는지 직접 확인해보자. 먼저 포인트 이력을 하나 더 넣어둔다.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e \
"INSERT INTO point_history (amount, reason, member_id) VALUES (500, '이벤트 보너스', 1);"
curl http://localhost:8080/members/1
{"id":1, ... ,"pointHistories":[{"id":1,"amount":1000,"reason":"웰컴포인트 지급"},{"id":2,"amount":500,"reason":"이벤트 보너스"}]}
이제 서비스에 삭제 메서드를 하나 추가한다. pointHistoryRepository.delete()를 호출하지 않는다는 점을 눈여겨보자.
@Transactional
public void removePointHistory(Long memberId, Long pointHistoryId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다: " + memberId));
member.getPointHistories().removeIf(history -> history.getId().equals(pointHistoryId));
}
@DeleteMapping("/members/{id}/point-histories/{pointHistoryId}")
public ResponseEntity<Void> removePointHistory(@PathVariable Long id, @PathVariable Long pointHistoryId) {
memberService.removePointHistory(id, pointHistoryId);
return ResponseEntity.noContent().build();
}
id=2인 이벤트 보너스 이력을 지워보자.
curl -X DELETE http://localhost:8080/members/1/point-histories/2
HTTP_STATUS:204
서버 로그를 확인해보자.
Hibernate: select m1_0.id, ... from member m1_0 where m1_0.id=?
Hibernate: select ph1_0.member_id, ... from point_history ph1_0 where ph1_0.member_id=?
Hibernate: delete from point_history where id=?
코드 어디에도 delete를 직접 호출한 곳이 없는데 delete 쿼리가 나갔다. DB를 다시 확인해보자.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e \
"SELECT id, amount, reason FROM point_history WHERE member_id=1;"
id amount reason
1 1000 웰컴포인트 지급
id=2 행이 실제로 사라진 것을 확인할 수 있다. member.getPointHistories().removeIf(...)로 자바 컬렉션에서만 뺐을 뿐인데, 트랜잭션이 커밋되는 시점에 Hibernate가 "부모 컬렉션에서 떨어져 나간 자식(고아, orphan)"을 감지해서 알아서 DELETE를 실행했다.
연관관계와 요청 흐름
sequenceDiagram
participant C as Client
participant M as MemberController
participant S as MemberService (@Transactional)
participant DB as MariaDB
C->>M: POST /members
M->>S: register(request)
S->>DB: insert into member
S->>DB: insert into point_history (cascade)
S-->>M: Member(id, pointHistories 포함)
M-->>C: MemberResponse
C->>M: DELETE /members/1/point-histories/2
M->>S: removePointHistory(1, 2)
S->>DB: select member, select point_history
S->>S: pointHistories.removeIf(id==2)
Note over S,DB: 트랜잭션 커밋 시점
S->>DB: delete from point_history (orphanRemoval)
전체 코드 (복붙용)
Member.java
package com.example.demo;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private Integer age;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PointHistory> pointHistories = new ArrayList<>();
protected Member() {
}
public Member(String name, String email, Integer age) {
this.name = name;
this.email = email;
this.age = age;
}
public void addPointHistory(int amount, String reason) {
pointHistories.add(new PointHistory(this, amount, reason));
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public Integer getAge() { return age; }
public List<PointHistory> getPointHistories() { return pointHistories; }
}
PointHistory.java
package com.example.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@Column(nullable = false)
private Integer amount;
@Column(nullable = false, length = 50)
private String reason;
protected PointHistory() {
}
public PointHistory(Member member, Integer amount, String reason) {
this.member = member;
this.amount = amount;
this.reason = reason;
}
public Long getId() { return id; }
public Member getMember() { return member; }
public Integer getAmount() { return amount; }
public String getReason() { return reason; }
}
PointHistoryResponse.java / MemberDetailResponse.java
package com.example.demo;
public record PointHistoryResponse(Long id, Integer amount, String reason) {
public static PointHistoryResponse from(PointHistory history) {
return new PointHistoryResponse(history.getId(), history.getAmount(), history.getReason());
}
}
package com.example.demo;
import java.util.List;
public record MemberDetailResponse(
Long id, String name, String email, Integer age,
List<PointHistoryResponse> pointHistories
) {
public static MemberDetailResponse from(Member member) {
List<PointHistoryResponse> histories = member.getPointHistories().stream()
.map(PointHistoryResponse::from)
.toList();
return new MemberDetailResponse(
member.getId(), member.getName(), member.getEmail(), member.getAge(), histories);
}
}
MemberService.java
package com.example.demo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public Member register(MemberRequest request) {
Member member = new Member(request.name(), request.email(), request.age());
member.addPointHistory(1000, "웰컴포인트 지급");
return memberRepository.save(member);
}
@Transactional(readOnly = true)
public Member findById(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다: " + id));
}
@Transactional
public void removePointHistory(Long memberId, Long pointHistoryId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다: " + memberId));
member.getPointHistories().removeIf(history -> history.getId().equals(pointHistoryId));
}
}
MemberController.java
package com.example.demo;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/members")
public MemberResponse create(@Valid @RequestBody MemberRequest request) {
Member saved = memberService.register(request);
return MemberResponse.from(saved);
}
@GetMapping("/members/{id}")
public MemberDetailResponse detail(@PathVariable Long id) {
Member member = memberService.findById(id);
return MemberDetailResponse.from(member);
}
@DeleteMapping("/members/{id}/point-histories/{pointHistoryId}")
public ResponseEntity<Void> removePointHistory(@PathVariable Long id, @PathVariable Long pointHistoryId) {
memberService.removePointHistory(id, pointHistoryId);
return ResponseEntity.noContent().build();
}
}
MemberRepository, MemberRequest, MemberResponse는 지난 글과 동일하다.
실행 결과 확인
| 단계 | 요청 | 결과 |
|---|---|---|
| 회원가입(cascade insert) | POST /members | 200, member+point_history 동시 insert |
| 엔티티 직접 반환(버그) | GET /members/1 | 200이지만 33,715바이트 무한 반복 후 HttpMessageNotWritableException |
| DTO로 수정 | GET /members/1 | 200, pointHistories 배열 정상 반환 |
| orphanRemoval | DELETE /members/1/point-histories/2 | 204, delete 쿼리 자동 실행, DB에서 실제로 사라짐 |
자주 만나는 에러와 해결
HTTP 200인데 응답 JSON이 중간에 끊긴다
양방향 @OneToMany/@ManyToOne 연관관계가 걸린 엔티티를 그대로 반환하고 있을 가능성이 높다. 로그에 HttpMessageNotWritableException과 nesting depth가 찍히는지 확인하고 응답 전용 DTO로 바꿔서 순환 참조 자체를 없애자.
org.hibernate.LazyInitializationException: could not initialize proxy - no Session
spring.jpa.open-in-view를 false로 꺼뒀거나, 별도 스레드(예: @Async)에서 지연 로딩 컬렉션에 접근할 때 발생한다. 트랜잭션이 열려 있는 서비스 메서드 안에서 필요한 데이터를 미리 DTO로 변환해서 꺼내오면 된다.
PropertyValueException / member_id NOT NULL 위반
PointHistory를 만들 때 생성자에 Member를 안 넘기고 나중에 setter로 넣으려다 빠뜨린 경우다. 본 글에서는 생성자에서 member를 필수로 받게 만들어서 이 실수 자체를 막았다.
마무리
Long memberId를 실제 @OneToMany/@ManyToOne 연관관계로 바꾸는 작업 자체는 어노테이션 몇 개 붙이는 정도라 어렵지 않았다. 예상 밖이었던 건 양방향 연관관계를 엔티티 그대로 응답에 내보냈을 때 벌어진 무한 재귀였다. 게다가 그게 StackOverflowError가 아니라 HttpMessageNotWritableException이었고 심지어 HTTP 상태 코드는 200으로 이미 나간 뒤였다는 게 제일 헷갈렸다. 이번 기회에 DTO를 왜 처음부터 써왔는지 다시 한번 체감했다.
다음 글에서는 지금까지 미뤄뒀던 예외 처리를 정리해볼 생각이다. findById()가 회원을 못 찾으면 지금은 그냥 500이 떨어지는데, @RestControllerAdvice로 404를 제대로 내려주는 것과 함께, @Transactional의 전파 속성(REQUIRES_NEW)이 실제로 어떻게 동작하는지도 다뤄볼 예정이다.
'개발 이야기 > Spring Boot' 카테고리의 다른 글
| 체크 예외를 던졌는데 회원 데이터가 롤백되지 않았다 - @Transactional 기본 롤백 정책과 rollbackFor (0) | 2026.07.10 |
|---|---|
| @Transactional(readOnly = true)를 붙였더니 분명히 바꾼 값이 그대로 사라졌다 - 읽기 전용 트랜잭션과 Dirty Checking (0) | 2026.07.09 |
| 회원가입은 됐는데 포인트가 안 쌓였다 - 서비스 계층 분리와 @Transactional 트랜잭션 경계 (0) | 2026.07.07 |
| Spring Data JPA findBy 쿼리 메서드로 조건 검색 만들기 — 메서드 이름만으로 SQL이 만들어지는 원리 (0) | 2026.07.06 |
| Spring Boot에 MariaDB 연결하고 JPA로 첫 CRUD 만들기 (0) | 2026.07.03 |