Backend/Java

SpringBoot JPA 에서 기본키, 자연키 vs 대리키

비비빅B 2025. 2. 23. 18:04

문제

최근 로그인ID를 이메일 말고 사번으로 변경해달라는 요구사항이 있었다. 기본키를 대리키로 설계했던 터라 별 무리없이 변경할 수 있을 듯 했는데, 자세히 살펴보니 대부분의 코드가 변경되어야 하는 대작업이었다. 분명 이런 상황을 대비해서 대리키를 채택한 것인데, 뭐가 잘못된걸까?

JPA 기본키

Entity를 DB에서 식별할 수 있는 값으로, 중복(null 포함)일 수 없고 변하지 않는 값이어야 한다. 개발이 어느정도 진행되고 난 다음에는 수정하기가 매우 어렵기 때문에, 설계 초기에 확실하게 규칙을 정하고 가는 것이 옳다.

자연키 vs 대리키

  자연키(Natural Key) 대리키(Surrogate Key)
정의 업무적으로 의미가 있는 실제 데이터 값을 키로 사용
(예: 주민등록번호, 사업자번호, ISBN)
임의로 생성된 식별 값을 키로 사용
(예: auto_increment, 시퀀스, UUID)
장점 • 별도 키 생성이 필요 없음
자체적으로 업무적 의미 포함
• 데이터 중복 방지 효과
조인 시 의미 있는 연결 제공
• 인덱스 추가 생성 불필요
• 업무 규칙 변경에 영향 받지 않음
성능에 최적화(보통 정수형으로 간단)
• 복합키 대신 단일키 사용 가능
• 물리적 크기가 작아 조인 성능 향상
• 숨겨진 의존성 없음
단점 업무 규칙 변경 시 키 변경 위험
복합키일 경우 관리 복잡
• 키 길이가 길 수 있어 성능 저하
• 다른 시스템과 통합 시 충돌 가능성
• 실제 데이터 노출로 보안 위험
의미 없는 값으로 직관적 이해 어려움
별도 인덱스 관리 필요
• 추가적인 저장 공간 필요
자연키 제약조건 별도 구현 필요
• 데이터 이관 시 매핑 작업 필요
적합상황 • 절대 변하지 않는 자연스러운 식별자가 있을 때
• 업무적 의미가 명확히 필요한 경우
• 레거시 시스템과의 호환성이 중요할 때
• 데이터 변경 가능성이 있는 경우
• 높은 성능이 요구되는 시스템
• 마이그레이션이 빈번한 환경
• 보안이 중요한 시스템

 

보통 변경에 다소 안전하고, 성능이 좋다는 이유로 대리키를 PK로 선택한다. 특히 JPA를 사용하는 경우, 복합키가 다소 복잡하기 때문에 대부분 대리키를 사용하는 것을 볼 수 있다.

 

대리키의 종류

  정수형 키(시퀀스, AUTO_INCREMENT, IDENTITY) UUID/GUID
크기 일반적으로 4바이트(INT) 또는 8바이트(BIGINT) 16바이트
생성 방식 데이터베이스에서 순차적으로 자동 생성 알고리즘을 통해 무작위 또는 시간 기반으로 생성
성능 매우 좋음 상대적으로 나쁨
저장 공간 효율성 높음 낮음 (4배 정도 더 많은 공간 필요)
보안성 낮음 (쉽게 추측 가능) 높음 (추측 어려움)

 

원인

대리키의 종류별 장단점을 충분히 검토하지 못하고, 무작정 익숙하면서 성능이 좋은 정수형 키로 선택한 것이 시작이었다.

 

추가 요구사항으로 보안적인 측면이 강조되면서, 쉽게 추측이 가능하다는 보안상의 이유로 API 응답으로 PK를 전달하지 않도록 개발되었다. 지금 생각해보면 이 시점에서 API 응답에 PK를 빼는 것이 아닌 PK를 UUID로 바꿨어야 했다.

 

이로 인해 다음과 같은 문제가 발생했다.

  1. 클라이언트에서는 PK(대리키)에 접근할 수 없음
  2. 클라이언트는 대상을 식별하기 위해 자연키(로그인 ID)만 사용할 수 있음
  3. 자연스럽게 백엔드 API도 자연키를 인자로 받도록 구현됨
