Troubleshooting: 무엇이 문제였는가?/본캠프 6주 차: 플러스 프로젝트

10단계: "세상에, 'HttpMessageNotWritableException'이라니! 순환 참조에 걸린 사람? 저요!" (수정 중)

writingforever162 2025. 1. 21. 23:54

[문제]

가게(Store), 카테고리(Category), 중간 테이블(Store Category)이 연관관계를 잘 맺었고 가게가 잘 생성되는지 확인했는데, 이상하게 입력된 카테고리가 데이터베이스(database)에만 저장되고 반환되지 않았다. 카테고리 목록을 제외한 여는 시간이나 주문 최소 금액 같은 다른 값은 모두 제대로 반환되었기 때문에, 혹시 응답 시 쓰는 DTO(Data Transfer Object)에 카테고리 목록이 들어가지 않았을지 모른다고 추측했다.

 

[원인 및 문제 해결 과정]

더보기
package com.example.outsourcingproject.store.service;

import com.example.outsourcingproject.auth.repository.OwnerAuthRepository;
import com.example.outsourcingproject.category.repository.CategoryRepository;
import com.example.outsourcingproject.entity.Category;
import com.example.outsourcingproject.entity.Owner;
import com.example.outsourcingproject.entity.Store;
import com.example.outsourcingproject.entity.StoreCategory;
import com.example.outsourcingproject.exception.badrequest.CategoryInvalidCountException;
import com.example.outsourcingproject.exception.badrequest.StoreInvalidCountExcessException;
import com.example.outsourcingproject.exception.notfound.OwnerNotFoundException;
import com.example.outsourcingproject.menu.repository.MenuRepository;
import com.example.outsourcingproject.store.dto.request.CreateStoreRequestDto;
import com.example.outsourcingproject.store.dto.response.CreateStoreResponseDto;
import com.example.outsourcingproject.store.repository.StoreCategoryRepository;
import com.example.outsourcingproject.store.repository.StoreRepository;
import com.example.outsourcingproject.utils.JwtUtil;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class StoreServiceImpl implements StoreService {

    private final StoreRepository storeRepository;
    private final OwnerAuthRepository ownerAuthRepository;
    private final JwtUtil jwtUtil;
    private final MenuRepository menuRepository;
    private final CategoryRepository categoryRepository;
    private final StoreCategoryRepository storeCategoryRepository;

    @Transactional
    @Override
    public CreateStoreResponseDto createStore(
        CreateStoreRequestDto requestDto,
        String token
    ) {
        String ownerEmail = jwtUtil.extractOwnerEmail(token);

        Owner foundOwner = ownerAuthRepository.findByEmail(ownerEmail)
            .orElseThrow(OwnerNotFoundException::new);

        Long storeCount = storeRepository.countByOwnerIdAndIsDeleted(
            foundOwner.getId(),
            0
        );

        if (storeCount >= 3) {
            throw new StoreInvalidCountExcessException();
        }

        Store storeToSave = new Store(
            foundOwner.getId(),
            requestDto.getStoreName(),
            requestDto.getStoreAddress(),
            requestDto.getStoreTelephone(),
            requestDto.getMinimumPurchase(),
            requestDto.getOpensAt(),
            requestDto.getClosesAt()
        );

        Store savedStore = storeRepository.save(storeToSave);

        List<Category> categoryList = new ArrayList<>();

        categoryList = categoryRepository.findAllByNameIn(
            requestDto.getCategoryList(),
            Sort.unsorted()
        );

        if (categoryList.size() > 3) {
            throw new CategoryInvalidCountException();
        }

        // 카테고리 목록을 StoreCategory 객체로 변환 및 저장 시작
        List<StoreCategory> storeCategoryList = new ArrayList<>();

        storeCategoryList = categoryList.stream()
            .map(category -> new StoreCategory(
                    category,
                    savedStore
                )
            )
            .toList();

        storeCategoryRepository.saveAll(storeCategoryList);
        // 카테고리 목록을 StoreCategory 객체로 변환 및 저장 종료

        return new CreateStoreResponseDto(savedStore);
    }
}

원인은 역시 카테고리 목록이 중간 테이블인 Story Category에만 저장된다는 점에 있었다. 카테고리 목록은 가게 객체가 생성될 때 저장되지 않고 가게가 생성된 이후에 별도로 저장해야 했기 때문에, 생성자에서 처리하지 않고 메서드(method)를 별도로 만들어서 서비스 레이어(service layer)에서 해당 메서드를 호출했다.

더보기
package com.example.outsourcingproject.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import org.hibernate.annotations.Comment;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "stores")
@Getter
public class Store extends BaseEntity {

    @Comment("가게 식별자")
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT")
    private Long id;

    @Comment("사장님 식별자")
    private Long ownerId;

