QueryDSL에서 NPE 해결하기 - 서브쿼리와 외부 조인의 활용
📌 서론
QueryDsl을 이용해서 쿼리문을 작성하던 중 NPE가 발생하는 이슈가 생겼다. 이유는 join의 방식에 있었는데 같이 알아보자.
문제 상황
회원 정보와 함께 프로필 이미지 경로를 조회하는 QueryDSL을 작성하는 과정에서, 특정 문제에 직면했다. 코드는 다음과 같다:
jpaQueryFactory
.select(Projections.fields(MyPage.class,
memberEntity.id.as("memberId"),
memberEntity.memberFileEntity.storedFilePath.as("profileImageFilePath"),
memberEntity.nickname.as("nickname"),
memberEntity.introduction.as("introduction")
))
.from(memberEntity)
.where(memberEntity.id.eq(memberId))
.fetchOne();
이 코드에서 memberEntity와 memberFileEntity 간의 내부 조인이 수행된다.
문제는 회원 파일에 해당하는 데이터가 없을 경우에 발생한다. 조인 조건이 맞지 않으면 Null Pointer Exception (NPE)이 발생한다. 프로젝트에서 프로필 이미지는 필수값이 아니기 때문에, 이미지가 없는 경우가 자주 발생하여 이 문제를 해결해야 했다.
원인 분석: 왜 NPE가 발생하는가?
위 코드에서 NPE가 발생하는 주된 이유는 내부 조인(inner join) 때문이다. 내부 조인은 두 테이블 간의 매칭되는 데이터가 반드시 존재해야 한다. 즉, memberEntity에는 데이터가 있지만 memberFileEntity에 해당 회원의 데이터가 없는 경우, 조인이 실패하여 결과로 null을 반환하게 되고, 이후 null에 접근하려 할 때 NPE가 발생한다. 즉, 프로필 이미지가 없는 회원을 조회하려고 하면, memberFileEntity와의 조인이 실패하고, 결과적으로 NPE가 발생하는 것이다.
해결 방안 1 - 서브쿼리 사용
서브쿼리를 사용하면, 메인 쿼리와 독립적으로 실행되며, 관련 데이터가 없는 경우 null 값을 반환한다. 이 방식은 내부 조인의 제한을 피하면서도 필요한 데이터를 안전하게 조회할 수 있다.
수정된 코드는 다음과 같다:
JPQLQuery<String> filePathSubQuery = JPAExpressions
.select(memberFileEntity.storedFilePath)
.from(memberFileEntity)
.where(memberFileEntity.memberEntity.id.eq(memberId));
jpaQueryFactory
.select(Projections.fields(MyPage.class,
memberEntity.id.as("memberId"),
ExpressionUtils.as(filePathSubQuery, "profileImageFilePath"),
memberEntity.nickname.as("nickname"),
memberEntity.introduction.as("introduction")
))
.from(memberEntity)
.where(memberEntity.id.eq(memberId))
.fetchOne();
이 코드에서, filePathSubQuery는 프로필 이미지의 경로를 조회하는 독립적인 서브쿼리다. 만약 해당 회원의 프로필 이미지가 없다면, 이 서브쿼리는 null을 반환한다. 이후 메인 쿼리에서 ExpressionUtils.as를 사용하여 서브쿼리의 결과를 'profileImageFilePath'로 매핑한다. 이 방식은 회원의 프로필 이미지가 없는 경우에도 NPE를 발생시키지 않고 안전하게 처리할 수 있도록 해준다.
실제로 실행된 쿼리문은 다음과 같다.
해결 방안 2 - 외부조인 사용
메인 쿼리에서 memberFileEntity의 데이터를 포함시키되, '외부 조인(outer join)'을 사용하면 문제를 해결할 수 있다. 이렇게 하면 관련 데이터가 없어도 에러가 발생하지 않고, 결과에서 해당 필드가 null로 처리된다.
jpaQueryFactory
.select(Projections.fields(MyPage.class,
memberEntity.id.as("memberId"),
memberEntity.memberFileEntity.storedFilePath.as("profileImageFilePath"),
// 기타 필드
))
.from(memberEntity)
.leftJoin(memberEntity.memberFileEntity) // 외부 조인 사용
.where(memberEntity.id.eq(memberId))
.fetchOne();
이 코드에서는 leftJoin() 메소드를 사용하여 memberEntity와 memberFileEntity 간의 외부 조인을 수행한다. 이 방법을 사용하면, 관련된 memberFileEntity 데이터가 없는 경우에도 profileImageFilePath는 null로 반환되며, 에러는 발생하지 않는다.
실제로 실행된 쿼리문은 다음과 같다.
각 해결 방안의 장단점 비교
서브쿼리 사용의 장단점
장점
- 독립성: 서브쿼리는 메인 쿼리와 독립적으로 실행되며, 메인 쿼리의 결과에 영향을 주지 않는다.
- 유연성: 관련 데이터가 없는 경우 null을 반환하여, 데이터의 유무에 따라 유연하게 대응할 수 있다.
- NPE 방지: 데이터가 없어도 Null Pointer Exception (NPE)이 발생하지 않는다.
단점
- 성능 문제: 서브쿼리는 각 행마다 별도의 쿼리를 실행할 수 있어, 대규모 데이터에 대해서는 성능 저하가 발생할 수 있다.
- 복잡성 증가: 쿼리의 구조가 복잡해져서 유지보수가 어려워질 수 있다.
외부조인 사용의 장단점
장점
- 효율성: 하나의 쿼리에서 모든 데이터를 처리할 수 있어, 서브쿼리보다 성능적으로 유리할 수 있다.
- 간결함: 쿼리의 구조가 간결하고 이해하기 쉬워, 유지보수가 용이하다.
단점
- 데이터 무결성: 관련 데이터가 없는 경우 null로 처리되므로, 결과 데이터의 무결성을 신중하게 관리해야 한다.
- 조인 순서: 복잡한 쿼리에서 조인 순서에 따라 예상치 못한 결과가 발생할 수 있다.
프로젝트 내 결정
위에서 살펴본 두 가지 해결 방안을 고려한 후, 최종적으로 우리는 서브쿼리를 이용해 이 문제를 해결하기로 결정했다. 그 이유는 다음과 같다.
- 데이터 무결성 우선: 프로필 이미지가 필수가 아니며, 데이터가 없는 경우도 자주 발생하기 때문에, NPE를 방지하고 데이터 무결성을 유지하는 것이 중요했다.
- 쿼리의 복잡성 관리: 프로젝트의 현재 단계와 데이터 규모를 고려했을 때, 서브쿼리의 복잡성은 관리 가능한 수준이었다.
- 성능 고려: 프로필 이미지 데이터의 규모가 크지 않아 서브쿼리 사용 시 성능 저하가 크게 우려되지 않았다.
결론
이번 기회를 통해 서브쿼리와 외부 조인이라는 두 가지 중요한 쿼리 방식에 대해 깊이 이해할 수 있었다.
그동안 나는 Native Query 작성에 더 익숙했기 때문에, QueryDSL을 사용하면서 처음에는 다소 어려움을 겪었다. Native Query로 작성했다면 발생하지 않았을 에러들이 QueryDSL을 사용하면서 이슈가 됐지만 앞으로 QueryDSL을 계속 사용하면서 익숙해지도록 노력해야겠다.
스프링 이벤트 리스너 테스트를 위한 @SpyBean과 @MockBean의 활용
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring 트러블 슈팅' 카테고리의 다른 글
QueryDSL에서 Projections.constructor 사용해서 SQL 함수 사용하기 (0) | 2024.02.01 |
---|---|
스칼라 서브쿼리(Scalar Subquery)에서 Limit절 오류 해결 (0) | 2024.01.25 |
단위테스트에서 Static 메소드 Mock 주입 문제 해결 방법 (5) | 2023.12.25 |
스프링 시큐리티: 커스텀 에러 처리 실패 원인 분석 및 해결 방법 (0) | 2023.12.23 |
로그인 실패 문제 - BCryptPasswordEncoder의 Salt 값과 Matches 메소드 이해하기 (2) | 2023.12.21 |