여는 글

거래대사 리포팅 기능을 개발하면서 기존 레거시 시스템의 DAO 라이브러리를 사용하여 엑셀 파일 생성을 위한 데이터를 불러오던 중 오류가 발생했습니다. 로그를 찍어보니 데이터는 분명히 존재했는데, 최종 결과 리스트는 텅 비어있었죠. 더 이상했던 건 에러 로그였습니다. java.lang.NullPointerException at ConcurrentHashMap.putVal().

데이터가 있는데 NPE라니?

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

증상

쿼리를 실행한 후, ResultSet을 내부적으로 ConcurrentHashMap을 상속받는 커스텀 클래스 리스트로 변환하는 내부 코드에서 문제가 발생했습니다.

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 메서드에서 발생했다는 것이 핵심 단서였습니다.

문제 분석 과정

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

조회한 데이터를 순회하면서 각 row의 데이터를 출력해봤습니다. 데이터는 정상적으로 출력되었지만, 엑셀을 만들기 위한 List객체는 여전히 비어있었습니다.

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

스택트레이스를 다시 자세히 보니, NPE가 ConcurrentHashMap.put() 메서드에서 발생했다는 것을 알 수 있었는데, 이것이 결정적인 단서였습니다. put 어찌 되었건 put이 문제구나

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

ConcurrentHashMap은 null을 허용하지 않는다

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

Map 구현체별 null 허용 여부

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

ResultSetMetaData의 함정

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

while (rs.next()) {
    커스텀맵 row = new 커스텀맵();
    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가 발생한 것입니다.

여기서 한 가지 더 주의할 점이 있습니다. ConcurrentHashMap은 null key뿐만 아니라 null value도 허용하지 않습니다. 즉, DB에서 조회한 컬럼 값이 NULL인 경우 rs.getObject()가 Java null을 반환하고, 이것 역시 NPE의 원인이 됩니다.

while (rs.next()) {
    커스텀맵 row = new 커스텀맵();
    for (int i = 1; i <= columnCount; i++) {
        String columnLabel = rsmd.getColumnLabel(i);  // null key 가능
        Object value = rs.getObject(i);               // null value 가능 (DB NULL)
        row.put(columnLabel, value);  // 둘 중 하나라도 null이면 NPE!
    }
    resultList.add(row);
}

정확히 말하면, 조회한 Row 데이터 자체는 정상이었습니다. 하지만 Map에 put하는 시점에 null key(또는 null value)가 들어가면서 NPE가 발생했고, 해당 row 전체가 리스트에 추가되지 못한 것이죠.

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 체크를 추가하는 것입니다.

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

// Value null 체크 (DB NULL 값 처리)
Object value = rs.getObject(i);
if (value == null) {
    value = "";  // 또는 적절한 기본값, 혹은 해당 컬럼 스킵
}

row.put(columnLabel, value);

하지만 이러한 방법으로 NPE는 막을 수 있겠지만, 기본값을 가진 Key를 사용하거나 빈 문자열로 NULL을 대체하는 것이 비즈니스 로직상 유의미한 처사는 아니겠죠.

2. SQL 쿼리 수정

핵심적인 근본 해결이긴 합니다. 모든 컬럼에 명시적으로 별칭을 추가합니다.

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. 자료구조 선택의 중요성

ConcurrentHashMap을 목적에 맞지 않게 모든 곳에 별생각 없이 사용하던 것이 문제의 시작이었습니다. 각 Map 구현체는 고유한 특성과 제약사항을 가지고 있으며, 사용 환경에 맞는 적절한 선택이 필요합니다.

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

“ResultSetMetaData가 null을 반환할 리 없어”라는 가정이 문제였습니다. 외부 데이터나 프레임워크 API를 다룰 때는 항상 null 체크가 필수입니다.

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

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

4. 복잡한 쿼리 관리

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

베스트 프랙티스

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이 어디서 왔는지 찾는 것이 진짜 실력입니다.