kkokkio - 프로젝트/트러블슈팅

JPA Query에 Pagination을 쓸 때 조심할 점(with 500 에러)

파란배개 2025. 6. 16. 09:33

🔥 로컬과 배포 등 싸움에 500 에러가 터진다 🔥


문제 개요

오류 상황과 영향 범위 (예: 특정 코드 실행 시 발생, 특정 라이브러리 버전 호환 문제 등) 발생 배경 (예: 코드 테스트 중, 배포 중 문제 발견 등)

평화로웠 5월 3일 오후. 나는 여느때와 다름없이 꿀잠을 즐기고 있었다. 청천벽력같은 FE 개발자님의 오류 수정 요청 댓글 전까지는.

/news/top

/videos/top

하필 이 두 엔드포인트에서 500 INTERNAL_SERVER_ERROR가 난다…! 로컬 환경에서는 404에러가 뜨는데, 배포 환경에서는 500에러가 뜨는 기이한 현상이 발생한 것이다.

오류 메시지 및 원인 분석

오류 코드, 오류 메시지 포함 HTTP 상태 코드, 애플리케이션 로그, 서버 로그 등 구체적인 오류 정보 정리 및 로그 메시지를 통한 분석 제공 문제 발생 원인에 대한 심층 분석

배포 환경에서 나오는 로그이다.

2025-05-04T10:57:13.610Z DEBUG 16 --- [kkokkio] [           main] org.hibernate.SQL                        : 
    /* select
        count(*) 
    from
        Member x */ select
            count(*) 
        from
            member m1_0
2025-05-04T10:57:13.643Z  INFO 16 --- [kkokkio] [           main] site.kkokkio.global.init.BaseInitData    : 이미 회원 데이터가 존재합니다.
2025-05-04T11:08:49.056Z  INFO 16 --- [kkokkio] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2025-05-04T11:08:49.056Z  INFO 16 --- [kkokkio] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2025-05-04T11:08:49.060Z  INFO 16 --- [kkokkio] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 4 ms
2025-05-04T11:08:49.269Z DEBUG 16 --- [kkokkio] [nio-8080-exec-1] org.hibernate.SQL                        : 
    /* <criteria> */ select
        kmh1_0.bucket_at,
        kmh1_0.keyword_id,
        kmh1_0.platform,
        kmh1_0.created_at,
        kmh1_0.low_variation,
        kmh1_0.no_post_streak,
        kmh1_0.novelty_ratio,
        kmh1_0.post_id,
        kmh1_0.rank_delta,
        kmh1_0.score,
        kmh1_0.updated_at,
        kmh1_0.volume,
        kmh1_0.weighted_novelty 
    from
        keyword_metric_hourly kmh1_0 
    order by
        kmh1_0.created_at desc 
    limit
        ?
