코틀린 클린코드(8) – 코틀린 예외 처리: require, check, Result로 실패를 다루는 법

코틀린 클린코드 8편 예외 처리와 Result를 읽기 좋게 작성하는 방법

1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를 다뤘고, 3편에서는 함수 설계를 정리했습니다.

4편에서는 null safety를, 5편에서는 data class와 sealed class를, 6편에서는 extension function과 scope function을 살펴봤습니다.

7편에서는 컬렉션과 람다를 읽기 좋게 쓰는 기준을 정리했습니다.

이번 8편에서는 실패를 어떻게 표현할지를 다룹니다. 주제는 코틀린 예외 처리입니다.

실무에서 실패 처리는 자주 뒤엉킵니다. 입력이 잘못된 경우, 객체 상태가 잘못된 경우, 외부 API가 실패한 경우, 코드상 절대 오면 안 되는 분기까지 한꺼번에 섞이기 쉽습니다.

그러면 코드가 금방 탁해집니다. 모든 곳에서 try-catch를 두르고, 아무 예외나 잡아서 null을 반환하고, 원인도 흐려집니다.

클린코드 관점에서 중요한 것은 예외를 적게 쓰는 것이 아닙니다. 실패의 종류에 맞는 도구를 고르는 것입니다.

이번 글에서는 require, check, error, try-catch, runCatching, Result를 어떤 기준으로 쓰면 좋을지 실무 관점에서 정리하겠습니다.

시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편, 5편, 6편, 7편도 함께 보시면 좋습니다.

예외 처리가 왜 금방 복잡해질까요?

실무 코드가 복잡해지는 가장 흔한 이유는, 서로 다른 실패를 같은 방식으로 처리하기 때문입니다.

아래 코드를 보겠습니다.

fun loadOrderSummary(orderId: Long): OrderSummary? {
    return try {
        if (orderId <= 0) {
            throw IllegalArgumentException("orderId must be positive")
        }

        if (!session.isLoggedIn()) {
            throw IllegalStateException("login required")
        }

        val response = orderApi.fetch(orderId)
        response.toSummary()
    } catch (e: Exception) {
        logger.error("loadOrderSummary failed", e)
        null
    }
}

겉으로 보기에는 간단합니다. 실패하면 로그를 남기고 null을 반환합니다.

문제는 정보가 너무 많이 사라진다는 점입니다.

  • 잘못된 입력과 로그인 상태 문제와 외부 API 실패가 모두 같은 처리로 합쳐집니다.
  • 호출자는 무엇이 잘못됐는지 알기 어렵습니다.
  • 실패 원인에 따라 다른 대응을 하기도 어렵습니다.
  • 예외를 정말 잡아야 하는 범위가 어디인지도 흐려집니다.

이런 코드는 처음에는 편합니다. 하지만 유지보수 단계에서는 금방 불편해집니다.

클린코드의 출발점은 단순합니다. 실패를 구분해서 다뤄야 합니다.

실패를 한 종류로 다루지 마세요

실패는 대충 네 가지로 나눠서 생각하면 편합니다.

  • 입력 검증 실패: 함수 인자가 잘못됐습니다. 이때는 require가 잘 맞습니다.
  • 상태 검증 실패: 객체가 아직 준비되지 않았거나, 호출 순서가 틀렸습니다. 이때는 check가 더 분명합니다.
  • 회복 가능한 외부 실패: 파일, 네트워크, 외부 API처럼 호출자가 대응할 수 있는 실패입니다. 이때는 Result가 유용할 수 있습니다.
  • 논리적으로 오면 안 되는 분기: 여기까지 실행되면 코드 가정이 깨진 상황입니다. 이때는 error가 잘 맞습니다.

이 네 가지를 같은 catch (e: Exception)로 묶는 순간, 코드의 의도는 약해집니다.

반대로 실패 종류를 나누면 함수의 계약이 또렷해집니다.

fun saveUser(userId: Long?, isReady: Boolean) {
    val validUserId = requireNotNull(userId) { "userId must not be null" }
    check(isReady) { "saveUser() can be called only after initialization" }

    // 실제 저장 로직
    println("save user: $validUserId")
}

짧은 예제지만 의도가 분명합니다. 잘못된 인자는 requireNotNull이 막습니다. 잘못된 상태는 check가 막습니다.

코틀린 예외 처리의 기본

