개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (12) - 화면 연동과 500 에러의 습격 (JPA 순환 참조)

baby-t 2026. 1. 7. 10:36

1. 들어가며: API는 멀쩡했는데?

지난 포스팅에서 자산 조회 API(api/my-assets)를 만들고, 브라우저에서 JSON 데이터가 잘 나오는 것까지 확인했습니다. "이제 HTML 만들어서 데이터를 뿌리기만 하면 끝이네!"라고 생각하며 가벼운 마음으로 프론트엔드 작업에 들어갔습니다.

하지만 그 안일한 생각이 **Spring Boot JPA 개발자라면 한 번쯤 겪는다는 '무한 루프의 늪'**으로 저를 이끌 줄은 몰랐습니다.


2. 프론트엔드 구현: 데이터를 받을 준비 (my_asset.html)

먼저 사용자가 볼 화면을 만들었습니다. 타임리프(Thymeleaf) 레이아웃을 잡고, 페이지가 로딩되면 Fetch API로 서버에서 데이터를 받아오도록 스크립트를 짰습니다.

JavaScript
// my_asset.html (스크립트 부분)
document.addEventListener("DOMContentLoaded", () => {
    fetch(`/api/my-assets?userId=29`) // 테스트 유저 ID
        .then(response => response.json())
        .then(data => {
            // 받아온 데이터로 화면 업데이트
            document.getElementById('totalAsset').innerText = data.totalAssetAmount;
            // ... (생략) ...
            
            // 문제의 발단: 보유 주식 리스트(Table) 그리기
            updateTable(data.stockHoldings); 
        })
        .catch(error => console.error('Error:', error));
});

3. 백엔드 수정: 리스트를 DTO에 담다

화면의 테이블에 데이터를 뿌리려면 MyAssetDto 안에 **'보유 주식 리스트'**가 있어야 합니다. 그래서 DTO에 필드를 추가하고, 서비스에서 엔티티 리스트를 그대로 넣어주었습니다. (이때까지만 해도 이게 폭탄인 줄 몰랐습니다.)

Java
// MyAssetDto.java (수정 전 - 문제의 코드)
@Getter
@AllArgsConstructor
public class MyAssetDto {
    private Long totalAssetAmount;
    // ... (기타 필드) ...
    
    // ★ 실수 포인트: Entity를 그대로 DTO에 담음
    private List<StockHolding> stockHoldings; 
}
Java
// StockHoldingService.java
public MyAssetDto getMyAssetStatus(Long userId) {
    // ... (계산 로직) ...
    
    List<StockHolding> myStocks = stockHoldingRepository.findAllByUserId(userId);

    // DTO 생성 (엔티티 리스트를 그대로 전달)
    return new MyAssetDto(..., myStocks);
}

4. 500 Internal Server Error: 서버가 비명을 지르다

코드를 수정하고 두근거리는 마음으로 새로고침을 눌렀습니다. 하지만 화면에는 아무것도 뜨지 않았고, 개발자 도구(F12) 콘솔창은 빨간색 500 에러로 도배되었습니다.

IDE 로그를 확인해보니, 끝도 없는 에러 메시지가 폭포수처럼 쏟아지고 있었습니다.

에러 로그 (요약) java.lang.StackOverflowError HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError) through reference chain: StockHolding["user"] -> User["stockHoldings"] -> StockHolding["user"] ...

5. 원인 분석: 꼬리에 꼬리를 무는 관계

로그를 천천히 뜯어보니 원인은 명확했습니다. JPA 엔티티의 양방향 관계 때문이었습니다.

  1. 순환 참조 (Infinite Recursion)
    • JSON 변환기(Jackson)가 stockHoldings 리스트를 JSON으로 만들려고 StockHolding 엔티티를 봅니다.
    • StockHolding 안에는 주인을 가리키는 User가 있습니다. ("User 정보를 적자!")
    • User 안에는 다시 stockHoldings 리스트가 있습니다. ("어? 리스트 또 있네? 적자!")
    • StockHolding -> User -> StockHolding -> User ... (무한 반복)
    • 결국 메모리가 터지면서 StackOverflowError 발생.
  2. 프록시 객체 (HibernateProxy)
    • JPA는 성능 최적화를 위해 연관된 객체(Stock 등)를 당장 가져오지 않고 **가짜 객체(Proxy)**로 넣어둡니다. (Lazy Loading)
    • Jackson 라이브러리는 이 텅 빈 가짜 객체를 어떻게 JSON으로 바꿔야 할지 몰라 또 에러를 뱉습니다. (ByteBuddyInterceptor 오류)

6. 해결책 모색: @JsonIgnore? 아니면 DTO?

처음엔 구글링을 통해 @JsonIgnore 어노테이션을 붙이면 해결된다는 글을 봤습니다. StockHolding 엔티티 안의 User 필드에 "이건 JSON으로 만들지 마!"라고 스티커를 붙이는 방식입니다.

Java
// 임시 방편
@ManyToOne
@JsonIgnore // 이렇게 하면 루프는 끊기지만...
private User user;

하지만 이건 근본적인 해결책이 아니었습니다.

  1. 엔티티가 화면(View) 로직에 의존하게 됩니다. (화면 보여주기 싫다고 DB 객체를 수정하는 꼴)
  2. 필요 없는 정보까지 다 노출됩니다. (프론트엔드는 주식 이름만 필요한데, 엔티티의 모든 정보가 다 넘어감)

7. 마치며: 정답은 '철저한 분리'다

결국 저는 **"Entity를 절대 Controller 밖으로 내보내지 말자"**는 대원칙을 다시금 깨달았습니다. 이 문제를 해결하려면 StockHolding 엔티티를 대체할 **전용 DTO(StockHoldingDto)**를 하나 더 만들고, 서비스 계층에서 Entity → DTO 변환 과정을 거쳐야 합니다.

다음 포스팅에서는 DTO 중첩 구조Stream API를 활용해 이 500 에러를 깔끔하게 해결하고, 드디어 화면에 자산 현황이 예쁘게 출력되는 최종 완성 과정을 다루겠습니다.