본문 바로가기
빌드노트 검색

빌드노트

computeIfAbsent를 썼는데 없는 값 조회가 계속 실행된다

computeIfAbsent는 조회 함수가 null을 반환하면 새 매핑을 저장하지 않는다. 같은 키를 두 번 조회한 호출 횟수와 기존 null 매핑을 비교하고, 부재 결과까지 보관하려면 Optional.empty()처럼 null이 아닌 값을 저장해야 하는 이유를 확인한다.

같은 Map과 같은 키로 computeIfAbsent를 호출하는데, 값을 찾지 못할 때마다 조회 함수가 다시 실행될 수 있다. 먼저 그 함수의 최종 반환값을 확인한다. 조회 함수가 null을 반환하면 computeIfAbsent는 새 매핑을 저장하지 않는다. “조회했지만 없었다”는 결과도 보관하려면 Optional.empty()처럼 null이 아닌 값으로 표현해야 한다.

Java 17의 Map.computeIfAbsent 문서 (새 탭에서 열림)는 키가 없거나 기존 값이 null이면 계산 함수를 실행하고, 그 결과가 null이면 새 매핑을 기록하지 않는다고 설명한다. Java 21 문서 (새 탭에서 열림)에서도 같은 계약을 확인했다. 메서드 이름의 Absent를 “키 자체가 없는 경우만”으로 읽으면 기존 null 매핑에서의 동작을 놓치게 된다.

두 번 조회한 뒤 함수 호출 수와 키를 함께 본다

준비물은 JDK 17과 그 안에 포함된 JShell이다. 터미널에서 java -version으로 버전을 확인하고 jshell --execution local로 시작한다. 명령을 찾지 못하면 JDK 설치와 bin 경로를 확인한다. 아래 입력은 이름이 없다고 가정해 항상 null을 반환하는 합성 조회 함수다. 실제 DB나 네트워크에는 접근하지 않는다. 같은 키를 두 번 조회해 함수 호출 수와 저장 상태를 확인한다.

import java.util.*;
import java.util.function.Function;

var cache = new HashMap<String, String>();
int[] calls = {0};
Function<String, String> loadName = key -> {
    calls[0]++;
    return null;
};
for (int i = 0; i < 2; i++) {
    cache.computeIfAbsent("user-7", loadName);
}
System.out.println("calls=" + calls[0] + ", containsKey="
    + cache.containsKey("user-7") + ", value=" + cache.get("user-7"));

Windows의 OpenJDK 17.0.2, 런타임 빌드 17.0.2+8-86에서 다음 출력을 확인했다. JShell의 변수 선언 안내는 제외하고 직접 출력한 줄만 옮겼다.

calls=2, containsKey=false, value=null

첫 호출에서 null이 반환돼 키가 추가되지 않았다. 두 번째 호출도 같은 부재 상태에서 시작하므로 loadName을 다시 실행했다. 반환값만 보면 두 호출이 모두 null이라 저장한 null을 꺼낸 것처럼 보일 수 있지만, calls=2와 containsKey=false를 함께 보면 계산 결과가 저장되지 않았음을 알 수 있다.

비교하려면 새 JShell 세션에서 위 입력의 return null;만 return "Mina";로 바꿔 실행한다. 실제 출력은 calls=1, containsKey=true, value=Mina였다. 첫 호출이 문자열을 저장했고 두 번째는 그 값을 재사용했다. 이 메서드로 조회 함수 호출을 줄이려면 다음 호출이 재사용할 null이 아닌 값이 남아 있어야 한다.

기존 null 매핑도 확인할 수 있다. 다시 새 세션에서 원래의 null 반환 입력을 쓰되, for 바로 앞에 cache.put("user-7", null);을 추가한다. 출력은 calls=2, containsKey=true, value=null이었다. 키가 있어도 값이 null이면 계산 대상이다. 이번에는 원래 있던 null 매핑이 남아 있어 containsKey가 true지만, 그것만으로 조회 함수 호출을 막지는 못했다. 함수가 null을 반환했다고 기존 null 매핑을 삭제한 것도 아니다.

