
코틀린은 문법이 간결해서 처음 접하면 “짧게 쓴 코드”가 곧 좋은 코드처럼 느껴질 때가 많습니다. 실제로 보일러플레이트가 줄어들고, 자바보다 훨씬 적은 코드로 같은 기능을 만들 수 있습니다. 하지만 실무에서는 한 가지를 더 봐야 합니다. 짧은 코드와 읽기 좋은 코드는 같지 않다는 점입니다.
코드가 몇 줄인지보다 더 중요한 것은, 다른 개발자가 빠르게 이해할 수 있는지, 다음 수정에서 버그가 생기지 않는지, 테스트가 쉬운지입니다. 특히 코틀린은 null safety, data class, scope function, extension function, 고차 함수처럼 표현력이 높은 도구를 많이 제공하기 때문에, 잘 쓰면 정말 읽기 좋은 코드가 되지만 잘못 쓰면 오히려 의도가 숨어버리기 쉽습니다.
코틀린 클린코드는 코드를 짧게 줄이는 기술이 아니라, 의도를 분명하게 드러내고 안전하게 수정할 수 있게 만드는 습관입니다.
이번 글은 코틀린 클린코드 시리즈의 첫 글입니다. 앞으로 이어질 글들을 이해하기 위한 기준점을 먼저 잡아드리겠습니다. 시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지도 함께 보시면 좋습니다.
왜 코틀린에서 클린코드를 따로 이야기해야 할까요?
클린코드 자체는 특정 언어에만 적용되는 개념이 아닙니다. 좋은 이름, 작은 책임, 예측 가능한 흐름, 낮은 결합도 같은 원칙은 어느 언어에서나 중요합니다. 다만 코틀린은 언어 차원에서 표현력이 높기 때문에, “읽기 좋은 코드”와 “문법을 많이 쓴 코드”가 쉽게 헷갈립니다.
예를 들어 아래 코드는 문법적으로 틀린 코드는 아니지만, 한눈에 이해하기가 쉽지 않습니다.
val userName = request?.user?.let { user ->
userRepository.findById(user.id)?.run {
takeIf { isActive }?.also {
logger.info("active user: ${it.id}")
}?.name
}
} ?: "unknown"
코틀린 기능을 많이 활용했다는 점에서는 그럴듯해 보이지만, 실제로 읽는 입장에서는 흐름을 따라가기가 피곤합니다. null 처리, 조회, 조건 검사, 로깅, 반환이 한 덩어리로 섞여 있기 때문입니다.
같은 로직도 아래처럼 풀어 쓰면 훨씬 읽기 쉬워집니다.
fun findActiveUserName(request: Request?): String {
val userId = request?.user?.id ?: return "unknown"
val user = userRepository.findById(userId) ?: return "unknown"
if (!user.isActive) return "unknown"
logger.info("active user: ${user.id}")
return user.name
}
두 번째 코드가 더 길어 보일 수는 있습니다. 그런데 실무에서는 이런 코드가 더 오래 살아남습니다. 이유는 간단합니다. 한 줄씩 읽을 때 판단해야 할 것이 적고, 수정 포인트가 분명하기 때문입니다. 코틀린 클린코드를 이야기할 때 가장 먼저 잡아야 하는 기준이 바로 이것입니다. 코드를 얼마나 영리하게 압축했는지가 아니라, 의도가 얼마나 빨리 읽히는지가 더 중요합니다.
코틀린 클린코드를 판단하는 핵심 기준 5가지
1. 이름만 봐도 역할이 보여야 합니다
좋은 이름은 가장 강력한 문서입니다. 변수, 함수, 클래스 이름만 읽어도 코드가 무슨 일을 하는지 감이 오면, 주석이 줄어들고 리뷰 속도도 빨라집니다. 반대로 이름이 모호하면 함수 내부를 다 읽고도 의도를 확신하기 어렵습니다.
fun calc(u: User, items: List<Item>): Int {
val sum = items.sumOf { it.price * it.quantity }
return if (u.grade == "VIP") sum * 90 / 100 else sum
}
위 코드에서 calc, u, items는 틀린 이름은 아니지만, 비즈니스 의미가 거의 드러나지 않습니다. 반면 아래 코드는 같은 로직이 훨씬 선명하게 보입니다.
fun calculateDiscountedTotal(
customer: Customer,
orderItems: List<OrderItem>,
): Int {
val subtotal = orderItems.sumOf { item -> item.price * item.quantity }
return if (customer.isVip) subtotal * 90 / 100 else subtotal
}
좋은 이름을 짓는다고 해서 무조건 길어져야 하는 것은 아닙니다. 다만 짧음보다 정확함을 먼저 선택하셔야 합니다. 코틀린은 타입 추론이 강하기 때문에 더더욱 이름이 중요합니다. 타입 선언이 줄어든 만큼, 이름이 역할을 대신 설명해야 하기 때문입니다.
2. 함수 안에는 한 가지 관심사만 남겨야 합니다
실무에서 읽기 힘든 함수는 대부분 “한 번에 너무 많은 일”을 합니다. 검증, 저장, 로깅, 외부 호출, 결과 생성이 한 함수 안에 섞이면, 수정할 때 어디를 건드려야 하는지 감을 잡기 어려워집니다.
fun registerUser(request: RegisterUserRequest): User {
require(request.email.isNotBlank()) { "이메일은 필수입니다." }
require(request.password.length >= 8) { "비밀번호는 8자 이상이어야 합니다." }
val user = userRepository.save(
User(
email = request.email.trim(),
password = passwordEncoder.encode(request.password),
)
)
emailSender.sendWelcomeMail(user.email)
auditLogger.log("user registered: ${user.id}")
return user
}
한 함수 안에 비즈니스 검증, 엔티티 생성, 저장, 메일 발송, 감사 로그까지 모두 들어 있습니다. 작동은 하지만, 변경이 생기면 함수가 빠르게 비대해질 가능성이 큽니다. 아래처럼 단계를 나누면 흐름이 훨씬 명확해집니다.
fun registerUser(request: RegisterUserRequest): User {
validate(request)
val user = createUser(request)
val savedUser = userRepository.save(user)
sendWelcomeMail(savedUser)
writeAuditLog(savedUser)
return savedUser
}
private fun validate(request: RegisterUserRequest) {
require(request.email.isNotBlank()) { "이메일은 필수입니다." }
require(request.password.length >= 8) { "비밀번호는 8자 이상이어야 합니다." }
}
private fun createUser(request: RegisterUserRequest): User {
return User(
email = request.email.trim(),
password = passwordEncoder.encode(request.password),
)
}
private fun sendWelcomeMail(user: User) {
emailSender.sendWelcomeMail(user.email)
}
private fun writeAuditLog(user: User) {
auditLogger.log("user registered: ${user.id}")
}
함수를 무조건 잘게 쪼개라는 뜻은 아닙니다. 다만 한 함수 안에 서로 다른 관심사가 섞이기 시작하면, 읽는 사람은 문맥 전환을 반복해야 합니다. 코틀린 클린코드에서 좋은 함수란 “짧은 함수”보다 흐름이 분리된 함수에 가깝습니다.
3. null은 가능한 한 경계에서 정리해야 합니다
코틀린의 가장 큰 장점 중 하나는 null을 타입으로 드러낼 수 있다는 점입니다. 그런데 실무에서는 이 장점을 반대로 사용하는 경우도 많습니다. nullable 값을 여기저기 퍼뜨리고, 함수 안쪽에서 계속 ?., ?:, !!로 방어하다 보면 로직이 금방 흐려집니다.
fun createOrder(request: CreateOrderRequest?): Order {
val customerId = request?.customerId ?: throw IllegalArgumentException("customerId는 필수입니다.")
val items = request.items ?: emptyList()
if (items.isEmpty()) {
throw IllegalArgumentException("주문 상품은 1개 이상이어야 합니다.")
}
return orderService.create(customerId, items)
}
이 코드도 나쁘지는 않지만, 입력값이 nullable한 채로 함수 안 깊숙이 들어와 있다는 점이 아쉽습니다. 아래처럼 경계에서 먼저 정리하면 이후 로직이 더 단순해집니다.
fun createOrder(request: CreateOrderRequest?): Order {
val command = request.toCommand()
return orderService.create(command.customerId, command.items)
}
private fun CreateOrderRequest?.toCommand(): CreateOrderCommand {
requireNotNull(this) { "요청 본문이 비어 있습니다." }
val customerId = requireNotNull(customerId) { "customerId는 필수입니다." }
val items = items.orEmpty()
require(items.isNotEmpty()) { "주문 상품은 1개 이상이어야 합니다." }
return CreateOrderCommand(
customerId = customerId,
items = items,
)
}
핵심은 nullable을 없애는 것이 아니라, null 가능성이 존재하는 위치를 제한하는 것입니다. 경계에서 한 번 정리하고 나면, 비즈니스 로직은 non-null 모델 위에서 더 단순하게 흘러갈 수 있습니다. 이 원칙은 앞으로 시리즈에서 다룰 null safety 글의 핵심 출발점이기도 합니다.
4. 코틀린다운 문법은 분명할 때만 쓰면 됩니다
코틀린의 let, run, apply, also 같은 scope function은 매우 유용합니다. 하지만 남용하면 변수의 주체가 계속 바뀌고, 현재 문맥이 무엇인지 헷갈리기 쉬워집니다. 특히 로직이 길어질수록 더 그렇습니다.
val order = request.run {
Order(
customerId = customerId,
items = items.map { it.toOrderItem() },
).also {
validator.validate(it)
orderRepository.save(it)
}
}
위 코드는 문법적으로 문제가 없지만, 객체 생성과 검증과 저장이 한 번에 묶여 있어 읽는 흐름이 부드럽지 않습니다. 아래처럼 풀어 쓰면 “무엇을 만드는지”, “어디서 검증하는지”, “언제 저장하는지”가 더 또렷해집니다.
val order = Order(
customerId = request.customerId,
items = request.items.map { it.toOrderItem() },
)
validator.validate(order)
orderRepository.save(order)
코틀린다운 코드는 코틀린 문법을 많이 쓴 코드가 아닙니다. 문법이 눈에 띄지 않고 의도만 남는 코드가 더 좋은 코드입니다. 한 줄로 줄일 수 있어도 독자가 멈춰서 해석해야 한다면, 그 축약은 비용이 됩니다.
5. 수정하기 쉬워야 클린코드입니다
지금 당장 읽히는 코드도 중요하지만, 조금 더 중요한 것은 다음 변경에서 쉽게 다룰 수 있는 코드인지입니다. 그래서 클린코드는 자연스럽게 테스트 가능성과 연결됩니다. 외부 시간, 난수, 외부 시스템 호출이 코드 안에 강하게 박혀 있으면 테스트가 어려워지고, 결국 코드 변경이 두려워집니다.
class CouponService(
private val couponRepository: CouponRepository,
) {
fun issue(userId: Long): Coupon {
val coupon = Coupon(
userId = userId,
code = UUID.randomUUID().toString(),
expiresAt = LocalDateTime.now().plusDays(7),
)
return couponRepository.save(coupon)
}
}
위 코드는 간단하지만 테스트하기가 애매합니다. 현재 시간과 랜덤 코드 생성이 내부에 숨겨져 있기 때문입니다. 아래처럼 외부 의존성을 분리하면 코드가 바뀌어도 테스트는 더 안정적입니다.
class CouponService(
private val couponRepository: CouponRepository,
private val codeGenerator: CodeGenerator,
private val clock: Clock,
) {
fun issue(userId: Long): Coupon {
val issuedAt = LocalDateTime.now(clock)
val coupon = Coupon(
userId = userId,
code = codeGenerator.generate(),
expiresAt = issuedAt.plusDays(7),
)
return couponRepository.save(coupon)
}
}
클린코드를 이야기할 때 종종 “예쁘다”, “깔끔하다”라는 표현을 쓰지만, 실무에서는 결국 변경 비용이 낮은 코드가 좋은 코드입니다. 읽기 쉽고, 테스트하기 쉽고, 영향 범위를 예측할 수 있다면 그 코드가 오래 갑니다.
실무에서 자주 하는 오해
코드가 짧으면 무조건 좋은 코드일까요?
그렇지 않습니다. 코드를 줄이는 과정에서 이름이 사라지고 단계가 합쳐지면, 오히려 읽는 시간이 늘어납니다. 실무에서는 “몇 줄이냐”보다 “몇 초 안에 이해되느냐”가 더 중요합니다.
체이닝이 많을수록 코틀린다운 코드일까요?
아닙니다. map, filter, let, also를 적절히 쓰는 것은 좋지만, 흐름이 길어질수록 중간 변수나 작은 함수로 의도를 분리하는 편이 더 낫습니다. 특히 예외 처리와 null 처리까지 한 체인 안에 몰아넣으면 이해 난도가 급격히 올라갑니다.
주석이 없으면 클린코드일까요?
주석을 없애는 것이 목표는 아닙니다. 이름과 구조만으로 설명이 되는 코드가 가장 좋지만, 비즈니스 규칙이나 외부 제약처럼 코드만으로 드러나지 않는 맥락은 주석이 필요할 수 있습니다. 중요한 것은 주석으로 코드를 변명하지 않는 것입니다.
처음부터 완벽하게 쓰는 것보다 더 중요한 것
클린코드는 한 번에 완성되지 않습니다. 실무에서는 일단 동작하게 만든 뒤, 이름을 바꾸고, 중복을 줄이고, null 경계를 정리하고, 테스트 가능한 구조로 다듬는 과정이 반복됩니다. 그래서 좋은 개발자는 처음부터 모든 것을 맞히는 사람이 아니라, 코드를 읽고 불편함을 감지한 뒤 조금씩 개선하는 사람에 더 가깝습니다.
리팩터링을 거창하게 생각하실 필요도 없습니다. 함수 이름을 더 정확하게 바꾸는 것, 긴 함수에서 검증 로직을 분리하는 것, nullable이 깊게 들어오지 않도록 변환 계층을 두는 것만으로도 코드 품질은 빠르게 좋아집니다. 이 시리즈 역시 그런 작은 개선 포인트를 하나씩 쌓아가는 방식으로 진행할 예정입니다.
오늘부터 바로 적용할 체크리스트
- 함수 이름만 읽어도 무엇을 반환하는지 떠오르는지 확인합니다.
- 한 함수 안에 검증, 저장, 외부 호출, 로깅이 한꺼번에 섞여 있지 않은지 봅니다.
- nullable 값이 비즈니스 로직 안쪽까지 깊게 들어오고 있지 않은지 점검합니다.
let,run,apply,also가 가독성을 높이는지, 아니면 단지 줄 수만 줄였는지 판단합니다.- 현재 시간, 랜덤 값, 외부 시스템 호출처럼 테스트를 어렵게 만드는 요소가 직접 박혀 있지 않은지 확인합니다.
- 주석이 필요한 이유가 “코드가 어려워서”인지, 아니면 “비즈니스 맥락이 따로 있어서”인지 구분합니다.
마무리
코틀린 클린코드는 특별한 트릭의 모음이 아닙니다. 좋은 이름을 짓고, 한 번에 한 가지 일을 하게 만들고, null을 경계에서 정리하고, 문법보다 의도를 앞세우는 태도에서 시작합니다. 결국 중요한 것은 “코드를 얼마나 멋지게 썼는가”가 아니라, 같이 일하는 사람이 얼마나 빨리 이해하고 안전하게 수정할 수 있는가입니다.
이번 글은 시리즈의 출발점입니다. 다음 글에서는 가장 먼저 실무 체감이 큰 주제인 코틀린 네이밍 컨벤션을 다뤄보겠습니다. 변수명, 함수명, 클래스명만 바뀌어도 코드가 얼마나 읽기 쉬워지는지 실제 예제로 정리해드리겠습니다.