코틀린에서는 모든 예외가 기본적으로 unchecked exception입니다.

즉, 자바의 checked exception처럼 throws 선언을 강제하지 않습니다.

그래서 더 편합니다. 동시에 더 신중해야 합니다. 잡지 않아도 되기 때문에, 어디서 던지고 어디서 처리할지 기준이 더 중요해집니다.

try-catch는 문장처럼도 쓸 수 있고, 표현식처럼도 쓸 수 있습니다.

val timeoutMillis: Long = try {
    config["timeout"]!!.toLong()
} catch (e: NumberFormatException) {
    3000L
}

이런 형태는 읽기 좋습니다. 실패 시 기본값을 선택한다는 의도가 바로 드러나기 때문입니다.

반대로 try 블록이 너무 넓으면 읽기 어려워집니다. 그 부분은 뒤에서 다시 보겠습니다.

입력 검증은 require와 requireNotNull

require는 함수 인자나 외부 입력을 검증할 때 쓰면 좋습니다.

조건이 만족되지 않으면 IllegalArgumentException을 던집니다.

이름 자체가 잘 말해줍니다. 이 함수가 실행되려면 이 조건이 필요하다는 뜻입니다.

먼저 좋지 않은 예를 보겠습니다.

fun createCoupon(name: String, discountPercent: Int) {
    if (name.isBlank()) {
        throw IllegalArgumentException("name must not be blank")
    }

    if (discountPercent !in 1..100) {
        throw IllegalArgumentException("discountPercent must be between 1 and 100")
    }

    println("coupon created")
}

틀린 코드는 아닙니다. 다만 조건이 많아질수록 반복이 커집니다.

같은 코드를 require로 바꾸면 조금 더 읽기 좋아집니다.

fun createCoupon(name: String, discountPercent: Int) {
    require(name.isNotBlank()) { "name must not be blank" }
    require(discountPercent in 1..100) {
        "discountPercent must be between 1 and 100. discountPercent=$discountPercent"
    }

    println("coupon created")
}

requireNotNull도 자주 유용합니다.

fun sendWelcomeMessage(userId: Long?) {
    val validUserId = requireNotNull(userId) { "userId must not be null" }

    println("send message to $validUserId")
}

이 방식의 장점은 두 가지입니다.

  • 입력 검증이라는 목적이 함수 이름으로 바로 드러납니다.
  • 검증이 끝난 뒤에는 non-null 값으로 안전하게 사용할 수 있습니다.

실무에서는 메시지도 구체적으로 적는 편이 좋습니다. 어떤 값이 문제였는지 남겨야 디버깅이 쉬워집니다.

fun changePrice(productId: Long, newPrice: Long) {
    require(productId > 0) { "productId must be positive. productId=$productId" }
    require(newPrice >= 0) { "newPrice must be non-negative. newPrice=$newPrice" }

    println("change product=$productId price=$newPrice")
}

여기서 중요한 점이 하나 더 있습니다. 입력 검증에 check를 쓰지 않는 것입니다. 인자가 잘못됐다면 상태 오류가 아니라 인자 오류입니다.

상태 검증은 check와 checkNotNull

check는 객체나 변수의 상태를 검증할 때 잘 맞습니다.

조건이 깨지면 IllegalStateException을 던집니다.

즉, 호출한 쪽이 인자를 잘못 준 것이 아니라, 이 객체가 지금 이 작업을 할 준비가 안 됐다는 의미에 가깝습니다.

class ReportExporter {
    private var outputDirectory: String? = null

    fun configure(directory: String) {
        outputDirectory = directory
    }

    fun export() {
        val directory = checkNotNull(outputDirectory) {
            "outputDirectory must be configured before export()"
        }
        check(directory.isNotBlank()) {
            "outputDirectory must not be blank"
        }

        println("export to $directory")
    }
}

이 코드는 흐름이 자연스럽습니다. 먼저 설정이 되었는지 확인합니다. 그다음 설정값이 유효한 상태인지 확인합니다.

반대로 이런 상황에서 require를 쓰면 읽는 사람이 헷갈릴 수 있습니다.

class TokenProvider {
    private var token: String? = null

    fun updateToken(newToken: String) {
        token = newToken
    }

    fun currentToken(): String {
        return checkNotNull(token) { "token is not initialized yet" }
    }
}

