코틀린 클린코드(9) – 테스트하기 좋은 코틀린 클래스 설계: 상태와 책임을 나누는 기준

코틀린 클린코드 9편 테스트하기 좋은 클래스 설계와 상태 관리 원칙
코틀린 클린코드 9편 테스트하기 좋은 클래스 설계와 상태 관리 원칙

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

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

7편에서는 컬렉션과 람다를, 8편에서는 예외 처리를 다뤘습니다.

이번 9편에서는 테스트하기 좋은 코틀린 클래스 설계를 이야기합니다. 주제는 상태를 줄이고 책임을 나누는 방법입니다.

많은 분들이 테스트가 어려우면 mocking 라이브러리부터 떠올립니다. 하지만 실제로는 도구보다 구조가 더 큰 문제인 경우가 많습니다.

의존성이 숨겨져 있고, 상태가 많고, 한 클래스가 너무 많은 일을 하면 테스트는 금방 무거워집니다. 반대로 입력과 출력이 분명하고, 외부 경계가 드러나고, 상태가 작으면 테스트는 자연스럽게 쉬워집니다.

테스트하기 좋은 클래스는 테스트를 위해 억지로 만든 구조가 아닙니다. 원래부터 읽기 쉽고, 수정하기 쉽고, 책임이 분명한 클래스입니다.

이번 글에서는 생성자 주입, 상태 최소화, 부수 효과 분리, 시간·UUID·랜덤 추상화, 적절한 인터페이스 사용, 가시성 제어까지 실무 기준으로 정리하겠습니다.

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

테스트가 어려운 클래스의 공통점

테스트가 어려운 클래스는 보통 비슷한 냄새를 가지고 있습니다.

  • 의존성을 클래스 안에서 직접 생성합니다.
  • 시간, UUID, 랜덤 같은 값을 함수 안에서 바로 가져옵니다.
  • 가변 상태가 많고, 그 상태가 여러 메서드에 걸쳐 퍼져 있습니다.
  • 검증, 계산, 저장, 외부 호출, 로그를 한 메서드에서 모두 처리합니다.

아래 코드를 보겠습니다.

class CheckoutService {
    private val orderRepository = OrderRepository()
    private val paymentGateway = PaymentGateway()
    private var lastOrderId: Long? = null

    fun checkout(userId: Long, amount: Long): Boolean {
        if (userId <= 0 || amount <= 0) {
            return false
        }

        val orderId = System.currentTimeMillis()
        val paid = paymentGateway.charge(userId, amount)
        if (!paid) {
            return false
        }

        orderRepository.save(orderId, userId, amount)
        lastOrderId = orderId
        return true
    }
}

겉으로는 짧습니다. 하지만 테스트하려고 보면 금방 불편해집니다.

  • OrderRepository()PaymentGateway()를 테스트에서 바꿔 끼우기 어렵습니다.
  • System.currentTimeMillis() 때문에 결과가 매번 달라집니다.
  • lastOrderId는 클래스에 불필요한 상태를 남깁니다.
  • 성공과 실패가 Boolean 하나로만 표현되어 의도도 흐립니다.

이런 클래스는 테스트 코드가 복잡해질 뿐 아니라, 실제 유지보수도 어렵습니다. 구조가 이미 무거운 상태이기 때문입니다.

테스트 가능한 설계는 도구보다 구조가 중요합니다

테스트를 쉽게 만드는 가장 좋은 방법은 mocking을 많이 쓰는 것이 아닙니다. 테스트가 자연스럽게 들어갈 수 있는 구조를 만드는 것입니다.

좋은 기준은 단순합니다.

  • 입력이 분명해야 합니다.
  • 출력이 예측 가능해야 합니다.
  • 외부 시스템과 맞닿는 부분이 드러나야 합니다.
  • 클래스가 가진 상태가 작아야 합니다.
  • 한 클래스가 한 가지 축의 변화에만 반응해야 합니다.

예를 들어 아래 함수는 테스트가 쉽습니다.

fun calculateShippingFee(totalPrice: Long, isIsland: Boolean): Long {
    val baseFee = if (totalPrice >= 50_000) 0 else 3_000
    val islandExtraFee = if (isIsland) 4_000 else 0
    return baseFee + islandExtraFee
}

