자동증가 ID를 이름으로 쓰지 마라

작성 · 수정


출처: 우아한형제들 기술블로그 — JPA에서 아이디를 자동증가 값으로 사용 시 하이버네이트의 @NaturalId 사용해 보기

운영환경과 테스트환경의 자동증가 ID가 어긋나면서 엉뚱한 상품에 할인이 붙었다. 그 장애에서 출발해 유니크 키 컬럼과 @NaturalId로 식별 방식을 바꾼 기록이다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.

문제

긴급 배포해야 할 상품 할인 기능이 있었다. 대상 상품을 코드에서 지목해야 하는데, 쓸 수 있는 이름이 자동증가 PK밖에 없었다. 그런데 운영환경과 테스트환경의 상품 ID는 이미 어긋나 있었다. 테스트 데이터가 쌓이면서 같은 상품이 환경마다 다른 번호를 달게 된 탓이다. 테스트환경에서 확인한 숫자를 그대로 운영에 올릴 수는 없었다.

그래서 저자는 운영환경의 ID를 추론했다. 원문의 표현으로는 “상품이 알파벳순으로 등록될 것으로 예상하여 현재 운영환경에 등록된 가장 큰 아이디값에 +1”을 했다. 실제 등록은 역순이었다. XXX가 먼저 들어갈 줄 알았는데 YYY가 먼저 들어가는 바람에 할인은 다른 상품에 붙었다.

분석

이 사고를 “예측이 빗나갔다”로 정리하면 다음 대응은 “다음엔 더 정확히 예측하자”가 된다. 등록 순서를 문서로 못 박고, 배포 절차에 확인 단계를 하나 더 넣고. 그러나 뿌리는 다른 데 있다. 그 상품을 가리킬 이름이 애초에 없었다.

auto_increment 값은 행이 저장되고 나서야 사후에 확정된다. 그런 값을 행을 넣기 전에 알아내려면 예측밖에 방법이 없다. 예측은 삽입 순서라는 운영 절차에 기댄다. 그렇게 애플리케이션 코드가 저장소의 물리적 배치에 매달린다. 이름이 없으니 저장소가 매긴 자리 번호를 이름 대신 빌려 쓴 셈이다.

product_key는 반대편에 있어 행을 넣기 전에 값을 정할 수 있다. 미리 정할 수 있으면 예측이 약속으로 바뀐다. 운영에서든 테스트에서든 DISCOUNT_TARGET_XXX는 같은 상품을 가리킨다. 두 환경의 PK가 아무리 벌어져 있어도 상관없다. 여기서 배포 순서에 대한 의존이 사라진다. 대리키를 도메인 식별자로 승격시킨 게 문제였다. 그 승격을 되돌리면 된다.

원문은 여기에 처방을 두 개 내놓는다. 유니크한 비즈니스 키 컬럼을 하나 추가하고, 그 컬럼에 @NaturalId를 선언해 전용 조회 경로를 만든다. 나란히 놓여 있지만 성격이 다르다. 장애를 막는 건 앞의 처방이고, 뒤의 처방은 장애와 상관이 없다. @NaturalId 없이 평범한 findByKey만 써도 엉뚱한 상품에 할인이 붙는 일은 재발하지 않는다. @NaturalId는 하나의 트랜잭션 안에서 같은 엔티티를 반복 조회할 때 쿼리를 줄이는 조회 최적화다.

원문 제목이 뒤의 처방을 주인공으로 세워서 이 구분이 잘 보이지 않는다. 둘 다 타당한 선택인데 푸는 문제가 다르다.

해결

원문은 auto_increment를 그대로 유지하기로 한다. MySQL에서 PK는 Clustered Index다. 데이터의 물리적 저장 순서가 PK 순서를 따르므로 값이 단조증가하면 삽입이 뒤쪽에 몰려 성능이 좋다. 배치에서도 offset-limit 대신 id > 100 같은 PK 범위 조회를 쓸 수 있어 효율적이다. 대신 유니크 키 컬럼 product_key를 하나 붙인 뒤 엔티티에 @NaturalId를 단다.

