여는 글

“분명 쿼리 결과는 있는데 왜 리스트가 비어있지?”

실시간 정산 시스템의 DAO 메서드가 갑자기 빈 리스트를 반환하기 시작했습니다. 로그를 찍어보니 ResultSet의 데이터는 분명히 존재했는데, 최종 결과 리스트는 텅 비어있었죠. 더 이상했던 건 에러 로그였습니다. java.lang.NullPointerException at ConcurrentHashMap.putVal().

ConcurrentHashMap에 데이터를 넣는데 NPE라니? 이 순간부터 시작된 디버깅 여정과 그 과정에서 깨달은 자료구조 선택의 중요성을 공유합니다.

문제 상황 - 사라진 데이터의 미스터리

증상

실시간 정산 데몬이 복잡한 통계 쿼리를 실행한 후, ResultSet을 SharedMap(내부적으로 ConcurrentHashMap을 사용하는 커스텀 클래스) 리스트로 변환하는 과정에서 문제가 발생했습니다.

// 예상 동작
ResultSet (10 rows) → List<SharedMap> (10 items)

// 실제 동작
ResultSet (10 rows) → List<SharedMap> (0 items) 🤔

에러 메시지

java.lang.NullPointerException
    at java.util.concurrent.ConcurrentHashMap.putVal(ConcurrentHashMap.java:1011)
    at java.util.concurrent.ConcurrentHashMap.put(ConcurrentHashMap.java:1006)
    at com.company.common.SharedMap.put(SharedMap.java:45)
    at com.company.dao.BaseDAO.convertResultSet(BaseDAO.java:128)

NPE가 ConcurrentHashMap의 putVal 메서드에서 발생했다는 것이 핵심 단서였습니다.

문제 분석 과정 - 잘못된 가정들

첫 번째 가정: SharedMap의 구현 문제

처음엔 우리가 만든 SharedMap 클래스의 put 메서드에 문제가 있다고 생각했습니다. 하지만 코드를 확인해보니 단순히 내부 ConcurrentHashMap에 위임하는 구조였습니다.

두 번째 시도: 디버깅 포인트 추가

ResultSet을 순회하면서 각 row의 데이터를 출력해봤습니다. 데이터는 정상적으로 출력되었지만, 최종 resultList는 여전히 비어있었습니다. 이상한 점은 예외가 발생했는데도 프로그램이 죽지 않고 빈 리스트를 반환한다는 것이었습니다.

핵심 발견: 스택트레이스의 진실

스택트레이스를 다시 자세히 보니, NPE가 ConcurrentHashMap.put() 메서드에서 발생했다는 것을 확인했습니다. ConcurrentHashMap에서 NPE? 이것이 결정적인 단서였습니다.

근본 원인 - ConcurrentHashMap의 숨겨진 제약

ConcurrentHashMap은 null을 허용하지 않는다

Java의 Map 구현체들은 null 처리에 있어 서로 다른 정책을 가지고 있습니다:

Map 구현체별 null 허용 여부

구현체 null key null value 동기화
HashMap
LinkedHashMap
TreeMap
ConcurrentHashMap
Hashtable

ResultSetMetaData의 함정

문제의 코드는 대략 이런 형태였습니다:

while (rs.next()) {
    SharedMap row = new SharedMap();
    for (int i = 1; i <= columnCount; i++) {
        String columnLabel = rsmd.getColumnLabel(i);  // 여기가 문제!
        Object value = rs.getObject(i);
        row.put(columnLabel, value);  // null key로 NPE 발생
    }
    resultList.add(row);
}

ResultSetMetaData.getColumnLabel()은 컬럼의 별칭(alias)을 반환하는데, 별칭이 없으면 null을 반환할 수 있습니다. 특히 복잡한 서브쿼리에서 일부 컬럼의 별칭을 깜빡하고 누락하는 경우가 있었습니다.

문제의 SQL 예시

SELECT 
    u.user_id,
    u.user_name,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.user_id),  -- 별칭 누락!
    (SELECT SUM(amount) FROM payments WHERE user_id = u.user_id) as total_amount
FROM users u

세 번째 컬럼에 별칭이 없어서 getColumnLabel()이 null을 반환했고, 이것이 ConcurrentHashMap의 key로 사용되면서 NPE가 발생한 것입니다.

Java Map 구현체들의 특성 이해하기

Map 계층 구조

Java Collections Framework에서 Map 인터페이스의 계층 구조를 이해하는 것은 중요합니다:

주요 특징:

  • HashMap: 가장 기본적인 구현체, O(1) 평균 성능, null 허용
  • LinkedHashMap: HashMap + 삽입 순서 유지, LRU 캐시 구현 가능
  • TreeMap: Red-Black Tree 기반, O(log n) 성능, 정렬된 순서
  • ConcurrentHashMap: 세그먼트 기반 동시성, null 불허, 높은 처리량

각 구현체의 특성과 사용 시나리오

HashMap

  • 가장 기본적인 Map 구현체
  • null key와 null value 모두 허용
  • 순서 보장 안 됨
  • 단일 스레드 환경에서 최고 성능

LinkedHashMap

  • HashMap + 삽입 순서 유지
  • null key와 null value 모두 허용
  • 순회 시 예측 가능한 순서
  • LRU 캐시 구현에 유용

TreeMap

  • Red-Black Tree 기반, 정렬된 순서 유지
  • null key 불허 (정렬 불가능), null value는 허용
  • O(log n) 성능
  • 범위 검색이 필요한 경우 유용

