소프트웨어 사색

고품질 코드 (1) - 기존 코드 건드리지 말기 (feat. 코틀린)

[하마] 이승현 (wowlsh93@gmail.com) 2023. 6. 5. 20:45

고품질의 코드란 무엇일까?  예외처리가 잘 된 ? 주석이 잘 달린? 성능,메모리 최적화된? 변수명이 좋은? 
다 옳은 말이다. 추가적으로 관련 프레임워크/라이브러리의 이해가 잘된 코드, 도메인을 잘 이해한 코드도 물론이고..

이 글에서 고품질의 코드란 새로운 기능을 구현 할 때 최대한 기존 구현된 코드는 건드리지 않는 구조라고 정하고
그것에 대한 이야기를 써 본다.

이 글은 아래에  나오는 지식 기반을 엮어서 설명한다. 

  • Ralph E. Johnson, Martin Fowler et al. DI,DIP,IoC(Dependency Injection etc) 
  • 마틴파울러의 리팩토링 - Refactor Conditional To Polymorphism
  • 로버트 C. 마틴 클린코드 -  Data/Object Anti-Symmetry
  • 버틀란트메이어 객체지향 소프트웨어 설계 - Open/Closed Principle
  • Gof의 디자인패턴 - Visitor Pattern (사실 Visitor말고도 수 많은 패턴들이 해당된다)
  • 더블 디스패칭 - Visitor패턴에서 엘리먼트(데이터)가 하나이고 Visitor (행위자)가 여럿일 경우 비슷 
  • Duncan McGregor, Nat Pryce의 Java to Kotlin - Open to Sealed Classes

위에 나오는 법칙들은 모두 한가지를 가르키는데 변경은 좁은/분리된 범위에서 하게 하자이다.

먼저 리펙토링(Refactor Conditional To Polymorphism)은 if 문의 중첩으로 코딩된 코드를 다형성으로 처리하여 if 문을 밑으로 더 길게 수정하는 행위보다는 해당 부분은 전혀 건드리지 말고, 다른 파일에 새로운 타입의 구현을 작성하기게 만들자라는 것이고, 그 방식을 다형성의 힘을 이용했다.  Data/Object Anti-Symmetry는 다형성을 이용해서 구현을 하게 되면 생기는 문제도 있음을 지적한다. 우리가 개발을 하다보면 동종의 타입들이 늘어나는 경우도 있고, 행위가 늘어나는 경우가 있다.예를들어 Shap(도형) 이라면 draw라는 작동방식은 한정되어 있지만 Circle, Rectange,Star,Square,Ellipse 등 타입은 계속 늘어날 가능성이 높은 경우가 있고, 반대로 타입은 남자,여자로 거의 정해졌지만 행위는 먹다,자다,놀다,걷다 등으로 무한정 늘어날 가능성이 높은 경우가 있을 것이다. 이때 후자의 경우 다형성 기반으로 코딩을 한다면 행동하나가 늘어 날 때 마다 모든 서브클래스의 파일을 찾아서 수정해 주어야하는 고생을 해야 할 것이라서, 그냥 타입만 구분하고 그것에 강결합되 있는 인터페이스의 동작을 작성하지 말고, 파일하나에 행위 하나를 작성한다면 새로운 행위가 추가되더라도 기존 파일에는 전혀 영향이 없이 새로운 파일을 만들어서 추가하면 될 것이다. 이런 것들은 OCP와도 일맥상충하는데 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계가 되어야 한다는 의미입니다.

이제 말보단 코드로 봅시다. 코틀린의 sealed class를 사용합니다.

sealed interface Error

class FileReadError(val file: String): Error
class DatabaseError(val source: String): Error
class RuntimeError : Error

위와 같은 코드가 있다고 합시다. Error라는 인터페이스를 구현하는 타입이 3가지가 있습니다.
만약 Error 라는 타입의 가지고 있는 행위는 log()만 있을 확률이 높고, Error 하위 타입이 늘어날 가능성이 높은 어플리케이션을 님이 만들고 있다면 이것은 다형성을 이용하는게 좋을 것입니다.

sealed interface Error {
    fun log()
}