이 함수는 인자를 받지 않습니다. 그러니 입력 검증보다 상태 검증이 더 자연스럽습니다.

정리하면 이렇습니다.

  • require: 외부에서 들어온 값이 맞는지 확인
  • check: 지금 객체가 이 작업을 수행할 수 있는 상태인지 확인

도달하면 안 되는 분기는 error

error는 논리적으로 오면 안 되는 상태를 드러낼 때 유용합니다.

이 함수는 IllegalStateException을 던집니다.

핵심은 의미입니다. 이 분기까지 오면 코드 가정이 깨졌다는 뜻입니다.

sealed interface UploadState {
    data object Idle : UploadState
    data object Uploading : UploadState
    data class Success(val url: String) : UploadState
    data class Failure(val message: String) : UploadState
}
fun requireUploadedUrl(state: UploadState): String =
    when (state) {
        is UploadState.Success -> state.url
        UploadState.Idle -> error("URL is not available in Idle state")
        UploadState.Uploading -> error("URL is not available while uploading")
        is UploadState.Failure -> error("URL is not available after failure: ${state.message}")
    }

이 코드는 아주 공격적입니다. 하지만 그만큼 분명합니다. 이 함수는 성공 상태에서만 호출되어야 한다는 계약을 강하게 드러냅니다.

모든 곳에서 error를 쓰라는 뜻은 아닙니다. 사용자 입력처럼 충분히 들어올 수 있는 값에는 더 부드러운 처리가 맞을 수 있습니다.

다만 절대 오면 안 되는 분기라면 null을 돌려서 흐리게 넘기지 않는 편이 좋습니다.

예외보다 안전한 API가 먼저인 경우

클린코드에서는 예외를 잘 던지는 것만큼, 예외를 아예 만들지 않는 선택도 중요합니다.

정상적인 부재나 흔한 파싱 실패까지 모두 예외로 다루면 코드가 무거워집니다.

코틀린 표준 라이브러리에는 이런 상황을 위한 안전한 함수가 많습니다.

문자열 숫자 파싱

val port = try {
    input.toInt()
} catch (e: NumberFormatException) {
    null
}
val port = input.toIntOrNull()

비어 있을 수 있는 컬렉션

val firstUser = users.first()
val firstUser = users.firstOrNull()

인덱스 접근

val fourthItem = items[3]
val fourthItem = items.getOrNull(3)

이런 API는 의도를 더 잘 드러냅니다. 값이 없을 수도 있다는 사실을 타입과 이름으로 보여주기 때문입니다.

예외는 비정상 흐름에 가깝습니다. 반면 빈 값이나 파싱 실패는 도메인에 따라 충분히 정상일 수 있습니다.

그래서 질문이 중요합니다. 이 상황이 정말 예외인가요, 아니면 흔한 분기인가요?

try-catch는 좁게 잡아야 읽기 좋습니다

try-catch 자체가 나쁜 것은 아닙니다. 문제는 범위입니다.

검증, 매핑, 외부 호출, 후처리까지 모두 한 번에 감싸면 어떤 코드가 왜 실패했는지 읽기 어려워집니다.

fun fetchProductName(productId: Long): String? {
    return try {
        require(productId > 0) { "productId must be positive" }
        val response = productApi.fetch(productId)
        response.name.trim()
    } catch (e: Exception) {
        logger.warn("fetchProductName failed", e)
        null
    }
}

이 코드는 입력 검증 실패까지 예외 처리에 섞여 있습니다. 읽는 사람은 어떤 실패를 예상하는지 알기 어렵습니다.

조금만 분리해보겠습니다.

fun fetchProductName(productId: Long): String? {
    require(productId > 0) { "productId must be positive. productId=$productId" }

    return try {
        val response = productApi.fetch(productId)
        response.name.trim()
    } catch (e: ProductApiException) {
        logger.warn("product API failed. productId=$productId", e)
        null
    }
}

이제 더 분명합니다. 인자 검증은 함수 입구에서 끝납니다. catch는 외부 API 실패만 다룹니다.

여러 예외를 잡아야 할 때도 순서를 조심해야 합니다. 더 구체적인 예외를 위에 두는 편이 좋습니다.

try {
    paymentGateway.charge(amount)
} catch (e: TimeoutException) {
    logger.warn("payment timed out", e)
} catch (e: PaymentGatewayException) {
    logger.error("payment gateway error", e)
}