입력이 명확합니다. 외부 의존성도 없습니다. 같은 입력을 넣으면 항상 같은 결과가 나옵니다.

이런 순수 계산을 많이 만들수록 테스트는 가볍고 빨라집니다. 반대로 외부 호출과 가변 상태가 섞일수록 테스트는 무거워집니다.

숨은 의존성을 만들지 말고 생성자에서 드러내세요

가장 먼저 고치기 좋은 부분은 의존성입니다. 클래스 안에서 직접 생성한 객체는 테스트에서도 교체하기 어렵습니다.

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

class UserService {
    private val userRepository = UserRepository()
    private val mailSender = SmtpMailSender()

    fun changeEmail(userId: Long, newEmail: String) {
        val user = userRepository.findById(userId)
        user.email = newEmail
        userRepository.save(user)
        mailSender.sendChangeNotice(newEmail)
    }
}

이 구조에서는 테스트가 실제 저장소나 실제 메일 전송기에 묶이기 쉽습니다.

같은 코드를 생성자 주입으로 바꾸면 훨씬 낫습니다.

interface UserRepository {
    fun findById(userId: Long): User
    fun save(user: User)
}

interface MailSender {
    fun sendChangeNotice(email: String)
}

class UserService(
    private val userRepository: UserRepository,
    private val mailSender: MailSender,
) {
    fun changeEmail(userId: Long, newEmail: String) {
        val user = userRepository.findById(userId)
        user.email = newEmail
        userRepository.save(user)
        mailSender.sendChangeNotice(newEmail)
    }
}

이제 테스트에서는 진짜 구현 대신 fake나 stub를 넣을 수 있습니다. 클래스가 무엇에 의존하는지도 한눈에 보입니다.

작은 의존성이라면 인터페이스 대신 함수 타입을 쓰는 것도 코틀린다운 선택입니다.

class CouponService(
    private val now: () -> LocalDate,
) {
    fun isExpired(expireDate: LocalDate): Boolean {
        return now().isAfter(expireDate)
    }
}

한 번만 호출하는 단순한 동작이라면 이 방식이 더 읽기 좋을 때가 많습니다.

상태를 작게 유지하고 val을 우선하세요

테스트가 어려워지는 가장 큰 이유 중 하나는 상태가 너무 많다는 점입니다. 특히 var가 많고, 파생 상태까지 따로 들고 있으면 버그가 쉽게 생깁니다.

먼저 상태가 쉽게 어긋나는 예를 보겠습니다.

data class CartItem(
    val name: String,
    val price: Long,
)

class Cart {
    val items = mutableListOf<CartItem>()
    var totalPrice: Long = 0

    fun addItem(item: CartItem) {
        items += item
        totalPrice += item.price
    }
}

겉보기에는 편합니다. 하지만 외부에서 items를 직접 바꾸면 totalPrice와 금방 어긋납니다.

상태를 줄이면 구조가 단순해집니다.

data class CartItem(
    val name: String,
    val price: Long,
)

class Cart {
    private val _items = mutableListOf<CartItem>()
    val items: List<CartItem>
        get() = _items

    val totalPrice: Long
        get() = _items.sumOf { it.price }

    fun addItem(item: CartItem) {
        _items += item
    }
}

이제 실제 상태는 _items 하나뿐입니다. totalPrice는 계산 결과이므로 따로 동기화할 필요가 없습니다.

읽기 전용으로 공개하고, 내부에서만 수정하는 구조는 테스트에도 유리합니다. 외부에서 상태를 임의로 깨뜨릴 수 없기 때문입니다.

값을 자주 바꾸지 않는다면 var보다 val을 먼저 떠올리는 습관이 좋습니다. 상태가 줄어들면 테스트 케이스도 줄어듭니다.

프로퍼티는 가능하면 선언과 동시에 초기화하는 편이 좋습니다. 초기화 순서가 단순해지고, 테스트 설정도 더 쉬워집니다.

계산과 부수 효과를 분리하세요

클래스가 어려워지는 또 다른 이유는 계산 로직과 부수 효과가 한곳에 섞이기 때문입니다.