// 대리키(이렇게 개발되었어야했지만)
public UserDto findByLoginId(Long pk) {
	User user = repository.findById(pk).orElseThrow();
    ...
}

// 자연키(이렇게 됨)
public UserDto findByLoginId(String loginId) {
	User user = repository.findByLoginId(loginId).orElseThrow();
    ...
}

 

이런 방식으로 개발이 진행되다 보니, 최종적으로는 아래와 같은 결과가 발생했다.

  1. 자연키에 추가 인덱스를 생성
  2. 테이블 간 조인에서도 자연키를 사용
  3. 결과적으로 "대리키를 사용하는 척하면서 실제로는 자연키에 의존하는" 최악의 설계

 

정리

올바른 접근법

노출되더라도 예측할 수 없는 UUID를 대리키를 사용해서 보안을 강화하면서, PK는 그대로 응답값에 포함시켜야 했다.

 

물론 UUID를 사용하면 성능이 감소하고 정렬하기가 어렵다는 문제점이 있다. 하지만

  1. 성능 문제: 데이터가 엄청 크지 않는 이상 성능 차이는 크지 않다. 최신 DB 엔진은 UUID 인덱싱 최적화가 잘 되어 있다.
  2. 정렬 문제: UUID 대신 ULID(Universally Unique Lexicographically Sortable Identifier)를 사용하면 해결할 수 있다.

사실 대리키를 정수형과 UUID 2개로 생각하는 것 자체가 틀렸다. 비즈니스와 관련이 없는 ID 생성방식은 모두 대리키다. 예로 일반 기업에서 채택한 Snowflake ID 같은 것도 대리키인 것이다.

 

추가 고려사항

+) Equals 동등비교는 무슨 필드로?

대리키를 사용하면 Equals 비교 필드를 대리키에 해야하나 생각이 들 수도 있지만, 동등 비교는 비즈니스와 관련된 자연키에 하는 것이 옳다. 대리키를 동등 비교 필드로 사용하면, 새로운 아이템을 등록한다고 했을 때, DB에 persist하기 전과 후가 다른 객체라고 뱉어낼 것이다.

 

+) 공통코드 테이블은 자연키vs대리키?

공통코드 테이블은 보통 다른 테이블의 조회 쿼리에서 조인해서 사용된다. 공통코드 테이블 PK를 대리키로 사용할 경우에는 정말 의미없는 조인 조건이 사용되거나 조인을 2번해야하는 경우가 생긴다. 따라서 코드성 테이블은 자연키를 PK로 하는 것이 좋다.

SELECT 
    O.ORDER_ID,
    O.ORDER_DATE,
    O.STATUS_CODE,
    C.CODE_NAME AS STATUS_NAME
FROM 
    ORDERS O
-- 대리키여서 2번 조인
LEFT JOIN
    CODE_GROUP P ON P.GROUP_ID = 'ORDER_STATUS'
LEFT JOIN 
    CODE_DETAIL C ON C.GROUP_ID = G.GROUP_ID AND O.STATUS_CODE = C.CODE_ID

-- 자연키였으면 아래처럼 한번만 조인
LEFT JOIN 
    CODE_DETAIL C ON O.STATUS_CODE = C.CODE_ID AND C.GROUP_ID = 'ORDER_STATUS'

 

물론 요새 코드는 Enum으로 빼는 것이 트렌드라, Enum으로 사용하는 것이 제일 좋아보이긴 한다.

아무튼 하고 싶은 말은, 무조건적으로 대리키가 좋다는 말이 아니라, 상황에 맞게 선택하는 것이 제일 좋다는 것이다.

 

참고로 JPA에서는 `@NaturalId`로 자연키를 선언하면 자연키로 조회할 수 있도록 메소드를 지원해준다.

@Entity
public class User {
    @Id
    private UUID id;
    
    @NaturalId
    private String loginId;
    
    // ...
}

// 사용 예시
User user = session.byNaturalId(User.class)
    .using("loginId", "user123")
    .load();