코틀린 클린코드(12) – 코드 스멜을 개선하는 before & after 실전 예시 모음

코틀린 클린코드 12편 코드 스멜을 before after로 정리한 클린 코드 실전 가이드
코틀린 클린코드 12편 코드 스멜을 before after로 정리한 클린 코드 실전 가이드

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

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

7편에서는 컬렉션과 람다를, 8편에서는 예외 처리를, 9편에서는 테스트하기 좋은 클래스 설계를, 10편에서는 코루틴을, 11편에서는 Kotlin-Java interop 경계를 살펴봤습니다.

이번 12편에서는 시리즈에서 다룬 원칙을 실제 리팩터링 사례로 묶어보겠습니다. 주제는 코틀린 리팩터링 실전: 코드 스멜을 개선하는 before & after 모음입니다.

리팩터링은 코드를 똑똑해 보이게 바꾸는 일이 아닙니다. 읽는 사람이 의도를 더 빨리 이해하게 만드는 일입니다.

좋은 리팩터링은 문법을 화려하게 바꾸는 작업이 아닙니다. 책임, 데이터 흐름, 실패 정책을 더 또렷하게 드러내는 작업입니다.

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

리팩터링을 시작하기 전에 먼저 볼 기준

before와 after를 보기 전에 기준부터 짚고 가겠습니다. 리팩터링은 보통 문법보다 순서가 더 중요합니다.

  1. 동작을 먼저 고정하세요. 테스트가 있으면 가장 좋습니다. 테스트가 없다면 최소한 입력과 출력, 예외, 로그, 저장 결과를 메모로라도 고정해두세요.
  2. 냄새를 하나만 고르세요. 이름 문제, null 문제, 상태 문제, 비동기 문제를 한 번에 모두 해결하려고 들면 오히려 코드가 흔들립니다.
  3. 경계부터 정리하세요. 외부 API, Java interop, DB, 메시지 발송처럼 실패 가능성이 큰 지점을 먼저 또렷하게 만들면 안쪽 코드는 훨씬 단순해집니다.
  4. 부수 효과를 늦추세요. 계산은 먼저 끝내고 저장, 전송, 로그 같은 작업은 뒤로 밀면 읽기가 쉬워집니다.
  5. 새 문법을 쓰기 전에 새 이름을 붙이세요. 대부분의 리팩터링은 fancy한 문법보다 좋은 이름에서 시작합니다.

리팩터링이 막힐 때는 문법을 더 공부하기보다, 이 값이 무엇인지, 이 함수가 무엇을 끝내는지, 실패하면 무엇을 돌려주는지 먼저 써보시는 편이 훨씬 효과적입니다.

1. 이름이 흐리고 함수 하나가 너무 많은 일을 합니다

가장 흔한 코드 스멜입니다. 함수 이름은 애매하고, 함수 안에는 조회, 검증, 계산, 저장, 알림이 한 번에 들어 있습니다.

이 문제는 3편에서 다룬 함수 설계와 바로 연결됩니다. 더 자세한 기준은 코틀린 함수 설계 글에서 함께 보시면 좋습니다.

before

class OrderService(
    private val orderRepository: OrderRepository,
    private val couponRepository: CouponRepository,
    private val mailSender: MailSender,
) {
    fun doIt(orderId: Long, couponCode: String?) {
        val order = orderRepository.findById(orderId)
            ?: throw IllegalArgumentException("order not found")

        if (order.items.isEmpty()) {
            throw IllegalStateException("empty order")
        }

        var discountedPrice = order.totalPrice

        if (couponCode != null) {
            val coupon = couponRepository.findByCode(couponCode)
            if (coupon != null && coupon.active) {
                discountedPrice -= coupon.discountAmount
            }
        }

        order.discountedPrice = discountedPrice
        orderRepository.save(order)
        mailSender.send(order.userEmail, "order completed")
    }
}

doIt는 아무 의미도 주지 못합니다. 함수 안에는 주문 조회, 입력 해석, 검증, 할인 계산, 저장, 메일 발송이 모두 섞여 있습니다.

이 코드는 길이보다도 의도 전환이 잦다는 점이 더 문제입니다. 읽는 사람은 지금 계산을 보는지, 저장을 보는지, 비즈니스 규칙을 보는지 계속 다시 파악해야 합니다.