예를 들어 아래 코드는 할인 계산, 저장, 알림 전송을 한 메서드에서 처리합니다.

class OrderSummaryService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: EventPublisher,
) {
    fun create(userGrade: String, items: List<CartItem>): OrderSummary {
        val totalPrice = items.sumOf { it.price }
        val discount = if (userGrade == "GOLD") totalPrice / 10 else 0
        val finalPrice = totalPrice - discount

        val summary = OrderSummary(
            totalPrice = totalPrice,
            discount = discount,
            finalPrice = finalPrice,
        )

        orderRepository.save(summary)
        eventPublisher.publish("order-summary-created")
        return summary
    }
}

이 코드는 테스트 자체는 가능하지만, 관심사가 섞여 있어서 케이스가 계속 늘어납니다.

계산을 먼저 분리해보겠습니다.

fun calculateOrderSummary(userGrade: String, items: List<CartItem>): OrderSummary {
    val totalPrice = items.sumOf { it.price }
    val discount = if (userGrade == "GOLD") totalPrice / 10 else 0

    return OrderSummary(
        totalPrice = totalPrice,
        discount = discount,
        finalPrice = totalPrice - discount,
    )
}
class OrderSummaryService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: EventPublisher,
) {
    fun create(userGrade: String, items: List<CartItem>): OrderSummary {
        val summary = calculateOrderSummary(userGrade, items)
        orderRepository.save(summary)
        eventPublisher.publish("order-summary-created")
        return summary
    }
}

이제 순수 계산은 빠르게 단위 테스트할 수 있습니다. 저장과 이벤트 발행은 경계 테스트로 따로 다루면 됩니다.

한 메서드 안에서 모든 것을 해결하려고 하지 않는 것이 중요합니다.

시간, UUID, 랜덤을 직접 호출하지 마세요

테스트가 자꾸 흔들리는 이유는 보통 현재 시간과 랜덤 값 때문입니다. 코드 안에서 바로 호출하면 같은 테스트라도 실행 시점마다 결과가 달라집니다.

먼저 흔한 예를 보겠습니다.

data class Invitation(
    val code: String,
    val email: String,
    val expiresAt: Instant,
)

class InvitationService {
    fun issue(email: String): Invitation {
        return Invitation(
            code = UUID.randomUUID().toString().take(8),
            email = email,
            expiresAt = Instant.now().plus(7, ChronoUnit.DAYS),
        )
    }
}

이 코드는 기능은 맞습니다. 하지만 테스트에서는 코드 값도 바뀌고, 만료 시각도 바뀝니다.

시간과 식별자 생성을 밖으로 빼면 테스트가 안정됩니다.

class InvitationService(
    private val nextCode: () -> String,
    private val now: () -> Instant,
) {
    fun issue(email: String): Invitation {
        val issuedAt = now()
        return Invitation(
            code = nextCode(),
            email = email,
            expiresAt = issuedAt.plus(7, ChronoUnit.DAYS),
        )
    }
}
val service = InvitationService(
    nextCode = { "ABC12345" },
    now = { Instant.parse("2026-05-05T00:00:00Z") },
)

이제 테스트는 항상 같은 결과를 얻습니다. flaky test가 크게 줄어듭니다.

인터페이스는 경계에 두고 남발하지 마세요

테스트하기 좋은 구조를 만든다고 해서 모든 클래스 앞에 인터페이스를 붙일 필요는 없습니다.

실제로는 바깥 세계와 만나는 경계에 인터페이스를 두는 편이 좋습니다. 저장소, 외부 API, 메시지 발행, 메일 전송 같은 부분이 여기에 해당합니다.

반대로 순수 계산만 하는 내부 클래스까지 모두 인터페이스로 분리하면 파일 수만 늘고 구조가 흐려질 수 있습니다.

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

interface PriceCalculator {
    fun calculate(price: Long, discountRate: Int): Long
}

class PriceCalculatorImpl : PriceCalculator {
    override fun calculate(price: Long, discountRate: Int): Long {
        return price - (price * discountRate / 100)
    }
}

이 코드는 구현이 하나뿐이고 외부 경계도 아닙니다. 이런 경우에는 인터페이스보다 그냥 함수나 final 클래스로 두는 편이 더 낫습니다.

