Kotlin 문법 복습 (4/4)

이전 편: [Kotlin] 3. Null Safety와 컬렉션

이 문서가 시리즈의 마지막 편입니다.

Kotlin 문법 복습 시리즈 (4/4)

이전 편: 3 Null Safety와 컬렉션

Java에는 없는, Kotlin만의 특징적인 기능들이 있다. 그중에서도 확장 함수, 스코프 함수, 람다는 Kotlin 코드를 읽을 때 가장 자주 마주치면서도, Java 배경에서는 가장 낯선 개념이다.

이 세 가지를 모르면 Kotlin 코드를 읽는 것 자체가 어렵다. 반대로 이 세 가지에 익숙해지면 "아, Kotlin이 이래서 좋구나"가 체감된다. 이 문서에서는 각각의 개념과 기본 사용법을 복습하고, 깊은 내용은 사고방식 시리즈로 넘긴다.

확장 함수

Java에서 String에 새로운 기능을 추가하고 싶으면 유틸 클래스를 만들어야 했다.

// Java — StringUtils라는 별도 클래스가 필요
public class StringUtils {
    public static boolean isValidEmail(String str) {
        return str.contains("@") && str.contains(".");
    }
}
StringUtils.isValidEmail("test@blog.com");

Kotlin에서는 기존 클래스를 수정하지 않고도 함수를 추가할 수 있다.

fun String.isValidEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

"test@blog.com".isValidEmail()   // true
  • String. — 확장할 대상 타입. 이것을 수신 타입(receiver type)이라 한다.
  • this — 함수 안에서 수신 객체를 가리킨다. 생략 가능.

확장 함수의 핵심은 호출하는 쪽의 가독성이다. StringUtils.isValidEmail(str) 보다 str.isValidEmail()이 자연스럽다. 마치 String 클래스에 원래부터 있던 메서드처럼 읽힌다.

확장 함수는 진짜 멤버가 아니다

확장 함수는 컴파일되면 static 메서드가 된다. 클래스 내부의 private 멤버에는 접근할 수 없다. 같은 이름의 멤버 함수가 있으면 멤버 함수가 우선한다.

실무에서 자주 쓰이는 패턴은 엔티티 → DTO 변환이다.

fun Post.toResponse() = PostResponse(
    id = id,
    title = title,
    content = content,
    viewCount = viewCount,
    createdAt = createdAt
)

// 사용
val response = post.toResponse()

Java에서 PostMapper.toResponse(post) 로 쓰던 걸, 마치 Post의 메서드인 것처럼 자연스럽게 호출할 수 있다.


람다

Kotlin에서 람다를 이해하는 가장 쉬운 방법은, "이름 없는 함수를 변수에 담는 것"이라고 생각하는 것이다.

val greet: (String) -> String = { name -> "안녕, $name" }
greet("관리자")  // "안녕, 관리자"
  • (String) -> String — 함수 타입. String을 받아서 String을 반환하는 함수.
  • { name -> "안녕, $name" } — 람다 표현식. name은 파라미터.

파라미터가 하나면 it으로 대체할 수 있다.

val greet: (String) -> String = { "안녕, $it" }

Java의 람다와 비교하면 문법이 조금 다르다. Java는 (name) -> "안녕, " + name이고, Kotlin은 { name -> "안녕, $name" } — 중괄호로 감싼다.


후행 람다(Trailing Lambda)

Kotlin에서 함수의 마지막 파라미터가 람다이면, 괄호 밖으로 뺄 수 있다.

// 일반적인 호출
posts.filter({ it.isPublished })

// 후행 람다 — 괄호 밖으로
posts.filter() { it.isPublished }

// 다른 파라미터가 없으면 괄호 생략
posts.filter { it.isPublished }

이것이 Kotlin 코드가 깔끔해 보이는 핵심 이유 중 하나다. filter { it.isPublished } 같은 코드는 영어 문장처럼 읽힌다.


고차 함수

함수를 파라미터로 받거나 반환하는 함수를 고차 함수(higher-order function)라 한다. filter, map 같은 컬렉션 함수가 대표적이다.

직접 만들 수도 있다.

fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) {
        if (predicate(item)) result.add(item)
    }
    return result
}
  • predicate: (T) -> Boolean — 함수 타입 파라미터. T를 받아서 Boolean을 반환하는 함수.
  • 호출하는 쪽에서 조건 로직을 주입할 수 있다.
val published = posts.customFilter { it.isPublished }

Java에서 Predicate<T>, Function<T, R> 인터페이스를 쓰던 것을 Kotlin은 함수 타입으로 간결하게 표현한다. 이 개념은 사고방식 시리즈에서 깊이 다룬다.


스코프 함수

Kotlin에서 처음 만나면 가장 헷갈리는 기능이다. let, apply, also, run, with — 다섯 개인데, 어떤 걸 언제 써야 하는지 감이 안 온다.

핵심은 두 가지 기준으로 나뉜다는 것이다.

  1. 객체를 뭐로 참조하나?it vs this
  2. 뭘 반환하나? — 람다의 결과 vs 객체 자신
함수참조반환값대표 용도
letit람다 결과null 체크, 변환
applythis객체 자신객체 초기화
alsoit객체 자신부수 효과 (로깅)
runthis람다 결과객체 함수 실행 + 결과
withthis람다 결과run과 유사, 비확장

처음에는 표를 외우려 하지 말고, 자주 쓰는 세 개만 익히면 된다.


let — null 체크 + 변환