2025-05-04T11:08:49.425Z DEBUG 16 --- [kkokkio] [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        k1_0.keyword_id,
        k1_0.created_at,
        k1_0.text,
        k1_0.updated_at 
    from
        keyword k1_0 
    where
        k1_0.keyword_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2025-05-04T11:08:49.501Z DEBUG 16 --- [kkokkio] [nio-8080-exec-1] org.hibernate.SQL                        : 
    /* SELECT
        DISTINCT s 
    FROM
        KeywordMetricHourly kmh 
    LEFT JOIN
        kmh.post p 
    JOIN
        PostSource ps 
            ON ps.post = p 
    JOIN
        ps.source s 
    WHERE
        kmh.id.keywordId IN (:topKeywordIds) 
        AND s.platform = :platform 
    ORDER BY
        s.publishedAt DESC,
        kmh.score desc */ select
            distinct s1_0.fingerprint,
            s1_0.created_at,
            s1_0.description,
            s1_0.normalized_url,
            s1_0.platform,
            s1_0.published_at,
            s1_0.thumbnail_url,
            s1_0.title,
            s1_0.updated_at 
        from
            keyword_metric_hourly kmh1_0 
        left join
            post p1_0 
                on p1_0.post_id=kmh1_0.post_id 
        join
            post_source ps1_0 
                on ps1_0.post_id=p1_0.post_id 
        join
            source s1_0 
                on s1_0.fingerprint=ps1_0.fingerprint 
        where
            kmh1_0.keyword_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 
            and s1_0.platform=? 
        order by
            s1_0.published_at desc,
            kmh1_0.score desc 
        limit
            ?
2025-05-04T11:08:49.513Z  WARN 16 --- [kkokkio] [nio-8080-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 3065, SQLState: HY000
2025-05-04T11:08:49.513Z ERROR 16 --- [kkokkio] [nio-8080-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : Expression #2 of ORDER BY clause is not in SELECT list, references column 'kkokkio.kmh1_0.score' which is not in SELECT list; this is incompatible with DISTINCT
2025-05-04T11:08:49.559Z ERROR 16 --- [kkokkio] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.orm.jpa.JpaSystemException: JDBC exception executing SQL [/* SELECT DISTINCT s FROM KeywordMetricHourly kmh LEFT JOIN kmh.post p JOIN PostSource ps ON ps.post = p JOIN ps.source s WHERE kmh.id.keywordId IN (:topKeywordIds) AND s.platform = :platform ORDER BY s.publishedAt DESC, kmh.score desc */ select distinct s1_0.fingerprint,s1_0.created_at,s1_0.description,s1_0.normalized_url,s1_0.platform,s1_0.published_at,s1_0.thumbnail_url,s1_0.title,s1_0.updated_at from keyword_metric_hourly kmh1_0 left join post p1_0 on p1_0.post_id=kmh1_0.post_id join post_source ps1_0 on ps1_0.post_id=p1_0.post_id join source s1_0 on s1_0.fingerprint=ps1_0.fingerprint where kmh1_0.keyword_id in (?,?,?,?,?,?,?,?,?,?) and s1_0.platform=? order by s1_0.published_at desc,kmh1_0.score desc limit ?] [Expression #2 of ORDER BY clause is not in SELECT list, references column 'kkokkio.kmh1_0.score' which is not in SELECT list; this is incompatible with DISTINCT] [n/a]] with root cause

Query에서 문제가 난 것이다. 제미니는

- PostSourceRepository의 findSourcesByTopKeywordIdsAndPlatform에 SELECT DISTINCTORDER BY kmh.score 충돌로 인한 SQL 오류 (500 ERROR) 발생

이라고 했지만 이상했다. 나는 레포지터리의 findSourcesByTopKeywordIdsAndPlatform에 그런 ORDER BY문을 적지 않았기 때문이다.

해결 및 고민 과정

처음 문제를 접했을 때 고려했던 대안들과 선택하지 않은 이유 실험적으로 적용한 방법이 실패한 사례와 그 이유 여러 해결책 중 최적의 방안을 선택한 과정

우선은 제미니로 오류를 풀어 나갔다. 로컬에서는 404 에러 (정상 응답)이 뜨고 배포 환경에서는 500 에러가 뜨니 미치고 환장할 노릇이었다. 결국 배포를 담당한 팀원분께 양해를 드려 같이 문제를 풀었다. 팀원분이 유료 GPT로 문제를 풀어달라했더니

<aside> <img src="attachment:662c45bb-02a7-419f-859e-7e52ea845e3f:gpt.png" alt="attachment:662c45bb-02a7-419f-859e-7e52ea845e3f:gpt.png" width="40px" />

JPA로 인한 오류입니다. ORDER BY kmh.score이 PostSourceRepository의 findSourcesByTopKeywordIdsAndPlatform에 없어 일어난 문제입니다.

</aside>

라고 답변을 했다..! JPA 문제라는 것을 알고나니 문제를 해결할 수 있었다.

최종 해결책 및 구현

문제 해결을 위한 단계별 조치 방법과 코드 예시 (예: 특정 애노테이션 추가, 라이브러리 버전 조정, 설정 변경 등) 참고 자료 (관련 공식 문서, 개발자 커뮤니티 게시물 등)

우선 /news/top이나 /videos/top은 인기 키워드에 관련된 영상을 인기순으로 조회해야되기 때문에 PostSourceRepository에 Page를 직접 반환하는 JPQL SELECT new 쿼리를 작성하여 정렬 기준을 score DESC로 명시했다.

/**
	 * 실시간 인기 키워드 ID와 플랫폼 기반으로, 출처(Source) 정보를 score 기준 내림차순 정렬하여 DTO로 조회.
	 * DISTINCT 문제 해결을 위해 SELECT new 사용.
	 */
	@Query("""
            SELECT new site.kkokkio.domain.source.dto.TopSourceItemDto(
                s.normalizedUrl,
                s.title,
                s.thumbnailUrl,
                s.publishedAt,
                s.platform,
                kmh.score
                )
            FROM KeywordMetricHourly kmh
            LEFT JOIN kmh.post p
            JOIN PostSource ps
            ON ps.post = p
            JOIN ps.source s
            WHERE kmh.id.keywordId
            IN (:topKeywordIds)
                AND s.platform = :platform
            ORDER BY kmh.score DESC
            """)
	Page<TopSourceItemDto> findTopSourcesByKeywordIdsAndPlatformOrderedByScore(
			@Param("topKeywordIds") List<Long> topKeywordIds,
			@Param("platform") Platform platform,
			Pageable pageable
	);

그리고 DISTINCT 문제를 피하기 위해 TopSourceItemDto에 score(기본 값 0) 포함시켰다!

@Builder
@Schema(description = "실시간 인기 출처 목록의 개별 항목 DTO")
public record TopSourceItemDto(
        @NonNull String url,
        @NonNull String title,
        @NonNull String thumbnailUrl, // 이후 @NonNull 제거
        @NonNull LocalDateTime publishedAt,
        @NonNull Platform platform,
        int score // 이 부분 추가!
) {
    public static TopSourceItemDto fromSource(Source source) {
        return TopSourceItemDto.builder()
                .url(source.getNormalizedUrl())
                .title(source.getTitle())
                .thumbnailUrl(source.getThumbnailUrl())
                .publishedAt(source.getPublishedAt())
                .platform(source.getPlatform())
                .score(0) // 이 부분 추가!
                .build();
    }
}

이후에 SourceService에서 기존 repository 호출 변경을 하여 수정했다!

간단하게 말하면 score를 dto에 추가해 따로 score 값을 포함한 dto를 service의 메서드를 통해 가공해 controller로 나오게 한 것이다..!

결과 및 성능 분석

개선 후 응답 속도, DB 부하 감소 수치 등 구체적인 데이터 포함 문제 해결 전/후 성능 비교 그래프나 로그 분석 내용 포함 이슈 트래킹 및 자동화 방안 추가 (내부 개발자가 반복적인 문제 해결을 효율적으로 관리할 수 있도록 가이드 제공)

/videos/top은 성공했다!! 🥳🥳🥳🥳

그런데 /news/top이 되질 않았다!!! 🤬🤬🤬🤬

2025-05-04T12:25:45.630Z ERROR 15 --- [kkokkio] [nio-8080-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.orm.jpa.JpaSystemException: Error instantiating class 'site.kkokkio.domain.source.dto.TopSourceItemDto'] with root cause

java.lang.NullPointerException: thumbnailUrl is marked non-null but is null
        at site.kkokkio.domain.source.dto.TopSourceItemDto.<init>(TopSourceItemDto.java:16) ~[!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(Unknown Source) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Unknown Source) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstance(Unknown Source) ~[na:na]
        at org.hibernate.sql.results.graph.instantiation.internal.DynamicInstantiationAssemblerConstructorImpl.assemble(DynamicInstantiationAssemblerConstructorImpl.java:52) ~[hibernate-core-6.6.11.Final.jar!/:6.6.11.Final]

이 문제는 간단하게 TopSourceItemDto의 thumbnailUrl이 Null을 허용해야된다고 해서 생긴 문제였다. 그래서 간단히 @NonNull을 제거해주었더니 잘 해결되었다!

추가 개선점

유사한 문제를 방지하기 위한 주의할 점 (예: 애노테이션 명시 여부, 프레임워크 업데이트 주기 확인 등) 문제 해결 과정에서 배운 점 및 중요한 교훈 공유 보안 고려 사항 추가 (해결 과정에서 발생할 수 있는 보안 리스크 및 예방 조치 포함)

JPA에서 잘 배워둬야겠다고 다짐하는 계기가 되었다. 워낙 내가 담당한 코드가 이것저것 연쇄로 연결되어있기도 해서 잘 파악을 해야겠다고 느꼈다!