주니어 개발자가 만난 클린 아키텍처

Image not Found

안녕하세요. 버즈빌 신입 개발자 Damon입니다. 해당 게시글에서는 버즈빌의 신입 개발자가 마주한 개발 설계 과정에서의 문제점을 설명합니다. 그리고, 개발자 JD가 저를 가이드하여 설계가 개선된 경험을 공유하고자 합니다.

좋은 설계의 중요성

Robert C. MartinClean Architecture에서 “좋은 설계란 시스템을 개발하고 유지 보수하는 데 필요한 인력, 비용, 시간을 줄이는 것”이라고 정의했습니다. 시스템은 개발하는 과정도 중요하지만 개발 이후 유지 보수의 과정도 매우 중요합니다. 소프트웨어에 대한 요구사항은 계속해서 변경되고, 새로운 기능이 추가되기도 합니다.

HW와 SW 유지보수 비용 (SE, Maintenance, Hans & Vilet, 2008)

위 그래프를 보면 소프트웨어의 유지보수 비용은 소프트웨어의 개발 비용과 비슷하거나 그 이상입니다. 그만큼 소프트웨어의 유지 보수는 중요합니다. ‘Clean Architecture’, ‘DDD’, ‘SOLID’ 등과 같은 설계 개념은 좋은 설계를 통해 개발 및 유지 보수 과정에서 소요되는 물리적인 비용을 줄일 수 있습니다.

SRP, OCP, DIP를 기반으로 한 설계

SOLID 원칙은 좋은 설계를 돕는 대표적인 원칙 중 하나입니다. 그중 SRP, OCP, DIP를 예시로 들어 어떻게 좋은 디자인을 할 수 있는지 소개합니다.

SRP, OCP, DIP는 다음과 같습니다.

  • SRP(Single Responsibility Principle): 단일 책임 원칙으로, 한 클래스는 하나의 책임만 가져야 한다.
  • OCP(Open/Closed Principle): 소프트웨어 컴포넌트는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
  • DIP(Dependency Inversion Principle): 의존 관계를 맺을 때, 변하기 쉬운 것보다 변하기 어려운 것에 의존해야 한다.

현재 상황

저는 Unit/UI 테스팅 라이브러리를 개발하고 있었습니다. 당시 다음과 같은 소스 코드를 작성했습니다.

  • TestRunner 클래스는 테스트에 필요한 유틸리티 클래스를 주입받는다.
  • RUN_TEST는 인자로 전달받은 테스트 블록(block)을 수행하는 함수이다.
  • TestUtil 클래스는 RUN_TEST에서 테스트 시작 전과 테스트 종료 후에 수행된다.

TestUtils.kt

interface TestUtils {
    fun runBeforeTest()
    fun runAfterTest()
}

TestRunner.kt

class TestRunner(
    private val testUtils : List<TestUtils>
) { 
    fun RUN_TEST(block : () -> Unit) {
        testUtils.forEach(TestUtil::runBeforeTest)
        
        //RUN TEST
    
        testUtils.forEach(TestUtil::runAfterTest)
    }
}

시나리오

  • 클라이언트는 테스트의 수행시간을 시스템 로그로 볼 수 있는 기능을 추가하고자 한다.
  • 다음과 같은 클래스를 추가하여 클라이언트의 요구사항을 만족시켰다.

ExecutionTimeSystemLogger.kt

class ExecutionTimeSystemLogger() : TestUtil {
    private var startTime : Long = 0
    
    override fun runBeforeTest() {
        startTime = System.nanoTime()
    }
    
    override fun runAfterTest() {
        val endTime = System.nanoTime()
        val executionTIme = endTime - startTime
        println("TIME: $executionTime")
    }
}

해당 설계는 클라이언트의 요구사항을 완벽하게 반영하고 있습니다. 하지만, 개발자 JD는 저의 코드를 보고 다음과 같은 리뷰를 남깁니다.

  1. 단일 책임 원칙(SRP)를 위반한다.
    • 실행 시간을 계산하는 책임
    • 출력하는 책임
  2. 개발 폐쇄 원칙(OCP)를 위반한다.
    • 클라이언트가 출력을 File 시스템을 통해서도 원한다면, 기존 코드를 수정해야 한다.
  3. 의존성 역전 원칙(DIP)를 위반한다.
    • TestRunner는 ExecutionTimeSystemLogger라는 구상 클래스에 의존한다.