class FileReadError(val file: String): Error {
    override fun log() { println("Error while reading file $file") }
}
class DatabaseError(val source: String): Error {
    override fun log() { println("Error while reading from database $source") }
}
class RuntimeError : Error {
    override fun log() { println("Runtime error") }
}

위의 클래스들이 모두 다른 파일에 작성되어 있다면, 기존 파일은 전혀 신경 쓰지 않고 다른 파일 하나 더 만들면 됩니다.
하지만 log 말고 다른 메소드들이 계속 생긴다면? 그 때 마다 다른 파일들 모두 찾아야 겠지요.

이젠 타입은 정해져있고 log()라는 행위 말고, 다른 행위들이 생긴다고 생각해 봅시다.

fun log(e: Error) = when(e) {
    is FileReadError -> { println("Error while reading file ${e.file}") }
    is DatabaseError -> { println("Error while reading from database ${e.source}") }
    is RuntimeError ->  { println("Runtime error") }
}

이때는 이런식으로 파일 하나에는 하나의 행위만 들어가기 때문에 , 다른 행위를 위해 다른 파일에서만 추가하면  됩니다.

이번에는 Visitor 패턴이며  이 패턴 역시 OCP에 관련된 기존 구현을 건드리지 않으면서 행위를 수행합니다.
예를 봅시다.

class Itninerary (
    val id : String,
    val route : Route,
    val accomodations: List<Accomodation> = emptyList()
        ){
}


대략 이런 여행계획서 엔터티가 있다고 합시다. 계획서 안에는 어디를 방문하는지에 관한 Route와 숙소들의 리스트가 있습니다. 해당 여행 계획에 대한 경비를 산출해주는 로직을 만든다고 할 경우 가장 먼저 떠오르는 것은 Route와 Accomodation의 내용을 읽어서 더해주면 될 거 같습니다. 

class CostSummaryCalculator {

    fun calc(itninerary: Itninerary): CostSummary {

        var total : BigDecimal = BigDecimal.ZERO
        
        itninerary.accomodations.forEach {
            total.add(it.cost.amount)
        }

        itninerary.route.paths.forEach{
            total.add(it.cost.amount)
        }
        
        // ...

        return CostSummary(Money.of(total, Currency.WON))
    }
}

그런데 이런식으로 구현 하게 되면 여행 계획에 무엇인가 행위 (숙박,교통요금,관광..) 가 추가 될 때 마다 이 기존 calc 함수는 변경되야 할 것입니다. 그럼 기존 코드는 안건드리고 새로운 코드만 추가해서 해결 하려면 어떻게 해야 할 까요?

interface CostCalc {
    fun addCostsTo(calc: CostSummaryCalculator);
}
interface ItnieraryItem : CostCalc

data class Path(val cost : Money) :  ItnieraryItem {
    override fun addCostsTo(calc: CostSummaryCalculator) {
        calc.addCost(cost)   
    }
}
class Route :  ItnieraryItem{
    val paths = mutableListOf<Path>()
    override fun addCostsTo(calc: CostSummaryCalculator) {
        paths.forEach{
            it.addCostsTo(calc)
        }
    }

    fun addPath(path: Path) {
        paths.add(path)
    }

}

class Accomodation(val cost : Money) : ItnieraryItem{

    override fun addCostsTo(calc: CostSummaryCalculator) {
        calc.addCost(cost) 
    }

}

class Itninerary  (
    val id : String,
    val items: List<ItnieraryItem> = emptyList()
        ){

    fun addCostTo(calc: CostSummaryCalculator){
        items.forEach {
            it -> it.addCostsTo(calc)
        }
    }
}


data class CostSummary (val cost: Money)

class CostSummaryCalculator {

    val list = mutableListOf<Money>()
    fun addCost(money: Money) {
        list.add(money)
    }

    fun getTotal() : BigDecimal {
        return list.sumOf { it.amount }
    }
}


이렇게 하게 되면 새로운 여행아이템이 등장해도 해당 아이템에만 addCost 메소드를 추가 구현 해 주면 됩니다. Visitor패턴의 accept 가 addCostTo가 될 것이고, Visitor 는 CostSummaryCalulator이며 visit 에는 Money가 들어가네요.
(Visitor패턴이 모든곳에 좋은 건 아니며 위의 코드도 부실합니다. 설명하고자하는 것에 집중된 단순 예제 일 뿐)