after

class OrderService(
    private val orderRepository: OrderRepository,
    private val couponRepository: CouponRepository,
    private val mailSender: MailSender,
) {
    fun completeOrder(orderId: Long, couponCode: String?) {
        val order = findOrder(orderId)
        validateOrder(order)

        val discountedPrice = calculateDiscountedPrice(
            order = order,
            couponCode = couponCode,
        )

        order.applyDiscount(discountedPrice)
        orderRepository.save(order)
        sendCompletedMail(order)
    }

    private fun findOrder(orderId: Long): Order {
        return orderRepository.findById(orderId)
            ?: throw IllegalArgumentException("order not found: $orderId")
    }

    private fun validateOrder(order: Order) {
        check(order.items.isNotEmpty()) { "order items must not be empty" }
    }

    private fun calculateDiscountedPrice(order: Order, couponCode: String?): Int {
        if (couponCode == null) return order.totalPrice

        val coupon = couponRepository.findByCode(couponCode)
            ?: return order.totalPrice

        return if (coupon.active) {
            order.totalPrice - coupon.discountAmount
        } else {
            order.totalPrice
        }
    }

    private fun sendCompletedMail(order: Order) {
        mailSender.send(order.userEmail, "order completed")
    }
}
fun Order.applyDiscount(discountedPrice: Int) {
    this.discountedPrice = discountedPrice
}

after가 더 길어 보일 수는 있습니다. 하지만 읽기는 훨씬 쉽습니다.

  • completeOrder라는 이름만 봐도 무엇을 끝내는 함수인지 알 수 있습니다.
  • 검증, 계산, 저장, 알림이 단계별로 드러납니다.
  • 할인 계산 규칙이 바뀌어도 저장과 메일 발송 코드를 건드리지 않아도 됩니다.

리팩터링의 첫걸음은 코드를 줄이는 일이 아니라, 책임을 나누고 이름을 붙이는 일입니다.

2. nullable이 퍼지고 !!가 등장합니다

4편에서 자세히 다뤘던 주제입니다. null 문제는 늦게 터질수록 더 찾기 어렵습니다. 그래서 리팩터링할 때도 nullability는 가장 먼저 고정하는 편이 좋습니다.

더 깊게 보시려면 코틀린 null safety 글을 함께 읽어보세요.

before

fun loadNicknameUppercase(userId: Long): String {
    val user = userRepository.find(userId)
    val nickname = user!!.profile!!.nickname!!
    return nickname.uppercase()
}

이 코드는 짧습니다. 하지만 짧다고 좋은 코드는 아닙니다.

!!가 세 번 나오면 사실상 이렇게 읽힙니다. “여기서 null이 나오지 않기를 바란다.”

after

fun loadNicknameUppercase(userId: Long): String? {
    val user = userRepository.find(userId) ?: return null
    val profile = user.profile ?: return null
    val nickname = profile.nickname
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
        ?: return null

    return nickname.uppercase()
}
fun requireNicknameUppercase(userId: Long): String {
    return loadNicknameUppercase(userId)
        ?: throw IllegalStateException("nickname is missing: $userId")
}

첫 번째 함수는 nullable을 그대로 드러냅니다. 두 번째 함수는 비즈니스 규칙상 꼭 필요할 때만 예외로 바꿉니다.

이렇게 나누면 실패 정책이 분명해집니다. 계산 함수는 안전하게 값을 정리하고, 경계 함수는 필요한 정책을 선택합니다.

null을 지우는 가장 좋은 방법은 억지로 non-null처럼 우기는 것이 아닙니다. 어느 단계에서 null을 허용하고, 어느 단계에서 더 이상 허용하지 않을지를 코드로 분명하게 쓰는 것입니다.

3. Boolean과 원시값이 의미를 숨깁니다

5편에서 data class와 sealed class를 다룬 이유도 여기 있습니다. 원시 타입만 나열하면 컴파일은 통과해도 의미는 흐려집니다.

더 자세한 모델링 기준은 data class와 sealed class 글에서 확인하실 수 있습니다.

before

fun createUser(
    name: String,
    email: String,
    isAdmin: Boolean,
    status: String,
) {
    // ...
}

