
1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를, 3편에서는 함수 설계를 정리했습니다.
4편에서는 null safety를, 5편에서는 data class와 sealed class를, 6편에서는 extension function과 scope function을 다뤘습니다.
7편에서는 컬렉션과 람다를, 8편에서는 예외 처리를, 9편에서는 테스트하기 좋은 클래스 설계를 살펴봤습니다.
이번 10편에서는 코틀린 코루틴 클린코드를 이야기합니다. 주제는 suspend 함수 설계와 structured concurrency를 실무에서 어떻게 읽기 좋게 쓰느냐입니다.
코루틴은 문법만 보면 간단해 보입니다. launch, async, withContext, delay 정도만 익혀도 금방 코드를 만들 수 있습니다.
문제는 그다음입니다. 어디서 코루틴을 시작해야 하는지, 누가 취소를 책임지는지, 어떤 실패가 전체 작업을 멈춰야 하는지 기준이 없으면 코드는 금방 흐려집니다.
코루틴 클린코드의 핵심은 비동기를 많이 쓰는 것이 아닙니다. 누가 시작하고, 누가 기다리고, 누가 취소하는지를 코드에서 숨기지 않는 것입니다.
이번 글에서는 suspend 함수 설계, coroutineScope와 supervisorScope의 기준, GlobalScope와 runBlocking 주의점, withContext와 dispatcher 사용법, 취소와 예외 처리 원칙까지 한 번에 정리하겠습니다.
시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편, 5편, 6편, 7편, 8편, 9편도 함께 보시면 좋습니다.
- 코루틴 클린코드는 문법보다 생명주기입니다
- 코루틴을 시작하는 위치를 줄이고, 아래 계층은 suspend로 이어가세요
- launch, async, withContext를 역할대로 나누세요
- 관련된 작업은 coroutineScope로 묶으세요
- 독립적으로 실패할 작업은 supervisorScope로 분리하세요
- 함수 안에서 임의로 CoroutineScope를 만들지 마세요
- GlobalScope와 runBlocking은 경계에서만 사용하세요
- dispatcher 선택도 코드의 의도입니다
- 취소를 정상 흐름으로 다루세요
- timeout과 blocking code를 혼동하지 마세요
- CoroutineExceptionHandler를 과신하지 마세요
- before & after 리팩터링 예제
- 체크리스트
- 마무리
코루틴 클린코드는 문법보다 생명주기입니다
코루틴 코드를 읽을 때 가장 먼저 봐야 할 것은 어느 스레드에서 도는가가 아닙니다. 누가 이 일을 소유하는가입니다.
작업이 화면 생명주기에 묶여 있는지, 요청 하나의 범위 안에서 끝나야 하는지, 애플리케이션이 살아 있는 동안 계속 돌아야 하는지 먼저 분명해야 합니다.
이 기준이 서야 launch를 쓸지, suspend 함수로 둘지, 별도의 scope가 필요한지 판단할 수 있습니다.
코틀린 공식 문서도 structured concurrency를 강조합니다. 부모 코루틴은 자식이 끝날 때까지 기다리고, 부모가 실패하거나 취소되면 자식도 함께 취소됩니다. 이 연결이 있어야 취소와 예외 처리의 흐름을 예측하기 쉬워집니다.
suspend fun syncDashboard() = coroutineScope {
launch { refreshUser() }
launch { refreshNotifications() }
launch { refreshRecommendations() }
}이 코드는 세 작업이 하나의 목표를 위해 함께 움직인다는 점을 드러냅니다. 이 함수는 내부 작업이 모두 끝날 때까지 반환되지 않습니다.
반대로 생명주기가 없는 코드를 만들면 문제가 생깁니다.
fun syncDashboard() {
GlobalScope.launch { refreshUser() }
GlobalScope.launch { refreshNotifications() }
GlobalScope.launch { refreshRecommendations() }
}겉보기에는 더 단순합니다. 하지만 누가 이 작업을 취소하는지, 실패하면 어디로 전달되는지, 호출자가 언제 끝났다고 볼 수 있는지 알기 어렵습니다.
코루틴 클린코드는 ‘비동기 작업을 잘 시작하는 코드’가 아니라 ‘작업의 생명주기를 설명할 수 있는 코드’입니다.
코루틴을 시작하는 위치를 줄이고, 아래 계층은 suspend로 이어가세요
실무에서 자주 보이는 문제는 서비스, 리포지토리, 유틸 함수가 제각각 launch를 시작하는 것입니다. 이렇게 되면 호출자가 흐름을 제어하기 어렵습니다.
가장 읽기 좋은 구조는 단순합니다. 코루틴을 실제로 시작하는 곳은 바깥 경계에 두고, 그 아래 계층은 가능한 한 suspend 함수로 이어가는 방식입니다.
예를 들어 UI, 컨트롤러, 스케줄러 같은 경계는 코루틴을 시작할 수 있습니다. 하지만 그 아래 서비스와 리포지토리는 되도록 결과를 반환하는 suspend 함수가 좋습니다.
class UserController(
private val scope: CoroutineScope,
private val userService: UserService,
) {
fun onRefreshClicked() {
scope.launch {
userService.refreshUsers()
}
}
}
class UserService(
private val userRepository: UserRepository,
) {
suspend fun refreshUsers() {
val users = userRepository.fetchUsers()
userRepository.saveUsers(users)
}
}이 구조에서는 누가 코루틴을 시작하는지가 분명합니다. 취소도 바깥 scope에서 관리할 수 있습니다.
반대로 아래처럼 서비스가 내부에서 몰래 launch를 시작하면 호출자는 완료 시점을 알기 어렵습니다.
class UserService(
private val userRepository: UserRepository,
) {
fun refreshUsers() {
CoroutineScope(Dispatchers.IO).launch {
val users = userRepository.fetchUsers()
userRepository.saveUsers(users)
}
}
}이 함수 이름은 동기처럼 보이지만 실제로는 fire-and-forget입니다. 호출자는 저장이 끝났는지, 실패했는지, 취소할 수 있는지 알 수 없습니다.
값 하나를 비동기로 계산해 돌려줘야 한다면 suspend가 가장 먼저 떠올라야 합니다.
suspend fun loadUserProfile(userId: Long): UserProfile {
return userRepository.loadProfile(userId)
}스트림처럼 여러 값을 순차적으로 내보내야 한다면 그때는 Flow를 검토하면 됩니다. 하지만 결과 하나면 우선 suspend를 생각하는 편이 코드가 훨씬 단순합니다.
launch, async, withContext를 역할대로 나누세요
코루틴 코드가 지저분해지는 이유 중 하나는 세 도구를 비슷하게 쓰기 때문입니다. 실제로는 역할이 꽤 다릅니다.
launch는 결과가 필요 없는 작업을 시작할 때 씁니다.async는 나중에await()할 값을 병렬로 준비할 때 씁니다.withContext는 현재 흐름 안에서 context를 바꿔 작업하고 결과를 바로 돌려받을 때 씁니다.
먼저 launch는 Job를 돌려주고, 결과값은 없습니다. 값을 계산해서 곧바로 받아야 하는 코드에는 어울리지 않습니다.
suspend fun findUser(userId: Long): User = coroutineScope {
var result: User? = null
launch(Dispatchers.IO) {
result = userRepository.findById(userId)
}
result!!
}이 코드는 읽는 사람을 불편하게 합니다. 결과가 필요한데 launch를 써서 상태를 우회하고 있기 때문입니다. 같은 코드는 withContext가 훨씬 낫습니다.
suspend fun findUser(userId: Long): User {
return withContext(Dispatchers.IO) {
userRepository.findById(userId)
}
}여러 값을 동시에 가져와야 한다면 그때 async가 빛납니다.
suspend fun loadDashboard(userId: Long): Dashboard = coroutineScope {
val userDeferred = async { userRepository.findById(userId) }
val ordersDeferred = async { orderRepository.findRecentOrders(userId) }
Dashboard(
user = userDeferred.await(),
orders = ordersDeferred.await(),
)
}반대로 아래처럼 async를 만들자마자 곧바로 await()하면 병렬 처리의 이점이 거의 사라집니다.
suspend fun loadDashboard(userId: Long): Dashboard = coroutineScope {
val user = async { userRepository.findById(userId) }.await()
val orders = async { orderRepository.findRecentOrders(userId) }.await()
Dashboard(user = user, orders = orders)
}이 코드는 문법상 맞아도 흐름이 좋지 않습니다. 실제 의도는 ‘둘을 함께 시작하고 나중에 합친다’인데, 코드에서는 그 의도가 흐려집니다.
값을 바로 받아서 이어갈 작업이면 withContext, 동시에 준비한 뒤 모을 값이면 async, 결과가 없는 시작 신호면 launch라고 생각하시면 대부분 정리가 됩니다.
또 하나 주의할 점이 있습니다. withContext는 context를 바꾸는 도구이지, 새로운 생명주기를 몰래 만드는 도구가 아닙니다.
suspend fun save(report: Report) {
withContext(Job() + Dispatchers.IO) {
reportRepository.save(report)
}
}이 코드는 dispatcher를 바꾸는 것처럼 보이지만, 실제로는 새로운 Job을 넣어 구조를 흐릴 수 있습니다. 대부분은 Job 없이 dispatcher만 넘기는 편이 맞습니다.
suspend fun save(report: Report) {
withContext(Dispatchers.IO) {
reportRepository.save(report)
}
}관련된 작업은 coroutineScope로 묶으세요
coroutineScope는 하나의 작업을 여러 하위 작업으로 쪼갤 때 가장 좋은 기본값입니다.
이 scope 안에서는 자식 작업이 함께 성공하거나 함께 실패합니다. 하나가 예외로 실패하면 다른 자식도 취소되고, 바깥 호출자에게 예외가 다시 전달됩니다.
suspend fun createOrder(command: CreateOrderCommand): OrderSummary = coroutineScope {
val productDeferred = async { productService.getProduct(command.productId) }
val couponDeferred = async { couponService.getCoupon(command.couponCode) }
val product = productDeferred.await()
val coupon = couponDeferred.await()
val price = priceCalculator.calculate(product, coupon, command.quantity)
orderService.create(command.userId, product.id, price)
}이 예에서는 상품 정보와 쿠폰 정보가 모두 필요합니다. 하나라도 실패하면 주문 생성 자체를 진행할 수 없습니다. 이런 작업은 coroutineScope가 자연스럽습니다.
아래처럼 계산이 서로 강하게 연결된 작업에 잘 어울립니다.
- 한 API 응답을 만들기 위해 여러 데이터를 함께 읽어와야 할 때
- 결제 전에 여러 검증을 동시에 수행하고 결과를 합쳐야 할 때
- 하나라도 실패하면 전체 작업을 중단해야 할 때
특히 suspend fun ... = coroutineScope { ... } 형태는 ‘이 함수는 내부 자식 작업까지 포함해서 끝날 때 반환된다’는 뜻을 분명하게 보여줘서 좋습니다.
독립적으로 실패할 작업은 supervisorScope로 분리하세요
모든 작업이 함께 실패해야 하는 것은 아닙니다. 어떤 화면이나 응답은 일부 데이터가 없어도 나머지를 보여줄 수 있습니다.
이럴 때는 supervisorScope를 검토할 수 있습니다. 자식 하나가 실패해도 다른 자식이 자동으로 함께 취소되지 않기 때문입니다.
suspend fun loadHomeSections(): HomeSections = supervisorScope {
val bannerDeferred = async { bannerService.loadBanners() }
val recommendationDeferred = async { recommendationService.loadRecommendations() }
val rankingDeferred = async { rankingService.loadRanking() }
HomeSections(
banners = try {
bannerDeferred.await()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emptyList()
},
recommendations = try {
recommendationDeferred.await()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emptyList()
},
ranking = try {
rankingDeferred.await()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emptyList()
},
)
}이 코드는 배너 호출이 실패해도 추천 상품과 랭킹은 계속 가져옵니다. 홈 화면처럼 일부 섹션이 비어도 전체가 완전히 깨질 필요가 없을 때 잘 맞습니다.
다만 supervisorScope는 ‘예외를 알아서 먹어준다’는 뜻이 아닙니다. 실패한 자식의 예외는 여전히 해당 위치에서 처리해야 합니다.
suspend fun loadHomeSections(): HomeSections = supervisorScope {
val bannerDeferred = async { bannerService.loadBanners() }
val recommendationDeferred = async { recommendationService.loadRecommendations() }
HomeSections(
banners = try {
bannerDeferred.await()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emptyList()
},
recommendations = recommendationDeferred.await(),
)
}정리하면 기준은 간단합니다.
- 하나라도 실패하면 전체가 무의미하다면
coroutineScope - 일부 실패를 국소적으로 처리할 수 있다면
supervisorScope
이 기준이 분명하면 예외 처리 코드도 짧아집니다.
함수 안에서 임의로 CoroutineScope를 만들지 마세요
코루틴을 조금 쓰다 보면 아래 같은 코드를 쉽게 만나게 됩니다.
suspend fun refreshAll() {
val scope = CoroutineScope(currentCoroutineContext())
scope.launch {
cacheService.refresh()
}
}겉으로는 현재 context를 재사용하는 것처럼 보여도, 이런 코드는 읽는 사람에게 혼란을 줍니다.
함수 본문 안에서 새 CoroutineScope를 만들어 자식을 띄우면, 그 자식이 언제 끝나는지 함수 시그니처만 보고 알기 어려워집니다.
대부분의 경우 이럴 때는 별도 scope를 만들지 말고 coroutineScope를 쓰거나, 바깥 scope를 파라미터로 명시적으로 받는 편이 낫습니다.
suspend fun refreshAll() = coroutineScope {
launch {
cacheService.refresh()
}
}함수가 자기 자식 작업까지 책임져야 한다면 이 방식이 가장 안전합니다.
fun refreshAll(scope: CoroutineScope) {
scope.launch {
cacheService.refresh()
}
}반대로 호출자의 생명주기에 붙여야 하는 fire-and-forget 작업이라면 이렇게 scope를 드러내는 편이 낫습니다.
정말로 객체가 자체 생명주기를 가지고 오래 살아야 한다면, 그때는 scope를 필드로 저장하고 종료 시점에 취소해야 합니다.
class PricePollingManager(
private val priceService: PriceService,
scope: CoroutineScope? = null,
) : AutoCloseable {
private val scope = scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun start() {
scope.launch {
while (isActive) {
priceService.poll()
delay(1_000)
}
}
}
override fun close() {
scope.cancel()
}
}이 구조는 임의의 지역 scope보다 훨씬 낫습니다. 객체가 scope를 소유하고 있다는 사실이 코드에 드러나기 때문입니다.
GlobalScope와 runBlocking은 경계에서만 사용하세요
GlobalScope는 이름 그대로 애플리케이션 전체 수명과 비슷한 수준으로 살아 있는 scope입니다. 그래서 편해 보이지만, 대부분의 애플리케이션 코드에는 너무 큽니다.
화면, 요청, 유스케이스처럼 분명한 생명주기가 있는 작업을 GlobalScope에 올리면 취소와 대기가 어려워집니다.
fun sendWelcomeMail(userId: Long) {
GlobalScope.launch {
mailService.sendWelcomeMail(userId)
}
}이 코드는 메일 전송 실패를 누가 받는지, 사용자가 화면을 떠났을 때 취소해야 하는지, 테스트에서 언제 완료를 기다려야 하는지 드러나지 않습니다.
대부분은 아래처럼 호출자 scope를 받거나, 더 아래로 내려가면 suspend 함수로 만드는 편이 좋습니다.
class UserFacade(
private val scope: CoroutineScope,
private val mailService: MailService,
) {
fun sendWelcomeMail(userId: Long) {
scope.launch {
mailService.sendWelcomeMail(userId)
}
}
}
class MailService {
suspend fun sendWelcomeMail(userId: Long) {
// ...
}
}runBlocking도 비슷합니다. 편리하지만 남용하면 흐름을 망칩니다. runBlocking은 현재 스레드를 실제로 막습니다. 그래서 일반 서비스 코드나 UI 코드 안에 무심코 들어가면 병목을 만들기 쉽습니다.
fun findUser(userId: Long): User {
return runBlocking {
userRepository.findById(userId)
}
}이 함수는 겉으로는 평범한 동기 API지만, 내부에서 코루틴을 억지로 동기 코드로 바꾸고 있습니다. 이런 코드는 상위 호출자에서 비동기 설계를 막아버립니다.
runBlocking은 보통 main 함수, 테스트, suspend가 아닌 외부 콜백과의 경계처럼 정말 다리를 놓아야 하는 자리에서만 쓰는 편이 좋습니다.
fun main() = runBlocking {
val result = userRepository.findById(1L)
println(result)
}GlobalScope와 runBlocking은 아예 금지어는 아닙니다. 다만 둘 다 범위가 큽니다. 그래서 일반 애플리케이션 로직의 기본값이 되면 거의 항상 읽기 어려운 코드가 됩니다.
dispatcher 선택도 코드의 의도입니다
dispatcher는 단순한 성능 옵션이 아닙니다. 어떤 종류의 작업인지 드러내는 신호이기도 합니다.
Dispatchers.Main은 UI와 상호작용하는 코드에 어울립니다.Dispatchers.Default는 CPU 연산 중심 작업에 어울립니다.Dispatchers.IO는 blocking I/O 작업을 분리할 때 주로 사용합니다.
실무에서 가장 흔한 냄새는 아무 생각 없이 전부 Dispatchers.IO로 보내는 것입니다.
suspend fun calculateStatistics(items: List<Int>): Statistics {
return withContext(Dispatchers.IO) {
statisticsCalculator.calculate(items)
}
}이 코드는 I/O가 아니라 CPU 계산입니다. 이런 경우에는 dispatcher가 의도를 흐립니다.
suspend fun calculateStatistics(items: List<Int>): Statistics {
return withContext(Dispatchers.Default) {
statisticsCalculator.calculate(items)
}
}반대로 실제로 파일, 소켓, JDBC처럼 blocking 호출을 감싸는 코드라면 Dispatchers.IO가 자연스럽습니다.
suspend fun readAuditLog(path: Path): String {
return withContext(Dispatchers.IO) {
Files.readString(path)
}
}또 하나 중요한 점은 dispatcher를 여기저기 하드코딩하지 않는 것입니다. 테스트와 재사용성 관점에서는 주입 가능한 형태가 더 좋습니다.
class ReportService(
private val ioDispatcher: CoroutineDispatcher,
private val reportRepository: ReportRepository,
) {
suspend fun save(report: Report) {
withContext(ioDispatcher) {
reportRepository.save(report)
}
}
}이 구조는 9편에서 다룬 테스트 가능한 설계와도 연결됩니다. 코루틴 코드는 보통 scope와 dispatcher를 숨길수록 테스트가 더 어려워집니다.
취소를 정상 흐름으로 다루세요
코루틴 취소는 예외 상황이 아니라 설계의 일부입니다. 사용자가 화면을 떠나거나, 요청이 취소되거나, 부모 작업이 실패하면 자식도 함께 멈출 수 있어야 합니다.
그래서 코루틴 코드에서는 취소를 억지로 무시하지 않는 것이 중요합니다.
먼저 CancellationException을 삼켜버리는 코드를 조심해야 합니다.
suspend fun refresh() {
try {
api.fetch()
} catch (e: Exception) {
logger.error("refresh failed", e)
}
}이 코드는 얼핏 안전해 보입니다. 하지만 CancellationException까지 잡아버릴 수 있어 취소 전파를 망가뜨릴 수 있습니다.
suspend fun refresh() {
try {
api.fetch()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("refresh failed", e)
}
}정리하면 취소는 실패 처리와 별개로 보셔야 합니다. 취소는 보통 다시 던지는 편이 맞습니다.
또한 긴 계산 루프처럼 중간에 잘 suspend되지 않는 코드라면 명시적으로 취소를 확인해야 합니다.
suspend fun findPrimeNumbers(): List<Int> = withContext(Dispatchers.Default) {
val result = mutableListOf<Int>()
for (number in 2..1_000_000) {
ensureActive()
if (isPrime(number)) {
result += number
}
}
result
}ensureActive()나 isActive 확인이 없는 긴 루프는 취소 요청을 받아도 한참 동안 멈추지 않을 수 있습니다.
정리 작업이 suspend 함수라면 finally에서 NonCancellable을 제한적으로 사용할 수 있습니다.
suspend fun uploadFile(file: File) {
val connection = storageClient.open()
try {
connection.upload(file)
} finally {
withContext(NonCancellable) {
connection.close()
}
}
}이 패턴은 cleanup에만 좁게 쓰는 편이 좋습니다. 일반 작업 로직을 NonCancellable로 감싸면 취소 가능성을 잃어버립니다.
timeout과 blocking code를 혼동하지 마세요
withTimeout은 편리합니다. 하지만 timeout이 걸렸다고 해서 블록 안의 모든 코드가 즉시 멈추는 것은 아닙니다. 취소는 cooperative하게 동작하기 때문입니다.
suspend fun callLegacyApi() {
withTimeout(500) {
Thread.sleep(5_000)
}
}이 코드는 기대와 다르게 오래 붙잡힐 수 있습니다. Thread.sleep()은 코루틴 취소를 이해하지 못하기 때문입니다.
JVM에서 blocking 코드를 취소 가능하게 연결해야 한다면 runInterruptible을 고려할 수 있습니다.
suspend fun callLegacyApi() {
withTimeout(500) {
runInterruptible(Dispatchers.IO) {
Thread.sleep(5_000)
}
}
}또는 가능하면 처음부터 blocking API를 직접 감싸는 얇은 adapter를 만들어 두는 편이 좋습니다.
class LegacyUserClient {
fun fetchUserBlocking(userId: Long): User {
Thread.sleep(300)
return User(userId, "Alice")
}
}
class UserClient(
private val legacyUserClient: LegacyUserClient,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun fetchUser(userId: Long): User {
return runInterruptible(ioDispatcher) {
legacyUserClient.fetchUserBlocking(userId)
}
}
}이렇게 경계에서 한 번 감싸두면 나머지 코드에서는 suspend API처럼 자연스럽게 사용할 수 있습니다.
CoroutineExceptionHandler를 과신하지 마세요
CoroutineExceptionHandler는 유용합니다. 하지만 이것을 일반적인 비즈니스 예외 처리 도구처럼 쓰기 시작하면 코드가 흐려집니다.
핵심 기준은 단순합니다. 문맥 안에서 복구하거나 대체값을 넣을 수 있는 예외는 그 자리에서 try-catch로 처리하고, handler는 마지막 안전망에 가깝게 쓰는 것입니다.
val handler = CoroutineExceptionHandler { _, throwable ->
logger.error("unhandled coroutine error", throwable)
}이 handler는 로그나 크래시 리포팅 같은 마지막 수단에는 어울립니다. 하지만 아래처럼 비즈니스 흐름 자체를 맡기면 읽기 어려워집니다.
scope.launch(handler) {
userState.value = UiState.Loading
val profile = profileService.load()
userState.value = UiState.Success(profile)
}이 코드에서 예외가 나면 state를 어떻게 바꾸는지 handler만 보고는 흐름을 파악하기 어렵습니다. UI 상태 전환처럼 맥락이 중요한 처리는 코루틴 내부에서 하는 편이 낫습니다.
scope.launch {
userState.value = UiState.Loading
try {
val profile = profileService.load()
userState.value = UiState.Success(profile)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
userState.value = UiState.Error("프로필을 불러오지 못했습니다.")
logger.error("profile load failed", e)
}
}이 코드는 읽는 즉시 성공, 실패, 취소 흐름을 이해할 수 있습니다. 클린코드에서는 이런 지역성이 중요합니다.
before & after 리팩터링 예제
마지막으로 실무에서 자주 보는 냄새를 한 번에 정리해보겠습니다.
먼저 좋지 않은 예입니다.
class HomeFeedService(
private val api: HomeFeedApi,
private val repository: HomeFeedRepository,
) {
fun refresh(userId: Long) {
CoroutineScope(Dispatchers.IO).launch {
try {
val user = async { api.loadUser(userId) }.await()
val articles = async { api.loadArticles(userId) }.await()
repository.save(user, articles)
} catch (e: Exception) {
logger.error("refresh failed", e)
}
}
}
fun findLatest(userId: Long): HomeFeed {
return runBlocking {
repository.findLatest(userId)
}
}
}이 코드에는 여러 냄새가 한 번에 들어 있습니다.
- 서비스가 내부에서 임의의 scope를 만듭니다.
refresh()는 완료 시점과 실패 여부를 시그니처로 드러내지 않습니다.async를 바로await()해서 병렬 의도가 흐려집니다.Exception을 넓게 잡아 취소까지 삼킬 위험이 있습니다.findLatest()는runBlocking으로 상위 비동기 설계를 막습니다.
같은 기능을 조금만 정리하면 훨씬 읽기 좋아집니다.
class HomeFeedService(
private val api: HomeFeedApi,
private val repository: HomeFeedRepository,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun refresh(userId: Long) = coroutineScope {
val userDeferred = async { api.loadUser(userId) }
val articlesDeferred = async { api.loadArticles(userId) }
val user = userDeferred.await()
val articles = articlesDeferred.await()
withContext(ioDispatcher) {
repository.save(user, articles)
}
}
suspend fun findLatest(userId: Long): HomeFeed {
return withContext(ioDispatcher) {
repository.findLatest(userId)
}
}
}이제 서비스는 코루틴을 몰래 시작하지 않습니다. 호출자는 이 작업을 언제 시작하고, 언제 기다리고, 어떻게 취소할지 스스로 결정할 수 있습니다.
경계에서는 아래처럼 launch를 사용하면 됩니다.
class HomeFeedController(
private val scope: CoroutineScope,
private val homeFeedService: HomeFeedService,
) {
fun onRefresh(userId: Long) {
scope.launch {
try {
homeFeedService.refresh(userId)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
showToast("홈 피드를 새로고침하지 못했습니다.")
logger.error("home feed refresh failed", e)
}
}
}
}이 구조의 장점은 세 가지입니다.
- 코루틴을 시작하는 위치가 경계에 모여 있습니다.
- 서비스는
suspend함수로 명확한 계약을 가집니다. - 예외 처리와 사용자 피드백이 실제 맥락 가까이에 있습니다.
결국 코루틴 클린코드는 문법 트릭이 아니라 역할 배치의 문제입니다.
체크리스트
코루틴 코드를 작성하거나 리뷰할 때 아래 항목으로 빠르게 점검해보시면 좋습니다.
- 코루틴을 누가 시작하는지 시그니처와 호출 위치만 보고 알 수 있는가
- 서비스와 리포지토리가 내부에서 임의의
launch나CoroutineScope()를 만들고 있지 않은가 - 결과 하나면
suspend또는withContext, 병렬 결과 결합이면async를 쓰고 있는가 - 연관된 하위 작업은
coroutineScope로 묶고, 독립 실패만supervisorScope로 분리했는가 GlobalScope와runBlocking이 정말 경계에만 있는가- dispatcher가 작업 성격에 맞는가. 그리고 테스트를 위해 주입 가능하게 만들 수 있는가
CancellationException을 무심코 삼키고 있지 않은가- blocking 호출을 timeout으로만 감싸고 끝내지 않았는가. 필요하면
runInterruptible같은 경계를 두었는가 CoroutineExceptionHandler를 일반 예외 처리 도구처럼 남용하고 있지 않은가
마무리
코틀린 코루틴은 강력합니다. 하지만 강력하다는 말은 곧, 설계가 흐려지면 문제도 크게 퍼진다는 뜻입니다.
그래서 코루틴 코드는 문법을 많이 아는 것보다 scope를 어디에 둘지, 어떤 실패가 전체 실패인지, 취소를 어떻게 전파할지를 먼저 정하는 편이 중요합니다.
실무에서는 아래 세 문장만 기억해도 도움이 됩니다.
- 코루틴은 바깥 경계에서 시작하고, 아래 계층은 가능한 한
suspend로 이어가세요. - 함께 성공하거나 실패할 작업은
coroutineScope로, 독립 실패가 가능한 작업은supervisorScope로 나누세요. - 취소와 예외를 숨기지 말고, 코드 가까운 곳에서 의도를 드러내세요.
이 기준이 잡히면 코루틴 코드는 훨씬 차분해집니다. 그리고 차분한 코드는 대체로 읽기 쉽고, 테스트하기 쉽고, 오래 버팁니다.
다음 글에서는 코틀린과 자바를 함께 쓸 때 클린코드가 왜 깨지는지를 다뤄보겠습니다. nullability, 플랫폼 타입, Java 스타일 유틸 코드, 예외 처리 습관이 Kotlin 코드에 어떤 영향을 주는지 실무 관점에서 이어서 정리하겠습니다.
코틀린 클린코드 시리즈 이어서 보기
이 글을 기준으로 앞뒤 흐름을 연결하면 내용이 더 잘 잡힙니다. 아래 글을 이어서 읽어보세요.