fun calculateDiscountedPrice(price: Long, discountRate: Int): Long {
    return price - (price * discountRate / 100)
}

반면 아래처럼 외부 시스템과 만나는 부분은 인터페이스가 잘 맞습니다.

interface PaymentGateway {
    fun charge(userId: Long, amount: Long): PaymentResult
}

interface ReceiptSender {
    fun send(orderId: Long, email: String)
}

코틀린의 인터페이스는 구현을 일부 가질 수 있지만, 상태를 저장하는 backing field는 둘 수 없습니다. 그래서 행동 계약을 표현하는 용도에 특히 잘 맞습니다.

또 하나 기억할 점이 있습니다. 코틀린 클래스는 기본적으로 final입니다. 그래서 테스트를 위해 모든 클래스를 open으로 열기보다, 바깥 경계만 적절히 분리하고 나머지는 real object로 테스트하는 편이 더 건강한 구조가 되는 경우가 많습니다.

공개 범위를 줄이면 테스트가 쉬워집니다

공개 API가 많을수록 테스트해야 할 표면도 넓어집니다. 클래스 바깥에서 바꿀 수 있는 상태가 많을수록 테스트는 더 복잡해집니다.

코틀린은 기본 visibility가 public입니다. 그래서 아무 modifier를 쓰지 않으면 공개 범위가 생각보다 빨리 커질 수 있습니다.

먼저 공개 범위가 너무 넓은 예를 보겠습니다.

class LoginSession {
    var isLoggedIn: Boolean = false
    var loginCount: Int = 0
}

이 구조에서는 어디서든 isLoggedInloginCount를 바꿀 수 있습니다. 테스트에서도 내부 상태를 직접 만지게 되기 쉽습니다.

행동을 통해 상태가 바뀌게 만드는 편이 좋습니다.

class LoginSession {
    var isLoggedIn: Boolean = false
        private set

    var loginCount: Int = 0
        private set

    fun login() {
        isLoggedIn = true
        loginCount += 1
    }

    fun logout() {
        isLoggedIn = false
    }
}

이제 테스트는 login()logout()을 호출한 뒤 결과를 확인하면 됩니다. 내부 상태를 직접 조작할 이유가 줄어듭니다.

보조 함수도 가능한 한 좁게 두는 편이 좋습니다. 파일 안에서만 쓰는 유틸은 top-level private 함수로 숨길 수 있습니다.

private fun normalizeEmail(email: String): String {
    return email.trim().lowercase()
}

도우미까지 모두 public으로 열어두면 설계보다 API가 먼저 커집니다.

lateinit와 전역 상태를 조심하세요

코틀린의 lateinit은 분명 유용합니다. 특히 테스트 setup 단계나 특정 DI 환경에서는 편합니다.

하지만 프로덕션 클래스에 lateinit var가 여러 개 보이기 시작하면 구조를 다시 보는 편이 좋습니다. 초기화 순서와 변경 가능성이 클래스 밖으로 새어나가기 쉽기 때문입니다.

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

object SessionManager {
    var currentUserId: Long? = null
}

class ReportService {
    lateinit var reportRepository: ReportRepository

    fun create(title: String) {
        val userId = checkNotNull(SessionManager.currentUserId)
        reportRepository.save(userId, title)
    }
}

문제는 두 가지입니다. reportRepository의 초기화가 숨겨져 있고, SessionManager는 전역 가변 상태입니다.

이 구조에서는 테스트 순서에 따라 결과가 바뀌기 쉽습니다.

같은 코드를 의존성 주입으로 바꾸면 더 단순해집니다.

interface SessionProvider {
    fun currentUserId(): Long?
}

class ReportService(
    private val reportRepository: ReportRepository,
    private val sessionProvider: SessionProvider,
) {
    fun create(title: String) {
        val userId = checkNotNull(sessionProvider.currentUserId())
        reportRepository.save(userId, title)
    }
}

테스트에서는 SessionProviderReportRepository를 원하는 값으로 쉽게 제어할 수 있습니다. 전역 상태를 초기화하는 코드도 필요 없습니다.

클래스를 만들기 전에 data class, extension, top-level function을 먼저 생각하세요