try-catch는 좁을수록 좋습니다. 어떤 실패를 의도적으로 처리하는지 바로 보여주기 때문입니다.

Result는 언제 유용할까요

Result는 성공과 실패를 값으로 감싸서 전달합니다.

모든 함수가 Result를 반환해야 하는 것은 아닙니다. 하지만 호출자가 실패를 받아서 대응할 수 있어야 하는 경계에서는 꽤 유용합니다.

예를 들어 외부 API 호출, 파일 읽기, 파싱처럼 실패가 자연스럽고 호출자가 복구 전략을 선택할 수 있는 경우가 있습니다.

data class Profile(
    val id: Long,
    val name: String,
)

class ProfileApiException(message: String) : RuntimeException(message)
fun fetchProfile(userId: Long): Result<Profile> {
    require(userId > 0) { "userId must be positive. userId=$userId" }

    return runCatching {
        val response = profileApi.fetch(userId)

        if (!response.isSuccessful) {
            throw ProfileApiException("profile API failed. status=${response.status}")
        }

        Profile(
            id = response.id,
            name = response.name,
        )
    }
}

이 함수는 입력 오류를 require로 바로 막습니다. 대신 외부 API 실패는 Result 안으로 전달합니다.

호출자는 상황에 따라 대응 방식을 고를 수 있습니다.

val message = fetchProfile(userId).fold(
    onSuccess = { profile -> "프로필 로드 성공: ${profile.name}" },
    onFailure = { throwable -> "프로필 로드 실패: ${throwable.message}" },
)

Result가 좋은 이유는 실패를 무조건 삼키지 않기 때문입니다. 성공과 실패를 모두 호출자에게 명시적으로 넘길 수 있습니다.

반대로 이런 함수에는 Result가 과할 수 있습니다.

fun isAdult(age: Int): Result<Boolean> =
    runCatching { age >= 19 }

이 코드는 실패 전략이 없습니다. 그냥 계산 결과만 있으면 됩니다.

fun isAdult(age: Int): Boolean {
    require(age >= 0) { "age must be non-negative. age=$age" }
    return age >= 19
}

즉, Result회복 가능한 실패를 호출자에게 넘길 때 의미가 큽니다. 단순 계산까지 모두 감싸면 오히려 코드가 무거워집니다.

runCatching을 넓게 감싸지 마세요

runCatching은 편리합니다. 하지만 그래서 더 조심해야 합니다.

runCatching은 블록 안에서 던져진 Throwable을 잡아 Result 실패로 감쌉니다.

문제는 범위를 너무 넓게 잡으면 안 잡아도 될 것까지 함께 감싼다는 점입니다.

fun registerUser(request: RegisterUserRequest): Result<Long> =
    runCatching {
        require(request.email.isNotBlank()) { "email must not be blank" }
        require(request.password.length >= 8) { "password must be at least 8 characters" }

        val savedUser = userRepository.save(request.toEntity())
        eventPublisher.publishUserCreated(savedUser.id)

        savedUser.id
    }

이 코드는 짧지만 아쉬움이 있습니다.

  • 입력 검증 실패까지 모두 Result 안으로 들어갑니다.
  • 저장 실패와 이벤트 발행 실패도 같은 방식으로 섞입니다.
  • 어느 실패를 호출자가 복구해야 하는지 분명하지 않습니다.

조금 더 나누어 보겠습니다.

fun registerUser(request: RegisterUserRequest): Result<Long> {
    require(request.email.isNotBlank()) { "email must not be blank" }
    require(request.password.length >= 8) {
        "password must be at least 8 characters"
    }

    val savedUser = userRepository.save(request.toEntity())

    return runCatching {
        eventPublisher.publishUserCreated(savedUser.id)
        savedUser.id
    }
}

이제 계약이 더 분명합니다. 잘못된 입력은 즉시 실패합니다. 저장은 이 함수의 핵심 흐름입니다. 이벤트 발행처럼 호출자가 나중에 대응할 수 있는 부분만 Result로 넘깁니다.

핵심은 하나입니다. runCatching은 불안정한 경계만 감싸는 편이 읽기 좋습니다.

map, mapCatching, recover, fold의 기준

Result를 쓸 때는 그다음이 더 중요합니다. 어떻게 꺼내고, 어떻게 변환하고, 어떻게 복구할지 기준이 있어야 합니다.

