OOP : 원시값 포장을 하는 이유

Murjune·2024년 3월 12일
4

OOP

목록 보기
1/2
post-thumbnail

Intro

1) 모든 원시값과 문자열을 포장한다.
2) 일급 컬렉션을 쓴다.

우테코의 로또 미션부터 위와 같은 요구 조건이 추가되었다.

원시값 포장, 일급 컬렉션 모두 소트웍스 앤솔러지객체지향 생활 체조 에서 나온 용어였고, 원시값 포장, 일급 컬렉션 둘다 거의 비슷한 맥락에서 나온 용어임을 알게 되었다.

이번 포스팅은 필자가 실 프로젝트에서 원시값 포장 를 활용하여 리팩토링해나간 경험을 통해 정리를 하고자 한다.

그 전에, SRP응집도에 대해 모르는 사람들을 위해 간단하게 용어 정리를 하겠다.

🥸 용어 설명) 응집도와 SRP

응집도 : 클래스 혹은 모듈이 담당하는 책임이나 역할을 얼마나 일관성 있게 지원(관리) 하는지에 대한 척도

  • 응집도가 높다: 서로 연관된 기능들이 잘 묶여 있다.
  • 응집도가 낮다: 서로 관계없는 기능들이 묶여 있다.

SRP : 클래스 혹은 모듈이 바뀌어야할 이유는 1가지여야 한다.

  • SRP 는 책임에 대한 원칙이 아니다. 변경역할 에 대한 원칙이다.

  • Class 가 오직 하나의 역할을 가지고 있어야하고, 즉 변경되어야할 이유 또한 1가지여야 한다. 역할이 2개 이상이면 바뀔 수 있는 부분도 2개 이상인 것이다.

  • 🙋‍♂️ 변경되어야할 이유가 많아지면 왜 안좋을까?

    클래스를 자주 변경될 가능성은 바로, 코드 수정이 잦을 가능성이 크다는 것을 의미하고, 유지보수하기 힘들 가능성이 높다는 것이다!

잘 이해가 가지 않는다면, 아래 글을 다 읽고 용어 설명을 다시 읽는 것을 추천한다!😸


1️⃣ 원시값을 포장하기 전 Member

data class Member(
    val id: String,
    val userCode: String,
    val name: String,
    val age: Int,
    val viewCount: Int,
    val job: JobGroup,
    val clubs: Set<Club>,
    val subways: Set<SubwayStation>,
) {
    init {
        require(userCode.isNotBlank()) { "Code must not be blank" }
        require(userCode.length == 4) { "Code must be 4 letters" }
        require(userCode.all { it in ('A'..'Z') || it in ('0'..'9') }) {
            "Code must be all numbers or upper case alphabets"
        }
        require(name.length in 2..10) { "Name must be 2 to 10 letters" }
        require(name.all { it.isWhitespace().not() }) { "Name must not contain whitespace" }
        require(age in 0..100) { "Age must be 0 to 100" }
        require(viewCount >= 0) { "View count must be 0 or more" }
    }
    
    fun matchClubs(member: Member): List<Club> {...}
    ...
}

위 코드는 필자가 실제로 참여한 프로젝트의 사용자에 대한 Member Entity 를 살짝 변형해서 가져왔다.

위에 코드를 처음 본 개발자는 검증문을 하나하나 읽을 때마다 "뭐이리 검증문이 많아?" 라며 투덜거리면서 개발을 할 것이다.

현재도 이렇게 검증문이 많은데 email, Address .. 등 더 많은 멤버 변수들이 추가가 된다면 수십 개의 검증문이 만들어질 것이고, test 함수 역시 최대 수십개가 만들어질 것이다. 🥲

그리고, userCode, name 등 해당 멤버들이 Member 말고 다른 곳에서도 사용할 경우, 매번 중복되는 검증문과 테스트 코드를 작성해야 할 것이다.

실제로 다른 곳에서도 빈번하게 사용되는 녀석들이였다..