클래스를 잘게 나누는 것은 중요합니다. 하지만 모든 문제를 클래스로만 풀 필요는 없습니다.

코틀린에서는 데이터를 담는 목적이라면 data class가 잘 맞고, 기존 타입에 동작을 붙이는 정도라면 extension이나 top-level function이 더 자연스러울 때가 많습니다.

예를 들어 아래 클래스는 조금 무겁습니다.

class PhoneNumberFormatter {
    fun format(phoneNumber: String): String {
        return phoneNumber
            .replace("-", "")
            .replace(" ", "")
    }
}

상태가 없고, 하는 일도 하나뿐입니다. 이런 경우에는 클래스를 만들지 않아도 됩니다.

fun formatPhoneNumber(phoneNumber: String): String {
    return phoneNumber
        .replace("-", "")
        .replace(" ", "")
}

문자열에 자연스럽게 붙이고 싶다면 extension도 가능합니다.

fun String.normalizedPhoneNumber(): String {
    return this
        .replace("-", "")
        .replace(" ", "")
}

데이터를 전달하는 목적이라면 data class가 더 분명합니다.

data class Money(
    val amount: Long,
    val currency: String,
)

불필요한 클래스를 줄이면 테스트 대상도 더 분명해집니다.

before & after 리팩터링 예제

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

data class SignUpCommand(
    val email: String,
    val name: String,
)

data class User(
    val id: String,
    val email: String,
    val name: String,
    val createdAt: Instant,
)

먼저 before 코드입니다.

class SignUpService {
    lateinit var userRepository: UserRepository
    private val mailSender = SmtpMailSender()
    private var lastSignedUpUserId: String? = null

    fun signUp(command: SignUpCommand): User? {
        if (command.email.isBlank()) {
            return null
        }
        if (!command.email.contains("@")) {
            return null
        }
        if (command.name.isBlank()) {
            return null
        }

        if (userRepository.exists(command.email)) {
            return null
        }

        val user = User(
            id = UUID.randomUUID().toString(),
            email = command.email,
            name = command.name,
            createdAt = Instant.now(),
        )

        userRepository.save(user)
        mailSender.sendWelcome(command.email)
        lastSignedUpUserId = user.id
        return user
    }
}

코드 길이는 짧습니다. 하지만 테스트 관점에서는 문제가 많습니다.

  • lateinit 때문에 초기화 순서를 알아야 합니다.
  • SmtpMailSender()가 숨어 있어서 테스트 교체가 어렵습니다.
  • UUID.randomUUID()Instant.now() 때문에 결과가 매번 달라집니다.
  • lastSignedUpUserId는 없어도 되는 상태입니다.
  • 실패를 모두 null로 돌려주어 이유를 파악하기 어렵습니다.

이제 같은 코드를 개선해보겠습니다.

interface UserRepository {
    fun exists(email: String): Boolean
    fun save(user: User)
}

interface MailSender {
    fun sendWelcome(email: String)
}
class SignUpService(
    private val userRepository: UserRepository,
    private val mailSender: MailSender,
    private val nextId: () -> String,
    private val now: () -> Instant,
) {
    fun signUp(command: SignUpCommand): User {
        val email = normalizeEmail(command.email)
        validate(command = command, normalizedEmail = email)
        check(!userRepository.exists(email)) {
            "email already exists. email=$email"
        }

        val user = User(
            id = nextId(),
            email = email,
            name = command.name.trim(),
            createdAt = now(),
        )

        userRepository.save(user)
        mailSender.sendWelcome(user.email)
        return user
    }

    private fun validate(command: SignUpCommand, normalizedEmail: String) {
        require(normalizedEmail.isNotBlank()) {
            "email must not be blank"
        }
        require('@' in normalizedEmail) {
            "invalid email. email=${command.email}"
        }
        require(command.name.isNotBlank()) {
            "name must not be blank"
        }
    }
}
private fun normalizeEmail(email: String): String {
    return email.trim().lowercase()
}

이제 구조가 훨씬 또렷합니다.

  • 의존성이 생성자에서 명확히 드러납니다.
  • 시간과 식별자가 외부에서 주입되어 테스트가 안정적입니다.
  • 불필요한 상태가 사라졌습니다.
  • 입력 검증과 외부 경계가 분리되어 읽기 쉽습니다.

