커스텀 ID 적용하기 : UUID와 NanoID, Hibernate IdentifierGenerator 기반으로 리팩토링까지!

🔹들어가며….

안녕하십니까…

오늘은 Entity를 생성하며 고민되었던 지점에 대해서 나눠보려고 합니다.

 

주요 주제는 기본키 생성전략을 따르는것이 아닌, 커스텀으로 조건을 적용해 id를 생성하는 방법을 구현하며 고민에 대한 내용입니다!

 

저는 JPA를 사용하며 IDENTITY, SEQUENCE, TABLE 정도의 기본 키 생성 전략을 사용했었습니다. 

그 중 저는 IDENTITY를 주로 사용했었는데, 이 전략은 데이터베이스가 자동으로 기본 키를 생성해줍니다.

 

이번 저희 기능에서의 Entity는 총 3가지 Like, Post, User입니다.

Post와 Like는 기본 키 생성 전략인 IDENTITY를 따릅니다.

하지만 USER의 경우…

다음과 같이 UUID로 생성된 값을 ID로 가집니다.

 

🔹UUID값을 가지는 ID 생성하기

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "user_id")
val id: UUID? = null

Hibernate에 UUID 생성을 맡기는 코드는 다음과 같습니다.

🔸Hibernate가 생성하는 UUID의 형식

d3c2a7a2-1b95-4c49-809f-2d94a9e36f2c

생성된 id를 살펴봅시다.

36자 (하이픈 포함)이며, 형식은 8-4-4-4-12 (총 32자리 + 하이픈 4개)입니다.

 

그런데... 이 포맷이 마음에 들지 않습니다

저희는 id 길이가 36자보다는 짧았으면 하고, 하이픈과 같은 특수문자는 따로 지정했으면 합니다.

 

찾아보니…

Hibernate의 UUID 전략에서는 길이를 줄일 수 없습니다.

Hibernate가 생성하는 UUID는 표준 36자 UUID 문자열이기 때문입니다.

 

그리고 *?와 같은 다른 특수문자도 포함할 방법이 없었기 때문에

저희가 원하는 조건을 맞추려면 Hibernate에게 ID 생성을 맡기지 말고 직접 ID를 생성해야 했습니다!

 

🔹커스텀 ID 생성

🔸첫번째 방법 - 유틸리티 클래스로 생성하기

object IDUtils {
    private const val ID_LENGTH: Int = 16

    fun customIdGenerator(): String {
        val symbols = listOf('!', '@', '#', '$', '%', '&', '*')

        return UUID.randomUUID()
            .toString()
            .map { if (it == '-') symbols.random() else it }
            .joinToString("")
            .take(ID_LENGTH)
    }
}

위 메서드는 UUID값에서 하이픈에 해당하는 문자들을 symbols라고 정의해둔 특수문자로 대치하는 기능을 합니다.

확인해보니까 저런식으로 잘 들어가더군요

오예 ~

 

🤔 고민….

그런데 UUID이긴 하지만 앞 16자만 잘라서 사용하기 때문에 중복 가능성이 높아졌습니다

그리고 겉으로만 봐도 연속적으로 입력된 uuid끼리의 유사도가 높아진 모습입니다

 

그래서 이 방법은 안된다! 다른 방법을 찾아보았습니다

  • 짧고 URL-safe한 ID가 필요 =>  NanoID
  • 시간순 정렬이 필요 =>  ULID
  • 그냥 UUID 쓰는데 보기 좋게 줄이고 싶다 => UUID → Base64 인코딩

이 중 NanoID 가 제일 적합해보였고 UUID를 비교해보았습니다.

 

📖 NanoID vs UUID 차이 정리

항목 NanoID UUID
길이 기본 21자 (조절 가능) 36자 (고정)
형식 커스텀 가능 (문자셋, 길이) 8-4-4-4-12 고정
중복 확률 매우 낮음 (길이/문자셋으로 조절 가능) 매우 낮음
URL-safe 기본적으로 안전 하이픈 포함, 일부 환경에서 부적합
속도 더 빠름 비교적 느림
사용 편의성 커스터마이징 유연 단순하고 표준화

NanoID 가 길이를 조절해 생성할 수 있다는 점이 매우 큰 장점이라고 느껴졌고, 지금 내냉멍봐 시스템에 적합한 기술은 NanoID라고 생각되어 리팩토링 했습니다!

 

🔸두번째 방법 - NanoID로 커스텀 ID 생성하기

  implementation("com.aventrix.jnanoid:jnanoid:2.0.0")
object IDUtils {
    private const val ID_LENGTH: Int = 16
    private const val CUSTOM_ALPHABET_STRING = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#\\$%^&*"

    fun customIdGenerator(): String {
        return NanoIdUtils.randomNanoId(
            NanoIdUtils.DEFAULT_NUMBER_GENERATOR,
            CUSTOM_ALPHABET_STRING.toCharArray(),
            ID_LENGTH
        )
    }
}

길이는 16으로 조정했고, 알파벳 + 하이픈을 제외한 여러 특수문자로 구성되도록 설정했습니다. 

오 예 ~

UUID를 사용할 때보다 연속 저장 시 ID의 유사도가 낮아진 것을 직관적으로 확인할 수 있었습니다!

 

🤔 고민… 2