fun sendNotification(
    userId: Long,
    urgent: Boolean,
    channel: String,
) {
    // ...
}

이 선언은 호출하는 쪽도 읽기 어렵습니다. 특히 true, false, "EMAIL" 같은 값은 호출부에서 맥락을 잃기 쉽습니다.

createUser("sun", "sun@example.com", true, "ACTIVE")
sendNotification(1L, true, "SLACK")

인자는 맞지만, 의도는 흐립니다. 상태값을 문자열로 주고, 역할을 Boolean으로 주면 실수도 늘어납니다.

after

enum class UserRole {
    MEMBER,
    ADMIN,
}

enum class UserStatus {
    ACTIVE,
    SUSPENDED,
}

enum class NotificationPriority {
    NORMAL,
    URGENT,
}

sealed interface NotificationChannel {
    data object Email : NotificationChannel
    data object Slack : NotificationChannel
}
data class CreateUserCommand(
    val name: String,
    val email: String,
    val role: UserRole,
    val status: UserStatus,
)

fun createUser(command: CreateUserCommand) {
    // ...
}

fun sendNotification(
    userId: Long,
    priority: NotificationPriority,
    channel: NotificationChannel,
) {
    // ...
}
createUser(
    CreateUserCommand(
        name = "sun",
        email = "sun@example.com",
        role = UserRole.ADMIN,
        status = UserStatus.ACTIVE,
    )
)

sendNotification(
    userId = 1L,
    priority = NotificationPriority.URGENT,
    channel = NotificationChannel.Slack,
)

after는 길어졌지만 해석 비용이 크게 줄었습니다. 역할, 상태, 채널이 타입으로 드러나기 때문입니다.

클린코드에서 중요한 것은 적은 글자 수가 아닙니다. 잘못 읽히지 않는 구조입니다.

4. scope function이 중첩되면서 thisit가 섞입니다

6편에서 말씀드렸듯이 scope function은 잘 쓰면 간결합니다. 하지만 중첩되기 시작하면 오히려 읽기 어려워집니다.

더 자세한 기준은 extension function과 scope function 글을 참고해주세요.

before

fun register(request: RegisterRequest): User =
    request.email?.trim()?.takeIf { it.isNotEmpty() }?.let { email ->
        request.name?.trim()?.takeIf { it.isNotEmpty() }?.let { name ->
            User(name = name, email = email).apply {
                profile = Profile().apply {
                    nickname = name.lowercase()
                    marketingAgree = request.marketingAgree ?: false
                }
            }.also {
                logger.info("user created: ${it.email}")
            }
        }
    } ?: throw IllegalArgumentException("invalid request")

한 줄처럼 보이지만, 실제로는 해석해야 할 층이 많습니다. 현재 it가 누구인지, this가 어떤 객체인지 계속 따라가야 합니다.

after

fun register(request: RegisterRequest): User {
    val email = requireText(request.email, "email")
    val name = requireText(request.name, "name")

    val profile = Profile(
        nickname = name.lowercase(),
        marketingAgree = request.marketingAgree ?: false,
    )

    val user = User(
        name = name,
        email = email,
        profile = profile,
    )

    logger.info("user created: ${user.email}")
    return user
}
private fun requireText(value: String?, fieldName: String): String {
    val normalized = value?.trim()
    require(!normalized.isNullOrEmpty()) { "$fieldName is required" }
    return normalized
}

after는 줄 수가 늘었습니다. 대신 단계가 보입니다. 입력 정리, 프로필 생성, 사용자 생성, 로그 기록이 각각 분리됩니다.

scope function을 리팩터링할 때는 아주 단순하게 보시면 됩니다. 중첩이 시작되면 이름 있는 변수로 꺼내고, 다른 역할이 나오면 함수로 빼면 됩니다.

5. 컬렉션 체이닝이 길어지고 중간 의미가 사라집니다

7편의 핵심도 이것이었습니다. 컬렉션 연산은 짧아 보이지만, 기준이 여러 개 섞이면 오히려 의미가 흐려집니다.

관련 기준은 컬렉션과 람다 클린코드 글에서 더 자세히 확인하실 수 있습니다.

before