성공 값만 바꿀 때는 map

val profileNameResult: Result<String> = fetchProfile(userId)
    .map { profile -> profile.name.trim() }

map은 성공 값만 변환합니다. 기존 실패는 그대로 유지합니다.

다만 변환 함수가 예외를 던질 수 있다면 주의가 필요합니다.

fun normalizeName(name: String): String {
    require(name.isNotBlank()) { "name must not be blank" }
    return name.trim()
}
val result = fetchProfile(userId)
    .map { profile -> normalizeName(profile.name) }

이 경우 변환 함수에서 던진 예외는 바깥으로 다시 나갑니다.

변환 중 예외까지 Result 안에 넣고 싶다면 mapCatching이 더 잘 맞습니다.

val result = fetchProfile(userId)
    .mapCatching { profile -> normalizeName(profile.name) }

실패를 기본값으로 바꿀 때는 getOrElse

val profileName = fetchProfile(userId)
    .map { profile -> profile.name }
    .getOrElse { throwable ->
        logger.warn("use fallback profile name", throwable)
        "알 수 없음"
    }

이 방식은 실패를 완전히 잊지 않으면서도, 호출 지점에서 기본값을 선택할 수 있게 해줍니다.

실패에서 다른 성공 값으로 복구할 때는 recover

val cachedProfileResult = fetchProfile(userId)
    .recover { throwable ->
        logger.info("load from cache because remote API failed", throwable)
        cachedProfileStore.get(userId)
    }

recover는 실패를 다른 성공 값으로 바꿉니다.

다만 복구 로직 자체가 예외를 던질 수 있다면 recoverCatching을 고려할 수 있습니다.

val cachedProfileResult = fetchProfile(userId)
    .recoverCatching { throwable ->
        logger.info("load from cache because remote API failed", throwable)
        cachedProfileStore.getOrThrow(userId)
    }

마지막 분기 처리에는 fold

val uiMessage = fetchProfile(userId).fold(
    onSuccess = { profile -> "환영합니다. ${profile.name} 님" },
    onFailure = { throwable -> "프로필을 불러오지 못했습니다: ${throwable.message}" },
)

fold는 성공과 실패를 한 자리에서 마무리할 때 읽기 좋습니다.

정리하면 이렇습니다.

  • map: 성공 값만 안전하게 바꿀 때
  • mapCatching: 변환 중 예외도 Result 안에 넣고 싶을 때
  • recover: 실패를 다른 성공 값으로 바꿀 때
  • recoverCatching: 복구 로직도 실패할 수 있을 때
  • getOrElse: 호출 지점에서 기본값을 주고 싶을 때
  • fold: 성공과 실패를 한 자리에서 정리할 때

커스텀 예외는 이렇게 정리하면 좋습니다

실패 종류가 많아지면 예외도 이름을 가져야 합니다.

모든 외부 실패를 RuntimeException 하나로 던지기 시작하면, 결국 다시 catch 블록이 흐려집니다.

sealed class PaymentException(
    message: String,
    cause: Throwable? = null,
) : RuntimeException(message, cause)

class PaymentTimeoutException(
    cause: Throwable? = null,
) : PaymentException("payment request timed out", cause)

class InvalidPaymentResponseException(
    status: Int,
) : PaymentException("invalid payment response. status=$status")

이렇게 정리하면 catch도 더 구체적으로 쓸 수 있습니다.

try {
    paymentGateway.charge(amount)
} catch (e: PaymentTimeoutException) {
    logger.warn("retry payment later", e)
} catch (e: InvalidPaymentResponseException) {
    logger.error("response format is invalid", e)
}

예외 이름이 구체적일수록 로그도, 대응도, 테스트도 좋아집니다.

before & after 리팩터링 예제

지금까지 정리한 기준을 한 번에 적용해보겠습니다.

data class SubmitOrderRequest(
    val userId: Long?,
    val itemIds: List<Long>,
)

data class OrderReceipt(
    val orderId: Long,
    val message: String,
)

class OrderApiException(message: String) : RuntimeException(message)

먼저 before 코드입니다.

