Spring Boot JPA: JSON 변환 시 ByteBuddyInterceptor 오류, DTO로 해결해야 하는 이유

API 개발 중 엔티티(Entity)를 직접 반환하도록 코드를 작성하고 포스트맨(Postman)을 실행했을 때, 예상치 못한 500 에러와 함께 com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor라는 긴 스택 트레이스를 마주한 경험이 있을 것입니다. 이는 단순한 문법 오류가 아니라 JPA의 지연 로딩(Lazy Loading) 메커니즘과 Jackson 라이브러리의 직렬화 방식이 충돌하여 발생하는 전형적인 아키텍처 문제입니다.

왜 Jackson은 Hibernate Proxy를 싫어할까?

이 문제의 핵심은 Hibernate가 성능 최적화를 위해 사용하는 프록시(Proxy) 기술에 있습니다. JPA에서 FetchType.LAZY로 설정된 연관 관계를 조회할 때, 하이버네이트는 실제 객체 대신 가짜 객체인 프록시(ByteBuddyInterceptor 포함)를 채워 넣습니다.

Error Log Example:
InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer

Spring Boot의 기본 JSON 라이브러리인 Jackson은 이 프록시 객체를 직렬화하려고 시도하지만, 프록시 클래스에는 직렬화할 수 있는 public 필드나 getter 메서드가 없기 때문에 예외를 발생시킵니다. 이를 해결하기 위한 실무적인 접근법 두 가지를 분석해 보겠습니다.

Solution 1: Hibernate5JakartaModule 등록 (빠른 해결)

가장 빠르게 에러를 우회하는 방법은 Jackson에게 "Hibernate 프록시는 무시해"라고 알려주는 모듈을 등록하는 것입니다. 하지만 이것은 임시방편에 가깝다는 점을 명심해야 합니다.

1. 의존성 추가 (build.gradle)

// Spring Boot 3.x (Jakarta namespace)
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'

2. Bean 등록

스프링 컨테이너에 모듈을 빈으로 등록하면 자동으로 ObjectMapper에 적용됩니다.

@Configuration
public class WebConfig {
    @Bean
    public Hibernate5JakartaModule hibernate5JakartaModule() {
        Hibernate5JakartaModule module = new Hibernate5JakartaModule();
        // 강제 지연 로딩 설정 (권장하지 않음: N+1 문제 발생 가능)
        // module.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
        return module;
    }
}
주의: FORCE_LAZY_LOADING 옵션을 켜면 연관된 모든 엔티티를 강제로 로딩하여 JSON으로 만듭니다. 이는 거대한 쿼리 폭풍(N+1 문제)을 일으켜 서버 성능을 심각하게 저하시킬 수 있습니다.

Solution 2: DTO 변환 (Best Practice)

엔티티를 직접 반환하는 것은 보안상으로도, 유지보수 측면에서도 좋지 않습니다. API 스펙이 DB 테이블 스키마에 종속되기 때문입니다. 가장 깔끔한 해결책은 필요한 데이터만 담은 DTO(Data Transfer Object)를 정의하여 반환하는 것입니다.

DTO 정의 및 변환 예시

// 1. DTO 정의
@Data
@AllArgsConstructor
public class MemberDto {
    private Long id;
    private String username;
    // Team 전체가 아닌 팀 이름만 필요하다면
    private String teamName;
}

// 2. Controller 혹은 Service에서 변환
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable Long id) {
    Member member = memberRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("Invalid ID"));
        
    // 지연 로딩이 초기화되면서 실제 쿼리가 실행됨
    return new MemberDto(member.getId(), member.getUsername(), member.getTeam().getName());
}

이 방식은 Hibernate 프록시 문제를 근본적으로 제거하며, API 스펙 변경이 엔티티에 영향을 주지 않아 유연한 설계가 가능합니다.

성능 및 안정성 비교

두 가지 해결책을 비교해 보면, 왜 실무에서 DTO 패턴을 고집하는지 알 수 있습니다.

구분 Hibernate5Module DTO 패턴 (권장)
구현 난이도 매우 쉬움 (설정만 추가) 보통 (클래스 추가 필요)
API 의존성 Entity 변경 시 API 스펙 깨짐 Entity와 API 스펙 분리됨
성능 리스크 높음 (의도치 않은 쿼리 실행) 낮음 (명시적인 데이터 조회)
순환 참조 @JsonIgnore 추가 필요 발생하지 않음
Spring REST Service 공식 가이드 보기
결론: 토이 프로젝트나 프로토타입 단계에서는 Hibernate5Module이 빠를 수 있으나, 운영 환경에서는 반드시 DTO를 사용하여 데이터 전송 계층을 분리하십시오.

Conclusion

InvalidDefinitionException은 단순한 에러라기보다 JPA와 JSON 직렬화 간의 철학적 차이에서 오는 충돌입니다. 프록시 객체를 강제로 직렬화하려 들기보다는, 클라이언트가 필요로 하는 데이터 형태(DTO)로 변환하여 응답하는 것이 백엔드 개발자의 올바른 태도입니다. 이 글에서 소개한 DTO 전략을 통해 더 안정적이고 빠른 API를 구축하시기 바랍니다.

Post a Comment