레벨 1 미션을 진행하면서 객체 지향 생활 체조 원칙을 지킬수 있으면 지키라는 프로그래밍 요구사항이 있었다.
객체 지향 생활 체조 원칙
- 한 메서드에 오직 한 단계의 들여쓰기만 한다
- else 표현을 사용하지 않는다
- 모든 원시 값과 문자열을 포장한다
- 한 줄에 점을 하나만 사용한다
- 이름을 줄여 쓰지 않는다(축약 금지)
- 모든 엔티티를 작게 유지한다
- 3개 이상의 스위프트 기본 데이터타입(Int, String, Double 등) 프로퍼티를 가진 타입을 구현하지 않는다
- 일급 콜렉션을 사용한다
- getter/setter를 구현하지 않는다
이 중 원시값 포장에 관한 내용을 다루려고 한다.
원시값을 포장한다는 것의 의미는, Primitive 한 타입, Kotlin에서는 Byte, Short, Int, Long, Float, Double, Char, Boolean 형들을 객체로 묶는 다는 것의 의미이다.
1차원적으로 생각해보면 아래와 같을 것이다
class Price(val value: Int) {
init {
require(value >= 0) { "가격은 0 이상이어야 한다" }
}
}
val productPrice = Price(1000)
좀 코틀린 스럽게 변환한다면, 아래와 같다.
@JvmInline
value class Price(val value: Int) {
init {
require(value >= 0) { "가격은 0 이상이어야 합니다." }
}
}
val productPrice = Price(1000)
Kotlin의 value class는 코틀린에서 값을 나타내기 위한 wrapper 클래스로, primitive 타입의 값을 객체와 같이 다룰 수 있게 해주면서 wrapper 클래스의 오버헤드 문제를 해결할 수 있다고 이해하면 된다. `@JVMInline`은 컴파일 타겟이 JVM 일 경우에만 붙이면 된다. (즉, KMP 프로젝트에서는 필요없게 될 수도 있다.)
자세한 내용은 아래에서 확인할 수 있다.
https://jaeyeong951.medium.com/kotlin-inline-class-10db9cbd169d
[Kotlin] inline class
코틀린도 여타 다른 언어와 마찬가지로 inlining 기능을 제공합니다. 이번 글에서는 kotlin inline class 에 관해 자세히 살펴봅니다.
jaeyeong951.medium.com
대략적으로 Inline class(@JVMInline과 value class 사용)가 런타임에 어떤 값으로 저장되는지 보여준다. 우리는 value class에 대해서 알아보는 것이 아니니까 일단 넘어가도록 하자
문제 상황
온라인 커머스 앱을 만드는데, 사용자가 상품을 장바구니에 담으려고 한다.




지금 보이는 삼다수를 장바구니에 담고 싶다고 가정을 하자. 현재 장바구니에는 삼다수가 없으니, 추가하는 버튼을 클릭하면 새롭게 추가가 된다.
그러면 리스트에 새로 1개가 들어가게 될 것이다.