fun sendCoupons(users: List<User>) {
    users
        .filter { it.enabled }
        .filter { user -> user.orders.sumOf { order -> order.price } >= 100_000 }
        .sortedByDescending { user -> user.orders.maxOfOrNull { order -> order.price } ?: 0 }
        .map { user -> user.email.trim().lowercase() }
        .distinct()
        .forEach { email ->
            couponSender.send(email)
            auditLogRepository.save("coupon sent: $email")
        }
}

이 코드가 틀린 것은 아닙니다. 하지만 계산과 부수 효과가 끝에서 한 번에 만나고, 중간 기준도 이름 없이 흘러갑니다.

특히 나중에 기준이 하나 더 추가되면 체이닝은 금방 길어집니다.

after

fun findVipEmails(users: List<User>): List<String> {
    val enabledUsers = users.filter(User::enabled)
    val vipUsers = enabledUsers.filter(::isVipUser)
    val sortedVipUsers = vipUsers.sortedByDescending(::highestOrderPrice)
    val normalizedEmails = sortedVipUsers.map(::normalizedEmail)

    return normalizedEmails.distinct()
}

private fun isVipUser(user: User): Boolean {
    return user.orders.sumOf { it.price } >= 100_000
}

private fun highestOrderPrice(user: User): Int {
    return user.orders.maxOfOrNull { it.price } ?: 0
}

private fun normalizedEmail(user: User): String {
    return user.email.trim().lowercase()
}
fun sendCoupons(users: List<User>) {
    val vipEmails = findVipEmails(users)

    vipEmails.forEach { email ->
        couponSender.send(email)
        auditLogRepository.save("coupon sent: $email")
    }
}

after에서 좋은 점은 계산이 먼저 끝난다는 점입니다. findVipEmails는 순수하게 데이터를 고르고 변환합니다. 그 다음에만 전송과 로그를 수행합니다.

컬렉션 리팩터링의 핵심은 체이닝을 없애는 것이 아닙니다. 중간 결과에 이름을 붙여서 의도를 보이게 하는 것입니다.

6. 모든 예외를 한곳에서 삼키고 null만 반환합니다

8편에서 정리한 예외 처리 원칙이 여기서 다시 중요해집니다. 실패를 숨기면 호출부는 편해 보이지만, 실제 원인은 더 늦게 발견됩니다.

관련 기준은 코틀린 예외 처리 글에서 더 자세히 다뤘습니다.

before

fun loadProductName(productId: Long): String? {
    return try {
        productClient.fetch(productId).name
    } catch (e: Exception) {
        logger.warn("loadProductName failed", e)
        null
    }
}

이 방식은 겉보기에는 안전합니다. 하지만 호출부는 null이 왜 왔는지 알 수 없습니다.

입력값이 잘못된 것인지, 외부 API가 실패한 것인지, 데이터가 비어 있는 것인지 모두 같은 null 하나로 뭉개집니다.

after

fun loadProductName(productId: Long): Result<String> {
    require(productId > 0) { "productId must be positive" }

    return runCatching {
        val product = productClient.fetch(productId)
        check(product.name.isNotBlank()) { "product name is blank" }
        product.name
    }
}
fun printProductName(productId: Long) {
    loadProductName(productId)
        .onSuccess { println(it) }
        .onFailure { error ->
            logger.warn("loadProductName failed: id=$productId", error)
        }
}
fun loadProductNameOrNull(productId: Long): String? {
    return loadProductName(productId).getOrNull()
}

이 구조에서는 정책이 나뉩니다. require는 잘못된 입력을 막고, check는 기대하던 상태가 아니라고 알려주고, Result는 호출부가 실패를 선택적으로 다루게 해줍니다.

실무에서는 모든 함수를 Result로 감쌀 필요는 없습니다. 다만 왜 실패했는지 구분이 필요한 경계라면 null 하나로 뭉개지 않는 편이 좋습니다.

7. 상태가 많은 서비스는 테스트를 어렵게 만듭니다

9편에서 다뤘던 핵심입니다. 상태가 퍼질수록 테스트는 느려지고, 버그는 재현하기 어려워집니다.

자세한 설계 기준은 테스트하기 좋은 코틀린 클래스 설계 글을 참고해주세요.

before

class PasswordResetService {
    var resetToken: String? = null
    var requestedUserId: Long? = null