    @Comment("가게 이름")
    @Column(
        name = "store_name",
        nullable = false
    )
    private String storeName;

    @Comment("가게 전화번호")
    @Column(
        name = "store_telephone",
        nullable = false
    )
    private String storeTelephone;

    @Comment("가게 주소")
    @Column(
        name = "store_address",
        nullable = false
    )
    private String storeAddress;

    @Comment("주문 최소 금액")
    @Column(
        name = "minimum_purchase",
        nullable = false
    )
    private Integer minimumPurchase;

    @Comment("여는 시간")
    @Column(
        name = "opens_at",
        nullable = false
    )
    private LocalTime opensAt;

    @Comment("닫는 시간")
    @Column(
        name = "closes_at",
        nullable = false
    )
    private LocalTime closesAt;

    @Comment("가게 카테고리")
    @OneToMany(mappedBy = "store")
    private List<StoreCategory> storeCategoryList
        = new ArrayList<>();

    protected Store() {
    }

    public Store(
        Long ownerId,
        String storeName,
        String storeAddress,
        String storeTelephone,
        Integer minimumPurchase,
        LocalTime opensAt,
        LocalTime closesAt
    ) {
        this.ownerId = ownerId;
        this.storeName = storeName;
        this.storeTelephone = storeTelephone;
        this.storeAddress = storeAddress;
        this.minimumPurchase = minimumPurchase;
        this.opensAt = opensAt;
        this.closesAt = closesAt;
    }

    // [메서드 추가]
    // (기능 1) 가게에 카테고리 목록 추가 
    // (기능 2) 가게와 카테고리 간 연관관계 설정
    public void addStoreCategoryList(
        List<StoreCategory> storeCategoryList
    ) {
        this.storeCategoryList.addAll(storeCategoryList);
    }

    public void update(
        String storeName,
        String storeAddress,
        String storeTelephone,
        Integer minimumPurchase,
        LocalTime opensAt,
        LocalTime closesAt
    ) {
        this.storeName = storeName;
        this.storeAddress = storeAddress;
        this.storeTelephone = storeTelephone;
        this.minimumPurchase = minimumPurchase;
        this.opensAt = opensAt;
        this.closesAt = closesAt;
    }
}
// 사용 X
public void setStoreCategoryList (
    List<StoreCategory> storeCategoryList
) {
    this.storeCategoryList = storeCategoryList;
}
// 사용 O
public void addStoreCategoryList(
    List<StoreCategory> storeCategoryList
) {
    this.storeCategoryList.addAll(storeCategoryList);
}

처음에는 'Store Category 목록을 설정하는 기능'으로 메서드를 만들었는데, 이렇게 만든 메서드는 '세터(setter)'처럼 동작할 수밖에 없었다. 세터를 무분별하게 사용하면 코드의 의도가 불분명해지고, 언제든 외부에서 세터를 호출해서 객체 상태를 변경할 수 있기 때문에 객체의 일관성을 유지하기가 매우 어려웠다. 이런 이유로 기능을 '설정'이 아니라 '저장'으로 바꾸었고, 그 결과 'addStoreCategoryList'라는 이름과 그 행위가 일치하는 메서드를 만들 수 있었다. 

더보기
package com.example.outsourcingproject.store.service;