그러면 위와 같이 1개를 담은 상태에서 3개를 더 담으면, 내부 로직은 어떻게 작동할까? 그냥 리스트에 3개 띡 넣으면 되는거 아닌가?
우리는 우선적으로 해당 상품이 장바구니에 존재하는지 확인해야한다. 만약 존재하지 않는다면 새로 추가해야하고 존재한다면 장바구니에 있는 해당 상품의 내부의 개수를 조정해야한다.
장바구니에 있는 해당 상품을 찾는 로직을 보자.
data class CartProduct(
val id: UUID,
val product: Product,
val amount: Int,
val price: Long,
) {
fun plusAmount(amount: Int): CartProduct = copy(amount = this.amount + amount)
}
data class Product(
val id: UUID,
val name: String,
...
)
fun addProduct() {
...
if(cartProduct.product.id == product.id) {
cartProduct.plusAmount(amount) // 같다면 해당 상품의 개수 증가
}
}
위와 같이 작성 할 것이고, addProduct와 같이 작성하게 될 것이다.
그런데, 나는 여기서 헷갈리는 부분이 있었다. cartProduct의 id와 product의 id가 겹친다.
그래서 개발 당시 실수로 cartProduct.id == product.id 와 같이 작성하게 되었다. 그래서 CartProduct의 ID와 Product의 ID가 중복되는게 없으니 매~번 새로운 Product들이 들어가게 되었다.
이런 ID가 헷갈리는 상황을 막을 순 없을까? ID를 더 객체에 맞게, 상황에 맞게 변형시킬 수 없을까? 라는 생각에서 시작했다.
위의 Product와 CartProduct 코드를 한번 고쳐보자.
data class CartProduct(
val id: CartProductId,
val product: Product,
val amount: Int,
val price: Long,
) {
fun plusAmount(amount: Int): CartProduct = copy(amount = this.amount + amount)
}
data class Product(
val id: ProductId,
val name: String,
...
)
@JvmInline
value class CartProductId(val id: UUID = UUID.randomUUID())
@JvmInline
value class ProductId(val id: UUID = UUID.randomUUID())
이런식으로 ID를 구분하게 된다면, 타입을 헷갈리지 않고 비교할 수 있다. 만약 cartProduct.id == product.id 와 같은 코드를 작성하게 된다면 컴파일러에서 에러를 띄워줄 것이다.
사실 이건 어리석은 나의 실수이기도 하다. 좀 더 심플하게 변수명을 좀 신경써볼만한다. 하지만 이런걸 헷갈린건 나 뿐만 아니라 다른 사람들도 비슷하게 헷갈렸지 않았을 까? 라는 생각에서 시작했다. 만약 amount가 product에도 존재했다면? 그러면 더 헷갈리지 않았을까? 라는 생각이 든다.
왜 객체지향 생활체조에서는 원시값 포장을 하라고 권장할까?
위는 내가 겪었던 예시이고, 좀 더 이해하기 쉬운 예시를 들고왔다.
없는게 없는 테코톡... 허락은 안받았지만 테코톡에서 예시를 들고와봤다. 개발동아리 WAP에서는 아래와 같이 회원 데이터를 저장하고 있다고 가정하자
data class WapCrew(
val name: String,
val age: Int,
val generation: Int, // 기수
)
그러면 아래와 같이 생성을 할 수 있을 것이다.
val Seongwoo = WapCrew("성우", 24, 27)
val Seongwoo2 = WapCrew("성우", 27, 24)
생성된 것만 보고 나이와 기수가 구분이 가는가? 솔직히 어렵다. 순서가 헷갈려서 나는 3살이나 더(오피셜로 늙크크 달성...) 많아졌고 WAP에 들어온 기수로는 3년이나 빨라졌다. (물론 최신 IDE와 언어에서는 Named Parameter, Argument를 지원해서 헷갈릴 일이 덜해졌기도 하다.)
그리고, 나이나 기수가 0으로 될 일은 없으므로 나이와 기수에 대한 기본 값도 세팅해야하고, 이름이 엄청나게 길 일도 없으니 최대 4글자 제한도 있어야 할 수도 있다. 이름은 한글로 지정해야하는 제한도 있을 수 있다. 이를 아래와 같이 표현할 수 있다.
data class WapCrew(
val name: String,
val age: Int,
val generation: Int, // 기수
) {
init {
require(name.length in 2..4) { "..." }
require(name.matches(Regex("^[가-힣]+$"))) { "..." }
require(age in 1 .. 100) { "..." }
require(generation in 1 .. 34) { "..." } // 34기까지 있다고 가정
}
}
지금은 데이터 클래스에 프로퍼티가 3개만 있으니 저런 경우가 가능하지만, 만약 회원 정보가 더 커지게 되면 어떡해야할까? 분야, 학과, 학번, 소속 팀명, 임원진 유무, 전화번호, 졸업생 여부 등등과 같은 정보가 들어온다면, 더 복잡해지고 커다란 클래스가 완성될 것이다.
그리고, 이러한 규칙을 생성 시점뿐만 아니라 수정 시점에도 계속 유지해야 한다는 점도 문제이다. 우리는 생성 단계에서 이름, 나이, 기수를 검증하고 있다. 만약 회원정보를 수정하게 되면, 나이, 기수가 수정되면 그대로 반영이 되어 200살이 될 수도 있을 것이다. 그렇기에 수정 메서드에 같은 규칙을 적어야할 것인데, 여러 곳에서 나이에 대한 값을 같은 규칙을 도입해야한다.
그러면 결국 같은 규칙이 여러 곳에 복사되고, 수정이 필요할 때도 여러 곳을 함께 수정해야한다.
data class WapCrew(
val name: String,
val age: Int,
val generation: Int, // 기수
) {
init {
require(name.length in 2..4) { "..." }
require(name.matches(Regex("^[가-힣]+$"))) { "..." }
require(age in 1 .. 100) { "..." } // 수정 포인트
require(generation in 1 .. 34) { "..." }
}
fun addAge(addAge: Int): WapCrew {
require((addAge + this.age) in 1 .. 100) { "..." } // 수정 포인트
return WapCrew(name, age + addAge, generation)
}
}
원시값 포장을 하지 않았을 때의 문제는 아래와 같다.
- 서로 다른 개념을 실수로 섞을 수 있다.
- age와 generation 모두 Int이므로, 실수로 값을 바꿔 넣어도 컴파일러는 알 수 없다.
- cartProduct.id == product.id 와 같이 헷갈릴 수도 있다.
- 도메인 규칙이 이곳저곳 흩어지게 된다.
- 여러곳에서 도메인 규칙을 작성하게 되고, 수정의 이유가 여러곳에서 발생한다.
- 값과 관련된 책임을 한 곳에 모으기 어렵다.
- 객체의 프로퍼티나 저장해야하는 것이 많아질 수록 책임과 규칙이 무거워진다.
그래서 각각의 프로퍼티들에 대해서 규칙을 분리할 수 있다.
data class WapCrew(
val name: CrewName,
val age: CrewAge,
val generation: CrewGeneration, // 기수
)
@JvmInline
value class CrewName(val name: String) {
init {
require(name.length in 2..4) { "..." }
require(name.matches(Regex("^[가-힣]+$"))) { "..." }
}
}
@JvmInline
value class CrewAge(val age: Int) {
init {
require(age in 1 .. 100) { "..." }
}
}
@JvmInline
value class CrewGeneration(val generation: Int) {
init {
require(generation in 1 .. 34) // 34기까지 있다고 가정
}
}
원시값 포장을 통해서 우리는 아래와 같은 점들을 만들어 냈다.
- 수정의 이유를 하나로 묶었다.
- WapCrew에 대해서 같은 규칙을 매번 강제할 수 있다.
- 실수로 값을 헷갈릴 일이 없다.
- 나이와 기수는 다른 개념이니 객체로 분리하게 된다면 컴파일 딴에서 두 개는 다른 값이라고 알려준다.
- Product와 CartProduct의 예시 또한 마찬가지이다.
그럼 너는 원시값 포장 할거야?
엄.... 사실 항상은 그렇지는 않다. ㅎㅎㅎ 근거는 여러가지가 있는데,
- 귀찮다.!!!!!!~~!!~~! 저런 것 까지 객체로 감싸면 가독성이 좋아 지기도 하는데, 반대로 안 좋아 지기도 한다. 내부의 value의 타입이 한번 포장되어 있어서 해당 클래스가 어떤 타입인지 가끔 헷갈릴 때가 있다.
- 이거도 결국 귀찮은 건데, 값을 호출 할 때 옆과 같이 .value로 호출하는 depth가 한번 늘어나게 된다.product.id.value
결국 항상 포장 할 것이라는 뜻은 아니다.
어떤 클래스가 다른 클래스를 포함되는데 같은 타입의 id 를 들고 있어서 헷갈릴만한 이슈가 있거나, 또는 팀 컨벤션이 작용하는 경우 원시값 포장을 고려를 해볼 만하긴 하다. 그리고 해당 값이 특정 도메인 규칙을 들고 있다면 해당 값을 책임에 맞게 분리해볼 수 있을 것 같다.
다만 위와 달리 별 이유가 없다면 굳이 원시값 포장을 안해도 된다가 나의 개인적인 의견이다. 그래도 클래스 생성에 따른 오버헤드가 value class를 통해서 어느정도 해결이 되므로 하지 말아야할 이유도 크게는 없다고 생각한다.
물론 다른 분들은 다르게 생각할 수 있다. 사실 이 부분은 크게 정해진 정답이 없다고 생각해 상대가 하자는 대로 할 것 같긴하다. 그렇지만 무지성으로 원시값 포장을 하는 것은 경계할 필요가 있을 것 같다.
'우테코 8기' 카테고리의 다른 글
| 레벨 2 회고 (2) | 2026.07.05 |
|---|---|
| 우테코 8기 합격 후기(안드로이드) (2) | 2026.04.24 |