    fun request(userId: Long) {
        requestedUserId = userId
        resetToken = UUID.randomUUID().toString()
        mailSender.sendPasswordReset(userId, resetToken!!)
    }

    fun confirm(token: String) {
        if (resetToken != token) {
            throw IllegalArgumentException("invalid token")
        }

        userRepository.markPasswordReset(requestedUserId!!)
        resetToken = null
        requestedUserId = null
    }
}

이 코드는 서비스 객체 안에 상태를 들고 있습니다. 동시에 여러 요청이 오면 쉽게 꼬일 수 있고, 테스트도 순서에 민감해집니다.

게다가 UUID.randomUUID(), 현재 시간, 외부 메일 발송이 함수 안에 바로 박혀 있으면 테스트에서 제어하기가 어렵습니다.

after

interface TokenGenerator {
    fun nextToken(): String
}

interface PasswordResetClock {
    fun now(): Instant
}
class PasswordResetService(
    private val userRepository: UserRepository,
    private val tokenGenerator: TokenGenerator,
    private val clock: PasswordResetClock,
    private val mailSender: MailSender,
) {
    fun request(userId: Long) {
        val token = tokenGenerator.nextToken()
        val requestedAt = clock.now()

        userRepository.saveResetToken(
            userId = userId,
            token = token,
            requestedAt = requestedAt,
        )

        mailSender.sendPasswordReset(userId, token)
    }

    fun confirm(userId: Long, token: String) {
        val savedToken = userRepository.findResetToken(userId)
            ?: throw IllegalArgumentException("reset token not found")

        require(savedToken == token) { "invalid token" }

        userRepository.markPasswordReset(userId)
        userRepository.clearResetToken(userId)
    }
}
class FakeTokenGenerator(
    private val token: String,
) : TokenGenerator {
    override fun nextToken(): String = token
}

class FixedClock(
    private val instant: Instant,
) : PasswordResetClock {
    override fun now(): Instant = instant
}
@Test
fun `request stores token and sends mail`() {
    val userRepository = FakeUserRepository()
    val mailSender = FakeMailSender()

    val service = PasswordResetService(
        userRepository = userRepository,
        tokenGenerator = FakeTokenGenerator("token-123"),
        clock = FixedClock(Instant.parse("2026-05-05T00:00:00Z")),
        mailSender = mailSender,
    )

    service.request(userId = 1L)

    assertEquals("token-123", userRepository.findResetToken(1L))
    assertEquals("token-123", mailSender.lastSentToken)
}

after에서는 상태를 서비스 내부에 붙잡아두지 않습니다. 대신 저장소와 외부 의존성으로 책임을 나눕니다.

이 구조가 좋은 이유는 단순합니다. 테스트가 쉬운 코드는 대개 실무에서도 덜 흔들립니다.

8. 비동기라고 해서 launch를 여기저기 쓰면 흐름이 사라집니다

10편에서 다뤘던 코루틴 원칙도 리팩터링에서 매우 중요합니다. 비동기 코드는 돌아가기만 하면 된다고 생각하기 쉽지만, 생명주기와 실패 정책이 보이지 않으면 금방 유지보수가 어려워집니다.

코루틴 기준이 필요하시면 코틀린 코루틴 클린코드 글도 함께 보시면 좋습니다.

before

class UserDashboardService(
    private val profileClient: ProfileClient,
    private val orderClient: OrderClient,
    private val dashboardRepository: DashboardRepository,
) {
    fun refresh(userId: Long) {
        GlobalScope.launch {
            val profile = async { profileClient.fetch(userId) }
            val orders = async { orderClient.fetch(userId) }

            dashboardRepository.save(
                UserDashboard(
                    profile = profile.await(),
                    orders = orders.await(),
                )
            )
        }
    }
}

refresh()는 즉시 끝나지만, 실제 작업은 백그라운드에서 이어집니다. 호출부는 성공 여부를 알기 어렵고, 실패가 어디로 가는지도 분명하지 않습니다.

after

