Tiny Star
본문 바로가기
Dev Log/Kotlin

[우테코 AN 8기] 프리코스 | 클린 코드란 무엇일까? (ex. Kotlin)

by 별터 2025. 10. 18.

개발 처음 배우기 시작한지 8개월차.. 
아직 많이 부족하다.

1주차 과제 제출을 앞두고, 이것저것 확인을 해보는 상태이다.

1주차라 과제가 많이 어렵지 않아서 기능은 일찍 구현이 끝났는데, 아직 PR 체크리스트를 확인해보지 않았다.

 

방금 막 체크리스트를 봤는데 첫 번째를 제외하고는 거의 다 지키지 못하였다. 현재로선 클린코드에 대한 개념도 잘 세우지 못 한 것 같아서 일어난 일 같아서 이번 글에서는 클린 코드에 대해 정리해보려고 한다.

클린코드
사진은 클린 코드 대신 클린 버블로 대체

우테코에서 제공 받은 PR checklist 는 아래와 같다.


  1. 코틀린 코드 컨벤션을 지키면서 프로그래밍했는가?
  2. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 허용했는가?
  3. else 예약어를 쓰지 않았는가?
  4. 모든 원시값과 문자열을 포장했는가?
  5. 콜렉션에 대해 일급 콜렉션을 적용했는가?
  6. 3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?
    - 쉽지 않은 연습일 수 있다. 가능하면 변수의 수를 줄이기 위해 노력한다.
  7. getter/setter 없이 구현했는가?
  8. 메소드의 인자 수를 제한했는가?
    - 4개 이상의 인자는 허용하지 않는다.
    - 3개도 가능하면 줄이기 위해 노력해 본다.
  9. 코드 한 줄에 점(.)을 하나만 허용했는가?
    - 디미터(Demeter)의 법칙("친구하고만 대화하라")을 지켰는가?
    - 예를 들어 location.current.representation.substring(0,1)과 같이 여러 개의 점(.)이 등장하면 리팩토링할 부분을 찾아본다.
  10. 메소드가 한 가지 일만 담당하도록 구현했는가?
  11. 클래스를 작게 유지하기 위해 노력했는가?

 

이제 한 가지씩 살펴보겠다.

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>) { ... }

 

 

이번 블로그 글을 적으면서, 지난 코드들이 떠올랐다. 위와 같은 사항을 지키지 않은 코드들..

그래서 코드가 길어질수록 내가 힘들었구나..를 느낀다.

이번 글을 자주 더 많이 들여다보며 코드를 클린하게 쓰는 습관을 들여야겠다.

아직 우테코의 프리코스 과정이지만, 배울 점이 정말 많은 부트캠프 같다.