반응형
포인트 이력에서 회원 이름을 보려면 매번 따로 조회해야 했다 - @OneToMany 연관관계 매핑과 Jackson 무한 재귀

지난 글에서 회원가입할 때 웰컴 포인트를 같이 적립하도록 만들면서 PointHistorymemberId라는 Long 컬럼을 하나 넣었다. 트랜잭션 경계를 확인하는 게 목적이라 이 정도로도 충분했는데, 막상 "이 포인트 이력이 누구 건지" 화면에 보여주려고 하니 문제가 생겼다. PointHistory 하나 가지고는 회원 이름도 이메일도 알 수 없고, 매번 memberIdMember를 따로 조회해야 했다.

결론만 이야기하면 이렇다. Long memberId는 그냥 숫자일 뿐이라 자바 코드 안에서 회원 객체로 바로 못 넘어간다. JPA의 @OneToMany/@ManyToOne으로 진짜 연관관계를 맺어주면 pointHistory.getMember().getName()처럼 객체 그래프를 그대로 타고 들어갈 수 있다. 그런데 이 연관관계를 양방향으로 만들어두고 엔티티를 API 응답으로 그대로 내보내면, 회원이 포인트 이력을 물고 포인트 이력이 다시 회원을 무는 식으로 직렬화가 끝없이 돈다. 본 글에서는 이 연관관계를 실제로 매핑하고 그 함정에 직접 빠졌다가 빠져나오는 과정을 그대로 보여주도록 하겠다.

준비물

항목버전비고
JDK21 LTS지난 글과 동일
Spring Boot3.3.4Spring Web + Validation + Data JPA
MariaDB10.11Ubuntu 24.04 apt 기본 패키지 버전
MariaDB Connector/J3.4.1JDBC 드라이버
Jackson2.17.2Spring 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. 연관관계 매핑하기

PointHistoryLong 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.ALLorphanRemoval = 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 /members200, member+point_history 동시 insert
엔티티 직접 반환(버그)GET /members/1200이지만 33,715바이트 무한 반복 후 HttpMessageNotWritableException
DTO로 수정GET /members/1200, pointHistories 배열 정상 반환
orphanRemovalDELETE /members/1/point-histories/2204, delete 쿼리 자동 실행, DB에서 실제로 사라짐

자주 만나는 에러와 해결

HTTP 200인데 응답 JSON이 중간에 끊긴다

양방향 @OneToMany/@ManyToOne 연관관계가 걸린 엔티티를 그대로 반환하고 있을 가능성이 높다. 로그에 HttpMessageNotWritableExceptionnesting depth가 찍히는지 확인하고 응답 전용 DTO로 바꿔서 순환 참조 자체를 없애자.

org.hibernate.LazyInitializationException: could not initialize proxy - no Session

spring.jpa.open-in-viewfalse로 꺼뒀거나, 별도 스레드(예: @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)이 실제로 어떻게 동작하는지도 다뤄볼 예정이다.

반응형