Enum의 해시코드

개요

Redis 외부 저장소에 데이터를 캐싱하는 상황이 있다. 캐시를 사용할 때에는 키를 유일한 값으로 설정해야 한다. 이를 위해 PK, UUID 등 고유성이 보장되는 값을 사용할 수 있다. 그런데 아래처럼 해시코드를 이용하여 캐싱할 경우 예상치 못한 문제가 발생할 수 있었다.

문제 상황

UserService

@Service  
@RequiredArgsConstructor  
public class UserService {  
  
    private final RedisTemplate<String, String> redisTemplate;  
    private final ApplicationEventPublisher eventPublisher;  
  
    public void save(final int id, final String name) {  
        eventPublisher.publishEvent(new UserSaveEvent(id, name));  
    }  
  
    @Nullable  
    public String find(final int id) {  
        final CacheKeySpec keySpec = CacheKeySpec.of(CacheKey.USER.prefix(), id, 1L, "default", KeyType.ENTITY);  
        final String hashCode = keySpec.toHashCode();  
        return redisTemplate.opsForValue().get(hashCode);  
    }  
}

UserCacheEventListener

@Component  
@RequiredArgsConstructor  
public class UserCacheEventListener {  
  
    private final RedisTemplate<String, String> redisTemplate;  
  
    @EventListener  
    public void onSave(final UserSaveEvent event) {  
        final CacheKeySpec keySpec = CacheKeySpec.of(  
            CacheKey.USER.prefix(), event.getId(), 1L, "default", KeyType.ENTITY  
        );  
        final String hashCode = keySpec.toHashCode();  
        redisTemplate.opsForValue().set(hashCode, event.getName(), Duration.ofHours(1));  
    }  
}

CacheKeySpec

public record CacheKeySpec(String prefix, int id, long version, String tenantId, KeyType keyType) {  
  
    public static CacheKeySpec of(  
        final String prefix,  
        final int id,  
        final long version,  
        final String tenantId,  
        final KeyType keyType  
    ) {  
        return new CacheKeySpec(prefix, id, version, tenantId, keyType);  
    }  
  
    public String toHashCode() {  
        final int combined = prefix.hashCode()  
            ^ Integer.hashCode(id)  
            ^ Long.hashCode(version)  
            ^ tenantId.hashCode()  
            ^ keyType.hashCode();  
        return String.valueOf(combined);  
    }  
}

회원 저장 이벤트가 발생하면 Redis에 캐싱한다. 캐시를 위해 사용하는 키 값은 CacheKeySpec의 각 필드 해시코드를 XOR연산하여 생성한다. 실제 API를 호출하면 문제없이 동작한다.

그러나 아래 시나리오를 살펴보자.

  1. id = 1, name = “Gildong” 으로 회원 저장
  2. id = 1 조회하면 캐시 히트
  3. 서버 애플리케이션 재배포
  4. id = 1 조회하면?

캐시가 만료되지 않은 상황에서 위 시나리오대로 동작한다면 4번에서 캐시 히트가 발생할 것이라 기대했다. 하지만 실제 동작은 그렇지 않았다.

원인 분석

문제는 CacheKeySpec.toHashCode()안에 있었다.

public String toHashCode() {
    final int combined = prefix.hashCode()
        ^ Integer.hashCode(id)
        ^ Long.hashCode(version)
        ^ tenantId.hashCode()
        ^ keyType.hashCode();

    return String.valueOf(combined);
}

Java의 Integer, Long, String 클래스의 해시코드는 항상 동일한 값을 반환한다.

하지만 keyType은 Enum 타입이고, Enum의 해시코드는 객체의 식별성을 기반으로 계산된다.

Enum

/**  
 * Returns a hash code for this enum constant.
 *
 * @return a hash code for this enum constant.  
 */
 public final int hashCode() {  
    // Once initialized, the hash field value does not change.  
    // HotSpot's identity hash code generation also never returns zero          // as the identity hash code. This makes zero a convenient marker
    // for the un-initialized value for both @Stable and the lazy
    // initialization code below.    int hc = hash;  
    if (hc == 0) {  
        hc = hash = System.identityHashCode(this);  
    }  
    return hc;  
}

Identity Hash는 객체의 논리적 값이 아니라 객체 식별성을 기반으로 생성된다. 즉 동일한 Enum 상수라도 JVM이 재시작되면서 해시코드가 이전 실행 시점과 달라질 수 있다.

위 실패 시나리오를 다시 보면 다음과 같다.

  1. id = 1, name = “Gildong” 으로 회원 저장
  2. id = 1 조회하면 캐시 히트
  3. 서버 애플리케이션 재배포 → JVM 재시작
  4. id = 1 조회하면? → 재시작 후 새롭게 계산된 해시값으로 조회하여 캐시 미스
  5. 기존 Redis 캐시 데이터는 TTL이 끝날 때까지 남아있음

더 위험한 상황은 과거에 캐싱된 데이터를 다른 유저가 조회하는 경우다.

예를 들어 재배포 전 Redis에 다음 데이터가 저장되었다고 하자.

id = 1 -> hash key = 1234, name = "Gildong"

이후 서버가 재배포되면서 id = 1의 캐시 키는 달라진다.

id = 1 -> hash key = 5678, name = "Gildong"

이때 Redis에 과거 데이터가 남아있다.

hash key = 1234 -> "Gildong"

이때 우연히 다른 유저의 캐시 키가 겹치면서 과거 캐시 데이터가 반환될 수 있다.

id = 100 조회
	-> hash key = 1234 
	-> 과거 id = 1 사용자의 캐시 데이터 "Gildong" 반환
	-> 🚨 장애

마무리

이 문제의 핵심은 Enum 자체가 위험하다는 것이 아니다. 문제는 Redis와 같은 외부 저장소의 키를 hashCode() 기반으로 생성했다는 점이다.

hashCode()는 해시 기반 자료구조에서 객체를 빠르게 찾기 위한 값이라고 생각한다. 그래서 외부 저장소에서 데이터를 식별하기 위한 고유 키로 이용하는 것이 좋은 선택은 아닌 것 같다. 특히 Enum처럼 객체 식별성 기반 해시코드가 포함되면 JVM 재시작 후 동일한 논리적 값이라도 다른 키가 생성될 수 있어 잠재적인 장애 지점이 될 수 있겠다.

댓글