data class MatchingMember(
    val userCode: String,
    val name: String,
    val age: Int,
    ...
) {
    init {
        // 으아악! 반복되는 코드 싫어~!🤬
        require(userCode.isNotBlank()) { "Code must not be blank" }
        require(userCode.length == 4) { "Code must be 4 letters" }
        ...

매번 userCode , name 에 대한 검증문과 테스트 코드를 반복적으로 작성하는 것은 개발 능률도 너무 떨어지고, 개발자가 실수할 위험도 존재한다.

그리고, 기획이 변경되어 Code의 길이는 6, name에 중국어나 일본어도 포함 등등 수정사항이 생겼다고 가정을 해보자!

😱 userCode와 name 를 사용하는 모든 곳(Member, MatchingMember..)에서 이를 수정할 것인가??

위와 같이 동일한 로직이 파편화되어있다면, 새로운 변경사항이 생길 때 유지보수하기 힘들다.. 😥
이는 SRP 를 명백히 위반하게 된 것이다.

SRP : 클래스를 변경하는 이유는 단 한 가지여야 한다

따라서, userCodename을 원시값 포장을 통해 Entity로 만들어보자!!

2️⃣ 원시값 포장

@JvmInline
value class Code(val value: String) {
    init {
        require(value.isNotBlank()) { "Code must not be blank" }
        require(value.length == 4) { "Code must be 4 letters" }
        require(value.all { it in ('A'..'Z') || it in ('0'..'9') }) {
        }
    }
}

@JvmInline
value class UserName(val value: String) {
    init {
        require(value.length in 2..10) { "Name must be 2 to 10 letters" }
        require(value.all { it.isWhitespace().not() }) { "Name must not contain whitespace" }
    }
}

@JvmInline
value class Age(val amount: Int) {
    init {
        require(amount in 0..100) { "Age must be 0 to 100" }
    }
}

@JvmInline
value class ViewCount(val amount: Int) {
    init {
        require(amount >= 0) { "View count must be 0 or more" }
    }
}
...

String 이나 Int에 불과하던 원시값에 Code, UserName, Age, ViewCount class로 wrapping 해주어 의미 있는 객체(=Entity) 로 만들었다!

이제, 수정사항이 생기면 해당 class를 찾아가 로직을 수정만 해줘도 해당 class를 사용하는 모든 곳에서 변경사항이 적용이 되는 것이다!

3️⃣ 원시값을 포장한 Member 😃

data class Member(
    val id: String,
    val userCode: Code,
    val name: UserName,
    val age: Age,
    val viewCount: ViewCount,
    val job: JobGroup,
    val clubs: Set<Club>,
    val subways: Set<SubwayStation>,
) {
    fun matchClubs(other: Member): List<Club> {...}
        

    fun matchSubwayStations(other: Member): List<SubwayStation> {...}
        

    fun matchSubwayLine(other: Member): SubwayStation.SubwayLine? {...} 
    ...

이제 Member 의 복잡해만 보였던 검증 로직들이 모두 사라졌다!😎

이제 Member은 Member 에 대한 기능이 변경될 때만 로직을 수정해주면 된다.

즉, 원시값을 class로 포장함으로써 파편화되고, 중복되었던 검증 로직들을 삭제하여, 이를 사용하던 class들의 응집도를 높이고, SRP를 준수하게 되었다.

4️⃣ 정리

원시값 포장에 대해 너무 복잡하게 생각할 필요 없다.
그저, 아무 의미 없던 Int, String 과 같은 데이터 쪼가리들에 의미를 부여하여 의미 있는 객체(Entity)로 만든 것이다!

이제 원시값을 포장에 대한 설명은 충분한거 같다.
그럼 이제 위에서 설명한 원시값 포장의 장점에 대해 정리를 해보겠다.😃

원시값 포장의 장점 🤓

  1. 개발자의 이해도/능률 🆙: code 또는 Name과 같은 특정 class로 wrapping하여 데이터를 캡슐화하면, 개발자는 다양한 Entity에 흩어져 있는 유효성 검사를 일일이 살펴보거나, 테스트 코드를 작성할 필요 없다.

  2. 코드 재사용성 🆙: 데이터의 유효성 검사 및 기타 관련 로직들을 캡슐화하면 중복이 줄어든다.

  3. 유지보수 🆙: 비즈니스 로직이나 요구 사항이 변경되면 여러 클래스나 파일에 걸쳐 수정하지 않고, Wrapping class 만 수정하면 된다.

  4. 데이터 안전성 🆙: class 타입을 통해 잘못된 다른 데이터가 들어.
    ex) 'name' 변수에 실수로 'code' 를 할당하는 것을 방지할 수 있음.

  5. SRP(단일 책임 원칙)와 응집도 🆙: 원시값을 포장하여, 각 Entity들이 담당한 기능에만 집중할 수 있게 됨.

마무리) 🤔 그럼 모든 원시값을 class로 포장해야하나??

필자는 모든 원시값을 class로 포장하는 것은 오버엔지니어링이라 생각한다.
사실 name, age 와 같은 멤버들도 Member 의 책임이라고도 볼 수 있다.

그런데, 왜 원시값을 포장했을까?

1️⃣ name, age 를 사용하는 class들이 많이 존재함
2️⃣ Member 에 기능이 추가되고, 비대해져 유지보수하기 힘듦

위와 같이 합당한 이유가 있기 때문에 원시값 포장 을 적용한 것이다.

🤩 : "어! 원시값이다 포장해야지~"
🤪 : "어 이거 반복되는 코드다 class로 만들어야지~"

필자는 이와 같이 1차원적인 생각을 가지고 코딩하는 습관을 최대한 지양하려한다.
pain point 가 확실히 존재할 때만 어떤 원칙이나 패턴을 적용하는 것이 좋다고 생각한다.

다음 일급 컬렉션 글도 많관부!!🙏

Reference

  • 소틀로지 블로그

규칙 3: 원시값과 문자열의 포장

https://tecoble.techcourse.co.kr/post/2020-05-29-wrap-primitive-type/

profile
열심히 하겠슴니다:D

0개의 댓글