fun submitOrder(request: SubmitOrderRequest): OrderReceipt? {
    return try {
        if (request.userId == null) {
            throw IllegalArgumentException("userId is null")
        }

        if (request.itemIds.isEmpty()) {
            throw IllegalArgumentException("itemIds is empty")
        }

        if (!session.isLoggedIn()) {
            throw IllegalStateException("login required")
        }

        val response = orderApi.submit(request.userId, request.itemIds)

        if (!response.success) {
            throw RuntimeException("submit failed")
        }

        OrderReceipt(
            orderId = response.orderId,
            message = response.message,
        )
    } catch (e: Exception) {
        logger.error("submitOrder failed", e)
        null
    }
}

짧아 보이지만 여러 실패가 한곳에 섞여 있습니다.

  • 입력 검증 실패와 상태 실패와 외부 API 실패가 한 catch로 합쳐집니다.
  • 호출자는 null만 받기 때문에 원인을 알기 어렵습니다.
  • 어떤 실패를 복구할 수 있는지도 드러나지 않습니다.

이제 같은 코드를 기준에 맞춰 나눠보겠습니다.

fun submitOrder(request: SubmitOrderRequest): Result<OrderReceipt> {
    val userId = requireNotNull(request.userId) {
        "userId must not be null"
    }
    require(request.itemIds.isNotEmpty()) {
        "itemIds must not be empty"
    }
    check(session.isLoggedIn()) {
        "login is required before submitOrder()"
    }

    return runCatching {
        val response = orderApi.submit(userId, request.itemIds)

        if (!response.success) {
            throw OrderApiException("submit failed. code=${response.code}")
        }

        OrderReceipt(
            orderId = response.orderId,
            message = response.message,
        )
    }
}

이제 흐름이 훨씬 분명해졌습니다.

  • 잘못된 입력은 함수 입구에서 바로 차단합니다.
  • 로그인 상태 문제는 상태 검증으로 분리합니다.
  • 외부 API 실패만 Result 안으로 전달합니다.
  • 호출자는 fold, getOrElse 같은 방식으로 대응을 선택할 수 있습니다.
val submitMessage = submitOrder(request).fold(
    onSuccess = { receipt -> "주문 완료: ${receipt.orderId}" },
    onFailure = { throwable -> "주문 실패: ${throwable.message}" },
)

클린코드는 종종 길이를 조금 늘립니다. 대신 읽는 사람이 왜 실패했고 어디서 처리해야 하는지 더 빨리 이해하게 만듭니다.

체크리스트

  • 입력 검증에 require를 쓰고 있나요
  • 상태 검증에 check를 쓰고 있나요
  • 논리적으로 오면 안 되는 분기를 error로 분명히 드러내고 있나요
  • 정상적인 부재나 파싱 실패를 예외로 과하게 다루고 있지 않나요
  • try-catch 범위가 너무 넓지는 않나요
  • catch (e: Exception) 하나로 모든 실패를 뭉개고 있지 않나요
  • runCatching이 불안정한 경계만 감싸고 있나요
  • Result를 정말 호출자 복구 전략이 필요한 곳에만 쓰고 있나요
  • 기본값, 복구, 최종 분기에 맞춰 getOrElse, recover, fold를 선택하고 있나요
  • 예외 메시지가 문제 상황과 값을 충분히 설명하나요

마무리

코틀린에서 예외 처리는 문법보다 기준이 더 중요합니다.

잘못된 입력은 require로, 잘못된 상태는 check로, 절대 오면 안 되는 분기는 error로 나누면 코드가 훨씬 또렷해집니다.

그리고 외부 실패처럼 호출자가 대응할 수 있는 경계에서는 Result가 좋은 선택이 될 수 있습니다.

  • 입력 오류와 상태 오류를 구분하기
  • try-catch를 좁게 유지하기
  • 정상적인 부재는 안전한 API로 처리하기
  • runCatching을 넓게 감싸지 않기
  • Result를 복구 가능한 경계에만 사용하기

실패 처리가 깔끔해지면 코드가 덜 무서워집니다. 새로운 기능을 추가할 때도 어디를 고쳐야 할지 빨리 보입니다.

다음 편에서는 테스트하기 좋은 코틀린 클래스 설계를 다뤄보겠습니다. 상태를 줄이고 책임을 나누어, 변경과 테스트에 강한 구조를 만드는 기준을 실무 예제로 정리하겠습니다.


코틀린 클린코드 시리즈 이어서 보기

이 글을 기준으로 앞뒤 흐름을 연결하면 내용이 더 잘 잡힙니다. 아래 글을 이어서 읽어보세요.

함께보면 좋은 글