위와 같은 문제는 소프트웨어의 변화를 매우 어렵게 만듭니다. 예를 들어, 위 코드가 실제 코드에 적용된 후, 클라이언트가 시스템 로그가 아닌 파일을 통해 로그를 남기고 싶다고 요청하는 경우에 어떻게 될까요? 아마 저는 ExecutionTimeFileLogger를 만들게 될 것입니다. 이 클래스는 기존의 ExecutionTimeSystemLogger의 많은 내용이 중복됩니다. 또한, 시간을 계산하거나, 출력하는 포맷이 변경된다면 두 클래스 모두를 수정해야 될 것입니다. 만약, 시스템, 파일 외 다른 형태로 출력하는 기능이 계속해서 추가된다면 이런 시스템을 유지 보수하는 것은 매우 어려워질 것입니다.

요구사항을 만족하는 유연한 소프트웨어 개발

이러한 상황을 대응하고 유연하게 소프트웨어를 설계하는 대표적인 원칙 중 하나가 ‘SOLID’입니다. 다음은 개발자 JD가 저를 가이드 하여 SOLID 원칙을 준수하게끔 코드를 개선한 결과입니다.

Logger.kt

interface Logger {
    fun log(message: String)
}

SystemLogger.kt

class SystemLogger() : Logger {
    override fun log(message: String) {
        println(message)
    }
}

ExecutionTimer.kt

class ExecutionTimer(
    private val logger: Logger
) : TestUtil {
    private var startTime : Long = 0

    override fun runBeforeTest() {
        startTime = System.nanoTime()
    }
    
    override fun runAfterTest() {
        val endTime = System.nanoTime()
        val executionTIme = endTime - startTime
        val message = "TIME: $executionTIme"
        logger.log(message)
    }
}

위를 통해 다음과 같은 사항이 개선되었습니다.

  • ExecutionTimeSystemLoggerExecutionTimer, Logger로 분리되어 각각이 할 일에 대해 책임집니다.
    • 각 클래스는 하나의 책임을 진다.
  • SystemLoggerLogger를 구현한다. 만약, 클라이언트가 System 로그가 아닌 File로 로그를 남기고 싶을 때는 단순히 FileSystemLogger를 구현하여 대응하면 됩니다.
    • 확장에 대해 열려있다.
  • ExecutionTimerLogger에 의존합니다. Logger는 바뀔 가능성이 매우 적기 때문에 그로 인해 ExecutionTimer가 변경될 가능성도 적어집니다.
    • 변하기 쉬운 것이 아닌, 변하지 않는 것에 의존한다.

이제 아래와 같은 클라이언트의 요구에 다음과 같이 FileSystemLogger를 추가하여 대응할 수 있습니다.

“File로 실행 시간을 출력하는 기능을 추가해 주세요.”

class FileSystemLogger() : Logger {
    override fun log(message: String) {
        //File System Logging..
    }
}

이렇게 설계하면 실행 시간을 계산하는 로직이나 출력의 포맷이 변하더라도 영향을 받지 않게 됩니다. 그리고, 하나 더 중요한 점은 ExecutionTimeSystemLoggerExecutionTimerLogger로 분리 덕분에 다른 곳에서도 Logger를 재활용할 수 있게 되었습니다.

글을 마무리하며

버즈빌의 신입 개발자가 가이드를 통해 코드를 개선해 나아가는 과정을 설명드렸습니다. 다음엔 더 재밌는 포스팅으로 찾아오겠습니다. 추가로, 버즈빌의 신입 개발자로 채용되시면, 많은 개발자들이 코드 리뷰를 통해 어떻게 소프트웨어를 유연하게 설계할 수 있는지 자세하게 설명해 드립니다!

버즈빌 개발자 지원하기 (클릭)

버즈빌 테크 리크루터와 Coffee Chat하기 (클릭)

You May Also Like

post-thumb

asyncio 뽀개기 2 - Future의 활용

Future를 잘 활용하면 단순히 await 하는 용도보다 더 다양한 흐름 제어를 할 수 있습니다. 이전 포스트에서는 asyncio의 핵심 컴포넌트인 코루틴과 Eventloop을 소개했습니다. 이번 포스트에서는 Future를 만드는 방법, Callback을 등록해서 활 …

Read Article