class UserDashboardService(
    private val profileClient: ProfileClient,
    private val orderClient: OrderClient,
    private val dashboardRepository: DashboardRepository,
) {
    suspend fun loadDashboard(userId: Long): UserDashboard = coroutineScope {
        val profileDeferred = async { profileClient.fetch(userId) }
        val ordersDeferred = async { orderClient.fetch(userId) }

        UserDashboard(
            profile = profileDeferred.await(),
            orders = ordersDeferred.await(),
        )
    }

    suspend fun refresh(userId: Long) {
        val dashboard = loadDashboard(userId)
        dashboardRepository.save(dashboard)
    }
}
class DashboardRefreshJob(
    private val service: UserDashboardService,
    private val logger: Logger,
) {
    suspend fun run(userId: Long) {
        runCatching { service.refresh(userId) }
            .onFailure { logger.error("dashboard refresh failed: userId=$userId", it) }
    }
}

after에서는 작업의 소유권이 분명합니다. 데이터 조합은 loadDashboard()가 맡고, 저장은 refresh()가 맡고, 실행과 로깅은 바깥 계층이 맡습니다.

비동기 리팩터링에서 중요한 것은 어디서 시작하고, 어디서 기다리고, 누가 실패를 책임지는지를 드러내는 것입니다.

9. Java 경계의 애매함을 서비스 전체로 퍼뜨립니다

11편에서 살펴본 interop 문제도 리팩터링에서 자주 만납니다. 자바 경계의 애매한 nullability나 오래된 API 관습을 그대로 퍼뜨리면 Kotlin 코드가 전체적으로 흐려집니다.

자세한 내용은 Kotlin-Java interop 설계 원칙 글에서 더 확인하실 수 있습니다.

before

public class LegacyUserClient {
    public String findNickname(long userId) {
        return null;
    }
}
class NicknameService(
    private val legacyUserClient: LegacyUserClient,
) {
    fun loadNickname(userId: Long): String {
        return legacyUserClient.findNickname(userId).uppercase()
    }
}

자바 경계에서는 이런 코드가 특히 위험합니다. Kotlin 쪽에서는 non-null처럼 보이지만, 실제 값은 언제든 null일 수 있기 때문입니다.

after

class LegacyUserGateway(
    private val client: LegacyUserClient,
) {
    fun findNickname(userId: Long): String? {
        val nickname: String? = client.findNickname(userId)

        return nickname
            ?.trim()
            ?.takeIf { it.isNotEmpty() }
    }
}
class NicknameService(
    private val gateway: LegacyUserGateway,
) {
    fun loadNickname(userId: Long): String {
        val nickname = gateway.findNickname(userId)
            ?: throw IllegalStateException("nickname is missing: $userId")

        return nickname.uppercase()
    }
}

핵심은 자바 경계를 숨기는 것이 아닙니다. 경계에서 애매함을 해석하고, 안쪽으로는 명확한 타입만 들여보내는 것입니다.

이 패턴은 외부 HTTP 클라이언트, 오래된 SDK, 레거시 DAO에도 그대로 적용할 수 있습니다.

리팩터링 순서만 기억해도 훨씬 덜 힘들어집니다

실무에서는 완벽한 시간을 기다리기 어렵습니다. 그래서 바로 써먹을 수 있는 순서를 하나 정해두는 편이 좋습니다.

  1. 이름부터 붙입니다. 변수, 함수, 중간 결과에 먼저 이름을 붙이세요. 이름이 붙지 않으면 분리 기준도 잘 안 보입니다.
  2. null과 실패 정책을 먼저 정리합니다. null을 돌려줄지, 예외로 바꿀지, Result로 감쌀지 경계에서 정하세요.
  3. 계산과 부수 효과를 나눕니다. 필터링, 매핑, 계산은 먼저 끝내고, 저장과 전송은 뒤로 미룹니다.
  4. 원시값을 의미 있는 타입으로 바꿉니다. 특히 Boolean, 문자열 상태값, 여러 개의 Long 인자는 가장 먼저 의심해볼 만합니다.
  5. 생명주기와 상태를 줄입니다. 서비스 내부에 오래 남는 상태를 없애고, 코루틴은 호출부가 소유하게 두세요.
  6. 경계에 어댑터를 둡니다. Java, 외부 API, 레거시 저장소의 애매함은 경계에서 번역하세요.