@Entity
@Table(name = "product_with_natural_id")
public class ProductWithNaturalId {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NaturalId
    @Column(name = "product_key", nullable = false, updatable = false, unique = true)
    private String key;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "price", nullable = false)
    private int price;
}

어노테이션만 붙여서는 이점이 나오지 않는다. Spring Data JPA의 파생 쿼리 findByKey는 그냥 WHERE product_key = ? 쿼리를 만들 뿐이라 자연키 캐시를 타지 않는다. 하이버네이트 Session의 전용 API를 직접 호출해야 해서 커스텀 리포지토리가 필요하다.

public class ProductWithNaturalIdRepositoryImpl implements ProductWithNaturalIdRepositoryCustom {
    private final EntityManager entityManager;

    @Override
    public Optional<ProductWithNaturalId> findByNaturalId(String key) {
        return entityManager.unwrap(Session.class)
            .bySimpleNaturalId(ProductWithNaturalId.class)
            .loadOptional(key);
    }
}

차이는 같은 트랜잭션 안에서 드러난다. findByKey()를 세 번 부르면 SELECT가 세 번 나가지만 findByNaturalId()는 세 번을 불러도 한 번이다. 게다가 findByNaturalId()로 한 번 조회한 뒤 findById()를 부르면 추가 쿼리가 없다. findAll()로 이미 엔티티가 올라와 있는 상태에서 findByNaturalId()를 불러도 마찬가지다.

저자는 여기서 멈추지 않고 하이버네이트 소스를 따라간다. BaseNaturalIdLoadAccessImpl.doLoad() 안에 naturalIdToPkMap이라는 캐시가 있다. 자연키로 PK를 먼저 찾은 다음 그 PK로 PersistenceContext에서 엔티티를 가져온다. 결국 findById()와 같은 경로로 합류한다. 양방향으로 캐시가 맞아떨어지던 이유가 여기 있다. 자연키 조회든 PK 조회든 같은 저장소를 보기 때문이다.

캐시가 사는 범위

이 캐시는 PersistenceContext, 즉 1차 캐시에 얹혀 있다. 2차 캐시를 따로 켜지 않았다면 트랜잭션이 끝나는 순간 함께 사라진다. 그러니 이점을 보려면 “하나의 트랜잭션 안에서 같은 엔티티를 여러 번 조회한다”는 상황이 실제로 있어야 한다.

드문 상황은 아니다. 서비스 레이어가 여러 갈래로 나뉘고 각 갈래가 필요한 엔티티를 자기가 조회하면 자연스럽게 생긴다. 다만 그 중복 조회는 조회 지점을 한 곳으로 모아 파라미터로 넘기는 리팩터링으로도 없앨 수 있다. 두 방법은 대체재다. @NaturalId는 그 중복을 없애지 않고 값싸게 만들 뿐이다. 어느 쪽이 나은지는 코드 구조에 달렸지 프레임워크가 정해주지 않는다.

유니크 인덱스는 공짜가 아니다

여기서부터는 원문이 다루지 않은 이야기다. InnoDB의 일반적인 동작을 두고 내가 덧붙이는 설명이다.

secondary index의 리프 노드는 행의 물리 주소가 아니라 PK 값을 담는다. product_key로 조회하면 secondary index를 타서 PK를 먼저 얻고 그 PK로 clustered index를 다시 탐색해야 행에 도달한다. 탐색이 두 번이다. 게다가 varchar 유니크 인덱스는 정수 PK보다 비교와 저장이 무겁다. 삽입과 갱신마다 인덱스를 갱신하는 비용도 든다.