자기 코드에서 null이 아닌 값이 저장됐는데도 함수가 계속 실행된다면 이 예제와 조건이 다르다. 호출 사이에 같은 Map 인스턴스를 유지하는지, 같은 키로 조회하는지, 해당 항목을 지우거나 바꾸는 코드가 있는지 확인한다. 호출 횟수를 셀 때는 computeIfAbsent 호출 지점과 실제 조회 함수 내부를 구분한다.

조회한 부재도 보관하려면 값 타입에 담는다

이름이 없는 경우까지 기억하려면 Map의 값 타입을 Optional<String>으로 바꿀 수 있다. Optional.ofNullable의 Java 17 문서 (새 탭에서 열림)에 따르면 null 입력은 빈 Optional 인스턴스로 바뀐다. Java 21 문서 (새 탭에서 열림)도 같은 동작을 설명한다. 빈 Optional은 안에 이름이 없을 뿐, Map에 저장하는 참조 자체는 null이 아니다.

다음 입력은 처음 정의한 null을 반환하는 loadName을 그대로 쓰면서, 조회 결과를 저장하기 전에 Optional로 감싼다. 문자열을 반환하도록 바꾼 세션이라면 원래 입력을 새 세션에서 실행한 뒤 이어서 넣는다. 호출 수는 0으로 되돌리고, 값 타입이 다른 새 Map을 만든다.

calls[0] = 0;
Map<String, Optional<String>> cachedNames = new HashMap<>();
for (int i = 0; i < 2; i++) {
    cachedNames.computeIfAbsent("user-7",
        key -> Optional.ofNullable(loadName.apply(key)));
}
System.out.println("calls=" + calls[0] + ", containsKey="
    + cachedNames.containsKey("user-7") + ", value="
    + cachedNames.get("user-7"));
calls=1, containsKey=true, value=Optional.empty

이번에는 첫 호출이 빈 Optional을 저장했다. 두 번째 호출은 null이 아닌 그 값을 발견해 조회 함수를 실행하지 않았다. 처음 예제의 “아직 저장된 결과가 없음”과 이번의 “조회했고 이름이 없었음”을 서로 다른 상태로 남긴 것이다. 별도 대조에서 조회 함수가 "Mina"를 반환할 때도 Optional로 감싸면 호출 수는 1회였고, 저장된 값은 Optional[Mina]였다.

이 방식을 적용하면 읽는 쪽도 Optional을 처리해야 한다. computeIfAbsent가 돌려준 Optional의 isEmpty() 등으로 이름의 부재를 판단한다. 함수의 반환 타입만 Optional로 선언하고 여전히 null을 반환하면 부재 결과는 저장되지 않는다. null을 빈 Optional로 바꾸는 경계까지 유지해야 한다.

부재를 보관할지는 데이터 성격에 따라 정한다. 없던 이름이 나중에 생길 수 있다면 언제 다시 조회할지도 함께 정해야 한다. 위 코드는 항목을 지우거나 만료시키는 처리를 넣지 않았으므로 그 항목이 남아 있는 동안 부재를 재사용한다. 매번 최신 부재 여부를 확인해야 한다면 null 결과가 저장되지 않는 동작을 그대로 둘 수도 있다.

실행을 마치면 JShell에 /exit를 입력한다. 직접 실행한 범위는 OpenJDK 17.0.2의 단일 스레드 HashMap과 합성 함수다. Java 21은 문서만 확인했으며 해당 JVM에서는 실행하지 않았다. 멀티스레드 상황과 다른 Map 구현도 실험하지 않았다. Map 문서 역시 기본 구현의 동기화·원자성을 보장하지 않으므로, 이 호출 횟수를 동시 캐시에서 조회가 한 번만 실행된다는 근거로 일반화할 수 없다.

← 전체 글 보기