가짜 객체로 테스트하는 예제

구조가 좋아지면 테스트 코드도 자연스럽게 단순해집니다. 예제는 kotlin.test 스타일로 보겠습니다.

kotlin.test는 여러 플랫폼에서 공통으로 쓸 수 있는 테스트 라이브러리입니다. 기본적인 assertion과 annotation을 같은 방식으로 가져가기 좋습니다.

class FakeUserRepository : UserRepository {
    private val users = mutableListOf<User>()

    override fun exists(email: String): Boolean {
        return users.any { it.email == email }
    }

    override fun save(user: User) {
        users += user
    }

    fun allUsers(): List<User> {
        return users.toList()
    }
}

class FakeMailSender : MailSender {
    val sentEmails = mutableListOf<String>()

    override fun sendWelcome(email: String) {
        sentEmails += email
    }
}
class SignUpServiceTest {
    @Test
    fun `회원 가입에 성공하면 사용자를 저장하고 환영 메일을 보낸다`() {
        val userRepository = FakeUserRepository()
        val mailSender = FakeMailSender()
        val service = SignUpService(
            userRepository = userRepository,
            mailSender = mailSender,
            nextId = { "user-1" },
            now = { Instant.parse("2026-05-05T00:00:00Z") },
        )

        val user = service.signUp(
            SignUpCommand(
                email = " HELLO@example.com ",
                name = "Alice",
            )
        )

        assertEquals("user-1", user.id)
        assertEquals("hello@example.com", user.email)
        assertEquals(1, userRepository.allUsers().size)
        assertEquals(listOf("hello@example.com"), mailSender.sentEmails)
    }
}

여기에는 mocking 라이브러리가 없습니다. 그래도 테스트는 충분히 읽기 쉽고, 의도도 분명합니다.

핵심은 테스트 기술이 아니라 구조입니다. 클래스가 외부 경계를 잘 드러내면 fake만으로도 대부분의 단위 테스트를 편하게 작성할 수 있습니다.

Gradle을 쓴다면 보통 아래처럼 테스트 의존성을 추가해 시작합니다.

dependencies {
    testImplementation(kotlin("test"))
}

체크리스트

  • 의존성을 클래스 안에서 직접 생성하고 있지 않나요
  • 상태를 줄일 수 있는데 var를 습관처럼 쓰고 있지 않나요
  • 파생 가능한 값을 별도 상태로 중복 저장하고 있지 않나요
  • 계산 로직과 저장·전송·로그 같은 부수 효과가 한 메서드에 섞여 있지 않나요
  • 시간, UUID, 랜덤을 직접 호출해서 테스트가 흔들리고 있지 않나요
  • 인터페이스를 경계가 아닌 내부 구현에 남발하고 있지 않나요
  • public 프로퍼티와 public 유틸이 과하게 많지 않나요
  • 프로덕션 코드의 lateinit var가 숨은 의존성을 만들고 있지 않나요
  • 가짜 객체로 테스트할 수 있는 구조인지 확인했나요
  • private 메서드를 억지로 테스트하려 하지 말고, 필요한 로직을 더 작은 단위로 추출했나요

마무리

테스트하기 좋은 코틀린 클래스는 특별한 패턴으로만 만들어지지 않습니다.

의존성을 드러내고, 상태를 줄이고, 계산과 부수 효과를 분리하면 클래스가 자연스럽게 테스트 친화적으로 바뀝니다.

  • 생성자 주입으로 의존성 드러내기
  • val 우선으로 상태 줄이기
  • 파생 상태 중복 저장하지 않기
  • 시간과 식별자 생성 분리하기
  • 경계에만 인터페이스 두기
  • 공개 범위 최소화하기

이 기준을 지키면 테스트 코드만 좋아지는 것이 아닙니다. 실무 코드 자체가 덜 복잡해지고, 변경에도 더 강해집니다.

다음 편에서는 코틀린 코루틴 클린코드를 다뤄보겠습니다. suspend 함수, structured concurrency, dispatcher 사용 기준을 중심으로 읽기 좋은 비동기 코드를 정리하겠습니다.


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

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

함께보면 좋은 글