본문 바로가기
SpringBoot/트러블 슈팅

[Spring] 양방향 맵핑 순환 참조 문제 해결

by rla124 2025. 4. 5.

스프링부트 공부를 하고 처음 마주친 에러의 해결 과정에 대해서 기록하고자 한다. 해당 이슈는 이런 문제가 생길 수 있다~라고 대략적으로 들어보기만 했는데 실제로 맞닥뜨린 것은 처음이었다. Let's Go

 

문제 상황 : 객체 직렬화 시 연관된 양방향 매핑으로 인한 순환 참조 문제

기존에 내가 원하던 것은 클라이언트의 req와 해당 요청에 대해 내 서버가 어떤 응답을 내렸는지를 로그 파일로 저장해두기 위해 작성했던 코드에서 발생한 에러였다.

@Transactional
public BouquetCustomizingResponseV2 updateBouquet(BouquetCustomizingRequest request, Long memberId, Long bouquetId, List<MultipartFile> multipartFiles) {
    String clientIP = getClientIP();
    Member member = bouquetCustomizingService.getMemberByMemberId(memberId);

    Bouquet existingBouquet = bouquetCustomizingService.getBouquetByMemberIdAndBouquetId(memberId, bouquetId);

    try {
        String jsonString = objectMapper.writeValueAsString(existingBouquet);
        if (!existingBouquet.getMember().equals(member)) {
            throw new WhoaException(
                NOT_MEMBER_BOUQUET,
                "updateBouquet - 실제 bouquet을 수정 요청을 한 memberId와 bouquet의 memberId가 불일치",
                clientIP,
                "memberId request : " + memberId + "\n" + "bouquet request : " + jsonString
            );
        }
    } catch (JsonProcessingException e) {
        e.printStackTrace();
        throw new WhoaException(
            ExceptionCode.OBJECT_MAPPER_JSON_PROCESSING_ERROR,
            "updateBouquet - pathVariable로 받은 bouquetId와 controller memberId로 찾은 bouquet 객체를 json string으로 전환하면서 오류 발생"
        );
    }
    
    // 생략

 

위와 같이 내가 설계해둔 코드에서 objectMapper로 bouquet 객체를 string json 형식으로 직렬화하는 과정에서 에러가 터져 catch문을 타는 상황이었다.

 

이 때 나타난 에러 로그를 보다 구체화 하기 위해 printStackTrace()를 통해 상세화하였더니 아래와 같이 원인을 파악할 수 있었다.

2025-04-05 01:49:21 [http-nio-8080-exec-2] TRACE org.hibernate.orm.jdbc.bind - binding parameter (1:BIGINT) <- [6]
com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: com.whoa.whoaserver.domain.bouquet.domain.Bouquet["member"]
->com.whoa.whoaserver.domain.member.domain.Member["bouquet"]
->org.hibernate.collection.spi.PersistentBag[0]
->com.whoa.whoaserver.domain.bouquet.domain.Bouquet["member"]
->com.whoa.whoaserver.domain.member.domain.Member["bouquet"]
// 생략

 

문제의 핵심은

Infinite recursion (StackOverflowError) 
(through reference chain: 
Bouquet["member"] → Member["bouquet"] → List<Bouquet> → Bouquet["member"] → ...)

 

Bouquet → Member → List<Bouquet> → Bouquet... 으로 전형적인 순환 참조 문제였다.

 

관련 DB 연관관계 설계는 Member : Bouquet이 일대다였으며 양쪽이 모두 각각 OneToMany로 List<Bouquet>, ManyToOne으로 Member 필드를 가지고 있었다.

 

그래서 코드 로직 상 Jackson은 existingBouquet을 json으로 변환하려고 할 때

1. Bouquet 직렬화 시작 → Member 필드도 직렬화

2. Member 직렬화 시작 → List<Bouquet> bouquets 필드가 있으니 Bouquet 리스트 직렬화

3. Bouquet 직렬화 → Member 직렬화 → Bouquet 직렬화....

그래서 JsonProcessingException이 계속 발생했던 것이다.

 

해결 방법 : annotation 적용

내가 선택한 방법은 @JsonManagedReference, @JsonBackReference를 사용하는 방법이었다.

@JsonIgnore 방식도 간편하지만 보다 일대다 연관관계 상 부모-자식 관계를 유지하여 직렬화를 직관적으로 방지하고 싶었다. 

 

지금 이 문제 상황에서 Member는 부모, Bouquet은 자식이라고 볼 수 있다.

그래서 Bouquet entity에서 Member 필드에 JsonManagedReference를 선언하고

vice versa로 Member entity에서 List<Bouquet> 필드에 JsonBackReference를 선언한다.

 

그러면 Member 객체를 직렬화 하면 JsonBackReference 가 순환참조 방지를 위해 json 직렬화를 제외하기 때문에 bouquets 필드는 json에 포함되지 않는다.

하지만 Bouquet 객체를 json으로 변환하면 JsonManagedReference는 json에 포함이 되므로 member 필드 정보를 확인할 수 있다.

 

어노테이션 네이밍 그대로 back은 json 빠꾸~를 시키는 것이고 Managed는 관리 의미를 가지니까 json에 포함이 되는 것이라고 직관적으로 이해했다. 그리고 또한 자식 쪽에서 부모쪽 정보는 확인이 가능해야 하므로 부모 필드에 Managed를 붙여야 한다 까지 기억하는 걸로~

 

 

부분적으로 Member-Bouquet 1:N 양방향 매핑을 다루었지만

해당 프로젝트에서 Bouquet-BouquetImage 1:N 양방향 매핑도 포함되어있었기 때문에 BouquetImage까지 같이 순환참조 문제가 터졌다. 이 부분도 마찬가지로 Member-Bouquet 순환참조와 구분하기 위해 

 

BouquetImage["bouquet"] 
→ Bouquet["images"] 
→ PersistentBag[0] 
→ BouquetImage["bouquet"] 
→ 생략(무한 루프)

 

// Bouquet.java
@JsonManagedReference("bouquet-image")
@OneToMany(mappedBy = "bouquet", cascade = CascadeType.ALL, orphanRemoval = true)
private List<BouquetImage> images = new ArrayList<>();

// BouquetImage.java
@JsonBackReference("bouquet-image")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bouquet_id")
private Bouquet bouquet;

"bouquet-image" 네이밍을 붙였다.

 

코드 PR 링크에서 전체 트러블 슈팅 내용을 확인할 수 있다.

 

이렇게 또 문제 상황 대처 능력을 키워가는 거지