import com.example.outsourcingproject.auth.repository.OwnerAuthRepository;
import com.example.outsourcingproject.category.repository.CategoryRepository;
import com.example.outsourcingproject.entity.Category;
import com.example.outsourcingproject.entity.Owner;
import com.example.outsourcingproject.entity.Store;
import com.example.outsourcingproject.entity.StoreCategory;
import com.example.outsourcingproject.exception.badrequest.CategoryInvalidCountException;
import com.example.outsourcingproject.exception.badrequest.StoreInvalidCountExcessException;
import com.example.outsourcingproject.exception.notfound.OwnerNotFoundException;
import com.example.outsourcingproject.menu.repository.MenuRepository;
import com.example.outsourcingproject.store.dto.request.CreateStoreRequestDto;
import com.example.outsourcingproject.store.dto.response.CreateStoreResponseDto;
import com.example.outsourcingproject.store.repository.StoreCategoryRepository;
import com.example.outsourcingproject.store.repository.StoreRepository;
import com.example.outsourcingproject.utils.JwtUtil;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class StoreServiceImpl implements StoreService {

    private final StoreRepository storeRepository;
    private final OwnerAuthRepository ownerAuthRepository;
    private final JwtUtil jwtUtil;
    private final MenuRepository menuRepository;
    private final CategoryRepository categoryRepository;
    private final StoreCategoryRepository storeCategoryRepository;

    @Transactional
    @Override
    public CreateStoreResponseDto createStore(
        CreateStoreRequestDto requestDto,
        String token
    ) {
        String ownerEmail = jwtUtil.extractOwnerEmail(token);

        Owner foundOwner = ownerAuthRepository.findByEmail(ownerEmail)
            .orElseThrow(OwnerNotFoundException::new);

        Long storeCount = storeRepository.countByOwnerIdAndIsDeleted(
            foundOwner.getId(),
            0
        );

        if (storeCount >= 3) {
            throw new StoreInvalidCountExcessException();
        }

        Store storeToSave = new Store(
            foundOwner.getId(),
            requestDto.getStoreName(),
            requestDto.getStoreAddress(),
            requestDto.getStoreTelephone(),
            requestDto.getMinimumPurchase(),
            requestDto.getOpensAt(),
            requestDto.getClosesAt()
        );

        Store savedStore = storeRepository.save(storeToSave);

        List<Category> categoryList = new ArrayList<>();

        categoryList = categoryRepository.findAllByNameIn(
            requestDto.getCategoryList(),
            Sort.unsorted()
        );

        if (categoryList.size() > 3) {
            throw new CategoryInvalidCountException();
        }

        // 카테고리 목록을 StoreCategory 객체로 변환 및 저장 시작 
        List<StoreCategory> storeCategoryList = new ArrayList<>();

        storeCategoryList = categoryList.stream()
            .map(category -> new StoreCategory(
                    category,
                    savedStore
                )
            )
            .toList();

        // 카테고리 목록을 StoreCategory 객체로 변환 및 저장 종료
        storeCategoryRepository.saveAll(storeCategoryList);

        // [addStoreCategoryList 메서드 호출]
        savedStore.addStoreCategoryList(storeCategoryList);

        return new CreateStoreResponseDto(savedStore);
    }
}

위와 같이 코드를 추가한 다음에 애플리케이션을 다시 실행했다. 오류가 해결되었기를 마음속으로 빌고 또 빌면서. 

그 결과…….

순환 참조에 제대로 걸렸다.

 

과장이 아니라 진짜 '이게 뭐지? 꿈인가?' 싶었다. 

HttpMessageNotWritableException: 
// (1) JSON으로 직렬화할 수 없는 예외 발생 

Could not write JSON: 
// (2) JSON을 직렬화할 수 없음

Document nesting depth (1001) exceeds the maximum allowed 
// (3) JSON 객체의 중첩 깊이(1001)가 허용된 최대 깊이(1000)를 초과함
//     1001: 객체 안에 객체가 있고, 또 그 안에 객체가 있는 구조일 때의 깊이 
//           == 객체가 중첩된 깊이 

(1000, from `StreamWriteConstraints.getMaxNestingDepth()`) 
// (4) 1000: JSON의 최대 중첩 깊이
//     `StreamWriteConstraints.getMaxNestingDepth()`에서 정의됨
//     Jackson 라이브러리에서는 보통 1000으로 설정
//     해당 값을 초괴하면 HttpMessageNotWritableException 발생

예외 메시지를 보고 추측할 수 있다시피, StoreCategory와 Category라는 두 객체가 양방향으로 연관관계를 맺어서 각 객체가 직렬화되어 반환되며 순환 참조에 빠지고 말았다. category 객체 안에 storeCategoryList 객체가 있고, storeCategoryList 안에 category가 있으며, 그 안에도 다시 storeCategoryList가 포함되어서 서로를 계속 참조하는 구조가 되어 무한히 직렬화되며 순환 참조 문제가 발생했다. 

 

'category → storeCategoryList → category → storeCategoryList → category …….'

 

이런 식으로 말이다.

 

순환 참조 문제를 해결하려면 첫째, Category 객체를 직접 반환하지 않고 필요한 정보만 담은 DTO로 반환하거나 둘째, @JsonManagedReference 같은 어노테이션(annotation)을 붙여서 직렬화 대상에서 제외해야 했다.

일단 다른 정보는 DTO로 잘 반환해 놓고, categoryList만 DTO가 아닌 객체로 직접 반환하려고 했다는 점이 어처구니없었지만, 두 가지 해결책 외에도 또 다른 질문이 머릿속을 맴돌았다. 

 

'Store Category와 Category 객체가 꼭 양방향으로 연관되어야 하나?'

 

'두 객체가 항상 함께 조회, 수정, 삭제될 필요가 있을까?'

 

'Store와 Store Category는 함께 저장되고 조회되어야 하지만, Category는 자신이 어느 가게와 연결되었는지 꼭 알아야 할까?'

 

양방향 연관관계는 한 객체를 수정했을 때 다른 객체도 함께 영향받는 등 의도치 않은 동작이 발생할 수 있어서, 꼭 필요할 때가 아니라면 단방향 연관관계를 설정하는 편이 바람직했다. 이런 이유로 Store와 Store Category를 단방향 연관관계로 설정하기로 했다.

 

[해결]