2024-08-04 @이영훈
개요
Sqids(구. hashids)를 사용하여 보안과 편의성을 높였습니다.
관계형 DB에서 ID를 숫자(bigint) + auto increment로 관리하는 경우가 있습니다. 이 때 특정 계정의 아이디가 10,000이라는 것이 노출되면 다음은 10,001번이라는 쉬운 추측이 가능한 문제가 있습니다.
유튜브는 비디오의 아이디를 string 타입으로 표시하고 있습니다. 그래서 내부에서는 number(Long) 타입으로 관리하고 클라이언트에게 string 타입으로 표현해서 내부의 보안을 높이고, DB에서는 bigint로 인덱스 속도를 빠르게 가져가고 싶었습니다.
이를 위해서 sqids를 도입했습니다. sqids를 Kotlin + Spring Boot에 적용한 방법을 기록으로 남깁니다.
본문
Sqids 알아보기
Sqids 라이브러리는 숫자 ID를 짧고 사람이 읽기 쉬운 문자열로 변환하는 도구입니다.
암호화 라이브러리이기 때문에 숫자 ID와 변환된 문제 ID를 암/복호화 할 수 있습니다.
Javascript 예시 코드
const { encode, decode } = require('@sqids/sqids');
const sqids = new Sqids();
// ✅ 숫자 ID -> 문자 ID로 만듦 (암호화)
const id = sqids.encode([12345]);
console.log(id); // 출력 예: "NkK9"
// ✅ 문자 ID -> 숫자 ID로 만듦 (복호화)
const numbers = sqids.decode(id);
console.log(numbers); // 출력: [12345]
JavaScript
복사
Sqids를 Kotlin Spring Boot 프로젝트에 적용하기
프로젝트에 다음 정책을 설정하였습니다.
1.
서버 외부로 들어오고 나가는 ID는 모두 String 타입이다
2.
서버 내부에서는 ID는 모두 Number(Long) 타입이다
이를 위해서 다음 전략을 구상하였습니다.
1.
String 타입의 ID를 uid로 명명하였고, Number 타입의 ID를 id로 명명하였습니다
2.
DTO에서 ID를 String으로 만드는 어노테이션을 만들었습니다
3.
Controller에서는 uid를 받아서 id로 변환해서 service로 전달합니다
DTO에서 사용할 @IdUidConverter 만들기
Response/Request DTO에서 id 필드에 적용할 @IdUidConverter를 만듭니다
저는 Sqids의 구버전인 hashids를 사용했습니다
신버전인 Sqids도 코드는 동일합니다
다음과 같이 두 확장함수 toId(), toUid() 를 만듭니다
private val hashids = Hashids(salt = "your-salt", minHashLength = 8)
fun String.toId() = hashids.decode(this).getOrNull(0)
?: throw RuntimeException("Incorrect Uid: $this")
fun Long.toUid(): String = hashids.encode(this)
Kotlin
복사
이어서 UidToIdDeserializer 와 IdToUidSerializer 를 만듭니다
•
UidToIdDeserializer 는 RequestDTO에서 uid를 id로 변경해주는 역할을 합니다
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import me.leedo.common.iduid.toId
class UidToIdDeserializer : JsonDeserializer<Long>() {
// String? -> Long?
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Long {
return p.valueAsString.toId()
}
}
Kotlin
복사
•
IdToUidSerializer 는 ResponseDTO에서 id를 uid로 변경해주는 역할을 합니다
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import me.leedo.common.iduid.toUid
class IdToUidSerializer : JsonSerializer<Long>() {
// Long? -> String?
override fun serialize(value: Long?, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(value?.toUid())
}
}
Kotlin
복사
최종적으로 IdUidConverter를 만듭니다
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import me.leedo.common.iduid.serializer.IdToUidSerializer
import me.leedo.common.iduid.serializer.UidToIdDeserializer
import io.swagger.v3.oas.annotations.media.Schema
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@JacksonAnnotationsInside
@JsonDeserialize(using = UidToIdDeserializer::class) //
@JsonSerialize(using = IdToUidSerializer::class)
@Schema(type = "string") // swagger에서 string 타입으로 표시되기 위해서 붙입니다
annotation class IdUidConverter
Kotlin
복사
•
@JacksonAnnotationsInside는 Jackson 라이브러리에서 사용자 정의 어노테이션을 만들 때 사용되는 메타 어노테이션입니다. 이 어노테이션을 사용하면, 사용자 정의 어노테이션이 Jackson의 어노테이션처럼 동작하게 할 수 있습니다.
•
@JsonSerialize 는 객체를 JSON 형식으로 직렬화할 때 사용자 정의 로직을 지정합니다.
•
@JsonDeserialize 는 JSON을 객체로 역직렬화할 때 사용자 정의 로직을 지정합니다.
DTO의 id 필드에 IdUidConverter 를 적용하면 자동으로 iduid로 변경해줍니다
예시 코드를 보면서 확인해보겠습니다
Response에 사용되는 UserDTO입니다
•
User는 식별 아이디와 이름과 이메일, 소속 팀을 가지고 있습니다
class UserDTO(
@IdUidConverter // ⭐️ IdUIdConverter가 적용됨
val id: Long, // Number(Long) 타입의 ID
val name: String,
val email: String,
@IdUidConverter
val teamId: Long,
)
Kotlin
복사
브라우저나 HTTP 클라이언트 (curl, postman 등)에서는 다음과 같이 응답이 json으로 직렬화되어 응답됩니다
{
"id": "ohmH9t28", // ✅ String 타입의 ID로 변환되어 응답됨
"name": "Leedo",
"email": "user1@leedo.me",
"teamId": "Liom4HYP"
}
JSON
복사
Request에 사용되는 UpdateUserDTO입니다
•
이름과 이메일, 소속팀을 변경할 수 있는 API의 요청 body 값을 정의하고 있습니다
class UpdateUserDTO(
val name: String,
val email: String,
@IdUidConverter
val teamId: Long,
)
Kotlin
복사
브라우저나 HTTP 클라이언트 (curl, postman 등)에서는 다음처럼 json으로 요청하면 됩니다
{
"name": "Leedo2",
"email": "user2@leedo.me",
"teamId": "5f0b5b3b" // ✅ String 타입의 ID로 요청합니다
}
JSON
복사