TL;DR: String으로 처리하던 이메일, 로그인ID를 Value Class로 리팩토링하면서 타입 안전성과 도메인 표현력을 얻었지만, 어디까지 Value Class로 만들어야 하는지 기준을 고민해야 했다.
시작: String이 모든 걸 해결해줄 거라 생각했다
처음 회원가입 기능을 구현할 때는 단순했다. 테스트를 먼저 작성하며 이메일도 String, 로그인 ID도 String. 빠르게 구현하고 테스트도 통과했으니 문제없다고 생각했다. 그런데 코드가 늘어나면서 이상한 생각이 들기 시작했다.
문제를 실감한 순간들
1. 과도한 책임부여
유효성 검증을 위해 User 클래스 내부에 대량의 검증 로직이 작성되고 있었다.
@Entity
class User(
val uid: String,
val email: String,
val birthDate: String,
) : BaseEntity() {
init {
validate()
}
private fun validate() {
require(ID_PATTERN.matches(uid)) { "ID는 영문과 숫자를 모두 포함한 10자 이하여야 합니다." }
require(EMAIL_PATTERN.matches(email)) { "이메일이 형식에 맞지 않습니다." }
try {
BIRTH_DATE_PATTERN.matches(birthDate)
LocalDate.parse(birthDate)
} catch (e: DateTimeParseException) {
throw IllegalArgumentException("생년월일은 yyyy-MM-dd 형식이어야 합니다.")
}
}
companion object {
private val ID_PATTERN = "^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$".toRegex()
private val EMAIL_PATTERN = "^[^@]+@[^@]+\\.[^@]+$".toRegex()
private val BIRTH_DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$".toRegex()
}
}
2. 타입 안전성의 저하
현재는 혼자 개발하고 있기 때문에 그런 문제가 없지만 추후 원시타입의 순서를 잘못 넣어 엔티티를 생성하게되면 문제가 발생할 듯 했다. (코틀린은 네임드아규먼트가 존재하지만 타입은 여전히 허용하므로)
// 이런 실수가 가능하다
val user1 = User("test@test.com", "user123", "2020-01-01") // 순서 바뀜
val user2 = User("user123", "test@test.com", "2020-01-01") // 정상
3. 도메인 의미의 모호함
예를 들어 특정 함수가 파라미터로 contact를 받는데 이메일인지 전화번호인지 어떻게 알 수 있을까?
Value Class 도입 결정
Kotlin의 Value Class가 정확히 이런 문제를 해결하기 위해 만들어졌다는 걸 알게 됐다. 특히 “타입 안전성 + 성능 최적화”라는 두 마리 토끼를 모두 잡을 수 있다는 점이 매력적이었다.
첫 번째 시도: LoginId Value Class
@JvmInline
value class LoginId(val value: String) {
init {
require(ID_PATTERN.matches(value)) { "ID는 영문과 숫자를 모두 포함한 10자 이하여야 합니다." }
}
companion object {
private val ID_PATTERN = "^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$".toRegex()
}
}
두 번째 시도: Email Value Class
@JvmInline
value class Email(val value: String) {
init {
require(EMAIL_PATTERN.matches(value)) { "이메일이 형식에 맞지 않습니다." }
}
companion object {
private val EMAIL_PATTERN = "^[^@]+@[^@]+\\.[^@]+$".toRegex()
}
}
생각보다 간결하게 작성할 수 있다.
Boxing과 성능 고려사항
Value Class의 큰 장점은 성능 최적화인데, 모든 상황에서 최적화되는 건 아니었다.
// Unboxed - 성능 최적화됨
fun processUser(userId: UserId) {
// 실제로는 processUser(value: String)으로 컴파일됨
}
// Boxed - 객체 생성 발생
fun processUsers(userIds: List<UserId>) {
// List<UserId>는 실제 객체 생성이 필요함
}
fun processNullableUser(userId: UserId?) {
// Nullable도 Boxing 발생
}
테스트에서의 변화
User("test123", "test@test.com", "2020-01-01")
// 도메인이 명확해짐
User(LoginId("test123), Email("test@test.com"),"2020-01-01")
얻은 것
- 컴파일 타임 타입 안전성
- 도메인 개념의 명시적 표현
- 유효성 검증 로직의 중앙화
- 대부분의 경우 성능 오버헤드 없음
- 테스트 코드의 명확성
고민되는 부분
- 모든 primitive를 Value Class로 만들어야 할까?
- Value Class 사용 기준을 어떻게 정할까?
결국 Value Class 도입은 “단기 개발 속도 vs 장기 유지보수성”의 트레이드오프였다.
Kotlin의 경우 성능 페널티가 거의 없어서 선택이 더 쉬웠다.
또한 컴파일시점에 내부값으로 변환이 되기 때문에 JPA 사용시 Embeddable 같은 애너테이션을 붙이지 않아도 된다는 장점이 있었다.
앞으로 비즈니스 핵심 개념들이 존재한다면 Value Class로 만들어볼 예정이다.
다만 어디까지 Value Class 로 만들어야 하는지 기준은 팀과 함께 정립해나가는 것이 중요할 것 같다.
참고
'프로젝트' 카테고리의 다른 글
| 하나로 묶인 트랜잭션을 쪼개다: 이벤트 기반 주문 시스템 리팩토링기 (2) | 2025.09.25 |
|---|---|
| Cache-Aside 적용기: DB 부하 감소와 응답 속도 안정화 (2) | 2025.09.25 |
| 동시성 제어 전략 선택기: 상황에 맞는 락을 고르는 기준 (0) | 2025.09.24 |
| 좋아요 수 집계 테이블 도입 고민기: 설계 단계에서의 선택 (0) | 2025.09.24 |