티스토리 뷰

💡 넥스트 스탭의 TDD, 클린 코드 with Kotlin 8기 강의를 수강하고 작성한 후기입니다.

 

목차

    배경

    친구의 추천으로 지난 5주간 넥스트스탭의 TDD, 클린 코드 with Kotlin 8기를 수강했다. 일반적인 강의보다 수강료가 비싼만큼 가격 부담이 돼서 고민을 했지만, 결과적으로 유의미한 시간이였고 많은 성장도 했다. (강의 수강 기간: 2024년 11월 12일 ~ 12월 23일)

     

    강의 내용은 코틀린의 언어적 특징과 함께 TDD에 대한 설명으로 이루어졌고 주 1회 온라인 강의를 수강하는 방식이였다. 이외 시간은 4개의 미션을 주차별로 구현하고 리뷰어에게 코드리뷰를 받는 방식으로 구성됐다. 온라인 강의보다는 미션 구현과 코드리뷰가 강의의 주 내용이다.

     

    미션의 구현 난이도는 기술적으로 엄청난 역량을 요구하지는 않는다. 오히려 기능 자체는 매우 단순하다. 이 강의의 목적은 단순히 의도대로 동작하는 프로그램을 만드는 것이 아니라, 잘 만드는 것에 있다. 어떻게 작성해야 프로그램이 견고하고 확장 가능한지 배우고, 그 방법을 훈련하는 것에 이 강의의 목적이 있다.

     

    객체지향을 준수하여 유지보수성과 재사용성을 높여야하며, 가능하면 TDD로 미션을 완주하는 것을 권고한다. (개인적으로 TDD로 작성을 하다보면 자연스레 객체지향 프로그래밍도 달성하게 된다고 느꼈다.) 또한 요구사항이 조금이라도 복잡하여 검증 로직이 필요한 경우 테스트 코드 작성 여부와 가독성, 견고함도 코드 리뷰 대상 요소에 포함된다.

     

    강의를 통해 얻은 지식과 경험으로는 TDD와 테스트 코드 작성, 객체지향 프로그래밍, 코틀린 숙련도, 개발자로서 성장하기 위한 도메인 이해도의 중요성이 있다.

    테스트 코드의 목적과 작성 방식

    개인적으로 이 강의를 통해 얻은 가장 좋은 경험이다. 현재 회사 프로젝트는 테스트 코드가 많이 누락되어있고, 개인적으로도 테스트 코드 작성에 많은 연습을 해보지 못해 숙련도가 부족했다. 강의 수강 전까지는 테스트 코드 작성에는 어떠한 기술적인 스킬과 Junit, Assertions 등 라이브러리에 대한 지식이 중요하다고 생각했다.

     

    물론 기술적인 지식도 중요하다. 하지만 테스트 코드 작성에 있어서 가장 중요한 것은 테스트 코드의 작성 목적을 이해하는 것이다. 미션을 진행하며 테스트 코드를 어떻게 작성해야할지 막혔던 적이 있다. 이 때 리뷰어에게 테스트 코드를 어떻게 작성해야할지 모르겠다 고 도움을 요청했다.

     

    이런 요청에 리뷰어로부터 테스트 코드 작성 목적에 대해 고민해보는 것이 좋을 것 이라는 답변을 받았다. 추가적으로 리뷰어는 자산의 로직이 의도한대로 잘 돌아가는지 눈으로 확인하는데에 그 목적이 있다고 생각한다고 의견을 주었다. 결국 내가 무엇을 검증하고자 하는지 고민해보라는 것이다. 이러한 답변을 듣고 무엇을 검증하려는지를 먼저 분명히 파악하니 테스트 코드를 작성할 수 있었다.

     

    또한 검증하려는 대상을 명확하게 해두니, 테스트 코드가 간결해져 가독성을 높일 수 있었다. 테스트 코드 작성도 비즈니스 로직만큼이나 가독성이 중요하다. 다른 개발자가 코드를 봤을때 무엇을 검증하려고 하는지, 어떤 케이스를 검증하는지 명확하게 파악해야하기 때문이다.

    따라서 테스트 코드 작성에 앞서 무엇을 검증하는지 명확하게 그 의도를 파악하는 것은, 테스트 코드의 가독성 향상에도 많은 도움을 주었다.

     

    또한 테스트 코드 작성의 목적 중에 정책과 도메인을 코드로 표현하는데에 있다. 특정 메서드가 어떤 케이스에 성공하고 어떤 케이스에 실패하는지 등 테스트 코드 작성을 통해 도메인과 정책을 코드로 표현할 수 있다. 잘 작성된 테스트 코드는 정책서의 역할도 할 수 있다고 생각한다.

     

    마지막으로 반복되는 검증을 자동화하는데에 있다. 매번 동일한 케이스에 대해 DB 데이어를 넣거나 디버깅을 반복하여 테스트하기보다는, 테스트 코드를 작성해두면 생산성을 높일 수 있다. 개인적으로 3번 이상 특정 케이스에 대한 테스트가 반복되면 잠시 기능 구현을 멈추고 테스트 코드 작성부터 하는 습관이 생겼다.

     

    이처럼 테스트 코드 작성의 기술적인 역량뿐만 아니라 그 목적부터 차근차근 고민해보고 더 좋은 품질의 테스트 코드 작성을 훈련을 할 수 있었다.

    테스트 코드 작성 스킬

    코드 리뷰를 통해 테스트 코드 작성 목적과 같은 소프트 지식 뿐만아니라, 테스트 코드 작성 스킬도 익힐 수 있었다. 먼저 테스트 픽스쳐(Test Fixture)가 있다. 테스트 픽스쳐는 유사한 케이스와 동일한 클래스에 대해 미리 파일로 정의해두는 방안이다. 이를 통해 케이스를 재활용하여 테스트 코드 작성에 드는 비용을 효율적으로 할 수 있다. 또한 어떤 케이스를 검증하기 위한 테스트인지 이름을 통해 표현함으로써 테스트 코드의 가독성을 높이는데 도움을 준다.

     

    다음으로는 테스트 코드 작성시 케이스를 파라미터화할 수 있는 유용한 어노테이션이다. 대표적으로 @ParameterizedTest, @MethodSource, @CsvSource가 있다. 예를 들어 너비와 높이를 매개변수로 전달받는 지도 생성 함수를 테스트할때 여러 케이스에 대해 반복되는 테스트가 발생한다. 너비 > 높이, 너비 = 높이, 너비 < 높이인 경우에 대해 지도 크기가 제대로 생성되었는지 그 결과를 테스트하고 싶다고 가정해보자.

     

    이 경우 유사한 테스트 메서드 3개를 작성하는 것보다, @ParameterizedTest를 사용하여 테스트 케이스를 파라미터로 전달하면 더욱 효율적으로 테스트 코드를 작성할 수 있다. 작성 방식은 다음과 같다. 대상 메서드에 @ParameterizedTest 어노테이션을 두고, 테스트 케이스를 전달할 방안을 @XXXSource 어노테이션으로 제공한다.

     

    아래 코드는 메서드를 기반으로 제공하기 위해 @MethdSource 어노테이션을 두었고, 어노테이션 파라미터로 메서드 명을 전달했다. 유의할 점은 코틀린에서 메서드로 어노테이션을 제공할때는 대상 메서드에 @JvmStatic을 두어야한다. @MethdSource는 객체 생성이 필요없는 static 메서드를 통해 파라미터를 주입하는데 @JvmStatic을 두지 않으면 Companion이라는 중간 객체를 인스턴스화하여 메서드를 호출하기 때문이다.

    class MapTest {
        @ParameterizedTest
        @MethodSource("mapSizes")
        fun `지뢰찾기 지도 생성을 테스트한다`(point: Pair<Height, Width>) {
            val heightSize = point.first.size
            val widthSize = point.second.size
            val map = generateGenerateTestMap(heightSize, widthSize)
    
            map.grid.rows.columns shouldHaveSize heightSize
            map.grid.rows.columns
                .forAll { it.points shouldHaveSize widthSize }
        }
        
        companion object {
            @JvmStatic
            fun mapSizes() =
                listOf(
                    Pair(Height(size = 3), Width(size = 4)),
                    Pair(Height(size = 5), Width(size = 5)),
                    Pair(Height(size = 2), Width(size = 3)),
                )
        }
    }

     

    @CsvSource 어노테이션을 두면 테스트 케이스별로 상이한 결과 값에 대해서도 간결하게 테스트 코드를 작성할 수 있다. 주의할점은 테스트 케이스와 결과 값을 모두 String으로 전달해야 한다.

    import org.junit.jupiter.params.ParameterizedTest
    import org.junit.jupiter.params.provider.CsvSource
    import kotlin.test.assertEquals
    
    class CalculatorTest {
    
        // 간단한 덧셈 메서드
        fun add(a: Int, b: Int): Int {
            return a + b
        }
    
        @ParameterizedTest
        @CsvSource(
            "1, 1, 2",   // 첫 번째 테스트 케이스: 1 + 1 = 2
            "2, 3, 5",   // 두 번째 테스트 케이스: 2 + 3 = 5
            "-1, 5, 4",  // 세 번째 테스트 케이스: -1 + 5 = 4
            "0, 0, 0"    // 네 번째 테스트 케이스: 0 + 0 = 0
        )
        fun `test addition`(a: Int, b: Int, expectedResult: Int) {
            // 실제 결과와 기대 결과 비교
            assertEquals(expectedResult, add(a, b))
        }
    }

     

    이처럼 테스트의 작성 목적 뿐만아니라 효율적으로 테스트 코드를 작성하기 위해 필요한 스킬(테스트 픽스쳐, @ParameterizedTest, @MethodSource, @CsvSource)을 배우고, 테스트 코드 작성를 잘 작성하기 위한 훈련할 수 있었다.

    객체지향 프로그래밍

    다음으로는 객체지향 프로그래밍을 위한 설계와 구현 방안을 배울 수 있었다. 먼저 객체에게 메시지를 던지는 구조를 배웠다. 예를 들어 처음에는 getter를 호출하여 값을 비교하고 결과에 따른 로직을 수행했다. 하지만 이러한 로직 대신 객체에게 대상 파라미터만 던지고, 객체 내부에서 값에 따른 처리와 결과를 반환할 수 있다.

     

    예를 들어 다음과 같이 User 클래스와 나이에 따른 할인율을 구하는 DiscountCalculator 클래스가 있다.

    class User(val age: Int)
    
    class DiscountCalculator {
    
        fun calculateDiscount(user: User): Double {
            return if (user.age >= 65) {
                0.20 // 65세 이상 할인율 20%
            } else {
                0.0 // 기본 할인 없음
            }
        }
    }

     

    나이에 따른 할인율을 구해야하는 요구사항이 발생했다고 가정했을 때, 강의 수강 전에는 주로 다음과 같이 작성했다.

    fun main() {
        val user = User(age = 70)
        val calculator = DiscountCalculator()
    
        // 기존 방식: Getter 호출로 값 비교
        val discount = if (user.age >= 65) {
            calculator.calculateDiscount(user)
        } else {
            0.0
        }
    
        println("Discount: $discount")
    }

     

    강의를 통해 객체에게 메시지를 던지는 구조를 연습할 수 있었고, 미션을 통해 다음과 같이 작성하게 됐다.

    fun main() {
        val user = User(age = 70)
    
        // 새로운 방식: 객체에 대상 파라미터를 던지고 결과 반환
        val discount = user.calculateDiscount()
    
        println("Discount: $discount")
    }

     

    극단적으로 객체지향에 있어서 Getter도 사용하지 말라는 의견을 들었던 적이 있다. 당시에는 너무 극단적이라는 생각이 들었지만, 미션을 진행하며 생각이 달라졌다. 이 말에 의미는 말 그대로 Getter를 절대 쓰지말라는 것이 아니다. 위 예시처럼 Getter를 호출하고 로직을 분기처리하는 것이 아니라, 객체에게 메시지를 던지는 구조로 설계할 수는 없는지 다시 한 번 검토해보라는 의미임을 깨달았다. (물론 단순 필드 조회가 필요한 경우도 있다. 사실 이 경우도 Getter 호출보다는 도메인의 특성을 살리는 방안으로 메서드를 네이밍하면 더 좋을 것이다.)

     

    이러한 방안으로 객체가 메시지를 통해 협력에 참여하는 구조를 이룰 수 있다. 또한 객체의 결과 값을 비교함으로써 테스트 코드를 작성할 수 있다. 결과적으로 유지보수가 쉽고 안정적인 소프트웨어를 만들 수 있는 방안을 배우고 미션을 통해 연습하여 그 숙련도를 높였다.

     

    다음으로는 클래스의 역할과 그에 따른 네이밍을 고민을 해볼 수 있었다. 클래스의 이름은 무엇을 하는지가 아니라 무엇인지(어떤 상태를 갖는지)에 기반을 두고 지어야 한다. 흔히 클래스가 단순히 데이터를 처리하는 역할만 하도록 설계하는 경우가 있다. 나 또한 그랬다. 이러한 코드의 결과로 클래스가 Executor, Creator와 같이 -er, -or로 끝나는 이름을 갖게 된다.

     

    이러한 네이밍이 발생하는 이유는 클래스의 객체가 무엇을 캡슐화할 것인지에 대한 설계가 부족하기 때문이다. 클래스의 객체가 무엇을 캡슐화할 것인지 관찰해야하고, 먼저 이러한 과정을 거쳤을때 적절한 이름을 짓게 된다. 결과적으로 객체에게 메시지를 던지고, 객체 스스로 무엇을 결정하는 구조가 완성된다.(=객제치향 프로그래밍을 준수하게된다.) 객체가 무엇을 하는지를 바탕으로 클래스 이름을 지어버리면, 거기에 강하게 묶여 추가 책임을 가질수도, 스스로 판단하여 메시지를 주고받을 수 었다.(자세한 내용은 [객체지향 프로그래밍] -er, -or로 끝나는 이름을 사용하지 마세요에 작성해두었다.)

     

    결과적으로 미션을 통해 테스트 코드 작성 뿐만아니라, 객체지향 프로그래밍을 하는데 고민해봐야할 요소를 생각할 수 있었다. 또한 객체지향 프로그래밍을 준수하기 위해 필요한 요소들은 무엇이고, 어떻게 코드로 작성할 수 있는지 연습하고 경험할 수 있었다. 미션을 진행하며 배운 객체지향 프로그래밍과 관련된 경험을 시스템 디자인 카테고리에 [객체지향 프로그래밍] 소제목으로 작성해두었다.

    코틀린

    마지막으로 코틀린이라는 언어에 대한 숙련도를 높이고 자신감을 얻었다.

     

    흔히 코틀린은 실수하기 어려운 언어라는 특징이 있다. null-safaty와 타입 추론을 통해 보다 안정적인 프로그래밍을 할 수 있다. 또한 Java로 개발할시, 완성도 높은 프로그래밍을 위해 필요한 요소들을 Kotlin은 기본적으로 제공한다. 예를 들어 Java에서 final 키워드를 둘 필요 없이 코틀린은 모든 클래스와 필드가 기본적으로 final이다. 상속과 오버라이딩이 필요한 경우에만 open 키워드를 작성한다.

     

    또한 코틀린은 함수형 프로그래밍 방식을 따르면서 객체지향 프로그래밍의 이점을 얻을 수 있다. ?.와 엘비스 연산자(?:)를 결합하여 let, also, run, apply 등과 함께 함수형 프로그래밍이 가능한다. 또한 생산성을 높일 수 있는 함수형 생성자 (List constructor-like function) 등을 배우면서, 코틀린의 언어적 특징을 익히고 숙련도를 높였다.

     

    또한 리뷰를 통해 도메인을 코드에 잘 녹여내고, 결과적으로 가독성을 향상하여 유지보수성을 높이는데 도움을 주는 Kotlin의 문법적 특징을 배웠다. 대표적으로 Sealed Class와 Value class와 @JvmInline 등을 경험했다. (코틀린의 Value Class를 포함한 이외 문법적 특징은 Java & Kotlin 카테고리에 [Kotlin] 소제목으로 작성해두었다.)

     

    코틀린에서는 예외를 던지는 것 대신 Sealed Class를 제공하고, 메서드가 이 Sealed Class를 오버라이딩한 결과를 반환한다.

    예외는 프로그램 흐름상 중단이 필요한 경우(데이터 무결성 위반, DB 오류 등)에만 사용하고, 이 외 정책이 어긋나거나 비즈니스 로직상 잘못된 케이스는 Sealed Class를 반환함으로써 프로그램 흐름을 유지할 수 있다. 

    sealed class Result {
        data class Success(val data: String) : Result()
        data class Failure(val message: String) : Result()
    }
    
    class OrderService {
    
        fun placeOrder(quantity: Int): Result {
            return if (quantity > 0) {
                Result.Success("주문 성공")
            } else {
                Result.Failure("잔여 재고 수량이 없습니다.")
            }
        }
    }
    
    fun handleResult(result: Result) {
        when (result) {
            is Result.Success -> println("Success: ${result.data}")
            is Result.Failure -> println("Failure: ${result.message}")
        }
    }
    
    // 사용 코드
    fun main() {
        val service = OrderService()
    
        // 정상적인 주문
        val result1 = service.placeOrder(5)
        handleResult(result1)
    
        // 잘못된 주문
        val result2 = service.placeOrder(0)
        handleResult(result2)
    }

     

    Sealed Class를 반환하면 테스트 코드 작성시 반환 값의 타입 대한 검증만 수행하면 된다. 또한 Service 클래스에 대한 모킹이 필요한 경우에도, 반환 타입만 모킹하면 된다. 결과적으로 테스트 코드 작성이 간편해진다. 또한 Sealed Class의 장점으로 모든 케이스를 처리하는지 컴파일 시점에 검증할 수 있다. 위 코드에서 handleResult가 그 역할을 하게된다.

     

    결과적으로 함수형 프로그래밍과 객체지향 프로그래밍의 장점을 결합하여 테스트 작성이 용이하고 안정적이고 예측 가능한 코드를 작성할 수 있다.

    후기

    이 글을 작성함으로써 강의를 통해 얻었던 지식과 경험을 다시 한 번 되돌아보았는데, 생각보다 많은 지식을 쌓고 경험을 한 것 같다. 회사에서 꼼꼼한 리뷰는 경험하기 다소 힘든데, 이 강의를 통해 그러한 부분을 많이 해소할 수 있었다.

     

    개인적으로 회사 프로젝트 일정과 업무량 증가로 미션을 진행하는데 시간상 어려움이 있었다. 가끔은 밤 11시 넘어서 퇴근하곤 했다. 하지만 아침 출근 준비 전 시간과 퇴근 후 새벽, 주말 시간을 쪼개어 미션을 진행했고 모든 미션을 완주했다. 체력적으로 힘들었지만 결과적으로 5주간 많은 성장을 했다.

     

    수강료가 비싼편이라 섣불리 추천할 수는 없지만, 경제적 여유가 있고 코드 리뷰를 받으며 다른 개발자와 소통하는 환경과 개발자로서 성장에 목마르다면 추천한다. 개인적으로 회사 업무를 통해서만은 겪어보기 어려운 경험을 할 수 있었고, 5주간 많은 성장을 했다고 느낀다.

    Comments