Spring

QueryDSL fetchOne() OOM 문제 분석 및 해결 후기

SpearZero 2025. 5. 8. 21:16

문제 발생

어느 날 회사 슬랙에 인증 서버에서 문제가 발생했다는 알림이 도착했다. 다행히 인증 서버는 OOM(Out Of Memory) 발생 시 힙 덤프를 자동으로 생성하도록 설정되어 있었고, 이를 기반으로 메모리 덤프를 분석할 수 있었다.

 

분석 결과, 약 73만 개가 넘는 회원 정보 객체가 메모리에 적재되었고, 이로 인해 OOM이 발생한 것으로 확인되었다. 왜 그렇게 많은 객체가 한 번에 올라왔는지 추적하기 위해 스택 트레이스를 분석한 결과, 특정 사용자의 설정 정보를 조회하기 전, 회원 정보를 불러오는 로직에서 문제가 발생하고 있었다.

 

기존에 작성되어 있던 코드에서는 fetchOne()으로 특정 회원의 정보를 가져오고 있었다.

// 문제가 발생한 부분 (QueryDSL 버전은 5.0.0 버전 사용중)
return Optional.ofNullable(
    jpaFactory
        .selectFrom(...)
        .where(...)
        .fetchOne()
);

처음에는 fetchOne()을 호출한 부분에서 문제가 발생했다고 스택 트레이스에 나와 있었기 때문에, "하나만 가져오는 메서드인데 왜 OOM이 발생하지?" 라는 의문이 들었다. 이 부분이 이상하게 느껴져, 실제로 fetchOne()의 내부 동작 방식을 직접 확인해 보기로 했다.

 

내부 동작 분석

(이슈가 발생한 당시 사용 중이던 QueryDSL 버전은 5.0.0이었다.)

 

fetchOne() 내부를 살펴본 결과 다음과 같은 순서로 데이터를 가져오고 있었다.

 

 

해당 코드에서 (T) getSingleResult(query) 부분을 살펴보니 다음과 같았다.

 

해당 코드에서 query.getSingleResult()의 내부 구현을 살펴보면 다음과 같은 코드가 나온다.

이 과정에서 쿼리 결과로 73만 개가 넘는 데이터를 불러오고, 이후 uniqueElement(list) 메서드가 이를 처리하면서 OOM 오류가 발생한 것이었다.

uniqueElement는 리스트의 크기를 확인하여, 결과가 0개면 null을, 1개면 해당 값을 반환하고, 2개 이상일 경우 예외를 던지는 구조다. 이 메서드 자체에는 문제가 없었지만, 73만 개의 결과가 담긴 리스트를 생성하는 시점에서 이미 메모리가 포화 상태였고, 이 리스트를 uniqueElement()에 전달하려는 과정에서 결국 OOM이 발생한 것으로 보였다.

 

원인 분석과 해결

이제 남은 문제는, 어떻게 해서 73만 건이나 되는 데이터가 조회되었는가였다. 이에 대한 원인은 비교적 쉽게 찾을 수 있었다. 에러가 발생한 시점의 서버 로그를 확인해보니, 앱에서 서버로 전달된 유저 키 값은 -1, 이메일은 공백 문자열이었다.

 

기존에 작성되어 있던 서버 측 컨트롤러 코드에서는 유저 키와 이메일이 null인지 여부만 검사하고 있었고, -1이나 공백 문자열은 유효하지 않은 값임에도 불구하고 그대로 통과되었다. 이후 쿼리 조건을 만들 때 유저 키가 -1이면 null로, 이메일이 공백이면 역시 null로 치환되었고, 그 결과 아무 조건 없이 '탈퇴하지 않은 모든 회원'을 조회하는 쿼리가 실행되었다.

 

그리고 그 쿼리는 fetchOne() 으로 실행되었지만, 결국 수십만 건을 메모리에 적재하게 되었고, OOM은 그렇게 발생한 것이었다. 해당 문제는 유효하지 않은 입력값이 조건절에 포함되지 않도록 로직을 수정함으로써 해결했다.(당시 로직을 작성했던 분도 유저 키가 -1이거나 이메일이 공백으로 들어오는 경우까지는 예상하지 못했던 것으로 보이며, 실제로 그러한 값으로 해당 API를 호출할 것이라 가정하기 어려웠던 상황이었다.  앱 측에서 파라미터 전달 로직에서의 실수로 인해 발생한 문제였던 것으로 판단된다.)

 

의문점 및 해결

한편, 코드를 분석하는 과정에서 또 하나의 의문이 생겼다. uniqueElement() 내부를 보면 리스트를 순회하며 값의 일치 여부를 비교하는 로직이 있었는데, 단순히 하나의 결과만 가져온다고 생각했던 fetchOne()이 왜 이런 식으로 동작하는지 처음엔 잘 이해되지 않았다.

 

궁금증을 해결하기 위해 QueryDSL 관련 문서를 찾아보니 다음과 같았다.

fetchOne()은 단순히 첫 번째 결과를 가져오는 것이 아니라, 유니크한 값을 가져오거나 null을 가져오는 것이었다. 즉 해당 리스트를 순회하면서 하나의 값이 유니크한지 비교하기 위해 반복을 하는 것이었다.

 

이번 경험을 통해 익숙하게 사용하던 메서드라도, 그 내부 동작을 정확히 이해하지 않으면 예상치 못한 장애로 이어질 수 있다는 사실을 다시금 느꼈다. 앞으로는 자주 사용하는 API일수록, 내부 동작 방식까지 이해하고 사용하는 습관을 들여야 할 것 같다.