리팩터링이 잘 되고 있는지 확인하는 가장 쉬운 기준은 “지금 이 줄이 무엇을 하는지 말로 바로 설명할 수 있는가”입니다. 설명이 길어지면 코드도 아직 정리되지 않은 경우가 많습니다.

실무 체크리스트

  • 함수 이름이 동사와 결과를 함께 드러내는가
  • 한 함수 안에서 조회, 검증, 계산, 저장, 알림이 한꺼번에 섞여 있지 않은가
  • !!를 없애거나 최소한 경계로 몰아낼 수 있는가
  • Boolean 인자와 문자열 상태값을 의미 있는 타입으로 바꿀 수 있는가
  • 중첩된 let, run, apply, also를 이름 있는 변수로 풀 수 있는가
  • 길어진 컬렉션 체이닝에 중간 결과 이름을 붙일 수 있는가
  • 실패를 모두 null 하나로 뭉개고 있지는 않은가
  • 서비스 객체가 불필요한 상태를 오래 들고 있지 않은가
  • 코루틴을 시작하는 위치와 실패를 처리하는 위치가 분명한가
  • Java나 외부 시스템의 애매함을 경계에서 해석하고 있는가

자주 헷갈리는 질문

리팩터링은 기능 개발이 끝난 뒤에만 해야 하나요

꼭 그렇지는 않습니다. 오히려 기능을 추가하기 직전에 작은 리팩터링을 하는 편이 더 안전할 때가 많습니다.

특히 이름이 흐리거나 null 정책이 애매한 상태로 기능을 얹기 시작하면, 새 기능까지 함께 흐려질 수 있습니다.

코드를 줄이지 못했는데도 리팩터링이 맞나요

그렇습니다. 리팩터링의 목표는 줄 수가 아닙니다. 의도와 책임이 더 잘 보이게 만드는 것이 목표입니다.

실제로 좋은 리팩터링은 짧아지기보다 단계가 분명해지는 방향으로 바뀌는 경우가 많습니다.

scope function은 쓰지 않는 편이 더 좋은가요

아닙니다. 한 객체를 짧게 초기화하거나, nullable 값을 짧게 다룰 때는 여전히 유용합니다.

다만 중첩이 시작되거나 thisit가 섞이면, 그 순간부터는 이름 있는 변수와 일반 함수가 더 읽기 좋습니다.

모든 실패를 Result로 바꿔야 하나요

그럴 필요는 없습니다. 단순한 조회나 내부 구현에서는 예외가 더 자연스러울 수 있습니다.

하지만 외부 API, 재시도, 사용자 메시지, 상세 실패 원인 분리가 필요한 경계에서는 Result가 도움이 될 수 있습니다.

data class와 sealed class를 쓰면 항상 더 좋은가요

항상 그렇지는 않습니다. 상태를 담는 모델과 제한된 상태 집합을 표현할 때 특히 강점이 큽니다.

반대로 동작 중심 객체나 복잡한 생명주기를 가진 객체까지 무조건 data class로 바꾸면 오히려 책임이 흐려질 수 있습니다.

마무리

이번 12편에서는 코틀린 리팩터링 실전 사례를 코드 스멜 중심으로 정리했습니다. 이름, 함수 책임, null 처리, 모델링, scope function, 컬렉션, 예외, 상태, 코루틴, Java interop까지 한 번에 연결해서 살펴봤습니다.

시리즈 전체를 관통하는 메시지는 하나입니다. 클린코드는 예쁜 문법이 아니라, 오해하지 않게 만드는 구조라는 점입니다.

그래서 리팩터링도 거창하게 시작할 필요가 없습니다. 이름을 붙이고, 경계를 정리하고, 실패 정책을 분명하게 쓰는 것부터 시작하시면 됩니다.

시리즈를 다시 처음부터 훑어보고 싶으시다면 코틀린 클린코드 허브 페이지를 참고해주세요. 각 주제를 더 깊게 보고 싶으시다면 1편, 2편, 3편, 4편, 5편, 6편, 7편, 8편, 9편, 10편, 11편도 이어서 읽어보시면 좋습니다.

좋은 코드는 한 번에 완성되지 않습니다. 하지만 좋은 리팩터링은 늘 같은 방향을 봅니다. 더 짧게가 아니라, 더 분명하게입니다.


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

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

함께보면 좋은 글