ConcurrentHashMap

  • 스레드 안전성 보장
  • null key와 null value 모두 불허
  • 세그먼트 단위 락으로 높은 동시성
  • 멀티스레드 환경 필수

null을 허용하지 않는 이유

ConcurrentHashMap이 null을 허용하지 않는 이유는 동시성 환경에서의 모호성 때문입니다:

// HashMap에서는 가능
map.get(key) == null  // key가 없거나, value가 null

// ConcurrentHashMap에서는 명확
map.get(key) == null  // key가 없음 (value가 null일 수 없으므로)

동시성 환경에서 containsKey()와 get()을 원자적으로 수행할 수 없기 때문에, null 값의 의미가 모호해집니다.

해결 방안들

1. 방어적 프로그래밍 - Null 체크 추가

가장 직접적인 해결책은 null 체크를 추가하는 것입니다:

String columnLabel = rsmd.getColumnLabel(i);
if (columnLabel == null || columnLabel.isEmpty()) {
    columnLabel = rsmd.getColumnName(i);  // 실제 컬럼명 사용
    if (columnLabel == null) {
        columnLabel = "column_" + i;  // 기본값 생성
    }
}

2. SQL 쿼리 수정

모든 컬럼에 명시적으로 별칭을 추가합니다:

SELECT 
    u.user_id,
    u.user_name,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.user_id) as order_count,
    (SELECT SUM(amount) FROM payments WHERE user_id = u.user_id) as total_amount
FROM users u

3. Map 구현체 변경

동시성이 꼭 필요하지 않다면 LinkedHashMap으로 변경하는 것도 방법입니다:

// 변경 전
private final Map<String, Object> data = new ConcurrentHashMap<>();

// 변경 후
private final Map<String, Object> data = new LinkedHashMap<>();
// 필요시 Collections.synchronizedMap()으로 래핑

4. 커스텀 ConcurrentMap 구현

null을 특별한 객체로 치환하는 래퍼 클래스를 만들 수도 있습니다:

public class NullSafeConcurrentMap<K, V> {
    private static final Object NULL_KEY = new Object();
    private static final Object NULL_VALUE = new Object();
    private final ConcurrentHashMap<Object, Object> map = new ConcurrentHashMap<>();
    
    public void put(K key, V value) {
        map.put(maskNull(key), maskNull(value));
    }
    
    private Object maskNull(Object obj) {
        return obj == null ? NULL_KEY : obj;
    }
}

교훈과 시사점

1. 자료구조 선택의 중요성

“그냥 Map 쓰면 되지”라는 안일한 생각이 문제의 시작이었습니다. 각 Map 구현체는 고유한 특성과 제약사항을 가지고 있으며, 사용 환경에 맞는 적절한 선택이 필요합니다.

2. 방어적 프로그래밍의 필요성

“ResultSetMetaData가 null을 반환할 리 없어”라는 가정이 문제였습니다. 외부 데이터나 프레임워크 API를 다룰 때는 항상 최악의 상황을 가정해야 합니다.

3. 스택트레이스 분석의 중요성

처음엔 우리 코드의 문제라고 생각했지만, 스택트레이스를 정확히 읽으니 ConcurrentHashMap 내부에서 발생한 NPE임을 알 수 있었습니다. 정확한 에러 위치 파악이 문제 해결의 첫걸음입니다.

4. 복잡한 쿼리 관리

서브쿼리가 많아질수록 별칭 관리가 어려워집니다. 코드 리뷰 시 SQL 쿼리의 별칭도 반드시 체크해야 합니다.

베스트 프랙티스

ResultSet 처리 시 체크리스트

  1. 메타데이터 검증: getColumnLabel()과 getColumnName() 모두 체크
  2. null 처리 전략: Map 구현체에 따른 null 처리 방안 수립
  3. 에러 핸들링: 개별 row 처리 실패가 전체 실패로 이어지지 않도록
  4. 로깅: 문제 발생 시 디버깅을 위한 충분한 정보 기록

Map 선택 가이드

상황별 Map 구현체 선택 가이드

  • 단일 스레드 + null 필요 → HashMap
  • 순서 유지 + null 필요 → LinkedHashMap
  • 정렬 필요 → TreeMap
  • 멀티스레드 + null 불필요 → ConcurrentHashMap
  • 멀티스레드 + null 필요 → Collections.synchronizedMap()

SQL 작성 규칙

  1. 모든 컬럼에 명시적 별칭 부여
  2. 서브쿼리 결과에도 별칭 필수
  3. 복잡한 쿼리는 CTE(Common Table Expression) 활용
  4. 쿼리 플랜과 함께 결과 구조 문서화

맺는글

이번 트러블슈팅을 통해 당연하게 생각했던 것들을 다시 돌아보게 되었습니다. ConcurrentHashMap이 null을 허용하지 않는다는 것, ResultSetMetaData가 null을 반환할 수 있다는 것, 그리고 각 자료구조가 가진 고유한 특성들.

“왜 갑자기 안 되지?”라는 질문에서 시작된 디버깅이 자료구조에 대한 깊은 이해로 이어졌습니다. 때로는 이런 문제들이 우리를 더 나은 개발자로 만들어주는 것 같습니다.

그리고 기억하세요. NPE는 언제나 null 때문에 발생합니다. 당연한 말 같지만, 그 null이 어디서 왔는지 찾는 것이 진짜 실력입니다.