개발 처음 배우기 시작한지 8개월차..
아직 많이 부족하다.
1주차 과제 제출을 앞두고, 이것저것 확인을 해보는 상태이다.
1주차라 과제가 많이 어렵지 않아서 기능은 일찍 구현이 끝났는데, 아직 PR 체크리스트를 확인해보지 않았다.
방금 막 체크리스트를 봤는데 첫 번째를 제외하고는 거의 다 지키지 못하였다. 현재로선 클린코드에 대한 개념도 잘 세우지 못 한 것 같아서 일어난 일 같아서 이번 글에서는 클린 코드에 대해 정리해보려고 한다.

우테코에서 제공 받은 PR checklist 는 아래와 같다.
- 코틀린 코드 컨벤션을 지키면서 프로그래밍했는가?
- 한 메서드에 오직 한 단계의 들여쓰기(indent)만 허용했는가?
- else 예약어를 쓰지 않았는가?
- 모든 원시값과 문자열을 포장했는가?
- 콜렉션에 대해 일급 콜렉션을 적용했는가?
- 3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?
- 쉽지 않은 연습일 수 있다. 가능하면 변수의 수를 줄이기 위해 노력한다. - getter/setter 없이 구현했는가?
- 메소드의 인자 수를 제한했는가?
- 4개 이상의 인자는 허용하지 않는다.
- 3개도 가능하면 줄이기 위해 노력해 본다. - 코드 한 줄에 점(.)을 하나만 허용했는가?
- 디미터(Demeter)의 법칙("친구하고만 대화하라")을 지켰는가?
- 예를 들어 location.current.representation.substring(0,1)과 같이 여러 개의 점(.)이 등장하면 리팩토링할 부분을 찾아본다. - 메소드가 한 가지 일만 담당하도록 구현했는가?
- 클래스를 작게 유지하기 위해 노력했는가?
이제 한 가지씩 살펴보겠다.
1. 코틀린 코드 컨벤션을 지키면서 프로그래밍했는가?
코틀린 코드 컨벤션은 코틀린 코드를 작성할 때 일관성을 유지하고 가독성을 높이기 위해 공식적으로 권장하는 코딩 스타일 가이드이다.
https://kotlinlang.org/docs/coding-conventions.html#scope-functions-apply-with-run-also-let
Coding conventions | Kotlin
kotlinlang.org
안내받은 코틀린 코드 컨벤션 사이트는 위와 같다.
주요 컨벤션은 크게 이름 규칙, 포매팅, 언어 기능 사용법 세 가지로 나눌 수 있다.
1. 이름 규칙 (Naming Rules)
| 패키지 (Packages) |
모두 소문자를 사용하고, 밑줄(_)은 사용하지 않는다. 여러 단어는 그대로 붙여 쓴다. |
| 클래스 및 인터페이스 (Classes & Interfaces) |
파스칼 표기법(PascalCase)을 사용한다. 즉, 각 단어의 첫 글자를 대문자로 시작한다. |
| 함수 및 변수 (Functions & Variables) |
카멜 표기법(camelCase)을 사용한다. 첫 단어는 소문자로, 이후 단어부터 첫 글자를 대문자로 쓴다. |
| 상수 (Constants) |
const val 이나 @JvmField 를 사용하는 최상위 또는 객체 프로퍼티는 대문자와 밑줄을 사용한다. |
2. 포매팅 (Formatting)
| 들여쓰기 (Indentation) |
스페이스 4개를 사용한다. (탭은 사용하지 않는다.) |
| 콜론 (Colon) |
타입, 상속 등을 표시하는 콜론(:)앞에는 공백을 넣지 않고, 뒤에만 한 칸 띄운다. |
| 중괄호 (Curly Braces) |
구조(클래스, 함수 등)의 시작을 알리는 여는 중괄호({)는 해당 선언문과 같은 줄 끝에 위치시키고, 닫는 중괄호(})는 새로운 줄에서 시작한다. |
| 람다 식 (Lamda expressions) |
간단한 람다는 items.filter { it > 10 } 처럼 한 줄로 표현하는 것이 좋고, 복잡한 경우 화살표(->)를 새로운 줄에 배치하여 가독성을 높일 수 있다. |
3. 언어 기능 사용법
| 불변성 (Immutability) |
가능한 한 항상 val(변경 불가능)을 먼저 사용하고, 꼭 필요한 경우에만 var(변경 가능)를 사용한다. 이는 코드의 안전성을 높여준다. |
| 타입 추론 (Type Inference) |
컴파일러가 타입을 명확히 추론할 수 있는 경우에는 타입을 생략하는 것을 권장한다. |
| 유틸리티 함수 (apply, let, with, run, also) |
스코프 함수(Scope Functions)는 목적에 맞게 사용한다. 예를 들어, 객체 초기화에는 apply를, null이 아닐 때 코드를 실행하려면 let을 사용하는 것이 좋다. |
| 문자열 템플릿 (String templates) |
문자열을 합칠 때는 + 연산자보다 문자열 템플릿($)을 사용하는 것이 더 효율적이고 가독성이 좋다. |
2. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 허용했는가?
하나의 함수 안에서 if, for, when 등이 중첩되어 2단계 이상 깊어지는 것을 피하는 규칙이다.
들여쓰기가 깊어지면 코드를 읽는 사람이 파악해야 할 '문맥(context)'이 많아져 코드가 복잡해진다.
중요한 이유: 함수의 복잡도를 낮추고 가독성을 높인다. 2단계 이상의 들여쓰기가 보인다면, 그 부분을 별도의 함수로 분리하라는 신호이다.
나쁜 예 (2단계 들여쓰기)
fun printValidItems(items: List<Item>) {
for (item in items) { // 1단계
if (item.isValid) { // 2단계
println(item.name)
}
}
}
좋은 예 (메서드 분리)
fun printValidItems(items: List<Item>) {
for (item in items) { // 1단계
printIfValid(item)
}
}
private fun printIfValid(item: Item) {
if (items.isValid) { // 1단계
println(item.name)
}
}
3. else 예약어를 쓰지 않았는가?
if-else 구조 대신 if 문을 사용한 가드 클로저(Guard Clause, 보호 구문) 패턴을 사용하라는 원칙이다. 예외적이거나 비정상적인 조건을 함수 초반에 검사하고 return 이나 throw 로 빠르게 종료시키는 방식이다.
중요한 이유: 함수의 주된 로직이 else 블록이나 깊은 들여쓰기 안에 갇히는 것을 막아준다.코드가 '실패 케이스'를 먼저 쳐내고, 아래쪽은 '성공 케이스'의 핵심 로직만 남게 되어 이해하기 쉬워진다.
나쁜 예
fun getDiscountRate(user: User): Double {
if (user.isVip) {
// ...
// 핵심 로직이 if 블록 안에 있음
return 0.2
} else {
return 0.05
}
}
좋은 예 (가드 클로저)
fun getDiscountRate(user: User): Double {
if (!user.isVip) {
return 0.05 // 예외 케이스를 먼저 리턴
}
// ...
// 핵심 로직이 들여쓰기 없이 깔끔하게 노출됨
return 0.2
}
+ 코틀린의 require 나 check 함수 사용
fun getDiscountRate(user: User): Double {
require(user.isVip) { "Vip 유저만 할인이 가능합니다." }
// ...
return 0.2
}
4. 모든 원시값과 문자열을 포장했는가?
Int, String 같은 원시 타입(Primitive Type)을 그대로 사용하지 말고, 의미 있는 이름을 가진 클래스로 감싸서 사용하라는 원칙이다. 이를 '값 객체(Value Object)'라고 부른다.
중요한 이유
1. 타입 안정성: fun register(name: String, email: String)은 register("test@a.com", "김별터")처럼 순서가 바뀌어도 컴파일러가 잡지 못한다. 하지만 fun register(name: Name, email: Email)은 절대 헷갈릴 수 없다.
2. 유효성 검증: Age 클래스를 만들고 생성자에서 '0세 이상'을 검증하면, Age 객체는 항상 유효한 값임을 보장할 수 있다.
나쁜 예
// 나이가 음수이거나 이름이 비어있을 수 있음
fun printUser(name: String, age: Int) {
if (age < 0) { ... } // 사용할 때마다 검증
println("$name ($age)")
}
좋은 예 (value class 활용)
@JvmInLIne
value class Name(val value: String) {
init {
require(value.isNotBlank()) { "이름은 비어있을 수 없습니다." }
}
}
@JvmInLine
value class Age(val value: Int) {
init {
require(vlaue >= 0) { "나이는 0보다 작을 수 없습니다." }
}
}
// Age 객체는 항상 0 이상임을 보장함
fun printUser(name: Name, age: Age) {
println("${name.value} (${age.value})")
}
코틀린에서는 value class 를 사용하면 객체 생성으로 인한 성능 저하(오버헤드) 없이 이 원칙을 지킬 수 있다.
5. 콜렉션에 대해 일급 콜렉션을 적용했는가?
List<Car>, Map<String, User>처럼 컬렉션을 그대로 반환하거나 필드로 사용하지 말고, 해당 컬렉션'만'을 감싸는 클래스를 따로 만들라는 규칙이다.
중요한 이유: 컬렉션과 관련된 로직(예: 자동차들의 일괄 이동, 우승자 찾기)을 컬렉션을 감싼 클래스(Cars) 안에 캡슐화할 수 있다. Race 클래스는 List<Car>의 내부 구현을 알 필요 없이 cars.findWinner() 처럼 메시지만 보내면 된다.
나쁜 예
class Race {
// 자동차 목록이 그대로 노출됨
val cars: List<Car> = ...
// 경주 로직이 Race 클래스에 섞여 있음
fun findWinner(): Car {
return cars.maxByOrNull { it.position }!!
}
}
좋은 예 (일급 컬렉션)
// '자동차 목록'만을 관리하는 일급 컬렉션
class Cars(val values: List<Car>) {
// 자동차와 관련된 로직은 Cars가 책임짐
fun findWinner(): Car {
return values.maxByOrNull { it.position }!!
}
fun moveAll(): Cars {
return Cars(values.map { it.move() })
}
}
class Race(private val cars: Cars) {
fun startRound(): Car {
val movedCars = cars.moveAll()
return movedCars.findWinner()
}
}
6. 3개 이상의 인스턴스 변수를 가진 클래스를 구현하진 않았는가?
하나의 클래스가 너무 많은 상태(프로퍼티, 인스턴스 변수)를 갖지 않도록 제한하는 규칙이다. (숫자 '3'은 절대적인 기준은 아니고 '최대한 적게'라는 의미이다.)
중요한 이유: 클래스에 변수가 많다는 것은 그 클래스가 너무 많은 책임을 지고 있다는 신호(단일 책임 원칙 위반)일 수 있다. 관련 있는 변수끼리 묶어 별도의 작은 클래스로 분리하면 코드를 이해하고 관리하기 쉬워진다.
나쁜 예 (User가 주소 정보까지 다 앎)
data class User(
val id: Long,
val name: String,
val email: String,
// 주소 정보가 User에 포함됨
val street: String,
val city: String,
val zipCode: String
) // 프로퍼티가 6개
좋은 예 (Address 객체로 분리)
data class Address(
val street: String,
val city: String,
val zipCode: String
)
// User는 이제 4개의 프로퍼티만 가짐 (더 줄일 수도 있음)
data class User(
val id: Long,
val name: String,
val email: String,
val address: Address // 관련된 프로퍼티를 객체로 묶음
)
7. getter / setter 없이 구현했는가?
객체의 상태(프로퍼티)를 외부에서 마음대로 읽거나(get) 변경(set)하도록 허용하지 말라는 원칙이다. 코틀린에서는 public var 프로퍼티를 지양하는 것과 같다.
중요한 이유: 객체 지향의 핵심은 캡슐화이다. 객체는 자신의 상태를 스스로 책임져야 한다. 외부에서 데이터를 꺼내(get) 로직을 처리하고 다시 넣는(set) 방식(절차적 프로그래밍)이 아니라, 객체에게 "네가 알아서 해"라고 메시지를 보내야(Tell, Don't Ask) 한다.
나쁜 예
class Car {
var position: Int = 0 // 외부에서 position을 마음대로 바꿀 수 있음
}
// 로직이 외부에 있음
val car = Car()
car.position = car.position + 1 // car의 상태를 밖에서 직접 제어
좋은 예 (Tell, Don't Ask)
class Car {
var position: Int = 0
private set // 외부에서는 읽기만 가능, 변경은 오직 Car 내부에서만!
// Car에게 '움직이라'고 명령(Tell)함
fun move() {
this.position++ // 상태 변경은 Car 스스로 책임짐
}
}
// 외부에서는 car의 내부를 모르고 명령만 함
val car = Car()
car.move()
8. 메소드의 인자 수를 제한했는가?
함수(메서드)가 받는 인자(파라미터)의 개수를 3~4개 이하로 제한하라는 규칙이다.
중요한 이유: 인자가 많아지면 함수를 호출할 때 순서를 헷갈리기 쉽고, 테스트하기 어렵다. 이는 그 함수가 너무 많은 일을 하라고 한다는 신호일 수 있다.
나쁜 예 (파라미터가 5개)
fun registerUser(
name: String,
email: String,
age: Int,
city: String,
zipCode: String
) { ... }
좋은 예 (data class로 묶기)
// 관련된 파라미터를 객체로 묶음
data class RegistrationFrom(
val name: String,
val email: String,
val age: Int,
val address: Address // 6번 규칙과도 연관됨
)
// 함수가 깔끔해지고 의도가 명확해짐
fun registerUser(form: RegistrationForm) { ... }
코틀린의 Named Arguments 기능(registerUser(name = "A", age = 10, ...)은 이 문제를 일부 완화해 주지만, 근본적으로 파라미터를 줄이는 것이 더 좋은 설계이다.
9. 코드 한 줄에 점(.)을 하나만 허용했는가?
user.getWallet().getMoney().getAmount() 처럼 점(.)을 연달아 사용하여 객체 내부로 계속 파고드는 코드(일명 '기차 충돌')를 작성하지 말라는 규칙이다. 디미터의 법칙(Law of Demeter)이라고도 한다.
중요한 이유: 객체는 '직속 친구'와만 이야기해야 한다. User가 Wallet의 내부에 Money가 있고, 그 안에 amount가 있다는 사실을 아는 것은 과도한 결합(Coupling)을 만든다. 만약 Wallet의 구조가 Money가 아닌 CreditCard로 바뀌면, User 코드까지 전부 수정해야 한다.
나쁜 예 (기차 충돌)
class User(val wallet: Wallet)
class Wallet(val money: Money)
class Money(val amount: Int)
// User가 Wallet의 내부, Money의 내부까지 모두 알고 있음
val amount = user.wallet.money.amount // 점 3개
좋은 예 (친구에게 위임)
class Money(val amount: Int)
class Wallet(private val money: Money) {
// Wallet이 자기 돈을 알려주는 책임을 짐
fun getAmount(): Int {
return money.amount
}
}
class User(private val wallet: Wallet) {
// User는 Wallet에게 물어볼 뿐, 그 내부는 모름
fun getMyMoneyAmount(): Int {
return wallet.getAmount() // 점 1개
}
}
10. 메소드가 한 가지 일만 담당하도록 구현했는가?
'단일 책임 원칙(Single Responsibility Principle, SRP)을 함수 단위에 적용한 것이다. 함수는 그 이름에 걸맞은 '딱 한 가지' 일만 해야 한다.
중요한 이유: 함수가 한 가지 일만 하면, 이름 짓기 쉽고, 이해하기 쉬우며, 테스트하기 매우 쉽다. 또한 재사용성이 높아진다. 만약 함수 이름에 'and'가 들어간다면(예: validateAndSave), 책임을 분리해야 한다는 신호이다
나쁜 예 (입력, 계산, 출력을 모두 함)
fun calculateAndPrint() {
// 1. 입력
println("숫자를 입력하세요:")
val input = readLine()!!
// 2. 계산
val result = input.toInt() * 2
// 3. 출력
println("결과: $result")
}
.
좋은 예 (책임 분리)
fun calculateAndPrint() {
val input = readInput()
val result = calculate(input)
printResult(result)
}
// 각 함수는 딱 한 가지 일만 함
private fun readInput(): String { ... }
private fun calculate(input: String): Int { ... }
private fun printResult(result: Int) { ... }
11. 클래스를 작게 유지하기 위해 노력했는가?
'단일 책임 원칙(Single Responsibillity Principle, SRP)을 클래스 단위에 적용한 것이다. 클래스는 '변경되어야 할 이유'가 오직 하나여야 한다.
중요한 이유: 하나의 클래스가 입력, 처리, 저장, 출력 등 너무 많은 책임을 가지면 '갓 클래스(God Class)'가 된다. 이런 클래스는 한 부분을 수정하면 다른 부분이 망가지기 쉽고(Side Effect), 테스트하기가 극도로 어렵다.
나쁜 예 (모든 걸 다 하는 클래스)
class RacingGame {
fun readCarNames(): List<String> { ... } // 1. 입력 책임
fun createCars(names: List<String>): List<Car> { ... } // 2. 생성 책임
fun runRace(cars: List<Car>): List<Car> { ... } // 3. 비즈니스 로직 책임
fun printResult(cars: List<Car>) { ... } // 4. 출력 책임
}
좋은 예 (책임을 분리한 클래스들)
// 입력만 책임
class InputView {
fun readCarNames(): List<String> { ... }
}
// 출력만 책임
class ResultView {
fun printResult(cars: List<Car>) { ... }
}
// 도메인 로직만 책임
class Race(val cars: Cars) {
fun runRound(): Race { ... }
}
// 일급 컬렉션
class Cars(val values: List<Car>) { ... }
이번 블로그 글을 적으면서, 지난 코드들이 떠올랐다. 위와 같은 사항을 지키지 않은 코드들..
그래서 코드가 길어질수록 내가 힘들었구나..를 느낀다.
이번 글을 자주 더 많이 들여다보며 코드를 클린하게 쓰는 습관을 들여야겠다.
아직 우테코의 프리코스 과정이지만, 배울 점이 정말 많은 부트캠프 같다.
'Dev Log > Kotlin' 카테고리의 다른 글
| [Kotlin] 예외 처리 패턴: 사용자 입력을 '우아하게' 다시 받는 법 (ft. 재귀 함수) (0) | 2026.01.02 |
|---|---|
| [Kotlin/Test] 테스트 코드 작성을 위한 JUnit5와 AssertJ 문법 총정리 (TDD 기초) (0) | 2026.01.02 |
| [Kotlin] 안전한 코드를 위한 최소 요구사항 검증! require, check, assert 총정리 (0) | 2026.01.02 |
| [Kotlin] 코틀린 문자열 자르기와 다루기 완전 정복 (split, substring, 정규식) (0) | 2026.01.02 |
| [우테코 AN 8기] 프리코스 1주차 과정을 진행하며.. | 깃허브 커밋 메시지, 코틀린 컨벤션 (0) | 2025.10.14 |