원문은 clustered index의 삽입 성능을 지키려고 auto_increment를 유지했다. 정작 애플리케이션의 주 조회 경로는 전부 secondary index를 타게 된다. 이 교환은 원문에 언급되지 않는다. 잘못된 선택이라는 뜻은 아니다. “정렬 친화적인 내부 PK + 의미 있는 외부 키”는 표준적인 배치다. 삽입 성능과 배치 범위 조회를 지키면서 식별의 안정성을 사는 거래다. 다만 공짜는 아니다.

그리고 @NaturalId 캐시는 정확히 이 두 번 탐색의 반복을 줄인다. 첫 조회 한 번만 두 인덱스를 탄다. 이후로는 naturalIdToPkMap이 자연키에서 PK로 가는 첫 단계를 대신한다. 뒤의 처방이 앞의 처방이 만든 비용을 일부 되돌려받는 구조다. 두 처방이 해결하는 문제는 여전히 다르지만 이 지점에서는 맞물린다.

결과

원문이 수치로 보여준 건 쿼리 수 하나다. 세 번이 한 번이 됐고 PK 조회와 자연키 조회가 같은 캐시를 공유한다. 응답 시간이나 실제 서비스에 적용한 뒤의 부하 변화는 공개되지 않았다. 유니크 컬럼을 추가하면서 기존 데이터에 키를 채워 넣은 과정도 원문에 없다.

대신 단서가 하나 붙어 있다. 하이버네이트 5.5 미만에서는 불필요한 쿼리가 한 번 더 나가니 쓰지 말라는 것. 이 단서가 앞에서 나눈 두 처방의 차이를 다시 한번 드러낸다. 유니크 키 컬럼을 추가하는 처방은 어느 DB, 어느 ORM, 어느 버전에서도 성립한다. 컬럼 하나와 그 컬럼을 쓰겠다는 합의가 전부이기 때문이다. @NaturalId의 이점은 특정 벤더의 특정 버전 이상에서만 나온다. 구현 세부에 얹혀 있는 이득이라 버전이 바뀌면 같이 흔들린다.

인상 깊었던 점

이 블로그에 @Transactional을 쓸 상황을 최대한 줄이자를 쓴 적이 있다. 카카오페이 온라인 결제팀이 발견한 건 이렇다. @Transactional(readOnly = true)가 붙은 단건 조회 하나가 SET autocommit=0 같은 부수 쿼리 여섯 개를 더 만든다. 그래서 이 팀은 조회를 트랜잭션 밖으로 빼면서 트랜잭션을 쓸 상황 자체를 줄이는 쪽으로 컨벤션을 다시 짰다.

@NaturalId는 정반대 조건을 요구한다. 하나의 PersistenceContext 안에 반복 조회가 있어야 이득이 생긴다. 트랜잭션을 잘게 자를수록 캐시가 살아 있을 시간은 짧아진다. 극단적으로 조회마다 컨텍스트가 새로 열리면 이 기능은 아무것도 하지 않는다. 최적화의 단위가 다를 뿐 한쪽이 틀린 게 아니다. 카카오페이는 트랜잭션 경계 자체를 비용으로 보고 그 개수를 줄였다. 우아한형제들은 경계 안에서 일어나는 왕복을 비용으로 보고 그 횟수를 줄였다. 자기 서비스에서 어느 쪽 비용이 더 큰지 재서 고를 문제다. 둘을 같은 코드베이스에 동시에 적용하려 들면 서로를 무력화한다.

정작 이 글에서 오래 남는 건 어노테이션 쪽이 아니다. 저 장애는 product_key라는 이름을 하나 만들기로 하면서 끝났다. 그 결정에는 프레임워크도 버전도 필요 없다. 값을 언제 정할 수 있는가, 그 값이 환경을 건너서도 같은 것을 가리키는가. 이건 ORM 기능 선택이 아니라 모델링 결정이다. 도구는 그 결정을 조금 더 싸게 만들어줄 뿐이다. 결정이 없으면 도구도 붙일 자리가 없다.