val name = user?.let { user ->
    "${user.firstName} ${user.lastName}"
} ?: "익명"
  • user가 null이 아닐 때만 블록이 실행된다.
  • 블록의 반환값이 let의 결과가 된다.
  • ?.let?: 조합은 코프링에서 매우 자주 등장하는 패턴이다.

apply — 객체 초기화

val post = Post().apply {
    title = "새 글"
    content = "<p>내용</p>"
    isPublished = true
}
  • 블록 안에서 thisPost 객체를 가리킨다. 그래서 this.title 대신 title만 써도 된다.
  • 객체 자신을 반환한다. 초기화 후 바로 변수에 담을 수 있다.
  • Java의 빌더 패턴과 비슷한 역할을 한다.

also — 부수 효과

val post = postRepository.save(newPost).also {
    log.info("글 저장 완료: ${it.id}")
}
  • it으로 객체를 참조한다.
  • 객체 자신을 반환한다. 체이닝 중간에 로깅 같은 부수 효과를 끼워넣을 때 유용하다.

applyalso의 차이가 헷갈릴 수 있는데, 단순하다. 객체의 프로퍼티를 설정할 때는 apply(this), 객체를 활용해서 뭔가 다른 일을 할 때는 also(it).


run — 실행 + 결과

val length = "Hello, World!".run {
    println(this)
    length       // 마지막 줄이 반환값
}
  • this로 객체를 참조하고, 람다의 결과를 반환한다.
  • let과 비슷하지만, 참조가 it이 아닌 this라는 점이 다르다.

실무에서는 let만으로 대부분 해결되기 때문에 run은 상대적으로 덜 쓴다.


with — run의 비확장 버전

val info = with(post) {
    "$title (조회수: $viewCount)"
}
  • run과 기능이 같지만, 확장 함수가 아니라 객체를 인자로 받는다.
  • nullable 객체에는 with보다 run이나 let이 낫다.

심화 분석

스코프 함수 선택 흐름

어떤 스코프 함수를 써야 할지 고민될 때 이 흐름을 따르면 된다.

graph TD A[null 체크가 필요한가?] -->|Yes| B["let (?.let)"] A -->|No| C[객체를 초기화하는가?] C -->|Yes| D[apply] C -->|No| E[부수 효과만 필요한가?] E -->|Yes| F[also] E -->|No| G["run 또는 let"] style B fill:#e1f5fe style D fill:#e8f5e9 style F fill:#fff3e0 style G fill:#fce4ec

대부분의 상황은 let, apply, also 세 가지로 해결된다. runwith는 특수한 경우에만 쓴다.

확장 함수의 컴파일 결과

확장 함수가 내부적으로 어떻게 동작하는지 알면 한계도 이해할 수 있다.

// Kotlin 코드
fun String.addExclamation() = "$this!"

// 컴파일 후 Java 코드 (개념적)
public static String addExclamation(String $this) {
    return $this + "!";
}

확장 함수는 결국 첫 번째 파라미터로 수신 객체를 받는 static 메서드다. 그래서 클래스의 private 멤버에 접근할 수 없고, 런타임에 다형성이 적용되지 않는다. "신택틱 슈가"이지만, 코드 가독성에 미치는 영향은 크다.


자주 하는 실수

스코프 함수 남용

// 나쁜 예 — 스코프 함수를 이유 없이 중첩
user?.let { u ->
    u.posts.let { posts ->
        posts.filter { it.isPublished }.let { published ->
            published.map { it.title }
        }
    }
}

// 좋은 예 — 체이닝이 더 읽기 좋다
user?.posts
    ?.filter { it.isPublished }
    ?.map { it.title }

스코프 함수는 도구일 뿐이다. 체이닝으로 해결되는 걸 굳이 let으로 감싸면 오히려 가독성이 떨어진다.

it의 중첩

posts.filter { post ->
    post.tags.any { tag ->   // 여기서 it을 쓰면 뭘 가리키는지 모호해진다
        tag.name == "Kotlin"
    }
}

람다가 중첩되면 it이 어떤 객체를 가리키는지 헷갈린다. 이럴 때는 명시적 파라미터 이름을 쓰는 것이 좋다. post, tag처럼 의미 있는 이름을 주면 가독성이 올라간다.

확장 함수를 너무 많이 만들기

확장 함수가 편하다고 모든 유틸 메서드를 확장 함수로 만들면, 나중에 IDE 자동완성에 불필요한 함수가 넘쳐나고 어떤 패키지에서 왔는지 추적이 어려워진다. 확장 함수는 해당 타입과 의미적으로 관련이 있는 경우에만 쓰자.


면접 Q&A

Q. Kotlin의 확장 함수는 어떻게 동작하나요?

컴파일 시 수신 객체를 첫 번째 파라미터로 받는 static 메서드로 변환됩니다. 클래스를 실제로 수정하지 않고, 호출 측의 가독성을 높이는 문법적 편의 기능입니다. 그래서 private 멤버에는 접근할 수 없고, 같은 시그니처의 멤버 함수가 있으면 멤버 함수가 우선합니다.

Q. let과 apply의 차이는?

letit으로 객체를 참조하고 람다의 결과를 반환합니다. null 체크나 변환에 적합합니다. applythis로 객체를 참조하고 객체 자신을 반환합니다. 객체 초기화에 적합합니다.

Q. Kotlin에서 불변 컬렉션은 정말 불변인가요?

인터페이스 레벨에서 수정 메서드를 노출하지 않을 뿐, 내부적으로는 가변 구현체(ArrayList 등)일 수 있습니다. 타입 시스템을 통한 "계약"이지 강제적인 불변은 아닙니다. 하지만 이 계약을 깨뜨리는 캐스팅은 하면 안 됩니다.