1. 문제 상황

2. 원인 분석
이 경우 대부분은 GlobalExceptionHandler가 모든 예외를 한꺼번에 잡고, 내부 로그를 찍지 않아서 발생하는 현상이다.
프로젝트에서 사용하는 ApiResponse, ResponseCode, GlobalExceptionHandler가 다음과 같이 되어 있었다.
@ExceptionHandler(RuntimeException.class)
protected ResponseEntity<ApiResponse<?>> handleRuntimeException(RuntimeException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ResponseCode.INTERNAL_SERVER_ERROR));
}
즉, 실제 예외는 존재하지만 log.error(e)가 없어서 콘솔에 찍히지 않은 것이다.
따라서 Postman에는 단순히 "서버 내부 오류"만 내려왔다.
그래서 log.error 와 sout으로 어느 부분에서 오류가 나는지 확인해보았다.
@RestController
@RequestMapping("/api/alerts")
@RequiredArgsConstructor
public class AlertHistoryController {
private final AlertHistoryService alertHistoryService;
@GetMapping("/today")
public ResponseEntity<?> getTodayAlertHistories(@AuthUser Long userId) {
System.out.println(">>> [DEBUG] /api/alerts/today userId=" + userId);
var histories = alertHistoryService.getTodayHistoryByUser(userId);
System.out.println(">>> [DEBUG] result size=" + histories.size());
return ResponseEntity.ok(histories);
}
}
@ExceptionHandler(RuntimeException.class)
protected ResponseEntity<ApiResponse<?>> handleRuntimeException(RuntimeException e) {
log.error("[INTERNAL_SERVER_ERROR]", e);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ResponseCode.INTERNAL_SERVER_ERROR));
}
원인을 파악한 결과, Hibernate는 정상적으로 데이터를 조회한 후 컨트롤러에서 엔티티를 그대로 리턴하면서 문제가 발생했다.


AlertHistory 엔티티는 내부에 Lazy 로딩 관계를 갖고 있었다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "alert_id", nullable = false)
private Alert alert;
이 Lazy 필드를 JSON으로 직렬화하려다가
Alert → AlertHistory → Alert ... 순환 참조 구조에 빠져 직렬화 예외(StackOverflowError) 가 발생한 것이다.
하지만 이 예외가 handler로 인해 잡혀 숨겨졌고 Postman에서만 INTERNAL_SERVER_ERROR가 노출된 것이었다.
2-1. 왜 Lazy 로딩이 문제일까
Lazy 로딩 시, Hibernate는 실제 Alert 엔티티 대신 org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor 가 붙은 프록시 객체를 반환하는 것이다.
즉, 이런 형태인 것이다.
alertHistory.getAlert(); // 실제로는 AlertProxy (ByteBuddy Proxy)
이 프록시는 DB를 아직 조회하지 않은 상태이고 필요할 때 세션을 통해 실제 데이터를 가져오게 된다.Spring Boot의 기본 설정은 spring.jpa.open-in-view=true라서 트랜잭션이 끝나도 Lazy 필드를 직렬화 시 접근할 수 있기 때문이다.
근데 JSON 직렬화(Jackson) 시점에서 Controller에서
return ResponseEntity.ok(alertHistory);
이런 식으로 리턴하면
Spring MVC는 내부적으로 Jackson(ObjectMapper) 를 써서 객체를 JSON으로 바꾼다.
문제는 이때 발생하는데 Hibernate Proxy는 순수 자바 객체가 아니라서 ByteBuddyInterceptor 필드를 가지고 있고 Jackson이 이걸 직렬화하려다 아래 오류가 난다
InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor
“이 프록시 안에 뭘 직렬화해야 하는지 모르겠음!”
3. 해결 방법
DTO(record)로 변환하여 반환하는 것이다. 엔티티를 그대로 반환하지 말고, DTO를 만들어서 필요한 데이터만 선택적으로 리턴한다.
@GetMapping("/today")
public ResponseEntity<?> getTodayAlertHistories(@AuthUser Long userId) {
var histories = alertHistoryService.getTodayHistoryByUser(userId)
.stream()
.map(AlertHistoryDto::from)
.toList();
return ResponseEntity.ok(histories);
}
public record AlertHistoryDto(
Long id,
LocalDateTime createdAt,
String indicatorSnapshot,
String stockCode
) {
public static AlertHistoryDto from(AlertHistory entity) {
return new AlertHistoryDto(
entity.getId(),
entity.getCreatedAt(),
entity.getIndicatorSnapshot(),
entity.getAlert() != null ? entity.getAlert().getStockCode() : null
);
}
}
→ Lazy 로딩된 Alert 전체를 건드리지 않고 alertId까지만 접근하므로 순환 참조 및 직렬화 오류가 사라진다.
'[AND] 사용자 정의 지표 기반 실시감 감지 및 자동매매 시스템 > 기술' 카테고리의 다른 글
| [Redis] private subnet에 있는 redis를 redis insight를 확인하고 싶을 때 (6) | 2025.10.25 |
|---|---|
| [FCM] Spring boot와 FCM(firebase) 연결하기 (0) | 2025.10.17 |
| [JavaBean] boolean 값이 계속해서 false가 되는 이유 (0) | 2025.10.17 |
| [JWT] Spring Boot security 와 JWT로 인증/인가 구현하기(2) - 설정 파일, 예외 처리 (0) | 2025.10.06 |
| [JWT] Spring Boot security 와 JWT로 인증/인가 구현하기(1) - Access Token, Refresh Token과 멀티 디바이스 (7) | 2025.10.05 |