그런데… 다 좋은데 말이죠...

고민되는 지점이 생겼습니다

1. 표준 생성 전략을 따르지 않는다

  • Hibernate나 JPA가 제공하는 ID 생성 전략(@GeneratedValue(strategy = ...))을 사용하면, 나중에 ID 생성 방식을 쉽게 변경하거나 표준 API를 통해 관리할 수 있습니다.
  • 하지만 현재 방식은 NanoID 생성 로직에 강하게 의존하고 있어서, 만약 시스템 요구사항이 변경되거나 새로운 ID 생성 규칙이 추가된다면 코드를 직접 수정해야 하는 부담이 생깁니다.

2. 확장성과 유연성 부족

  • ID 생성이  NanoID 생성 로직에 의존적이기 때문에, 새로운 ID 정책을 적용하거나, 환경별로 다르게 동작하게 하려면 그때마다 직접적으로 코드를 수정해야 하므로 확장성이 부족하다고 판단되었습니다.

3. 테스트 신뢰성 문제

  •  테스트 중에 특정 ID를 검증하려면 IDUtils.customIdGenerator()가 직접 호출되므로, 이 함수의 결과를 예측하고 통제하기 어려워져 테스트의 신뢰성이 떨어질 수 있다고 생각되었습니다.

4. 엔티티 생명주기 문제

  • 현재 방식이 JPA의 엔티티 생명주기안에서 완벽하게 통합되어 동작하지 않는다는 점입니다.
  • 표준 JPA 방식의 ID 생성은 Persist 단계에서 자연스럽게 처리되지만, 직접 생성하는 방식은 생명주기 중 어디서, 언제 생성되는지 명확히 컨트롤하기 어렵습니다.

 

🌟🌟 그러다 발견한 한 블로그!!

https://techblog.woowahan.com/2607/

해당 블로그에서는 하이버네이트의 @GenericGeneratorIdentifierGenerator 인터페이스를 이용해 기본키를 생성하는 방법을 설명하고 있습니다

 

위 방법을 통해서 앞서 말한 유연성 부족, 확장성 부족, 테스트 신뢰성, 생명주기 문제를 해결할 수 있을 것 같습니다!

 

그래서 저도 사용을 해보려고 했는데….

deprecated 된 것들이 많았습니다

들어가보니

hibernate 6.2 이상은 @IdGeneratorType 기반의 새로운 방식을 사용하는 것이 권장된다고 합니다!

 

적용해봅시다!

 

🔸최종 방법 - ID 생성 방식 Hibernate IdentifierGenerator 기반으로 리팩토링

class CustomIdGenerator : IdentifierGenerator {
    companion object {
        private const val ID_LENGTH: Int = 16
        private const val CUSTOM_STRING = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#\\$%^&*"
    }

    override fun generate(session: SharedSessionContractImplementor?, `object`: Any?): Any {
        return NanoIdUtils.randomNanoId(
            NanoIdUtils.DEFAULT_NUMBER_GENERATOR,
            CUSTOM_STRING.toCharArray(),
            ID_LENGTH,
        ) }
}

Hibernate의 IdentifierGenerator 인터페이스를 상속받아 ID 생성 방식을 리팩토링했습니다. 

 

이 인터페이스는 ID를 어떻게 생성할지를 직접 정의할 수 있게 해주며, Hibernate가 엔티티를 저장할 때 generate()를 호출해서 ID를 만들어내는 구조입니다.

@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
@IdGeneratorType(CustomIdGenerator::class)
annotation class UserId
@Id
@field:UserId
@Column(name = "user_id")
val id: String? = null

간편하고 직관적으로 나타내기 위해 커스텀 어노테이션을 따로 적용하였습니다. 

오 예 ~

 

이를 통해 ID 생성 전략의 유연성과 확장성을 높였으며, JPA 표준을 준수하여 향후 요구사항 변화에 대한 대응이 용이해졌습니다!

또한, 테스트에서 ID 생성 로직을 더 쉽게 제어할 수 있게 되어 신뢰성을 개선하였습니다!

 

그리고 하이버네이트의 전체적인 트랜잭션과 통합되어 ID 생성 과정을 JPA의 엔티티 생명주기 내에서 자동으로 처리할 수 있게 되었습니다!!!

 

🔸+) @field:

Java에서는 보통 필드에 어노테이션을 붙이지만, Kotlin에서는 기본적으로 getter에 어노테이션이 붙는 방식입니다.

그래서 JPA, Hibernate, Jackson처럼 필드 기반으로 동작하는 라이브러리를 쓸 때는 @field:를 명시해야 제대로 작동한다고 합니다!

 

[관련 글]

https://medium.com/@nipunasan/how-to-use-idgeneratortype-in-hibernate-6-5-spring-boot-3-3-for-custom-id-generation-403be9b51d6f

 

How to use @IdGeneratorType in Hibernate 6.5 (Spring boot 3.3) for custom id generation

With @IdGeneratorType the custom ID generation has been more simplified.

medium.com

 

🔹마무리

이번 작업을 통해 엔티티 생명주기에 대한 이해와 함께 유연하고 확장성 있는 코드 설계의 중요성을 깊이 느끼게 되었습니다.

이 경험을 바탕으로 앞으로도 더 좋은 코드를 고민하고 만들어나가고 싶습니다!!