백엔드 개발자를 위한 코틀린
JVM부터 코틀린다운 코드, 백엔드 객체 설계까지
목차
1부. 코틀린 시작하기
1장. 백엔드 개발자가 코틀린을 배우는 이유
- 1.1 코틀린은 어떤 언어인가
- 1.2 Kotlin 코드는 어떻게 실행되는가
- 1.3 개발 환경 준비하기
- 1.4 첫 번째 Kotlin 프로그램
2부. 코틀린 기본 문법
2장. 변수와 타입
- 2.1
val과var - 2.2 타입과 타입 추론
- 2.3 문자열 다루기
- 2.4 Kotlin의 특별한 타입
- 2.5 값의 비교와 객체의 비교
3장. 조건과 반복
- 3.1 표현식으로서의
if - 3.2
when - 3.3 범위와 반복
- 3.4 구조 분해
4장. 함수
- 4.1 함수 선언하기
- 4.2 기본 인자와 이름 붙은 인자
- 4.3 가변 인자
- 4.4 함수 오버로딩
- 4.5 로컬 함수
3부. 안전하게 데이터를 다루는 코틀린
5장. Null Safety
- 5.1 Nullable과 Non-null
- 5.2 Safe Call
?. - 5.3 Elvis Operator
?: - 5.4 Not-null Assertion
!! - 5.5 Safe Cast
as? - 5.6 Nullable 값을 다루는 함수
- 5.7 초기화를 늦추기
- 5.8 Collection과 Null
6장. Java와 Kotlin 사이의 Null
- 6.1 Platform Type
- 6.2 Java API 호출하기
- 6.3 백엔드에서 만나는 Null
- 6.4 Null을 어떻게 모델링할 것인가
4부. 객체를 설계하는 코틀린
7장. 클래스와 프로퍼티
- 7.1 클래스 만들기
- 7.2 생성자
- 7.3 Getter와 Setter
- 7.4 접근 제어
- 7.5 불변 객체 만들기
8장. 상속과 인터페이스
- 8.1 Kotlin 클래스는 기본적으로
final - 8.2 상속
- 8.3 인터페이스
- 8.4 인터페이스로 의존성 분리하기
9장. 데이터를 표현하는 클래스
- 9.1 Data Class
- 9.2
copy - 9.3 구조 분해
- 9.4 백엔드 DTO 만들기
10장. Enum과 Sealed Type
- 10.1 Enum
- 10.2 Sealed Class
- 10.3 Sealed Interface
- 10.4 결과를 타입으로 표현하기
- 10.5 Enum과 Sealed Type 선택하기
11장. Object와 Companion Object
- 11.1
object - 11.2 Companion Object
- 11.3 Java의
static과 비교 - 11.4 익명 객체
5부. 컬렉션과 함수형 프로그래밍
12장. Kotlin Collection
- 12.1 List, Set, Map
- 12.2 읽기 전용과 Mutable Collection
- 12.3 Collection 생성하기
- 12.4 Collection 조회하기
13장. Collection을 가공하는 함수
- 13.1 선택하고 변환하기
- 13.2 데이터를 찾고 검사하기
- 13.3 데이터를 묶기
- 13.4 정렬하고 중복 제거하기
- 13.5 집계하기
- 13.6 누적 계산
- 13.7 백엔드 데이터 가공 예제
14장. Lambda와 함수 타입
- 14.1 Lambda Expression
- 14.2 함수도 값이다
- 14.3 고차 함수
- 14.4 Closure
- 14.5 함수 참조
- 14.6 백엔드 로직을 함수로 분리하기
15장. Scope Function
- 15.1
let - 15.2
run - 15.3
with - 15.4
apply - 15.5
also - 15.6
this와it - 15.7 어떤 Scope Function을 선택할 것인가
- 15.8 중첩된 Scope Function 피하기
16장. Sequence와 지연 연산
- 16.1 Collection 연산은 어떻게 실행되는가
- 16.2 Sequence란 무엇인가
- 16.3
asSequence - 16.4 Intermediate Operation과 Terminal Operation
- 16.5 Lazy Evaluation
- 16.6 Collection과 Sequence 선택하기
- 16.7 Sequence가 항상 빠른 것은 아니다
- 16.8 대량 데이터 처리와 Sequence의 한계
6부. 코틀린다운 코드 작성하기
17장. 확장 함수와 확장 프로퍼티
- 17.1 Extension Function
- 17.2 Extension Property
- 17.3 확장 함수의 실제 동작
- 17.4 Member Function과 Extension Function
- 17.5 백엔드 공통 로직 분리하기
18장. 연산자와 Kotlin Convention
- 18.1 Operator Overloading
- 18.2 비교 연산
- 18.3
contains - 18.4
get과set - 18.5 Iterator
- 18.6 구조 분해의 동작 원리
- 18.7 필요한 경우에만 연산자 오버로딩하기
19장. 위임
- 19.1 Delegation이란 무엇인가
- 19.2 Interface Delegation과
by - 19.3 Property Delegation
- 19.4
lazy - 19.5 Observable Property
- 19.6 Custom Delegate
- 19.7 상속보다 위임이 적절한 경우
7부. 타입 시스템을 깊게 이해하기
난이도가 높은 심화 파트입니다. 처음에는 건너뛰고 필요할 때 돌아와도 됩니다.
20장. Generic
- 20.1 Generic Class
- 20.2 Generic Function
- 20.3 Type Parameter 제한
- 20.4 Nullable Generic
- 20.5 Generic API 응답 객체 만들기
21장. Variance
- 21.1 왜
List<Child>는List<Parent>가 아닌가 - 21.2 Invariance
- 21.3 Covariance와
out - 21.4 Contravariance와
in - 21.5 Star Projection
- 21.6 읽는 타입과 쓰는 타입
- 21.7 실무 코드를 읽기 위한 Variance
22장. Inline과 Reified
- 22.1 고차 함수와 함수 객체
- 22.2
inline - 22.3
noinline - 22.4
crossinline - 22.5 JVM의 Type Erasure
- 22.6
reified - 22.7 런타임 타입을 사용하는 Generic 함수
8부. 예외와 오류를 설계하기
23장. 예외 처리
- 23.1
try,catch,finally - 23.2 Expression으로서의
try - 23.3
throw - 23.4 Kotlin에는 Checked Exception이 없다
- 23.5 Custom Exception
- 23.6 예외 메시지와 오류 정보 설계
24장. 실패를 표현하는 여러 방법
- 24.1 Nullable로 실패 표현하기
- 24.2 Exception으로 실패 표현하기
- 24.3
Result - 24.4
runCatching - 24.5
getOrNull,getOrElse,getOrThrow - 24.6 Sealed Type으로 결과 표현하기
- 24.7 어떤 방식을 선택해야 하는가
9부. Java 생태계에서 코틀린 사용하기
25장. Java와 Kotlin 함께 사용하기
- 25.1 Java 코드를 Kotlin에서 호출하기
- 25.2 Java Getter와 Setter
- 25.3 Java Collection
- 25.4 SAM Conversion
- 25.5 Checked Exception
- 25.6 Platform Type 다시 살펴보기
26장. Kotlin 코드를 Java에 공개하기
- 26.1 Kotlin 코드가 JVM에서 보이는 모습
- 26.2
@JvmStatic - 26.3
@JvmField - 26.4
@JvmOverloads - 26.5
@JvmName - 26.6 Top-level Function
- 26.7 Java 친화적인 Kotlin API 만들기
27장. Annotation
- 27.1 Annotation 사용하기
- 27.2 Annotation 정의하기
- 27.3 Target과 Retention
- 27.4 Kotlin의 Use-site Target
- 27.5 DTO Validation을 예로 이해하기
28장. Reflection
- 28.1 Reflection이란
- 28.2 Java Reflection과 Kotlin Reflection
- 28.3
KClass - 28.4 Property Reflection
- 28.5 Function Reflection
- 28.6 Annotation 조회하기
- 28.7 Reflection의 비용과 사용 시 주의점
- 28.8 프레임워크가 객체를 다루는 방식 이해하기
10부. 프로젝트를 구성하고 테스트하기
29장. Gradle과 Kotlin 프로젝트
- 29.1 Gradle이 필요한 이유
- 29.2 Gradle Wrapper
- 29.3
build.gradle.kts - 29.4 Plugin
- 29.5 Repository
- 29.6 Dependency
- 29.7 Configuration
- 29.8 Task
- 29.9 Kotlin DSL 읽기
- 29.10 프로젝트 디렉터리 구조
30장. Kotlin 코드 테스트하기
- 30.1 테스트가 필요한 이유
- 30.2 JUnit 5
- 30.3 테스트 코드 작성하기
- 30.4 Given-When-Then
- 30.5 Kotlin의 백틱 함수 이름
- 30.6 Assertion
- 30.7 Test Fixture
- 30.8 예외 테스트
- 30.9 Parameterized Test
31장. 의존성이 있는 코드 테스트하기
- 31.1 Test Double
- 31.2 Dummy
- 31.3 Stub
- 31.4 Fake
- 31.5 Mock
- 31.6 Repository를 Fake로 교체하기
- 31.7 MockK 맛보기
- 31.8 테스트하기 좋은 코드의 구조
11부. 백엔드 코드로 익히는 Kotlin
지금까지 배운 언어 기능을 백엔드 프로그램의 구조로 연결합니다.
특정 웹 프레임워크는 사용하지 않습니다.
32장. 백엔드 애플리케이션의 객체 나누기
- 32.1 회원과 주문 예제 설계
- 32.2 Domain Object
- 32.3 Request와 Response
- 32.4 Service
- 32.5 Repository
- 32.6 객체의 역할과 책임
- 32.7 패키지 구성하기
33장. DTO 설계하기
- 33.1 요청 객체 만들기
- 33.2 응답 객체 만들기
- 33.3 Data Class 활용하기
- 33.4 Nullable Field 결정하기
- 33.5 Default Parameter 사용하기
- 33.6 Domain Object를 그대로 응답하지 않기
- 33.7 Domain과 DTO 변환하기
- 33.8 Extension Function으로 변환 코드 정리하기
34장. 도메인 객체 설계하기
- 34.1 데이터를 담는 객체와 행동을 가진 객체
- 34.2 상태를 외부에서 변경하지 못하게 하기
- 34.3 생성 시점에 유효한 객체 만들기
- 34.4 Enum으로 상태 표현하기
- 34.5 Value Object 만들기
- 34.6 불변성을 활용한 모델링
35장. Repository 설계하기
- 35.1 Repository가 필요한 이유
- 35.2 Repository Interface
- 35.3 Memory Repository 만들기
- 35.4 조회 결과와 Nullable
- 35.5 Repository 구현체 교체하기
- 35.6 데이터 저장 방식과 비즈니스 로직 분리하기
36장. Service와 의존성
- 36.1 Service의 역할
- 36.2 생성자를 통한 의존성 전달
- 36.3 구현체가 아닌 Interface에 의존하기
- 36.4 Dependency Injection의 기본 개념
- 36.5 Service 테스트하기
- 36.6 Fake Repository 활용하기
- 36.7 의존성이 늘어날 때 발생하는 문제
12부. 실전 프로젝트
37장. Kotlin으로 주문 시스템 만들기
웹 서버나 데이터베이스 없이 순수 Kotlin으로 백엔드 핵심 로직을 완성합니다.
- 37.1 프로젝트 요구사항
- 37.2 프로젝트 구조 만들기
- 37.3 회원 기능 구현하기
- 37.4 상품 기능 구현하기
- 37.5 주문 기능 구현하기
- 37.6 Collection을 활용한 주문 데이터 처리
- 37.7 오류와 예외 처리
- 37.8 DTO 변환
- 37.9 테스트 작성
- 37.10 전체 코드 리팩터링
13부. 더 나은 Kotlin 코드를 위해
38장. Java스럽게 작성한 Kotlin 개선하기
- 38.1 Getter와 Setter를 그대로 옮기지 않기
- 38.2 불필요한
var줄이기 - 38.3 불필요한
!!없애기 - 38.4 반복문을 Collection 함수로 바꾸기
- 38.5 Utility Class를 Extension Function으로 바꾸기
- 38.6 과도한 Scope Function 줄이기
- 38.7 불필요한 상속 제거하기
- 38.8 Null과 Exception을 명확하게 표현하기
39장. 백엔드 개발자가 기억해야 할 Kotlin
- 39.1 불변성을 기본값으로 사용한다
- 39.2 Null을 타입의 일부로 생각한다
- 39.3 데이터와 상태를 명확하게 모델링한다
- 39.4 Interface로 경계를 만든다
- 39.5 Collection 함수를 활용한다
- 39.6 Kotlin 문법을 과도하게 사용하지 않는다
- 39.7 Java 생태계와의 연결을 이해한다
- 39.8 테스트하기 쉬운 객체를 만든다
부록
부록 A. Kotlin 문법 빠른 참조
val/varif/when- Nullable 연산자
- Collection 함수
- Scope Function
- 클래스 관련 키워드
부록 B. 자주 사용하는 Collection 함수
- 조회
- 필터
- 변환
- 그룹
- 정렬
- 집계
부록 C. Scope Function 선택표
| 함수 | 객체 접근 | 반환값 | 주요 용도 |
|---|---|---|---|
let | it | Lambda 결과 | 변환, Nullable |
run | this | Lambda 결과 | 객체 기반 계산 |
with | this | Lambda 결과 | 여러 멤버 접근 |
apply | this | 객체 | 객체 설정 |
also | it | 객체 | 부가 작업 |
부록 D. Java 개발자를 위한 Kotlin 비교표
- Java POJO → Kotlin Data Class
static→ Companion ObjectOptional→ Nullable- Stream → Collection 함수
- 익명 클래스 → Lambda / Object Expression
- Checked Exception 차이
1장. 백엔드 개발자가 코틀린을 배우는 이유
스프링을 배우기 전에, 왜 코틀린부터 시작할까요?
많은 사람들이 “백엔드 = 자바“라고 생각합니다.
하지만 요즘은 코틀린으로 서버를 만드는 회사가 빠르게 늘고 있습니다.
이 장에서는 코틀린이 어떤 언어인지,
그리고 코드가 실제로 컴퓨터에서 어떻게 실행되는지 살펴봅니다.
마지막에는 개발 환경을 직접 준비하고,
첫 번째 코틀린 프로그램을 실행해 봅니다.
문법을 외우는 장이 아닙니다.
“코틀린이라는 언어의 큰 그림“을 그리는 장이라고 생각해 주세요.
1.1 코틀린은 어떤 언어인가
Kotlin의 등장 배경
코틀린(Kotlin)은 2011년,
젯브레인즈(JetBrains)라는 회사에서 만든 프로그래밍 언어입니다.
젯브레인즈는 개발자들이 많이 쓰는
IntelliJ IDEA라는 프로그램을 만드는 회사입니다.
이 회사는 오랫동안 자바로 프로그램을 만들어 왔습니다.
그런데 자바를 쓰다 보니 불편한 점이 자꾸 눈에 들어왔습니다.
- 같은 코드를 반복해서 길게 써야 한다
- 실수로 인한 오류(특히 null 오류)가 자주 발생한다
- 문법이 예전 방식 그대로라 답답하다
그래서 이런 생각을 하게 됩니다.
“자바와 같이 쓸 수 있으면서,
더 짧고 더 안전한 언어를 우리가 직접 만들자.”
그렇게 태어난 언어가 바로 코틀린입니다.
Kotlin/JVM과 JVM 생태계
코틀린을 이해하려면
먼저 JVM이라는 단어를 알아야 합니다.
JVM은 Java Virtual Machine,
우리말로 “자바 가상 머신“입니다.
이름은 어렵지만 역할은 간단합니다.
JVM은 프로그램을 실제로 실행해 주는
“가상의 컴퓨터“입니다.
우리가 만든 코드는 JVM 위에서 동작합니다.
그래서 윈도우든 맥이든 리눅스든,
JVM만 설치되어 있으면 같은 코드가 그대로 실행됩니다.
자바도 JVM 위에서 돌아가고,
코틀린도 JVM 위에서 돌아갑니다.
이렇게 JVM 위에서 실행되는 코틀린을
Kotlin/JVM이라고 부릅니다.
JVM 위에서 오랫동안 쌓여 온
수많은 라이브러리와 도구들을
JVM 생태계라고 부릅니다.
코틀린은 이 거대한 생태계를
처음부터 그대로 물려받았습니다.
이것이 코틀린의 큰 장점입니다.
Java와 Kotlin의 관계
코틀린은 자바를 “대체“하려고 만든 언어가 아닙니다.
자바와 “함께” 쓰려고 만든 언어에 가깝습니다.
두 언어의 관계를 정리하면 이렇습니다.
- 같은 JVM 위에서 실행된다
- 코틀린에서 자바 코드를 그대로 불러 쓸 수 있다
- 자바에서 코틀린 코드도 불러 쓸 수 있다
- 한 프로젝트 안에 두 언어를 섞어 써도 된다
즉, 코틀린을 배운다고 해서
자바를 버려야 하는 것이 아닙니다.
오히려 자바 세계의 좋은 것들을 그대로 쓰면서,
문법만 더 편하고 안전하게 바꾼 것이라고
이해하면 쉽습니다.
서버 개발에서 Kotlin을 사용하는 이유
그렇다면 백엔드,
즉 서버 개발에서 코틀린을 쓰는 이유는 무엇일까요?
크게 세 가지입니다.
첫째, 코드가 짧고 명확합니다.
같은 기능을 자바보다 훨씬 적은 줄로 표현할 수 있습니다.
코드가 짧아지면 읽기 쉽고 실수도 줄어듭니다.
둘째, null 오류를 크게 줄여 줍니다.
서버에서 가장 자주 터지는 오류 중 하나가
“값이 없는데 값을 쓰려다 나는 오류“입니다.
코틀린은 이 문제를 문법 차원에서 막아 줍니다.
(자세한 내용은 3부에서 집중해서 다룹니다.)
셋째, 스프링과 잘 어울립니다.
백엔드에서 가장 많이 쓰는 스프링 프레임워크가
코틀린을 공식적으로 지원합니다.
그래서 코틀린으로 스프링 서버를 만드는 것이
지금은 아주 자연스러운 선택이 되었습니다.
정리하면,
코틀린은 “자바의 안정성“과 “현대적인 편리함“을
동시에 가진 언어입니다.
1.2 Kotlin 코드는 어떻게 실행되는가
우리가 작성한 코틀린 코드는
사실 컴퓨터가 바로 이해하지 못합니다.
코드가 실행되기까지
몇 단계를 거치는지 살펴봅시다.
소스 코드에서 JVM 바이트코드까지
우리가 키보드로 작성한 코드를
소스 코드(source code)라고 합니다.
사람은 소스 코드를 읽을 수 있지만,
컴퓨터(JVM)는 이 상태로는 이해하지 못합니다.
그래서 소스 코드를
JVM이 이해할 수 있는 형태로 번역해야 합니다.
이 번역된 결과물을
바이트코드(bytecode)라고 부릅니다.
전체 흐름을 그림으로 보면 이렇습니다.
내가 쓴 코틀린 코드 (.kt)
│ 번역
▼
바이트코드 (.class)
│ 실행
▼
JVM
│
▼
프로그램 동작
즉, 코드는 곧바로 실행되는 것이 아니라
“번역 → 실행“의 두 단계를 거칩니다.
JDK와 JVM
여기서 자주 헷갈리는 두 단어를 정리합시다.
바로 JDK와 JVM입니다.
- JVM: 프로그램을 실행해 주는 가상 컴퓨터
- JDK: 코틀린/자바 개발에 필요한 도구 모음
JDK는 Java Development Kit의 줄임말입니다.
안에는 JVM도 들어 있고,
번역에 필요한 도구들도 함께 들어 있습니다.
비유하자면 이렇습니다.
JDK는 “요리 도구 세트 전체“이고,
JVM은 그 안에 든 “가스레인지” 하나입니다.
개발을 하려면 도구 세트 전체가 필요하므로,
우리는 JDK를 설치하게 됩니다.
(설치 방법은 1.3에서 다룹니다.)
Kotlin Compiler
소스 코드를 바이트코드로 번역해 주는 도구를
컴파일러(compiler)라고 합니다.
코틀린 코드를 번역하는 도구는
코틀린 컴파일러(Kotlin Compiler)입니다.
번역하는 이 과정을
컴파일(compile)이라고 부릅니다.
정리하면 이렇습니다.
코틀린 코드 ── 코틀린 컴파일러 ──▶ 바이트코드
(컴파일)
다행히 이 과정은 대부분 자동으로 처리됩니다.
우리가 실행 버튼을 누르면
컴파일과 실행이 알아서 이어집니다.
그래도 “안에서 이런 일이 일어나는구나” 하고
알아 두면 나중에 오류를 이해하기 훨씬 쉽습니다.
Java 라이브러리를 그대로 사용할 수 있는 이유
앞에서 코틀린과 자바가
“같은 바이트코드“로 번역된다고 했습니다.
바로 이 점 때문에
자바 라이브러리를 코틀린에서 그대로 쓸 수 있습니다.
라이브러리(library)란
누군가 미리 만들어 둔 편리한 코드 묶음입니다.
매번 처음부터 만들 필요 없이
가져다 쓸 수 있게 해 줍니다.
자바로 만든 라이브러리든
코틀린으로 만든 라이브러리든,
결국 JVM 위에서는 똑같은 바이트코드일 뿐입니다.
그래서 JVM 입장에서는
둘을 구분할 필요조차 없습니다.
이 덕분에 코틀린은
지금까지 쌓여 온 방대한 자바 라이브러리를
처음부터 전부 활용할 수 있습니다.
1.3 개발 환경 준비하기
이제 직접 코드를 작성할 준비를 해 봅시다.
두 가지만 있으면 됩니다.
- 코드를 작성하는 프로그램 (IntelliJ IDEA)
- 코드를 실행해 주는 도구 (JDK)
IntelliJ IDEA
코드는 메모장으로도 작성할 수 있습니다.
하지만 그렇게 하면 너무 불편합니다.
그래서 우리는 IDE라는 전문 도구를 씁니다.
IDE는 Integrated Development Environment,
우리말로 “통합 개발 환경“입니다.
쉽게 말해,
코드 작성에 필요한 기능을 한곳에 모아 둔
“개발 전용 프로그램“입니다.
코틀린 개발에는 IntelliJ IDEA를 추천합니다.
코틀린을 만든 회사가 함께 만든 도구라
가장 잘 맞기 때문입니다.
하나로 통합된 IntelliJ IDEA
여기서 한 가지 짚고 넘어갈 점이 있습니다.
예전에는 IntelliJ IDEA가
두 종류로 나뉘어 있었습니다.
- Community Edition : 무료지만 기능이 제한적
- Ultimate : 유료지만 기능이 풍부
그래서 “무료 버전을 받아야 하나,
유료 버전을 받아야 하나” 고민하는 경우가 많았습니다.
하지만 2025년 12월(2025.3 버전)부터
이 둘이 하나로 통합되었습니다.
이제는 설치 파일이 하나뿐입니다.
Community와 Ultimate를 고를 필요가 없습니다.
정리하면 이렇습니다.
하나의 IntelliJ IDEA를 설치하고,
그중 기본 기능은 무료로,
고급 기능은 구독으로 쓰는 방식입니다.
무료로 쓸 때의 장점은 다음과 같습니다.
- 로그인이나 인증(활성화) 없이 바로 쓸 수 있다
- 개인 프로젝트뿐 아니라 상업용 프로젝트에도 무료다
- 예전 Community Edition의 기능을 모두 포함한다
- 그보다 더 많은 기능이 무료로 열렸다
무료로 열린 대표적인 기능은 이렇습니다.
- 스프링, Jakarta EE, Thymeleaf 기본 문법 강조
- 스프링 부트 프로젝트 생성 마법사
- 데이터베이스 연결과 스키마 확인
- SQL 언어 지원
이 책에서 배우는 코틀린과,
나중에 배울 스프링 기초까지
모두 무료 범위 안에서 충분히 다룰 수 있습니다.
고급 기능(Ultimate 전용)은 구독이 필요합니다.
처음 설치하면 30일 무료 체험이 제공되고,
체험이 끝나도 코어 Java/Kotlin 개발은
계속 무료로 쓸 수 있습니다.
즉, 지금 우리는 아무것도 결제하지 않고
바로 시작하면 됩니다.
IntelliJ IDEA 설치하기
설치 순서는 간단합니다.
https://www.jetbrains.com/idea/접속- Download 버튼 클릭 (설치 파일은 하나뿐)
- 내려받은 설치 프로그램 실행
- 안내에 따라 설치 진행
설치가 끝나면 IntelliJ IDEA를 실행합니다.
앞서 말했듯 로그인이나 결제는 필요하지 않습니다.
JDK 설치와 버전 확인
앞에서 배운 JDK도 필요합니다.
다행히 요즘 IntelliJ IDEA는
프로젝트를 만들 때 JDK를 함께 내려받아 줍니다.
그래서 대부분 따로 설치하지 않아도 됩니다.
이 책에서는 JDK 17 이상을 기준으로 합니다.
(17이나 21처럼 짝수 위주의 안정 버전을 권장합니다.)
이미 JDK가 설치되어 있는지 궁금하다면
터미널에서 다음 명령으로 확인할 수 있습니다.
java -version
설치되어 있다면
버전 정보가 아래처럼 출력됩니다.
openjdk version "17.0.10" ...
숫자가 17 이상이면 문제없습니다.
확인이 어렵다면 이 단계는 건너뛰어도 됩니다.
프로젝트를 만들 때 IntelliJ가 도와줍니다.
Kotlin 프로젝트 만들기
이제 IntelliJ IDEA를 실행해 봅시다.
- 첫 화면에서 New Project 클릭
- 왼쪽 목록에서 Kotlin 선택
- 프로젝트 이름 입력 (예:
hello-kotlin) - Build system은 우선 IntelliJ 선택
- JDK 항목에서 17 이상 선택
- Create 클릭
여기서 Build system,
즉 빌드 도구는 나중에 자세히 배웁니다. (10부)
지금은 가장 단순한 IntelliJ 방식으로 충분합니다.
복잡한 설정 없이 바로 코드를 실행할 수 있습니다.
Kotlin 코드 실행하기
프로젝트가 만들어지면
왼쪽에 파일 목록이 보입니다.
src 폴더를 열면Main.kt라는 파일이 있을 수 있습니다.
없다면 다음 절에서 직접 만들어 보겠습니다.
코드를 실행하는 방법은 간단합니다.
- 코드 줄 옆의 초록색 ▶ 버튼 클릭
- 또는 파일에서 마우스 오른쪽 클릭 후 Run 선택
실행하면 화면 아래쪽에
결과를 보여 주는 창이 열립니다.
이 창을 콘솔(console)이라고 부릅니다.
이제 진짜 첫 프로그램을 만들어 봅시다.
1.4 첫 번째 Kotlin 프로그램
프로그래밍을 처음 배울 때는
관례처럼 “Hello, World!“를 출력해 봅니다.
우리도 그 전통을 따라가 봅시다.
src 폴더 안에Main.kt 파일을 만들고
아래 코드를 그대로 입력합니다.
fun main() {
println("Hello, World!")
}
실행하면 콘솔에
다음과 같이 출력됩니다.
Hello, World!
축하합니다.
첫 번째 코틀린 프로그램을 완성했습니다.
이제 이 짧은 코드를
한 줄씩 뜯어보겠습니다.
main 함수
fun main() {
println("Hello, World!")
}
main은 특별한 이름의 함수입니다.
함수(function)란
“어떤 일을 하는 코드 묶음“입니다.
(함수는 4장에서 자세히 다룹니다.)
여기서 main이 특별한 이유는,
프로그램이 실행될 때
가장 먼저 이 함수를 찾아 실행하기 때문입니다.
즉, main은 프로그램의 “출발점“입니다.
코드를 하나씩 보면 이렇습니다.
fun: 함수를 만들겠다는 표시main: 함수의 이름(): 함수에 전달하는 값을 담는 자리{ }: 함수가 실제로 할 일을 적는 공간
println은
괄호 안의 내용을 화면에 출력하고
줄을 바꿔 주는 명령입니다.
이름도 “print line”,
즉 “한 줄 출력“에서 왔습니다.
패키지와 파일
코드가 많아지면
파일을 정리할 방법이 필요합니다.
이때 쓰는 것이 패키지(package)입니다.
패키지는 쉽게 말해
“코드 파일을 담는 폴더“입니다.
파일 맨 위에
아래처럼 패키지를 적을 수 있습니다.
package com.example.hello
fun main() {
println("Hello, World!")
}
com.example.hello는
폴더 경로를 점(.)으로 표현한 것입니다.
지금 단계에서는
패키지를 적지 않아도 괜찮습니다.
프로젝트가 커질 때 자연스럽게 쓰게 됩니다.
Kotlin 파일의 특징
여기서 자바를 조금 아는 분이라면
낯선 점을 느꼈을 수 있습니다.
자바에서는 코드를 쓰려면
반드시 클래스(class) 안에 넣어야 합니다.
(클래스는 4부에서 배웁니다.)
하지만 코틀린은 다릅니다.
fun main() {
println("Hello, World!")
}
보다시피 클래스가 하나도 없습니다.
함수를 파일에 바로 적었습니다.
이렇게 클래스 밖에 바로 놓인 함수를
톱레벨 함수(top-level function)라고 부릅니다.
덕분에 코틀린은
간단한 코드를 아주 간단하게 쓸 수 있습니다.
또 한 가지,
코틀린은 줄 끝에 세미콜론(;)을
붙이지 않아도 됩니다.
println("Hello, World!") // 세미콜론이 없어도 됩니다
이런 작은 차이들이 모여
코틀린 코드를 더 깔끔하게 만들어 줍니다.
Java와 다른 첫인상
같은 “Hello, World!“를
자바로 쓰면 이렇게 됩니다.
public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
코틀린은 이렇습니다.
fun main() {
println("Hello, World!")
}
두 코드를 나란히 보면
차이가 한눈에 들어옵니다.
- 클래스를 감싸지 않아도 된다
public static void같은 긴 표현이 없다System.out.없이 바로 출력한다- 세미콜론이 없다
이것이 코틀린의 첫인상입니다.
같은 일을,
더 짧고 더 읽기 쉽게.
물론 짧다고 무조건 좋은 것은 아닙니다.
앞으로 이 책에서는
“짧으면서도 이해하기 쉬운 코드“를
함께 찾아갈 것입니다.
1장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 코틀린은 자바와 함께 쓰는,
더 짧고 안전한 JVM 언어라는 점 - 코드는 컴파일을 거쳐
바이트코드로 번역된 뒤 JVM에서 실행된다는 점 - 개발 환경(IntelliJ IDEA, JDK)을 준비하는 방법
- 첫 코틀린 프로그램을 만들고,
자바와의 첫인상 차이를 확인한 것
아직 문법을 몰라도 괜찮습니다.
다음 장부터는 변수와 타입을 시작으로
코틀린 문법을 하나씩 익혀 나갑니다.
큰 그림을 그렸으니,
이제 안으로 들어가 봅시다.
2장. 변수와 타입
프로그램은 결국 “값을 다루는 일“입니다.
숫자를 더하고, 이름을 저장하고,
참인지 거짓인지 판단합니다.
이때 값을 담아 두는 상자가 바로 변수입니다.
그리고 그 값이 어떤 종류인지 알려 주는 것이 타입입니다.
이 장에서는 코틀린에서 변수를 만드는 방법과,
값의 종류(타입)를 다루는 방법을 배웁니다.
특히 코틀린이 강조하는 “불변성“이라는 생각도
여기서 처음 만나게 됩니다.
2.1 val과 var
변경 가능한 값과 변경할 수 없는 값
코틀린에서 변수를 만드는 방법은 두 가지입니다.val과 var입니다.
먼저 코드로 보겠습니다.
val name = "홍길동"
var age = 20
val: 한 번 정하면 바꿀 수 없는 값var: 나중에 바꿀 수 있는 값
val은 value(값)에서,var은 variable(변수)에서 왔다고 생각하면 쉽습니다.
var로 만든 값은 다시 넣을 수 있습니다.
var age = 20
age = 21 // 가능: var이므로 값 변경 OK
하지만 val로 만든 값은 바꿀 수 없습니다.
val name = "홍길동"
name = "이순신" // 오류: val은 다시 넣을 수 없음
이 코드는 아예 실행되지 않습니다.
컴파일 단계에서 오류로 걸러집니다.
즉, 잘못된 변경을 프로그램 실행 전에
미리 막아 주는 것입니다.
불변성을 기본으로 생각하기
여기서 코틀린의 중요한 철학이 하나 나옵니다.
값은 되도록 바뀌지 않게 만들자.
바뀌지 않는 성질을
불변성(immutability)이라고 부릅니다.
왜 굳이 못 바꾸게 만들까요?
값이 여기저기서 바뀌면 추적하기가 어렵기 때문입니다.
예를 들어 어떤 값이 잘못되어 있을 때,
그 값이 var이라면 이렇게 고민하게 됩니다.
- 이 값이 대체 어디서 바뀐 거지?
- 몇 번이나 바뀐 거지?
반면 val이라면 고민할 필요가 없습니다.
처음 넣은 값 그대로이기 때문입니다.
그래서 코틀린에서는 이렇게 권장합니다.
일단
val로 시작하고,
꼭 바꿔야 할 때만var로 바꾼다.
이 습관 하나만으로도
버그가 눈에 띄게 줄어듭니다.
2.2 타입과 타입 추론
명시적 타입 선언
모든 값에는 타입(type)이 있습니다.
타입이란 “그 값이 어떤 종류인가“입니다.
숫자인지, 글자인지, 참/거짓인지 같은 것입니다.
타입을 직접 적어 줄 수도 있습니다.
변수 이름 뒤에 콜론(:)을 쓰고 타입을 적습니다.
val name: String = "홍길동"
val age: Int = 20
name은 String(문자열) 타입age는 Int(정수) 타입
여기서 순서를 기억해 둡시다.
val 이름: 타입 = 값
자바와 순서가 반대라는 점이 눈에 띕니다.
자바는 String name처럼 타입을 먼저 씁니다.
Kotlin의 타입 추론
그런데 앞 절에서는 타입을 안 적었습니다.
val name = "홍길동"
val age = 20
이렇게 써도 문제가 없습니다.
코틀린이 값을 보고 타입을 알아채기 때문입니다.
"홍길동"은 딱 봐도 문자열이고,20은 딱 봐도 정수입니다.
이렇게 값으로부터 타입을 알아내는 것을
타입 추론(type inference)이라고 합니다.
덕분에 코드가 짧아집니다.
하지만 타입이 사라진 것은 아닙니다.
적지 않았을 뿐,
타입은 그대로 정해져 있습니다.
그래서 아래 코드는 오류가 납니다.
var age = 20
age = "스무 살" // 오류: age는 Int인데 문자열을 넣음
age는 이미 Int로 정해졌기 때문에
문자열을 넣을 수 없습니다.
Int, Long, Double, Boolean, String
자주 쓰는 기본 타입을 정리해 봅시다.
| 타입 | 담는 값 | 예시 |
|---|---|---|
| Int | 정수 | 20, -3 |
| Long | 아주 큰 정수 | 10000000000 |
| Double | 소수점이 있는 수 | 3.14, 0.5 |
| Boolean | 참 또는 거짓 | true, false |
| String | 문자열(글자의 나열) | "안녕하세요" |
몇 가지만 짚어 보겠습니다.
val count: Int = 100
val big: Long = 10_000_000_000
val price: Double = 3.14
val isActive: Boolean = true
val greeting: String = "안녕하세요"
큰 숫자에는 밑줄(_)을 넣을 수 있습니다.10_000_000_000처럼요.
밑줄은 사람이 읽기 편하라고 넣는 것이며,
값 자체에는 영향을 주지 않습니다.
숫자 타입 변환
자바를 아는 분이라면 여기서 놀랄 수 있습니다.
코틀린은 숫자 타입을 자동으로 바꿔 주지 않습니다.
val a: Int = 10
val b: Long = a // 오류: Int를 Long에 그냥 넣을 수 없음
Int와 Long은 다른 타입이기 때문입니다.
바꾸려면 직접 변환 함수를 불러야 합니다.
val a: Int = 10
val b: Long = a.toLong() // 명시적으로 변환
주요 변환 함수는 이렇습니다.
toInt(): Int로 변환toLong(): Long으로 변환toDouble(): Double로 변환
조금 번거롭게 느껴질 수 있습니다.
하지만 이 규칙 덕분에
“나도 모르게 값이 바뀌는” 실수를 막을 수 있습니다.
2.3 문자열 다루기
문자열(String)은 백엔드에서 가장 많이 다루는 값입니다.
이름, 주소, 메시지, 오류 내용 모두 문자열입니다.
문자열 템플릿
문자열 안에 값을 끼워 넣고 싶을 때가 많습니다.
자바에서는 +로 이어 붙였습니다.
코틀린에도 그 방법이 있지만, 더 편한 방법이 있습니다.
바로 문자열 템플릿입니다.
값 앞에 달러 기호($)를 붙이면 됩니다.
val name = "홍길동"
val age = 20
println("이름: $name, 나이: $age")
// 출력: 이름: 홍길동, 나이: 20
계산식이나 함수 호출을 넣고 싶다면
중괄호(${ })로 감쌉니다.
val price = 1000
val count = 3
println("총액: ${price * count}원")
// 출력: 총액: 3000원
정리하면 이렇습니다.
$변수: 변수 하나를 넣을 때${식}: 계산이나 함수 호출을 넣을 때
여러 줄 문자열
줄바꿈이 여러 번 들어가는 긴 문자열은
큰따옴표 세 개(""")로 감쌉니다.
val message = """
안녕하세요.
코틀린 책에 오신 것을 환영합니다.
즐겁게 배워 봅시다.
""".trimIndent()
println(message)
이것을 여러 줄 문자열(triple-quoted string)이라고 합니다.
끝에 붙인 trimIndent()는
코드에서 생긴 앞쪽 여백을 깔끔하게 정리해 줍니다.
이 방식은 나중에
긴 SQL 문이나 JSON 예시를 적을 때 특히 편합니다.
문자열 비교
두 문자열이 같은지 비교할 때는==를 사용합니다.
val a = "kotlin"
val b = "kotlin"
println(a == b) // true
자바를 아는 분이라면 이 부분이 반가울 것입니다.
자바에서는 == 대신 equals()를 써야 했기 때문입니다.
코틀린에서는 ==가
“값이 같은가“를 자연스럽게 비교해 줍니다.
이 비교의 원리는 2.5에서 더 자세히 다룹니다.
2.4 Kotlin의 특별한 타입
코틀린에는 조금 특별한 타입 몇 가지가 있습니다.
지금 전부 외울 필요는 없습니다.
“이런 게 있구나” 정도로 읽어 두면
나중에 코드에서 만났을 때 당황하지 않습니다.
Any
Any는 모든 타입의 최상위 타입입니다.
즉, 어떤 값이든 담을 수 있습니다.
val something: Any = "문자열"
val other: Any = 123
문자열도, 숫자도 Any에 담깁니다.
자바의 Object와 비슷한 역할입니다.
Unit
Unit은 “돌려줄 값이 없다“를 뜻하는 타입입니다.
어떤 함수는 값을 돌려주지 않고
그냥 일만 하고 끝납니다.
예를 들어 화면에 출력만 하는 함수가 그렇습니다.
이런 함수의 반환 타입이 Unit입니다.
fun printHello(): Unit {
println("Hello")
}
사실 Unit은 거의 생략합니다.
안 적으면 자동으로 Unit으로 취급됩니다.
fun printHello() { // 반환 타입 생략 = Unit
println("Hello")
}
자바의 void와 비슷한 자리라고
이해하면 쉽습니다.
Nothing
Nothing은 “정상적으로 끝나지 않는다“를 뜻합니다.
값을 절대 돌려주지 못하는 경우에 쓰입니다.
대표적으로 항상 예외를 던지는 함수입니다.
fun fail(): Nothing {
throw RuntimeException("실패")
}
이 함수는 값을 돌려주는 대신
항상 오류를 발생시키고 끝납니다.
지금은 “그런 타입도 있다” 정도로 충분합니다.
예외는 8부에서 자세히 배웁니다.
Any?
앞에서 본 Any에 물음표(?)가 붙었습니다.
이 물음표는 아주 중요합니다.
“값이 없을 수도 있다(null일 수 있다)“는 표시입니다.
val a: Any = "값" // null이 될 수 없음
val b: Any? = null // null이 될 수 있음
이 물음표 하나가
코틀린의 가장 큰 특징으로 이어집니다.
바로 null 안전성(Null Safety)입니다.
이 내용은 3부에서 집중적으로 다룹니다.
지금은 이렇게만 기억해 둡시다.
타입 뒤의
?는
“null이 들어올 수 있음“을 뜻한다.
2.5 값의 비교와 객체의 비교
값을 비교하는 방법에는 두 종류가 있습니다.
코틀린은 이 둘을 명확히 구분합니다.
==와 !=
==는 “값이 같은가“를 비교합니다.!=는 “값이 다른가“를 비교합니다.
val a = 10
val b = 10
println(a == b) // true (값이 같다)
println(a != b) // false (다르지 않다)
문자열도 마찬가지입니다.
val x = "kotlin"
val y = "kotlin"
println(x == y) // true
우리가 일상적으로 “같다“고 말할 때의 의미가
바로 이 ==입니다.
===와 !==
등호가 세 개인 ===는 조금 다릅니다.
이것은 “완전히 같은 객체인가“를 비교합니다.
여기서 잠깐 개념 하나를 짚겠습니다.
- 값이 같다 : 내용물이 같다
- 같은 객체다 : 메모리에서 같은 존재다
비유하자면 이렇습니다.
내용이 똑같은 두 장의 복사본은
“값은 같지만” “같은 종이는 아닙니다”.
===는 “같은 종이인가“를 묻는 것입니다.
val a = "kotlin"
val b = "kotlin"
println(a == b) // true (값이 같다)
println(a === b) // 상황에 따라 다름 (같은 객체인지)
실무에서는 대부분 ==만 씁니다.===는 아주 특별한 경우에만 필요합니다.
지금은 이렇게만 구분해 두면 됩니다.
==: 값이 같은가 (거의 이것만 씀)===: 같은 객체인가 (드물게 사용)
Java의 equals()와 비교하기
자바에서는 값 비교가 늘 헷갈리는 지점이었습니다.
자바에서 ==는
“같은 객체인가“를 비교합니다.
그래서 문자열 값을 비교하려면
반드시 equals()를 써야 했습니다.
// 자바
String a = "kotlin";
String b = "kotlin";
a == b; // 같은 객체인지 (헷갈림의 원인)
a.equals(b); // 값이 같은지 (이걸 써야 함)
코틀린은 이 혼란을 정리했습니다.
- 코틀린의
=== 자바의equals()(값 비교) - 코틀린의
==== 자바의==(객체 비교)
즉, 코틀린에서는
평소에 그냥 ==만 쓰면 됩니다.
값을 비교하려다 실수하는 일이
크게 줄어드는 것입니다.
2장을 마치며
이 장에서 우리는 다음을 배웠습니다.
val은 바꿀 수 없는 값,var은 바꿀 수 있는 값이라는 점- 되도록
val을 써서
불변성을 기본으로 삼는다는 생각 - 타입을 직접 적을 수도 있고,
코틀린이 추론하게 둘 수도 있다는 점 - 문자열 템플릿(
$)으로
값을 깔끔하게 끼워 넣는 방법 Any,Unit,Nothing, 그리고?의 의미- 값 비교(
==)와 객체 비교(===)의 차이
특히 두 가지는 앞으로 계속 나옵니다.
“일단 val”, 그리고 “타입 뒤의 ?“입니다.
다음 장에서는
조건과 반복을 다뤄 보겠습니다.
3장. 조건과 반복
프로그램은 늘 같은 일만 하지 않습니다.
상황에 따라 다르게 행동하고,
같은 일을 여러 번 반복하기도 합니다.
“로그인한 사용자면 이 화면을,
아니면 저 화면을 보여 준다.”
이런 판단이 조건입니다.
“목록에 있는 상품을 하나씩 꺼내
가격을 더한다.”
이런 되풀이가 반복입니다.
이 장에서는 코틀린에서 조건과 반복을
어떻게 다루는지 배웁니다.
특히 코틀린의 조건문은
자바와 결정적으로 다른 점이 하나 있습니다.
바로 “값을 돌려준다“는 점입니다.
이 작은 차이가 코드를 얼마나 깔끔하게 만드는지
직접 확인해 봅시다.
3.1 표현식으로서의 if
문장과 표현식
먼저 두 단어를 구분하고 시작하겠습니다.
문장과 표현식입니다.
- 문장(statement) : 일을 시키기만 하는 코드
- 표현식(expression) : 값으로 계산되는 코드
예를 들어 3 + 4는 표현식입니다.7이라는 값으로 바뀌기 때문입니다.
반면 “화면에 출력하라” 같은 명령은
값을 남기지 않으므로 문장에 가깝습니다.
이 구분이 왜 중요할까요?
코틀린의 if가 바로 표현식이기 때문입니다.
값을 반환하는 if
자바에서 if는 문장이었습니다.
판단만 하고 값을 남기지 않았습니다.
하지만 코틀린에서 if는 값을 돌려줍니다.
즉, if의 결과를 변수에 바로 담을 수 있습니다.
먼저 익숙한 형태부터 보겠습니다.
val score = 85
if (score >= 60) {
println("합격")
} else {
println("불합격")
}
여기까지는 자바와 거의 같습니다.
조건이 참이면 위쪽을, 거짓이면 아래쪽을 실행합니다.
이제 코틀린다운 방식을 보겠습니다.
val score = 85
val result = if (score >= 60) "합격" else "불합격"
println(result) // 합격
if 전체가 하나의 값이 되어result에 담겼습니다.
조건이 참이면 "합격"이,
거짓이면 "불합격"이 result의 값이 됩니다.
중괄호를 쓰는 형태에서도 값을 돌려받을 수 있습니다.
이때는 블록의 마지막 줄이 그 값이 됩니다.
val score = 85
val grade = if (score >= 90) {
"A"
} else if (score >= 60) {
"B"
} else {
"F"
}
println(grade) // B
각 블록의 마지막 줄에 적힌 값이
곧 그 블록의 결과입니다.
블록에서는
마지막 줄이 돌려주는 값이 된다.
이 규칙은 앞으로 나올 when에서도
그대로 쓰이니 기억해 둡시다.
삼항 연산자가 없는 이유
자바에는 삼항 연산자라는 것이 있었습니다.조건 ? 참일_때 : 거짓일_때 형태입니다.
// 자바
String result = (score >= 60) ? "합격" : "불합격";
짧게 쓸 수 있어 편했지만,
기호가 많아 처음 보면 읽기 어려웠습니다.
그런데 코틀린에는 삼항 연산자가 없습니다.
일부러 넣지 않았습니다.
이미 if가 값을 돌려주기 때문입니다.
val result = if (score >= 60) "합격" else "불합격"
자바의 삼항 연산자와 하는 일이 똑같습니다.
그러면서도 if와 else라는
읽기 쉬운 단어를 그대로 씁니다.
정리하면 이렇습니다.
| 자바 | 코틀린 |
|---|---|
if 문 (값 없음) | if 표현식 (값 있음) |
삼항 연산자 ? : | if 표현식이 대신함 |
코틀린은 문법을 하나 없애는 대신,
기존if를 더 강하게 만들었다.
한 가지 주의할 점이 있습니다.if를 값으로 쓸 때는 else가 반드시 있어야 합니다.
조건이 거짓일 때 돌려줄 값이 없으면
변수에 무엇을 담을지 정할 수 없기 때문입니다.
// 오류: else가 없어 거짓일 때 값이 없음
val result = if (score >= 60) "합격"
else까지 갖추면
어떤 경우든 값이 정해지므로 안전합니다.
3.2 when
값에 따른 분기
조건이 두세 개면 if로 충분합니다.
하지만 경우의 수가 많아지면 어떨까요?
val grade = "B"
if (grade == "A") {
println("훌륭해요")
} else if (grade == "B") {
println("잘했어요")
} else if (grade == "C") {
println("괜찮아요")
} else {
println("분발해요")
}
else if가 계속 이어지면
읽기가 점점 힘들어집니다.
이럴 때 쓰는 것이 when입니다.
자바의 switch와 비슷한 자리입니다.
val grade = "B"
when (grade) {
"A" -> println("훌륭해요")
"B" -> println("잘했어요")
"C" -> println("괜찮아요")
else -> println("분발해요")
}
훨씬 깔끔해졌습니다.
읽는 법은 간단합니다.grade의 값이 화살표(->) 왼쪽과 같으면
오른쪽을 실행합니다.
어디에도 해당하지 않으면else가 실행됩니다.
자바의 switch를 아는 분이라면
반가운 점이 있을 것입니다.
break를 적지 않아도 된다case라는 단어를 반복하지 않아도 된다
자바 switch에서는 break를 빠뜨리면
다음 항목까지 줄줄이 실행되는 실수가 잦았습니다.
코틀린 when은 그런 걱정이 없습니다.
여러 조건 묶기
여러 값을 같은 방식으로 처리하고 싶을 때가 있습니다.
이때는 값을 쉼표(,)로 나열합니다.
val day = "토"
when (day) {
"토", "일" -> println("주말입니다")
else -> println("평일입니다")
}
// 출력: 주말입니다
"토" 또는 "일"이면
같은 줄을 실행합니다.
자바에서 case를 여러 줄 늘어놓던 것을
한 줄로 묶은 셈입니다.
범위 검사
값이 어떤 범위 안에 드는지도 검사할 수 있습니다.
이때는 in과 범위 표현을 함께 씁니다.
val score = 85
when (score) {
in 90..100 -> println("A")
in 60..89 -> println("B")
else -> println("F")
}
// 출력: B
in 90..100은
“90부터 100까지 사이에 있는가“를 뜻합니다.
여기서 90..100 같은 표현을 범위라고 부릅니다.
범위는 다음 절(3.3)에서 자세히 다룹니다.
지금은 when 안에서
숫자 구간을 이렇게 나눌 수 있다는 점만 봐 둡시다.
타입 검사
값의 타입이 무엇인지에 따라
다르게 처리할 수도 있습니다.
이때는 is라는 키워드를 씁니다.is는 “이 타입인가“를 묻습니다.
val value: Any = "안녕하세요"
when (value) {
is Int -> println("정수입니다")
is String -> println("길이는 ${value.length}")
else -> println("모르는 타입입니다")
}
// 출력: 길이는 5
value가 String이면
그 줄 안에서는 value를 문자열로 다룰 수 있습니다.
그래서 value.length처럼
문자열 전용 기능을 바로 쓸 수 있습니다.
여기서 Any 타입이 다시 나왔습니다.
2.4에서 배운 “무엇이든 담는 타입“입니다.
is로 검사하면 그 안에서
자동으로 타입이 좁혀집니다.
이 편리한 기능은 3부에서 더 깊이 다룹니다.
값을 반환하는 when
if가 값을 돌려주었듯이when도 값을 돌려줍니다.
앞의 예제를 값으로 받아 보겠습니다.
val score = 85
val grade = when {
score >= 90 -> "A"
score >= 60 -> "B"
else -> "F"
}
println(grade) // B
여기서 눈여겨볼 점이 있습니다.when 뒤의 괄호가 사라졌습니다.
괄호 없이 쓰면
화살표 왼쪽에 조건을 자유롭게 적을 수 있습니다.
when {
조건1 -> 값1
조건2 -> 값2
else -> 기본값
}
이 형태는 사실상if ... else if ...를 대신합니다.
그러면서 훨씬 읽기 좋습니다.
when을 값으로 쓸 때도 규칙이 하나 있습니다.
모든 경우를 빠짐없이 다뤄야 합니다.
값을 돌려줘야 하는데
빠진 경우가 있으면 값을 정할 수 없기 때문입니다.
그래서 보통 else를 함께 적습니다.
정리하면 이렇습니다.
if가 값을 돌려주듯when도 값을 돌려준다.
여러 갈래를 값으로 정할 때 가장 편하다.
3.3 범위와 반복
Range
앞에서 잠깐 나온 90..100을
이제 제대로 살펴보겠습니다.
..을 쓰면 범위(Range)를 만들 수 있습니다.
범위란 “어디부터 어디까지“를 나타내는 값입니다.
val numbers = 1..5 // 1, 2, 3, 4, 5
1..5는 1부터 5까지를 뜻합니다.
여기서 끝 숫자인 5도 포함된다는 점이 중요합니다.
1..5는
1, 2, 3, 4, 5 다섯 개를 모두 포함한다.
in과 !in
어떤 값이 범위 안에 있는지 확인할 때in을 씁니다.
val age = 20
println(age in 1..100) // true
println(age in 1..10) // false
age in 1..100은
“age가 1부터 100 사이에 있는가“를 묻습니다.
반대로 범위 밖에 있는지 확인하려면!in을 씁니다.
앞의 느낌표(!)가 “아니다“를 뜻합니다.
val age = 20
println(age !in 1..10) // true (10보다 크므로 밖에 있음)
이 표현은 if나 when과도 잘 어울립니다.
val score = 75
if (score in 60..100) {
println("합격 범위입니다")
}
60 <= score && score <= 100을score in 60..100 한 번으로 쓴 것입니다.
훨씬 읽기 쉽습니다.
for
이제 반복을 배워 봅시다.
가장 많이 쓰는 것이 for입니다.
for는 여러 값을 하나씩 꺼내
차례로 처리합니다.
for (i in 1..5) {
println(i)
}
출력은 이렇습니다.
1
2
3
4
5
읽는 법은 이렇습니다.
“1..5 범위에서 값을 하나씩 꺼내 i에 담고,
그때마다 중괄호 안을 실행하라.”
자바를 아는 분이라면
차이가 확 느껴질 것입니다.
// 자바
for (int i = 1; i <= 5; i++) {
System.out.println(i);
}
자바는 시작값, 조건, 증가를
세 부분으로 나눠 적어야 했습니다.
코틀린은 “이 범위를 돈다“고만 적으면 됩니다.
숫자를 직접 세거나 증가시킬 필요가 없습니다.
for는 범위뿐 아니라
목록 같은 값도 순회할 수 있습니다.
val fruits = listOf("사과", "바나나", "포도")
for (fruit in fruits) {
println(fruit)
}
출력은 이렇습니다.
사과
바나나
포도
여기서 listOf는 목록을 만드는 방법입니다.
목록(리스트)은 6부에서 자세히 다룹니다.
지금은 “여러 값을 담은 묶음” 정도로 보면 됩니다.
while
정해진 범위를 도는 것이 아니라
“어떤 조건이 참인 동안” 반복하고 싶을 때가 있습니다.
이때는 while을 씁니다.
var count = 1
while (count <= 3) {
println("count = $count")
count++
}
출력은 이렇습니다.
count = 1
count = 2
count = 3
while은 괄호 안의 조건이 참인 동안
중괄호 안을 계속 반복합니다.
여기서 count++는count를 1 늘리라는 뜻입니다.
이 줄이 없으면 조건이 영원히 참이라
반복이 끝나지 않습니다.
이런 상태를 무한 루프(infinite loop)라고 부릅니다.
while을 쓸 때는
언젠가 조건이 거짓이 되도록
값을 꼭 바꿔 줘야 한다.
for와 while은 이렇게 구분하면 쉽습니다.
for: 몇 번 돌지 대강 정해져 있을 때while: 언제 끝날지 조건에 달려 있을 때
until / downTo / step
범위를 만드는 방법을 조금 더 알아봅시다.
상황에 따라 편한 표현이 나뉩니다.
먼저 until입니다...과 달리 끝 숫자를 포함하지 않습니다.
for (i in 1 until 5) {
print("$i ")
}
// 출력: 1 2 3 4
1 until 5는
1부터 4까지입니다. (5는 빠짐)
목록을 다룰 때 이 표현이 특히 편합니다.
목록의 자리 번호는 0부터 시작하기 때문입니다.
(자리 번호는 6부에서 자세히 배웁니다.)
다음은 downTo입니다.
숫자를 거꾸로 세고 싶을 때 씁니다.
for (i in 5 downTo 1) {
print("$i ")
}
// 출력: 5 4 3 2 1
5 downTo 1은
5부터 1까지 하나씩 내려갑니다.
마지막은 step입니다.
건너뛰는 간격을 정할 때 씁니다.
for (i in 1..10 step 2) {
print("$i ")
}
// 출력: 1 3 5 7 9
step 2는 두 칸씩 건너뛰라는 뜻입니다.
그래서 홀수만 출력됩니다.
downTo와 step을 함께 쓸 수도 있습니다.
for (i in 10 downTo 1 step 3) {
print("$i ")
}
// 출력: 10 7 4 1
정리하면 이렇습니다.
| 표현 | 뜻 | 예 |
|---|---|---|
1..5 | 끝 숫자 포함 | 1,2,3,4,5 |
1 until 5 | 끝 숫자 제외 | 1,2,3,4 |
5 downTo 1 | 거꾸로 | 5,4,3,2,1 |
1..10 step 2 | 간격 두고 | 1,3,5,7,9 |
이 표현들만 알아도
대부분의 반복을 자연스럽게 쓸 수 있습니다.
3.4 구조 분해
Destructuring Declaration
값 하나에 여러 정보가 담겨 있을 때가 있습니다.
예를 들어 좌표는 x와 y를 함께 가집니다.
이런 값에서 여러 조각을
한 번에 꺼내는 방법이 있습니다.
구조 분해(Destructuring Declaration)라고 부릅니다.
말이 어렵지만 예를 보면 쉽습니다.
val (name, age) = Pair("홍길동", 20)
println(name) // 홍길동
println(age) // 20
Pair는 값 두 개를 하나로 묶는 도구입니다.Pair("홍길동", 20)은
이름과 나이를 한 덩어리로 만듭니다.
그 덩어리를 val (name, age)로 받으면
두 값이 각각의 변수로 풀립니다.
하나로 묶인 값을
여러 변수로 한 번에 풀어내는 것,
이것이 구조 분해다.
이렇게 안 하면 이렇게 써야 합니다.
val pair = Pair("홍길동", 20)
val name = pair.first // 첫 번째 값
val age = pair.second // 두 번째 값
구조 분해를 쓰면
이 과정을 한 줄로 줄일 수 있습니다.
Pair와 Map 순회
구조 분해는 반복문과 만나면 더 빛납니다.
특히 Map을 순회할 때 자주 씁니다.
Map은 “짝지어진 값“을 담는 묶음입니다.
이름표(키)와 내용물(값)이 짝을 이룹니다.
(Map은 6부에서 자세히 다룹니다.)
val ages = mapOf(
"홍길동" to 20,
"이순신" to 35
)
for ((name, age) in ages) {
println("$name 님은 $age 세입니다")
}
출력은 이렇습니다.
홍길동 님은 20세입니다
이순신 님은 35세입니다
for ((name, age) in ages) 부분을 봅시다.
Map에서 짝을 하나씩 꺼내면서,
그 자리에서 바로 키와 값으로 풀었습니다.
여기서 "홍길동" to 20의to도 짚어 보겠습니다.
to는 두 값을 짝으로 묶어 줍니다.
사실 앞에서 본 Pair를 만드는 것과 같습니다.
val p1 = Pair("홍길동", 20)
val p2 = "홍길동" to 20 // 위와 완전히 같음
to를 쓰면 더 읽기 편해서
Map을 만들 때 주로 이 방식을 씁니다.
여러 값을 자연스럽게 다루기
구조 분해는 목록을 돌 때도 쓸 수 있습니다.
번호와 값을 함께 다루고 싶을 때 특히 편합니다.
val fruits = listOf("사과", "바나나", "포도")
for ((index, fruit) in fruits.withIndex()) {
println("$index 번: $fruit")
}
출력은 이렇습니다.
0 번: 사과
1 번: 바나나
2 번: 포도
withIndex()는 각 값에
자리 번호를 붙여 줍니다.
그 번호와 값을(index, fruit)로 한 번에 풀어 받았습니다.
자바에서는 번호를 세는 변수를
따로 만들어 관리해야 했습니다.
코틀린은 이 과정을 문법으로 깔끔하게 정리했습니다.
정리하면 구조 분해는
이런 상황에서 빛을 발합니다.
- 값 두세 개가 하나로 묶여 있을 때
- Map의 키와 값을 함께 다룰 때
- 목록의 번호와 값을 함께 다룰 때
여러 값을 한 번에 풀어
이름을 붙이면 코드가 훨씬 읽기 쉬워진다.
구조 분해의 원리는 사실
“데이터 클래스“라는 개념과 이어집니다.
이 내용은 4부에서 다시 만나게 됩니다.
3장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 코틀린의
if는 값을 돌려주는 표현식이고,
그래서 삼항 연산자가 필요 없다는 점 - 갈래가 많을 때는
when이 깔끔하고,
값 비교뿐 아니라 범위와 타입 검사까지 된다는 점 ..,until,downTo,step으로
다양한 범위를 만들고for로 순회한다는 점while은 조건이 참인 동안 반복한다는 점- 구조 분해로 여러 값을
한 번에 풀어 다룰 수 있다는 점
특히 두 가지를 기억해 둡시다.
“if와 when은 값을 돌려준다”,
그리고 “범위는 in으로 검사한다“입니다.
이 두 가지는 앞으로도 계속 나옵니다.
다음 장에서는
코드를 묶는 단위인 함수를 다뤄 보겠습니다.
4장. 함수
프로그램은 결국 “같은 일을 반복하는 것“의 모음입니다.
값을 더하고, 인사말을 만들고,
입력이 올바른지 검사합니다.
이렇게 “어떤 일을 하는 코드 묶음“에
이름을 붙여 둔 것이 바로 함수입니다.
한 번 만들어 두면
필요할 때마다 이름만 불러서 다시 쓸 수 있습니다.
이 장에서는 코틀린에서 함수를 만드는 방법과,
함수를 더 편하게 쓰는 여러 장치를 배웁니다.
특히 기본 인자, 이름 붙은 인자처럼
자바에는 없던 편리한 기능들을 만나게 됩니다.
4.1 함수 선언하기
우리는 이미 함수를 하나 봤습니다.
바로 1장에서 만든 main 함수입니다.
fun main() {
println("Hello, World!")
}
이제 이 fun이 무엇인지 제대로 살펴봅시다.
함수(function)란
“어떤 일을 하는 코드 묶음에 이름을 붙인 것“입니다.
비유하자면 이렇습니다.
함수는 “자판기“와 비슷합니다.
값을 넣으면(입력) 결과가 나옵니다(출력).
매개변수와 반환 타입
함수를 만들 때는 fun이라는 키워드로 시작합니다.
간단한 예를 하나 보겠습니다.
fun add(a: Int, b: Int): Int {
return a + b
}
이 함수는 두 숫자를 받아 더한 값을 돌려줍니다.
한 조각씩 뜯어보겠습니다.
fun: 함수를 만들겠다는 표시add: 함수의 이름(a: Int, b: Int): 함수가 받는 값들: Int: 함수가 돌려주는 값의 타입return a + b: 실제로 돌려주는 값
여기서 함수가 받는 값을
매개변수(parameter)라고 부릅니다.
매개변수는 “함수 안에서만 쓰이는 변수“입니다.a와 b가 바로 그것입니다.
매개변수를 적을 때는 순서를 기억해 둡시다.
이름: 타입
2장에서 변수를 val 이름: 타입 순서로 적었죠.
매개변수도 똑같은 순서입니다.
함수가 돌려주는 값의 타입은
반환 타입(return type)이라고 합니다.
위 코드에서 add의 반환 타입은 Int입니다.
소괄호 뒤에 콜론(:)을 쓰고 적습니다.
실제로 값을 돌려줄 때는return 뒤에 그 값을 적습니다.
이제 만든 함수를 불러 써 봅시다.
함수를 불러 쓰는 것을 호출(call)이라고 합니다.
fun add(a: Int, b: Int): Int {
return a + b
}
fun main() {
val result = add(3, 5)
println(result)
}
8
호출할 때 넘기는 실제 값(3, 5)은
인자(argument)라고 부릅니다.
용어를 정리하면 이렇습니다.
- 매개변수 : 함수를 “만들 때” 정하는 받을 자리
- 인자 : 함수를 “부를 때” 실제로 넣는 값
자판기로 비유하면,
매개변수는 “동전 투입구“이고
인자는 “실제로 넣는 동전“입니다.
두 단어가 자주 섞여 쓰이지만,
뜻을 구분해 두면 나중에 도움이 됩니다.
참고로 자바의 같은 함수는 이렇게 생겼습니다.
// 자바
public int add(int a, int b) {
return a + b;
}
타입을 이름 앞에 쓰고,
클래스 안에 넣어야 한다는 점이 다릅니다.
Unit: 돌려줄 값이 없을 때
모든 함수가 값을 돌려주는 것은 아닙니다.
그냥 일만 하고 끝나는 함수도 많습니다.
예를 들어 화면에 출력만 하는 함수가 그렇습니다.
fun printHello(): Unit {
println("Hello")
}
여기서 Unit은
“돌려줄 값이 없다“를 뜻하는 타입입니다.
(2장에서 잠깐 만났던 그 타입입니다.)
그런데 이 Unit은 거의 생략합니다.
반환 타입을 안 적으면 자동으로 Unit이 됩니다.
fun printHello() { // 반환 타입 생략 = Unit
println("Hello")
}
자바를 아는 분이라면void가 있던 자리라고 생각하면 쉽습니다.
// 자바
public void printHello() {
System.out.println("Hello");
}
- 자바의
void= 코틀린의Unit - 다만 코틀린은 아예 생략해도 됨
단일 표현식 함수
앞에서 만든 add 함수를 다시 봅시다.
fun add(a: Int, b: Int): Int {
return a + b
}
이 함수는 하는 일이a + b라는 한 줄뿐입니다.
이렇게 몸통이 한 줄로 끝나는 함수는
더 짧게 쓸 수 있습니다.
중괄호와 return을 지우고,
등호(=)로 이어 붙이면 됩니다.
fun add(a: Int, b: Int): Int = a + b
이런 형태를
단일 표현식 함수(single-expression function)라고 합니다.
“함수의 결과가 표현식 하나로 정해진다“는 뜻입니다.= 오른쪽 값이 그대로 반환값이 됩니다.
여기서 한 걸음 더 나갈 수 있습니다.
반환 타입도 생략할 수 있습니다.
fun add(a: Int, b: Int) = a + b
a + b가 Int라는 것을
코틀린이 알아서 추론하기 때문입니다.
(2장에서 배운 타입 추론이 여기서도 쓰입니다.)
정리하면 같은 함수를
세 가지 방법으로 쓸 수 있습니다.
// 1. 기본 형태
fun add(a: Int, b: Int): Int {
return a + b
}
// 2. 단일 표현식
fun add(a: Int, b: Int): Int = a + b
// 3. 반환 타입까지 생략
fun add(a: Int, b: Int) = a + b
셋 다 완전히 똑같이 동작합니다.
짧은 함수는 단일 표현식으로,
긴 함수는 중괄호로.
이렇게 쓰면 코드가 한결 깔끔해집니다.
한 가지만 조심합시다.
공개용(라이브러리 등) 함수라면
반환 타입을 적어 두는 편이 읽기에 좋습니다.
타입이 눈에 보이면
“이 함수가 무엇을 돌려주는지”
한눈에 알 수 있기 때문입니다.
4.2 기본 인자와 이름 붙은 인자
함수를 쓰다 보면
“대부분 같은 값을 넘기는” 경우가 자주 생깁니다.
예를 들어 인사말을 만드는 함수를 생각해 봅시다.
fun greet(name: String, greeting: String): String {
return "$greeting, $name!"
}
인사말은 보통 “안녕하세요“입니다.
그런데 부를 때마다 매번 적어야 합니다.
greet("홍길동", "안녕하세요")
greet("이순신", "안녕하세요")
greet("강감찬", "안녕하세요")
같은 값을 계속 반복하니 번거롭습니다.
이 문제를 코틀린은 깔끔하게 풀어 줍니다.
기본 인자 (Default Parameter)
매개변수에 미리 값을 정해 둘 수 있습니다.
이것을 기본 인자(default parameter)라고 합니다.
매개변수 뒤에 =로 기본값을 적으면 됩니다.
fun greet(name: String, greeting: String = "안녕하세요"): String {
return "$greeting, $name!"
}
이제 인사말을 생략하면
자동으로 “안녕하세요“가 쓰입니다.
fun main() {
println(greet("홍길동"))
println(greet("이순신", "반갑습니다"))
}
안녕하세요, 홍길동!
반갑습니다, 이순신!
- 값을 안 넘기면 : 기본값 사용
- 값을 넘기면 : 넘긴 값 사용
필요한 것만 넘기면 되니
호출이 훨씬 간단해집니다.
이름 붙은 인자 (Named Argument)
매개변수가 많아지면
“어떤 값이 어디로 가는지” 헷갈립니다.
예를 들어 이런 함수가 있다고 합시다.
fun createUser(name: String, age: Int, isAdmin: Boolean): String {
return "$name / $age / $isAdmin"
}
이 함수를 이렇게 부르면
읽는 사람이 한 번에 이해하기 어렵습니다.
createUser("홍길동", 20, false)
false가 무엇을 뜻하는지
함수 정의를 열어 봐야 알 수 있습니다.
이럴 때 인자 앞에 이름을 붙일 수 있습니다.
이것을 이름 붙은 인자(named argument)라고 합니다.
createUser(
name = "홍길동",
age = 20,
isAdmin = false
)
이제 각 값이 무엇인지
코드만 봐도 바로 알 수 있습니다.
이름을 붙이면 순서를 바꿔도 됩니다.
createUser(
isAdmin = false,
name = "홍길동",
age = 20
)
이름으로 자리를 찾아가기 때문입니다.
기본 인자와 함께 쓰면 더욱 강력해집니다.
중간 값만 골라서 넘길 수 있습니다.
fun order(
item: String,
count: Int = 1,
giftWrap: Boolean = false
) {
println("$item / $count 개 / 포장: $giftWrap")
}
fun main() {
// count는 기본값, giftWrap만 지정
order("커피", giftWrap = true)
}
커피 / 1 개 / 포장: true
만약 이름을 안 붙였다면giftWrap만 골라 넘길 방법이 없었습니다.count부터 순서대로 다 채워야 했겠죠.
이름 붙은 인자는
“필요한 값만 콕 집어” 넘기게 해 줍니다.
Java의 메서드 오버로딩과 비교
자바에는 기본 인자가 없습니다.
그래서 “값을 생략할 수 있게” 만들려면
같은 이름의 함수를 여러 개 만들어야 했습니다.
// 자바
public String greet(String name) {
return greet(name, "안녕하세요");
}
public String greet(String name, String greeting) {
return greeting + ", " + name + "!";
}
이렇게 같은 이름으로
여러 버전을 만드는 것을
메서드 오버로딩(method overloading)이라고 합니다.
매개변수가 늘어날수록
만들어야 할 버전도 늘어납니다.
경우의 수가 많으면 아주 번거롭습니다.
코틀린은 이 모든 것을
기본 인자 한 줄로 대신합니다.
// 코틀린: 함수 하나면 충분
fun greet(name: String, greeting: String = "안녕하세요"): String {
return "$greeting, $name!"
}
두 방식을 나란히 비교하면 이렇습니다.
| 상황 | 자바 | 코틀린 |
|---|---|---|
| 값 생략을 허용하고 싶다 | 함수 여러 개(오버로딩) | 기본 인자 한 줄 |
| 인자 순서를 바꾸고 싶다 | 불가능 | 이름 붙은 인자 |
| 코드 양 | 많아짐 | 적음 |
즉, 자바에서 오버로딩으로 풀던 일을
코틀린은 기본 인자와 이름 붙은 인자로
더 간단히 해결합니다.
4.3 가변 인자
지금까지는 인자의 개수가 정해져 있었습니다.add(a, b)는 항상 두 개를 받았습니다.
그런데 개수가 정해지지 않은 경우도 있습니다.
예를 들어 여러 숫자를 모두 더하는 함수를
만든다고 생각해 봅시다.
두 개를 더할 때도, 다섯 개를 더할 때도
같은 함수를 쓰고 싶습니다.
vararg
이럴 때 쓰는 것이
가변 인자(variable arguments)입니다.
“개수가 변할 수 있는 인자“라는 뜻입니다.
매개변수 앞에 vararg를 붙입니다.
fun sum(vararg numbers: Int): Int {
var total = 0
for (n in numbers) {
total += n
}
return total
}
이제 숫자를 몇 개 넣든 상관없습니다.
fun main() {
println(sum(1, 2))
println(sum(1, 2, 3, 4, 5))
println(sum())
}
3
15
0
함수 안에서 numbers는
여러 값이 담긴 목록처럼 다뤄집니다.
그래서 for로 하나씩 꺼내 쓸 수 있습니다.
(반복문은 3장에서 다뤘습니다.)
사실 우리는 이미 vararg를 써 왔습니다.println도 여러 값을 받을 수 있고,listOf(1, 2, 3) 같은 것도 가변 인자를 씁니다.
자바에도 비슷한 기능이 있습니다.
바로 ... 표기입니다.
// 자바
public int sum(int... numbers) {
int total = 0;
for (int n : numbers) {
total += n;
}
return total;
}
역할은 같고,
표기 방식만 ...에서 vararg로 바뀐 셈입니다.
스프레드 연산자 (Spread Operator)
이미 목록(배열)에 값이 담겨 있을 때가 있습니다.
이 목록을 가변 인자에 통째로 넘기고 싶다면 어떻게 할까요?
그냥 넘기면 오류가 납니다.
val numbers = intArrayOf(1, 2, 3)
sum(numbers) // 오류: 배열 하나를 통째로 넘길 수 없음
sum은 Int 여러 개를 받는데,numbers는 “배열 하나“이기 때문입니다.
이때 배열 앞에 별표(*)를 붙입니다.
이 별표를 스프레드 연산자(spread operator)라고 합니다.
val numbers = intArrayOf(1, 2, 3)
sum(*numbers) // OK: 배열을 낱개로 펼쳐서 넘김
“펼친다(spread)“는 이름 그대로,
배열을 낱개의 값들로 풀어서 넘겨 줍니다.
비유하자면 이렇습니다.
묶음으로 포장된 물건을,
하나씩 낱개로 풀어서 건네는 것입니다.
낱개 값과 배열을 섞어서 넘길 수도 있습니다.
val middle = intArrayOf(2, 3, 4)
println(sum(1, *middle, 5))
15
1, 펼친 2, 3, 4, 그리고 5가
모두 이어져서 전달됩니다.
지금은 배열이 낯설어도 괜찮습니다.
목록을 다루는 방법은 5부에서 자세히 배웁니다.
여기서는 이렇게만 기억해 둡시다.
vararg: 개수가 정해지지 않은 인자를 받는다*: 이미 있는 배열을 낱개로 펼쳐서 넘긴다
4.4 함수 오버로딩
앞에서 자바의 오버로딩을 잠깐 봤습니다.
코틀린에도 오버로딩은 있습니다.
기본 인자로 많은 경우를 해결할 수 있지만,
오버로딩이 더 자연스러운 상황도 있기 때문입니다.
같은 이름의 여러 함수
같은 이름이지만
받는 값이 다른 함수를 여러 개 만들 수 있습니다.
이것을 함수 오버로딩(function overloading)이라고 합니다.
fun describe(value: Int): String {
return "정수: $value"
}
fun describe(value: String): String {
return "문자열: $value"
}
fun describe(value: Boolean): String {
return "참/거짓: $value"
}
이름은 모두 describe로 같지만,
받는 타입이 각각 다릅니다.
부를 때는 넘기는 값에 맞춰
코틀린이 알맞은 함수를 골라 줍니다.
fun main() {
println(describe(100))
println(describe("커피"))
println(describe(true))
}
정수: 100
문자열: 커피
참/거짓: true
이름이 같아도 헷갈리지 않는 이유는,
매개변수의 타입이나 개수가 다르기 때문입니다.
코틀린은 이 차이를 보고
“어떤 함수를 부른 것인지” 판단합니다.
오버로딩의 핵심은
“이름은 같되, 받는 값이 달라야 한다“입니다.
주의할 점이 하나 있습니다.
반환 타입만 다른 것으로는 오버로딩이 안 됩니다.
fun read(): Int { ... }
fun read(): String { ... } // 오류: 구분할 수 없음
호출하는 쪽에서는
넘기는 값만 보이고 반환 타입은 안 보입니다.
그래서 코틀린이 둘을 구분할 수 없습니다.
기본 인자와 오버로딩의 선택
여기서 자연스러운 질문이 떠오릅니다.
기본 인자와 오버로딩,
언제 무엇을 써야 할까요?
간단한 기준은 이렇습니다.
- 하는 일이 “같다” : 기본 인자
- 하는 일이 “다르다” : 오버로딩
앞의 greet 함수를 떠올려 봅시다.
인사말이 있든 없든 하는 일은 똑같습니다.
“인사말을 만든다“는 것이죠.
이런 경우는 기본 인자가 잘 맞습니다.
fun greet(name: String, greeting: String = "안녕하세요") =
"$greeting, $name!"
반면 describe는
받는 타입에 따라 하는 일이 다릅니다.
정수를 다룰 때와 문자열을 다룰 때가 다르죠.
이런 경우는 오버로딩이 어울립니다.
한 가지 더 조심할 점이 있습니다.
둘을 섞으면 헷갈리는 상황이 생길 수 있습니다.
fun log(message: String) {
println("로그: $message")
}
fun log(message: String, level: String = "INFO") {
println("[$level] $message")
}
fun main() {
log("서버 시작") // 어느 함수가 불릴까?
}
log("서버 시작")은
두 함수 모두에 들어맞습니다.
이럴 때 코틀린은
기본 인자 없이 더 딱 맞는 쪽을 고릅니다.
즉 첫 번째 함수가 불립니다.
하지만 읽는 사람은 헷갈립니다.
그래서 이런 애매한 조합은 피하는 편이 좋습니다.
기본 인자와 오버로딩을
억지로 섞지 않는 것이 안전합니다.
4.5 로컬 함수
함수는 보통 파일이나 클래스 안에 만듭니다.
그런데 함수 “안에” 함수를 만들 수도 있습니다.
이것을 로컬 함수(local function)라고 합니다.
“그 함수 안에서만 쓰는, 국소적인 함수“라는 뜻입니다.
함수 안의 함수
먼저 간단한 예를 보겠습니다.
fun outer() {
fun inner() {
println("안쪽 함수입니다")
}
inner()
}
inner는 outer 안에 들어 있습니다.
그래서 outer 밖에서는 inner를 부를 수 없습니다.
inner() // 오류: outer 밖에서는 보이지 않음
로컬 함수의 큰 장점이 하나 있습니다.
바깥 함수의 변수를 그대로 쓸 수 있다는 점입니다.
fun greetLoudly(name: String) {
fun shout() {
// 바깥의 name을 그대로 사용
println("$name!!!")
}
shout()
shout()
}
shout은 매개변수로 name을 받지 않았지만,
바깥 greetLoudly의 name을 바로 씁니다.
값을 다시 넘겨줄 필요가 없어서 편리합니다.
반복되는 검증 코드 정리하기
로컬 함수가 진짜 빛나는 순간은
“같은 검사를 여러 번 할 때“입니다.
사용자 정보를 저장하는 함수를 예로 들어 봅시다.
이름과 비밀번호가 비어 있으면 안 된다고 합시다.
먼저 로컬 함수 없이 써 보겠습니다.
fun saveUser(name: String, password: String) {
if (name.isEmpty()) {
throw IllegalArgumentException("이름이 비어 있습니다")
}
if (password.isEmpty()) {
throw IllegalArgumentException("비밀번호가 비어 있습니다")
}
println("$name 님의 정보를 저장했습니다")
}
isEmpty()는
“값이 비어 있는가“를 알려 주는 함수입니다.
문자열에 아무 글자도 없으면 true를 돌려줍니다.
throw는 오류를 발생시키는 명령입니다.
잘못된 값이 들어오면 프로그램을 멈춥니다.
(예외 처리는 8부에서 자세히 다룹니다.)
코드를 보면 검사 부분이 비슷하게 반복됩니다.
“비어 있으면 오류를 던진다“가 두 번 나옵니다.
이 반복을 로컬 함수로 묶어 봅시다.
fun saveUser(name: String, password: String) {
fun validate(value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("${fieldName}이(가) 비어 있습니다")
}
}
validate(name, "이름")
validate(password, "비밀번호")
println("$name 님의 정보를 저장했습니다")
}
검사 로직이 validate 한 곳에 모였습니다.
검사할 항목이 늘어나도 한 줄만 추가하면 됩니다.
validate(email, "이메일") // 항목이 늘어도 이렇게 한 줄
여기서 “이 검사 함수를 밖으로 빼면 안 되나?”
하고 생각할 수 있습니다.
물론 밖으로 뺄 수도 있습니다.
하지만 이 검사가 saveUser에서만 쓰인다면,
안에 두는 편이 더 깔끔합니다.
로컬 함수를 쓰면 좋은 점을 정리하면 이렇습니다.
- 반복되는 코드를 한곳에 모을 수 있다
- 그 함수 안에서만 쓰이니 밖을 어지럽히지 않는다
- 바깥 변수를 그대로 쓸 수 있어 편하다
함수 안에서만 반복되는 코드가 있다면,
로컬 함수를 떠올려 보세요.
다만 로컬 함수가 너무 길어지거나
여러 곳에서 쓰인다면,
그때는 밖으로 꺼내 독립된 함수로 만드는 것이 낫습니다.
4장을 마치며
이 장에서 우리는 다음을 배웠습니다.
fun으로 함수를 만들고,
매개변수와 반환 타입을 정하는 방법- 값을 돌려주지 않는 함수의 반환 타입은
Unit이며 대부분 생략한다는 점 - 몸통이 한 줄이면
단일 표현식 함수로 짧게 쓸 수 있다는 점 - 기본 인자와 이름 붙은 인자로
자바의 오버로딩을 간단히 대신하는 방법 vararg로 개수가 정해지지 않은 인자를 받고,*로 배열을 펼쳐 넘기는 방법- 함수 오버로딩과,
기본 인자와의 선택 기준 - 함수 안에 함수를 만들어
반복되는 코드를 정리하는 로컬 함수
특히 두 가지는 앞으로 계속 나옵니다.
“기본 인자”, 그리고 “단일 표현식 함수“입니다.
다음 장에서는
값을 묶어 다루는 컬렉션을 만나 보겠습니다.
5장. Null Safety
프로그램을 짜다 보면
“값이 있어야 하는데 없는” 상황을 자주 만납니다.
회원을 찾았는데 그런 회원이 없거나,
입력값이 비어 있거나,
아직 값을 넣지 않은 변수가 그렇습니다.
이렇게 “값이 없음“을 나타내는 특별한 표시가
바로 null입니다.
null은 “아무 값도 없음“을 뜻합니다.
비어 있는 상자를 떠올리면 쉽습니다.
문제는 이 빈 상자를 열어서
안에 든 물건을 꺼내려 할 때 생깁니다.
상자가 비어 있는데 물건을 꺼내라고 하면
프로그램은 어쩔 줄 몰라 멈춰 버립니다.
자바에서는 이때
NullPointerException이라는 오류가 터집니다.
NullPointerException은
“널 포인터 예외“라고 읽고,
줄여서 NPE라고 부릅니다.
이 오류는 서버에서 가장 자주 터지는 오류 중 하나입니다.
심지어 이 오류를 만든 사람조차
“10억 달러짜리 실수“라고 후회했을 정도입니다.
자바에서 NPE가 무서운 이유는
프로그램을 실행해 봐야만 알 수 있기 때문입니다.
코드를 짤 때는 멀쩡해 보이다가,
실제 사용자가 쓰는 도중에 갑자기 터집니다.
코틀린은 이 문제를 정면으로 해결한 언어입니다.
null이 될 수 있는 값과 아닌 값을
타입 단계에서 아예 구분합니다.
그래서 대부분의 null 오류를
실행 전에, 즉 컴파일 단계에서 미리 잡아냅니다.
이 장에서 배우는 내용이
코틀린이 “안전한 언어“라고 불리는 가장 큰 이유입니다.
5.1 Nullable과 Non-null
String과 String?
코틀린에서는 타입을 두 종류로 나눕니다.
- null이 들어올 수 없는 타입
- null이 들어올 수 있는 타입
이 둘을 나누는 표시는 아주 간단합니다.
타입 뒤에 물음표(?)를 붙이느냐 마느냐입니다.
먼저 코드로 보겠습니다.
val name: String = "홍길동"
val nickname: String? = null
String: null이 들어올 수 없는 타입String?: null이 들어올 수 있는 타입
물음표가 없는 타입에는
절대 null을 넣을 수 없습니다.
val name: String = null // 오류: String에는 null을 넣을 수 없음
이 코드는 실행조차 되지 않습니다.
컴파일 단계에서 바로 걸러집니다.
반대로 물음표가 붙은 타입에는
null을 넣을 수 있습니다.
val nickname: String? = null // 가능: String?은 null을 담을 수 있음
여기서 두 가지 새 용어를 정리하겠습니다.
null이 들어올 수 없는 타입을
Non-null 타입이라고 부릅니다.
우리말로 “널이 아닌 타입“입니다.
null이 들어올 수 있는 타입을
Nullable 타입이라고 부릅니다.
우리말로 “널이 될 수 있는 타입“입니다.
물음표가 없으면 Non-null,
물음표가 있으면 Nullable.
이 물음표 하나가
코틀린 Null Safety의 출발점입니다.
Kotlin이 null을 타입으로 구분하는 이유
왜 굳이 타입을 두 종류로 나눌까요?
이유는 “실수를 미리 막기 위해서“입니다.
앞에서 본 자바의 문제를 다시 떠올려 봅시다.
자바에서는 모든 타입에
null이 몰래 들어올 수 있었습니다.
// 자바
String name = null; // 문제없이 통과
int length = name.length(); // 실행하는 순간 NPE 발생!
이 코드는 컴파일도 잘 되고
실행도 시작됩니다.
하지만 name이 null인 상태에서length()를 부르는 순간 터집니다.
즉, 문제가 있는데도
프로그램을 돌려 보기 전까지는 알 수 없습니다.
코틀린은 이 문제를 이렇게 해결합니다.
null이 될 수 있는 값은
타입에 아예 표시해 두자.
그러면 컴파일러가
“이 값은 null일 수 있으니 조심하라“고
미리 알려 줄 수 있습니다.
예를 들어 Nullable 타입의 값을
그냥 사용하려고 하면 이렇게 됩니다.
val nickname: String? = null
val length = nickname.length // 오류: null일 수 있어서 바로 못 씀
이 코드는 실행되지 않습니다.
컴파일러가 먼저 막아 줍니다.
자바라면 실행 중에 터졌을 오류를,
코틀린은 코드를 짜는 순간 잡아 주는 것입니다.
정리하면 이렇습니다.
| 구분 | 자바 | 코틀린 |
|---|---|---|
| null 가능성 | 모든 타입에 숨어 있음 | 타입에 ?로 표시됨 |
| 오류 발견 시점 | 실행 중 (사용자가 겪음) | 컴파일 중 (개발자가 미리 봄) |
| NPE 위험 | 항상 있음 | 크게 줄어듦 |
물론 코틀린에서도 NPE가
완전히 사라진 것은 아닙니다.
하지만 “실수로 터지는” 대부분의 NPE는
이 물음표 규칙 하나로 막을 수 있습니다.
그럼 이제 궁금해집니다.
Nullable 타입의 값은
대체 어떻게 안전하게 써야 할까요?
바로 그 방법들을
이 장의 나머지에서 하나씩 배웁니다.
5.2 Safe Call ?.
null이 아닐 때만 실행하기
Nullable 타입의 값은
그냥 점(.)을 찍어서 쓸 수 없다고 했습니다.
val nickname: String? = "길동"
val length = nickname.length // 오류: null일 수 있음
그러면 어떻게 해야 할까요?
가장 먼저 배울 도구가 Safe Call입니다.
Safe Call은 “안전한 호출“이라는 뜻이고,
물음표와 점을 붙인 ?. 모양입니다.
val nickname: String? = "길동"
val length = nickname?.length
?.의 동작은 아주 간단합니다.
값이 null이 아니면 점 뒤의 코드를 실행하고,
값이 null이면 그냥 null을 돌려준다.
즉, “null이면 아무 일도 하지 않는” 것입니다.
NPE를 낼 위험 자체가 사라집니다.
두 가지 경우를 나란히 보겠습니다.
val a: String? = "길동"
val b: String? = null
println(a?.length) // 2 (null이 아니므로 length 실행)
println(b?.length) // null (null이므로 실행하지 않고 null 반환)
여기서 중요한 점이 하나 있습니다.a?.length의 결과 타입은 Int?입니다.
값이 있을 때는 Int가 나오지만,
null일 때는 null이 나올 수 있기 때문입니다.
Safe Call의 결과는
항상 Nullable 타입이 된다.
비유하자면 ?.는
“조심스럽게 문을 두드리는 것“과 같습니다.
방에 사람이 있으면(=null이 아니면) 말을 걸고,
방이 비어 있으면(=null이면) 조용히 돌아섭니다.
문이 비어 있는데
억지로 들어가 소리치는 일(=NPE)이
일어나지 않습니다.
연속 Safe Call
?.는 여러 번 이어서 쓸 수 있습니다.
이것을 “연속 Safe Call“이라고 부릅니다.
실무에서는 객체 안에 또 객체가 들어 있는
“중첩된 구조“를 자주 다룹니다.
예를 들어 이런 상황을 생각해 봅시다.
- 회원(user)이 있고
- 회원 안에 주소(address)가 있고
- 주소 안에 도시(city)가 있다
이때 각 단계가 모두
null일 수 있다고 해 봅시다.
(아래 코드의 ?가 붙은 타입들이
“없을 수도 있다“는 뜻입니다.)
class User(val address: Address?)
class Address(val city: String?)
val user: User? = User(Address("서울"))
여기서 도시 이름을 꺼내려면
세 단계를 모두 통과해야 합니다.
자바라면 단계마다
null인지 일일이 확인해야 했습니다.
// 자바: 계단처럼 깊어지는 null 검사
String city = null;
if (user != null) {
if (user.getAddress() != null) {
city = user.getAddress().getCity();
}
}
코틀린은 이것을 한 줄로 끝냅니다.
val city: String? = user?.address?.city
println(city) // 서울
?.를 이어서 쓰면
중간 어느 하나라도 null인 순간
전체가 null이 됩니다.
val user: User? = User(Address(null)) // 도시가 null
val city = user?.address?.city
println(city) // null (마지막 city가 null이므로)
val user: User? = null // 회원 자체가 null
val city = user?.address?.city
println(city) // null (첫 단계에서 이미 null)
즉, 연속 Safe Call은
“체인 어딘가에서 끊기면 그냥 null“이라고
이해하면 됩니다.
하나라도 null이면,
끝까지 가지 않고 null을 돌려준다.
깊은 구조를 안전하게 파고들 때
아주 강력한 도구입니다.
5.3 Elvis Operator ?:
기본값 처리
Safe Call은 편리하지만
한 가지 아쉬운 점이 있습니다.
결과가 null일 수 있다는 것입니다.
때로는 “null 대신 기본값“을 쓰고 싶습니다.
예를 들어 별명이 없으면
“손님“이라고 표시하고 싶을 수 있습니다.
이때 쓰는 도구가 Elvis 연산자입니다.
물음표와 콜론을 붙인 ?: 모양입니다.
이름이 특이하지요??:를 옆으로 눕혀서 보면
엘비스 프레슬리의 앞머리와 눈을 닮았다고 해서
붙은 별명입니다.
동작은 이렇습니다.
왼쪽 값이 null이 아니면 그 값을 쓰고,
null이면 오른쪽 값을 대신 쓴다.
코드로 보겠습니다.
val nickname: String? = null
val display = nickname ?: "손님"
println(display) // 손님
nickname이 null이므로
오른쪽의 "손님"이 대신 쓰였습니다.
값이 있을 때는 원래 값을 씁니다.
val nickname: String? = "길동"
val display = nickname ?: "손님"
println(display) // 길동
Safe Call과 함께 쓰면 더 유용합니다.
둘을 이어 쓰는 패턴이 아주 흔합니다.
val nickname: String? = null
val length = nickname?.length ?: 0
println(length) // 0
이 한 줄의 뜻을 풀어 보면 이렇습니다.
nickname이 null이 아니면 그 길이를,- null이면
0을 쓴다.
결과 타입도 눈여겨봅시다.nickname?.length는 Int?였지만,?: 0을 붙이면 결과는 Int가 됩니다.
null이 될 가능성이0이라는 기본값으로 메워졌기 때문입니다.
Elvis 연산자는
Nullable을 Non-null로 바꾸는 다리 역할을 한다.
자바의 삼항 연산자와 비교하면 이렇습니다.
// 자바
String display = (nickname != null) ? nickname : "손님";
// 코틀린
val display = nickname ?: "손님"
같은 일을
훨씬 짧고 읽기 쉽게 표현합니다.
null이면 예외 발생시키기
기본값을 주는 대신,
“null이면 아예 오류를 내고 멈추는” 방법도 있습니다.
값이 없으면 더 진행하는 것이 의미 없을 때
이렇게 처리합니다.
Elvis 연산자의 오른쪽에throw를 적으면 됩니다.
val nickname: String? = null
val display = nickname ?: throw IllegalArgumentException("별명이 없습니다")
throw는 “예외를 던진다“는 뜻입니다.
프로그램을 그 자리에서 멈추고
오류를 알리는 명령입니다.
이 코드의 뜻은 이렇습니다.
nickname이 있으면 그 값을 쓰고,- null이면 예외를 던지며 멈춘다.
백엔드에서 아주 자주 쓰는 패턴입니다.
예를 들어 회원 조회는 이렇게 씁니다.
fun findUser(id: Long): User? {
// 회원을 찾지 못하면 null을 돌려준다고 가정
return null
}
fun getUserOrThrow(id: Long): User {
return findUser(id)
?: throw NoSuchElementException("회원을 찾을 수 없습니다: $id")
}
getUserOrThrow의 반환 타입은User?가 아니라 User입니다.
null인 경우를 예외로 걸러 냈기 때문에,
이 함수를 쓰는 쪽에서는
null을 걱정할 필요가 없습니다.
“값이 없으면 여기서 확실히 멈춘다“고
선을 그어 주는 방식입니다.
이렇게 하면 null이
프로그램 깊숙한 곳까지 흘러 들어가는 것을 막습니다.
예외에 관한 자세한 내용은
8부에서 따로 다룹니다.
5.4 Not-null Assertion !!
!!의 동작
지금까지는 null을 “안전하게” 다루는
방법을 배웠습니다.
이번에는 정반대의 도구를 소개합니다.
Not-null Assertion, 느낌표 두 개(!!)입니다.
Not-null Assertion은
우리말로 “널이 아님 단언“입니다.
단언(assertion)이란
“틀림없이 그렇다고 확신하며 말하는 것“입니다.
!!의 동작은 이렇습니다.
“이 값은 절대 null이 아니다“라고
개발자가 컴파일러에게 강하게 우기는 것.
코드로 보겠습니다.
val nickname: String? = "길동"
val length = nickname!!.length
println(length) // 2
nickname!!은
“이 값은 null이 아니니 그냥 쓰게 해 줘“라는 뜻입니다.
그 결과 nickname!!의 타입은String?이 아니라 String이 됩니다.
Nullable을 강제로 Non-null로
바꿔 버리는 것입니다.
언제 위험한가
문제는 이 “우기기“가 틀렸을 때입니다.
만약 값이 실제로 null이라면,!!는 그 자리에서 NPE를 터뜨립니다.
val nickname: String? = null
val length = nickname!!.length // 실행 순간 NPE 발생!
실행하면 이런 오류가 납니다.
Exception in thread "main" kotlin.KotlinNullPointerException
여기서 아주 중요한 점이 있습니다.
!!를 쓰는 순간,
코틀린이 애써 막아 주던 NPE가 다시 돌아온다.
즉, !!는
코틀린의 Null Safety를
스스로 꺼 버리는 스위치와 같습니다.
비유하자면 이렇습니다.
안전벨트를 일부러 풀고
“나는 사고 안 난다“고 우기는 것과 같습니다.
대부분의 경우!!는 좋지 않은 신호입니다.
코드에 !!가 많다면,
그만큼 NPE가 터질 구멍이 많다는 뜻입니다.
!!를 피하는 방법
그렇다면 !! 대신
무엇을 쓰면 될까요?
거의 모든 경우,
앞에서 배운 도구로 바꿀 수 있습니다.
먼저 Safe Call로 바꾸는 방법입니다.
// 위험한 방식
val length = nickname!!.length
// 안전한 방식
val length = nickname?.length ?: 0
null이면 터지는 대신0이라는 기본값을 쓰게 됩니다.
값이 반드시 있어야 하는 상황이라면,
의미 있는 예외로 바꾸는 것이 좋습니다.
// 무엇이 문제인지 알 수 없는 방식
val user = findUser(id)!!
// 원인을 알려 주는 방식
val user = findUser(id)
?: throw NoSuchElementException("회원을 찾을 수 없습니다: $id")
두 방식 모두 값이 없으면 멈추지만,
아래쪽은 “왜 멈췄는지“를 알려 줍니다.
!!가 낸 NPE는
“어디선가 null이었다“는 것 말고는
아무것도 알려 주지 않습니다.
정리하면 이렇습니다.
| 상황 | !! 대신 이렇게 |
|---|---|
| null이면 기본값을 쓰고 싶다 | ?: 사용 |
| null이면 멈춰야 한다 | ?: throw 사용 |
| null이면 그냥 넘어가도 된다 | ?. 사용 |
!!가 손에 닿을 때마다,
“정말 다른 방법이 없나?“를 먼저 떠올려 보세요.
그래도 !!가 꼭 필요한 순간이
아주 가끔 있기는 합니다.
“내가 방금 확인했으니 이 값은 확실히 있다“는
것이 코드로 증명되는 경우입니다.
하지만 초보 단계에서는!!를 쓰지 않는다고 생각하는 편이
훨씬 안전합니다.
5.5 Safe Cast as?
타입 캐스팅
이번에는 조금 다른 상황입니다.
“타입을 바꾸는” 이야기입니다.
어떤 값이 Any 타입으로 들어왔다고 해 봅시다.
(Any는 2장에서 배운, 무엇이든 담는 타입입니다.)
이 값을 문자열로 다루고 싶다면
“이건 문자열이야“라고 알려 줘야 합니다.
이렇게 값의 타입을 바꾸는 것을
타입 캐스팅(type casting)이라고 합니다.
우리말로 “형 변환“입니다.
코틀린에서 캐스팅은as 키워드로 합니다.
val value: Any = "안녕하세요"
val text = value as String
println(text.length) // 5
value as String은
“이 값을 String으로 다루겠다“는 뜻입니다.
그런데 여기에 위험이 숨어 있습니다.
만약 실제 타입이 문자열이 아니라면 어떻게 될까요?
val value: Any = 123 // 실제로는 숫자
val text = value as String // 오류 발생!
실행하면 이런 오류가 납니다.
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String
ClassCastException은
“클래스 형 변환 예외“입니다.
“숫자를 문자열이라고 우겼다가
들통난 것“이라고 보면 됩니다.
이런 캐스팅을
“강제 캐스팅“이라고 부릅니다.
틀리면 예외로 터진다는 점에서!!와 비슷한 위험을 가집니다.
실패를 null로 처리하기
이 위험을 안전하게 바꾸는 도구가
Safe Cast입니다.
Safe Cast는 “안전한 형 변환“이고,as? 모양입니다.
동작은 이렇습니다.
캐스팅에 성공하면 바뀐 값을 돌려주고,
실패하면 예외 대신 null을 돌려준다.
코드로 보겠습니다.
val value: Any = 123
val text = value as? String
println(text) // null (문자열이 아니므로 실패 → null)
예외로 터지지 않고
조용히 null이 나옵니다.
성공하는 경우도 봅시다.
val value: Any = "안녕하세요"
val text = value as? String
println(text) // 안녕하세요
as?의 결과는
항상 Nullable 타입이 됩니다.
실패하면 null이 나올 수 있기 때문입니다.
그래서 앞에서 배운 도구들과
자연스럽게 이어집니다.
예를 들어 Elvis 연산자와 함께 쓰면
“캐스팅 실패 시 기본값“을 줄 수 있습니다.
val value: Any = 123
val text = value as? String ?: "문자열이 아님"
println(text) // 문자열이 아님
정리하면 이렇습니다.
| 방식 | 성공할 때 | 실패할 때 |
|---|---|---|
as | 값 반환 | 예외 발생 (ClassCastException) |
as? | 값 반환 | null 반환 |
타입이 확실하지 않다면
as보다as?가 안전합니다.
특히 외부에서 들어온 데이터처럼
타입을 100% 믿을 수 없을 때as?가 든든한 안전장치가 됩니다.
5.6 Nullable 값을 다루는 함수
지금까지는 연산자(?., ?:, !!, as?)를
살펴봤습니다.
이번에는 Nullable 값을 다룰 때
자주 쓰는 함수 몇 가지를 배웁니다.
이 함수들을
범위 지정 함수(scope function)라고 부르기도 합니다.
자세한 내용은 나중에 더 다루지만,
여기서는 null 처리에 초점을 맞춥니다.
let
let은 “이 값을 가지고 무언가를 해라“라는
함수입니다.
값을 하나 받아서
그 값을 it이라는 이름으로 쓸 수 있게 해 줍니다.
먼저 기본 모양을 봅시다.
val name = "길동"
name.let {
println("이름은 $it 입니다") // it = name
}
// 출력: 이름은 길동 입니다
여기서 it은
“방금 넘어온 값“을 가리키는 기본 이름입니다.
let이 null 처리에서 빛나는 이유는
Safe Call과 함께 쓸 때입니다.
val nickname: String? = "길동"
nickname?.let {
println("별명 길이: ${it.length}")
}
?.let { }의 뜻은 이렇습니다.
값이 null이 아닐 때만
중괄호 안의 코드를 실행한다.
즉, “null이 아닐 때만 실행하는 블록“을
만들어 주는 것입니다.
null인 경우에는
블록이 아예 실행되지 않습니다.
val nickname: String? = null
nickname?.let {
println("이 줄은 실행되지 않습니다")
}
// 아무것도 출력되지 않음
게다가 블록 안에서는it이 Non-null 타입입니다.
?.let을 통과했다는 것은
이미 null이 아님이 확인됐다는 뜻이기 때문입니다.
val nickname: String? = "길동"
nickname?.let {
// 이 안에서 it은 String (String?이 아님)
val upper = it.uppercase() // 안전하게 사용 가능
println(upper)
}
정리하면 이렇습니다.
값?.let { ... }은
“값이 있을 때만, 그 값으로 무언가 하기“입니다.
takeIf
takeIf는 “조건에 맞으면 그 값을,
아니면 null을 돌려주는” 함수입니다.
동작은 이렇습니다.
조건이 참이면 원래 값을 돌려주고,
조건이 거짓이면 null을 돌려준다.
코드로 보겠습니다.
val age = 20
val adult = age.takeIf { it >= 19 }
println(adult) // 20 (조건이 참이므로 값 그대로)
val age = 15
val adult = age.takeIf { it >= 19 }
println(adult) // null (조건이 거짓이므로 null)
takeIf는
“조건을 통과한 값만 남기고 싶을 때” 유용합니다.
Elvis 연산자와 함께 쓰면
“조건에 맞지 않으면 기본값“도 만들 수 있습니다.
val input = " " // 공백만 있는 문자열
val name = input.takeIf { it.isNotBlank() } ?: "이름 없음"
println(name) // 이름 없음
이 코드의 뜻은 이렇습니다.
- 입력값이 공백이 아니면 그 값을,
- 공백이면 “이름 없음“을 쓴다.
isNotBlank()는
“공백만 있는 게 아닌지“를 확인하는 함수입니다.
takeUnless
takeUnless는takeIf와 정반대입니다.
동작은 이렇습니다.
조건이 거짓이면 원래 값을 돌려주고,
조건이 참이면 null을 돌려준다.
이름 그대로
“unless(~가 아니라면)“의 느낌입니다.
val age = 15
val minor = age.takeUnless { it >= 19 }
println(minor) // 15 (19 이상이 아니므로 값 그대로)
val age = 20
val minor = age.takeUnless { it >= 19 }
println(minor) // null (19 이상이므로 null)
takeIf와 takeUnless는
같은 조건을 반대로 읽는 것뿐입니다.
어느 쪽이 더 읽기 자연스러운지에 따라
골라 쓰면 됩니다.
세 함수를 한눈에 정리하면 이렇습니다.
| 함수 | 하는 일 |
|---|---|
let | 값이 있을 때만, 그 값으로 코드 실행 |
takeIf | 조건이 참이면 값, 거짓이면 null |
takeUnless | 조건이 거짓이면 값, 참이면 null |
이 함수들은?., ?:와 조합할 때 특히 강력합니다.
null 처리 흐름을
한 줄로 깔끔하게 표현할 수 있게 해 줍니다.
5.7 초기화를 늦추기
값을 나중에 넣고 싶을 때
가끔 이런 상황이 있습니다.
변수를 선언할 때는
아직 넣을 값이 없지만,
나중에는 반드시 값이 채워지는 경우입니다.
예를 들어 스프링 같은 프레임워크가
값을 대신 넣어 주는 상황이 그렇습니다.
(이런 것은 나중 부에서 배웁니다.)
이럴 때 억지로 Nullable로 만들면
불편해집니다.
class UserService {
var repository: Repository? = null // 매번 null 검사를 해야 함
}
값을 쓸 때마다?.나 !!를 붙여야 하기 때문입니다.
“나중에 채워지지만, 채워진 뒤에는
절대 null이 아닌” 값을 위해
코틀린은 두 가지 방법을 준비했습니다.
바로 lateinit과 lazy입니다.
lateinit
lateinit은
“늦은 초기화(late initialization)“의 줄임말입니다.
초기화(initialization)란
변수에 처음으로 값을 넣는 일입니다.
lateinit은
“지금은 값을 안 넣지만,
곧 넣을 테니 기다려 달라“는 표시입니다.
class UserService {
lateinit var repository: Repository
fun setup() {
repository = Repository() // 나중에 값을 넣음
}
fun use() {
repository.findAll() // ?. 없이 바로 사용
}
}
lateinit을 쓰면
타입에 ?를 붙이지 않아도 됩니다.
값을 쓸 때도?.나 !! 없이 그냥 쓸 수 있습니다.
몇 가지 규칙이 있습니다.
var에만 쓸 수 있다 (val에는 못 씀)- 기본 타입(Int, Boolean 등)에는 못 쓴다
- 값을 넣기 전에 쓰면 예외가 난다
특히 마지막 규칙을 조심해야 합니다.
class UserService {
lateinit var repository: Repository
fun use() {
repository.findAll() // 값을 안 넣고 쓰면 예외 발생!
}
}
값을 넣기 전에 쓰면
이런 오류가 납니다.
kotlin.UninitializedPropertyAccessException:
lateinit property repository has not been initialized
“아직 초기화되지 않았다“는 뜻입니다.
값이 들어왔는지 궁금할 때는::변수명.isInitialized로 확인할 수 있습니다.
if (::repository.isInitialized) {
repository.findAll()
}
lazy
lazy는
“게으른(lazy) 초기화“입니다.
동작은 이렇습니다.
값을 미리 만들지 않고,
처음 사용하는 순간에 딱 한 번 만든다.
lazy는 val과 함께 씁니다.by lazy { } 모양으로 적습니다.
class Config {
val settings: String by lazy {
println("설정을 불러옵니다")
"설정값"
}
}
fun main() {
val config = Config()
println("아직 안 씀")
println(config.settings) // 이때 처음 만들어짐
println(config.settings) // 이미 만들어진 값을 재사용
}
실행 결과는 이렇습니다.
아직 안 씀
설정을 불러옵니다
설정값
설정값
눈여겨볼 점이 두 가지입니다.
먼저, “설정을 불러옵니다“가config.settings를 처음 쓸 때 나옵니다.
객체를 만들 때가 아니라,
값을 실제로 쓰는 순간에
만들어지기 때문입니다.
다음으로, 그 메시지가 한 번만 나옵니다.
두 번째부터는 이미 만든 값을
그대로 다시 쓰기 때문입니다.
값을 만드는 데 시간이 오래 걸린다면,lazy로 “필요할 때만” 만들게 하면
프로그램이 더 빨라질 수 있습니다.
두 방식의 차이
lateinit과 lazy는
둘 다 초기화를 미루지만
쓰임새가 다릅니다.
| 구분 | lateinit | lazy |
|---|---|---|
| 대상 | var | val |
| 값 넣는 시점 | 내가 직접, 원할 때 넣음 | 처음 쓸 때 자동으로 만들어짐 |
| 값 넣는 방법 | 변수 = 값 으로 대입 | by lazy { } 안에서 계산 |
| 기본 타입 가능? | 불가능 | 가능 |
| 어울리는 상황 | 외부가 값을 넣어 주는 경우 | 무거운 값을 필요할 때 만드는 경우 |
간단한 기준은 이렇습니다.
내가 직접 값을 넣어 줘야 하면
lateinit,
처음 쓸 때 알아서 만들어지면 좋겠으면lazy.
둘 다 Nullable을 쓰지 않고도
“나중에 채워지는 값“을
안전하게 다루게 해 줍니다.
5.8 Collection과 Null
물음표의 위치가 중요하다
컬렉션(collection)은
“값을 여러 개 담는 그릇“입니다.
(리스트 등은 7부에서 자세히 다룹니다.)
여기서는 컬렉션과 null이 만날 때
헷갈리기 쉬운 부분만 짚겠습니다.
리스트에 ?를 붙이는 위치에 따라
뜻이 완전히 달라집니다.
세 가지 경우를 봅시다.
val a: List<String?> // 리스트 안의 요소가 null일 수 있음
val b: List<String>? // 리스트 자체가 null일 수 있음
val c: List<String?>? // 요소도, 리스트도 null일 수 있음
하나씩 뜯어보겠습니다.
List<String?>은
“리스트는 반드시 있지만,
그 안의 값들은 null일 수 있다“는 뜻입니다.
val names: List<String?> = listOf("길동", null, "순신")
// 리스트는 있고, 요소 중 하나가 null
List<String>?은
“리스트 안의 값들은 모두 있지만,
리스트 자체가 없을 수 있다“는 뜻입니다.
val names: List<String>? = null
// 리스트 자체가 null
List<String?>?은
“둘 다 null일 수 있다“는 뜻입니다.
val names: List<String?>? = null
// 리스트도 null일 수 있고, 요소도 null일 수 있음
표로 정리하면 한눈에 들어옵니다.
| 타입 | 리스트 자체 | 안의 요소 |
|---|---|---|
List<String> | 있음 | 있음 |
List<String?> | 있음 | null 가능 |
List<String>? | null 가능 | 있음 |
List<String?>? | null 가능 | null 가능 |
?가 꺾쇠(< >) 안에 있으면 요소,
꺾쇠 밖에 있으면 리스트 자체.
이 위치 감각을 익혀 두면
복잡한 타입도 차분히 읽을 수 있습니다.
filterNotNull
리스트 안에 null이 섞여 있을 때,
null만 걸러 내고 싶을 때가 많습니다.
이때 쓰는 함수가filterNotNull입니다.
이름 그대로
“null이 아닌 것만 걸러 낸다“는 뜻입니다.
val names: List<String?> = listOf("길동", null, "순신", null)
val clean = names.filterNotNull()
println(clean) // [길동, 순신]
여기서 아주 중요한 점이 있습니다.
결과의 타입이 바뀝니다.
names는 List<String?>였지만,filterNotNull()을 거친 clean은List<String>입니다.
null을 걸러 냈으므로
더 이상 요소가 null일 수 없기 때문입니다.
val clean: List<String> = names.filterNotNull()
for (name in clean) {
println(name.length) // ?. 없이 안전하게 사용
}
null을 제거한 뒤에는?.나 !! 없이
바로 쓸 수 있게 되는 것입니다.
mapNotNull
mapNotNull은 조금 더 똑똑합니다.
“변환과 null 걸러 내기를 한 번에” 합니다.
map은 각 요소를
다른 값으로 바꾸는 함수입니다.
(자세한 내용은 7부에서 배웁니다.)
mapNotNull은 여기에
“변환 결과가 null이면 버린다“를 더한 것입니다.
예를 들어 문자열 리스트를
숫자로 바꾸는 상황을 봅시다.
val inputs = listOf("1", "둘", "3", "네", "5")
val numbers = inputs.mapNotNull { it.toIntOrNull() }
println(numbers) // [1, 3, 5]
toIntOrNull()은
“숫자로 바꿀 수 있으면 숫자를,
없으면 null을 돌려주는” 함수입니다.
"1"→1"둘"→ null (숫자가 아님 → 버려짐)"3"→3"네"→ null (버려짐)"5"→5
그 결과 숫자로 바꿀 수 있는 것만
깔끔하게 남았습니다.
numbers의 타입도List<Int>입니다. (null이 없음)
filterNotNull과 mapNotNull을
비교하면 이렇습니다.
| 함수 | 하는 일 |
|---|---|
filterNotNull | 이미 있는 null 요소를 걸러 냄 |
mapNotNull | 값을 변환하면서, 결과가 null이면 걸러 냄 |
두 함수 모두
“null이 없는 깨끗한 리스트“를
만들어 준다는 공통점이 있습니다.
컬렉션을 다룰 때
아주 자주 쓰게 될 도구들입니다.
5장을 마치며
이 장에서 우리는
코틀린의 핵심인 Null Safety를 배웠습니다.
- null이 될 수 있는 타입은
?로 표시하고,
코틀린은 이를 타입 단계에서 구분한다는 점 - Safe Call(
?.)로
null이 아닐 때만 안전하게 실행하는 법 - Elvis 연산자(
?:)로
기본값을 주거나 예외를 던지는 법 - Not-null Assertion(
!!)의 위험과
이를 피하는 방법 - Safe Cast(
as?)로
캐스팅 실패를 null로 안전하게 다루는 법 let,takeIf,takeUnless로
Nullable 값을 흐름 속에서 다루는 법lateinit과lazy로
초기화를 안전하게 늦추는 두 가지 방법- 컬렉션에서
?의 위치가 갖는 의미와,filterNotNull,mapNotNull로 null을 걸러 내는 법
가장 중요한 습관은 하나입니다.
null을 만나면
!!로 우기지 말고,?.와?:로 안전하게 흘려보내자.
이 습관만 몸에 배어도
서버에서 가장 자주 터지는 오류를
크게 줄일 수 있습니다.
한 가지 덧붙일 것이 있습니다.
자바 코드를 코틀린에서 불러 쓸 때는,
코틀린이 null 여부를 확신하지 못하는
“플랫폼 타입(platform type)“이 등장합니다.
이 내용은 바로 다음 6장에서
이어서 다룹니다.
null을 두려워하지 않고
다룰 수 있게 되었으니,
이제 자바와 코틀린이 만나는 지점으로
넘어가 봅시다.
6장. Java와 Kotlin 사이의 Null
앞 장에서 우리는 코틀린의 Null Safety를 배웠습니다.
타입 뒤에 ?가 있느냐 없느냐로
null을 다루는 방법이었죠.
그런데 여기서 한 가지 의문이 생깁니다.
코틀린은 자바 라이브러리를 그대로 가져다 씁니다.
(1장에서 이야기했던 내용입니다.)
그런데 자바에는 ? 같은 표시가 없습니다.
자바는 모든 값이 null이 될 수도, 안 될 수도 있으니까요.
그렇다면 코틀린은
자바에서 넘어온 값을
null로 봐야 할까요, 아닐까요?
이 장은 바로 그 경계에 대한 이야기입니다.
코틀린과 자바가 만나는 지점에서
null이 어떻게 다뤄지는지 살펴봅니다.
그리고 백엔드 개발자가
실제 서버에서 만나는 null들을
어떻게 다뤄야 하는지도 함께 고민해 봅니다.
6.1 Platform Type
Java에서 넘어오는 String!
먼저 코틀린 코드 하나를 봅시다.
val name: String = "홍길동" // null이 될 수 없음
val nick: String? = null // null이 될 수 있음
5장에서 배운 그대로입니다.?가 없으면 null 금지,?가 있으면 null 허용입니다.
그런데 자바 코드에서 값을 가져오면
이 규칙이 애매해집니다.
예를 들어 자바로 만든 클래스가 있다고 합시다.
// Java 코드
public class User {
public String getName() {
return name;
}
}
이 getName()은 무엇을 돌려줄까요?
문자열을 돌려주긴 하는데,
그게 null일 수도 있습니다.
자바에는 “이 값은 절대 null이 아니다“라고
표시하는 문법이 없기 때문입니다.
그래서 코틀린은
이 값을 특별한 이름으로 부릅니다.
String!
느낌표(!)가 붙은 이 타입을
플랫폼 타입(platform type)이라고 합니다.
플랫폼 타입이란
“코틀린이 null 여부를 확신하지 못하는 타입“입니다.
느낌표는 이렇게 읽으면 됩니다.
“null일 수도 있고 아닐 수도 있어.
그건 네가 판단해.”
Kotlin이 null 여부를 판단할 수 없는 이유
왜 코틀린은 판단하지 못할까요?
이유는 간단합니다.
자바 코드 안에 정보가 없기 때문입니다.
코틀린은 ?로 null 여부를 구분합니다.
하지만 자바에는 그런 구분 자체가 없습니다.
즉, 코틀린 입장에서는
“이 자바 값이 null인지 아닌지“를
알아낼 방법이 없는 것입니다.
여기서 코틀린은 두 가지 선택을 할 수 있었습니다.
첫째, 모든 자바 값을 nullable(String?)로 취급하기.
그러면 안전하긴 하지만,
자바 값을 쓸 때마다 ? 처리를 해야 해서
코드가 너무 번거로워집니다.
둘째, 모든 자바 값을 non-null(String)로 취급하기.
그러면 편하지만,
실제로 null이 오면 그대로 터져 버립니다.
코틀린은 그 사이의 절충안을 택했습니다.
그것이 바로 플랫폼 타입입니다.
정리하면 이렇습니다.
| 상황 | 타입 표기 | 뜻 |
|---|---|---|
| 코틀린 non-null | String | null 절대 안 됨 |
| 코틀린 nullable | String? | null 될 수 있음 |
| 자바에서 넘어옴 | String! | 코틀린이 모름 (개발자가 책임) |
여기서 중요한 점이 하나 있습니다.
플랫폼 타입은
우리가 코드에 직접 적는 타입이 아닙니다.
val name: String! = user.getName() // 이렇게 직접 쓰지 않음
String!은 컴파일러가 속으로 쓰는 표현입니다.
우리는 값을 받을 때 타입을 직접 정해 주면 됩니다.
// 이 값은 null이 아니라고 내가 확신할 때
val name: String = user.getName()
// null일 수도 있다고 안전하게 받을 때
val name: String? = user.getName()
즉, 플랫폼 타입은
“코틀린이 판단을 개발자에게 넘기는 순간“입니다.
그래서 자바 값을 받을 때는
습관처럼 이렇게 생각하는 것이 좋습니다.
“이 자바 값,
정말 null이 아닐까?”
이 한 번의 의심이
서버에서 터질 오류를 미리 막아 줍니다.
6.2 Java API 호출하기
Nullable Annotation
앞에서 “자바에는 null 정보가 없다“고 했습니다.
그런데 사실 방법이 아예 없는 것은 아닙니다.
자바에서도 값에
“이건 null이 될 수 있어“라거나
“이건 null이 아니야“라고
표시를 붙일 수 있습니다.
이때 쓰는 것이 애너테이션(annotation)입니다.
애너테이션이란
코드에 붙이는 “설명 딱지“라고 생각하면 쉽습니다.@로 시작하는 표시입니다.
대표적인 두 가지는 이렇습니다.
@Nullable: 이 값은 null이 될 수 있다@NotNull: 이 값은 null이 될 수 없다
자바 코드에 붙이면 이런 모습입니다.
// Java 코드
public class User {
@NotNull
public String getName() { // null 아님을 약속
return name;
}
@Nullable
public String getNickname() { // null일 수 있음
return nickname;
}
}
코틀린은 이 딱지를 읽어 줍니다.
그래서 애너테이션이 붙어 있으면
플랫폼 타입(String!)이 아니라
정확한 타입으로 인식합니다.
| 자바 쪽 표시 | 코틀린이 보는 타입 |
|---|---|
@NotNull String | String |
@Nullable String | String? |
| 아무 표시 없음 | String! (플랫폼 타입) |
즉, 자바 라이브러리가
이 애너테이션을 잘 붙여 두었다면
코틀린에서 훨씬 안전하게 쓸 수 있습니다.
좋은 자바 라이브러리일수록
null 애너테이션을 꼼꼼히 붙여 둡니다.
스프링 같은 유명한 프레임워크도
이 애너테이션을 적극적으로 붙여 놓았습니다.
그래서 스프링을 코틀린으로 쓸 때
null 처리가 꽤 자연스럽게 이어집니다.
(스프링과 코틀린의 관계는 나중에 스프링 편에서 다룹니다.)
Java 코드에서 발생하는 NPE
이제 가장 조심해야 할 상황을 봅시다.
애너테이션도 없고,
그래서 플랫폼 타입으로 넘어온 자바 값.
그걸 우리가 non-null로 받았을 때입니다.
여기서 잠깐 NPE라는 말을 짚고 갑시다.
NPE는 NullPointerException,
우리말로 “널 포인터 예외“입니다.
NPE란
“값이 없는데(null) 그 값을 쓰려다”
나는 오류입니다.
자바 세계에서 가장 흔한 오류이고,
서버를 멈추게 하는 대표적인 원인입니다.
코드로 보겠습니다.
// user.getName()이 사실은 null을 돌려줬다면?
val name: String = user.getName() // 여기서 문제
println(name.length)
이 코드는
컴파일할 때는 오류가 나지 않습니다.
코틀린이 getName()을 플랫폼 타입으로 보고,
“개발자가 non-null이라고 했으니 믿어 줄게” 하기 때문입니다.
하지만 실제로 null이 넘어오면
실행 중에 NPE가 터집니다.
Exception in thread "main" java.lang.NullPointerException
여기서 코틀린의 특징이 드러납니다.
순수 코틀린 코드에서는
NPE가 거의 나지 않습니다.? 규칙이 미리 막아 주기 때문입니다.
하지만 자바와 만나는 경계에서는
여전히 NPE가 날 수 있습니다.
코틀린의 null 안전은
“코틀린 안에서” 강력합니다.
자바 경계에서는 개발자가 한 번 더 챙겨야 합니다.
그래서 자바 API를 부를 때는
안전한 습관이 필요합니다.
확신이 없다면
일단 nullable(?)로 받는 것입니다.
// 안전하게 nullable로 받기
val name: String? = user.getName()
// 그다음 5장에서 배운 방법으로 처리
println(name?.length) // 안전 호출
val safe = name ?: "이름 없음" // 엘비스 연산자
?.(안전 호출)과 ?:(엘비스 연산자)는
5장에서 배운 도구입니다.
자바 경계에서 이 도구들이 특히 빛을 발합니다.
정리하면 이렇습니다.
- 자바 값을 non-null로 받으면 → 편하지만 NPE 위험
- 자바 값을 nullable로 받으면 → 조금 번거롭지만 안전
처음에는 안전한 쪽을 기본으로 삼기를 권합니다.
6.3 백엔드에서 만나는 Null
지금까지는 자바와의 경계에서
null이 어떻게 다뤄지는지 봤습니다.
이제 시선을 조금 넓혀 봅시다.
백엔드 서버를 만들다 보면
null은 정말 자주 등장합니다.
경계에서만 나오는 것이 아닙니다.
이 절에서는 서버에서 만나는
대표적인 null 상황 네 가지를 살펴봅니다.
요청 값
서버는 클라이언트로부터 요청(request)을 받습니다.
여기서 클라이언트란
서버에 무언가를 요청하는 쪽입니다.
웹 브라우저나 앱이 대표적입니다.
문제는,
요청에 담긴 값이 항상 있는 것은 아니라는 점입니다.
예를 들어 회원가입 요청을 받는다고 합시다.
- 이름 : 반드시 있어야 함
- 전화번호 : 안 적어도 됨 (선택 사항)
이때 전화번호는
값이 올 수도 있고 안 올 수도 있습니다.
안 오면 그 값은 null이 됩니다.
그래서 요청을 담는 코드에서는
선택 값에 ?를 붙입니다.
// 회원가입 요청을 담는 그릇
class SignupRequest(
val name: String, // 필수 → non-null
val phone: String?, // 선택 → nullable
)
이렇게만 해 두어도
“이 값은 없을 수 있다“는 사실이
타입에 그대로 드러납니다.
코드를 읽는 사람이
문서를 따로 안 봐도 알 수 있는 것입니다.
DB 조회 결과
서버는 데이터베이스(database)에서
데이터를 꺼내 옵니다.
데이터베이스란
데이터를 저장해 두는 창고입니다.
줄여서 DB라고 부릅니다.
(DB는 나중에 데이터 접근 편에서 자세히 다룹니다.)
그런데 DB에서 무언가를 찾을 때
“찾는 게 없을” 수 있습니다.
예를 들어
“3번 회원을 찾아 줘“라고 했는데
3번 회원이 이미 탈퇴했다면?
이럴 때 조회 결과는 null이 됩니다.
// id로 회원을 찾는 함수 (없으면 null)
fun findUser(id: Long): User? {
// ... DB에서 조회 ...
}
반환 타입이 User?인 것에 주목합시다.
“찾으면 User를 주고,
없으면 null을 준다.”
이 의미가 타입 하나로 표현됩니다.
이 함수를 쓰는 쪽에서는
자연스럽게 null 처리를 하게 됩니다.
val user = findUser(3)
if (user == null) {
println("회원을 찾을 수 없습니다.")
} else {
println("${user.name}님 반갑습니다.")
}
DB 조회에서 “없음“은 아주 흔한 일입니다.
그래서 null 처리는
백엔드에서 매일 하는 일이라고 봐도 됩니다.
외부 API 응답
서버는 다른 서버를 부를 때도 있습니다.
예를 들어
날씨 정보를 얻으려고
날씨 서버에 물어보는 식입니다.
이렇게 외부의 서버를 부르는 통로를
외부 API(external API)라고 합니다.
API는 Application Programming Interface,
쉽게 말해
“프로그램끼리 대화하는 창구“입니다.
외부 API의 응답에는
null이 특히 자주 섞여 있습니다.
이유는 이렇습니다.
- 상대 서버가 값을 안 줄 수도 있다
- 어떤 항목은 원래 비어 있을 수 있다
- 응답 형식이 우리 예상과 다를 수 있다
즉, 외부 API는
“내가 통제할 수 없는 값“입니다.
그래서 외부 응답을 다룰 때는
더욱 조심해야 합니다.
// 외부 날씨 API 응답을 담는 그릇
class WeatherResponse(
val temperature: Double?, // 없을 수 있음
val description: String?, // 없을 수 있음
)
외부 값은 “일단 의심“이
기본자세입니다.
필수처럼 보여도 nullable로 두는 편이 안전합니다.
Optional과 Nullable
자바를 조금 아는 분이라면Optional이라는 것을 들어 봤을 수 있습니다.
Optional은 자바가
“값이 없을 수 있음“을 표현하려고 만든 도구입니다.
자바에는 코틀린의 ?가 없으니,
대신 값을 상자에 담아
“안이 비었을 수도 있다“고 알려 준 것입니다.
자바 코드로는 이렇게 생겼습니다.
// Java 코드
Optional<User> findUser(Long id);
이 함수는 User를 바로 주는 게 아니라
User가 담긴 “상자“를 줍니다.
상자를 열어 봐야 값이 있는지 알 수 있습니다.
그런데 코틀린에는 이미 ?가 있습니다.
그래서 굳이 상자가 필요 없습니다.
같은 의미를 이렇게 간단히 씁니다.
// Kotlin 코드
fun findUser(id: Long): User?
두 방식을 비교하면 이렇습니다.
| 구분 | 자바 Optional | 코틀린 Nullable |
|---|---|---|
| 표현 | Optional<User> | User? |
| 값 꺼내기 | .get(), .orElse() | ?., ?: |
| 코드 길이 | 길다 | 짧다 |
| 코틀린과 어울림 | 어색함 | 자연스러움 |
정리하면 이렇습니다.
자바에서 Optional이 하던 일을,
코틀린에서는?하나가 대신합니다.
그래서 코틀린 코드에서는Optional을 거의 쓰지 않습니다.
그냥 nullable 타입을 쓰는 것이 정석입니다.
다만 자바 라이브러리가Optional을 돌려주는 경우는 있습니다.
그럴 때는 코틀린에서
값을 꺼내 nullable로 바꿔 받으면 됩니다.
// 자바가 Optional<User>를 돌려줄 때
val user: User? = repository.findUser(3).orElse(null)
이렇게 하면
그다음부터는 코틀린 방식(?., ?:)으로
편하게 이어 갈 수 있습니다.
6.4 Null을 어떻게 모델링할 것인가
지금까지는 null을 “다루는 방법“을 봤습니다.
이제 한 단계 더 나아가 봅시다.
“애초에 null을 써야 하는가“라는 질문입니다.
값이 없는 상황을 표현하는 방법은
null만 있는 것이 아닙니다.
상황에 따라
더 좋은 방법이 있을 수 있습니다.
이렇게 “값이 없음을 어떻게 표현할지 정하는 일“을
모델링(modeling)이라고 합니다.
세 가지 갈림길을 살펴봅시다.
null과 기본값
첫 번째 갈림길입니다.
“값이 없을 때, 그냥 기본값을 쓰면 안 될까?”
기본값(default value)이란
“값이 없을 때 대신 쓰는 값“입니다.
예를 들어 봅시다.
사용자가 자기소개를 안 적었다고 합시다.
이때 두 가지 방법이 있습니다.
방법 1. null로 두기.
val introduction: String? = null
방법 2. 빈 문자열을 기본값으로 두기.
val introduction: String = ""
어느 쪽이 좋을까요?
정답은 “경우에 따라 다르다“입니다.
- 자기소개가 없다는 사실 자체가 중요하다면 → null
- 그냥 비어 있는 걸로 취급해도 된다면 → 기본값
기본값을 쓰면
그 뒤의 코드가 편해집니다.
null 처리를 안 해도 되니까요.
// 기본값이라면 그냥 바로 쓸 수 있음
println("소개 길이: ${introduction.length}")
그래서 이런 기준을 세울 수 있습니다.
“없음“과 “비어 있음“이 같은 의미라면
기본값이 더 깔끔하다.
null과 예외
두 번째 갈림길입니다.
“값이 없으면, 그냥 오류를 내면 안 될까?”
여기서 예외(exception)란
“프로그램이 정상적으로 진행할 수 없는 상황“을
알리는 신호입니다.
(예외는 나중에 예외 처리 편에서 자세히 다룹니다.)
예를 들어 봅시다.
“결제 정보를 찾아 줘“라고 했는데
결제 정보가 없다면?
이건 그냥 “없음“이 아닙니다.
있어야 할 것이 없는,
분명히 잘못된 상황입니다.
이럴 때는 null보다 예외가 낫습니다.
fun findPayment(orderId: Long): Payment {
return paymentRepository.find(orderId)
?: throw IllegalStateException("결제 정보가 없습니다: $orderId")
}
?:(엘비스 연산자) 뒤에서
바로 예외를 던지는 모습입니다.
이렇게 하면 반환 타입이Payment?가 아니라 Payment가 됩니다.
즉, 이 함수를 쓰는 쪽은
null 걱정 없이 값을 그대로 쓸 수 있습니다.
null과 예외를 나누는 기준은 이렇습니다.
| 상황 | 어울리는 방법 |
|---|---|
| 없을 수도 있는 게 자연스러움 | null |
| 없으면 명백히 잘못된 것 | 예외 |
“없어도 괜찮은가?“를 스스로 물어보세요.
괜찮으면 null,
안 괜찮으면 예외입니다.
null과 별도의 상태 객체
세 번째 갈림길입니다.
조금 더 발전된 방법입니다.
가끔 null 하나로는
상황을 다 설명하기 어려울 때가 있습니다.
예를 들어
외부 API를 불렀는데 결과가 없다고 합시다.
이 “없음“에는
사실 여러 이유가 숨어 있습니다.
- 성공했는데 진짜로 데이터가 없음
- 요청이 잘못돼서 실패함
- 네트워크가 끊겨서 실패함
이 셋을 전부 null로만 표현하면
“왜 없는지“를 알 수 없습니다.
이럴 때는
상태를 담는 별도의 객체를 만들면 좋습니다.
// 결과 상태를 표현하는 객체
sealed class Result {
data class Success(val data: User) : Result()
object NotFound : Result()
data class Error(val message: String) : Result()
}
여기 나온 sealed class와 data class는
지금 자세히 몰라도 괜찮습니다.
(클래스 편에서 다룹니다.)
지금은 이 그림만 기억하면 됩니다.
null은 “없다“까지만 말해 줍니다.
상태 객체는 “왜 없는지“까지 말해 줍니다.
이 방식은
성공과 실패를 구분해야 하는
복잡한 상황에서 특히 강력합니다.
물론 모든 곳에 쓸 필요는 없습니다.
단순한 “있음/없음“에는
그냥 nullable(?)이 훨씬 간단합니다.
세 가지 방법을 마지막으로 정리해 봅시다.
| 방법 | 언제 쓰나 | 예 |
|---|---|---|
| 기본값 | 없음 = 비어 있음일 때 | 빈 문자열, 0 |
| null | 없을 수 있는 게 자연스러움 | 선택 입력 값 |
| 예외 | 없으면 명백히 잘못됨 | 필수 데이터 누락 |
| 상태 객체 | 없는 이유가 여러 개일 때 | 성공/실패/오류 구분 |
이 선택에 정답은 없습니다.
상황에 맞게 고르는 감각을
경험으로 쌓아 가면 됩니다.
6장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 자바에서 넘어온 값은 플랫폼 타입(
String!)이 되고,
코틀린이 null 여부를 확신하지 못한다는 점 @Nullable,@NotNull애너테이션이 있으면
코틀린이 정확한 타입으로 인식한다는 점- 자바 경계에서는 여전히 NPE가 날 수 있어,
확신이 없으면 nullable로 받는 것이 안전하다는 점 - 백엔드에서 만나는 null(요청 값, DB 조회,
외부 API 응답)과 자바 Optional과의 관계 - 값이 없음을 표현하는 여러 방법(기본값, null,
예외, 상태 객체)과 그 선택 기준
5장에서 배운 null 도구들(?, ?., ?:)이
자바 경계에서 어떻게 쓰이는지 보았습니다.
문법을 아는 것과
“언제 무엇을 쓸지” 아는 것은 다릅니다.
이 장은 후자를 위한 장이었습니다.
다음 장부터는
코틀린으로 코드를 구조화하는 방법,
즉 함수와 클래스의 세계로 들어갑니다.
7장. 클래스와 프로퍼티
지금까지 우리는 값 하나하나를 따로 다뤄 왔습니다.
이름은 이름대로, 나이는 나이대로
변수에 담아 두었습니다.
그런데 프로그램이 커지면
“서로 관련된 값들“을 하나로 묶고 싶어집니다.
예를 들어 회원 한 명은
이름, 나이, 이메일을 함께 가집니다.
이렇게 관련된 값들을 하나로 묶는 도구가
바로 클래스(class)입니다.
이 장에서는 클래스를 만드는 방법과,
클래스가 값을 담아 두는 그릇인 프로퍼티(property)를 배웁니다.
여기서부터가 이 책의 4부,
“객체를 설계하는 코틀린“의 시작입니다.
자바를 조금 아는 분이라면
클래스라는 단어가 익숙할 수 있습니다.
하지만 코틀린의 클래스는
자바보다 훨씬 짧고 깔끔합니다.
그 차이를 하나씩 확인해 봅시다.
7.1 클래스 만들기
클래스란 무엇인가
클래스를 배우기 전에
“객체“라는 말부터 짚고 넘어가겠습니다.
우리가 다루는 회원, 상품, 주문처럼
“현실에 있는 무언가“를 프로그램 안에서 표현한 것을
객체(object)라고 부릅니다.
그리고 이 객체를 만들기 위한
“설계도“가 바로 클래스입니다.
비유하자면 이렇습니다.
클래스는 붕어빵 “틀“이고,
객체는 그 틀로 찍어낸 “붕어빵“입니다.
틀은 하나지만,
그 틀로 붕어빵을 여러 개 만들 수 있습니다.
마찬가지로 클래스는 하나지만,
그 클래스로 객체를 여러 개 만들 수 있습니다.
클래스 선언하기
코틀린에서 클래스는class라는 키워드로 만듭니다.
가장 단순한 클래스를 하나 만들어 보겠습니다.
class Member
fun main() {
println("Member 클래스가 만들어졌습니다")
}
class Member,
이 한 줄이 전부입니다.
중괄호({ })조차 없어도 됩니다.
내용이 없는 클래스라면 이렇게 선언만 해도 됩니다.
자바에서는 같은 클래스를 만들 때
최소한 중괄호가 필요했습니다.
// 자바
public class Member {
}
코틀린은 필요 없는 부분을
과감히 생략할 수 있습니다.
객체 생성하기
클래스는 설계도일 뿐입니다.
실제로 쓰려면 객체를 만들어야 합니다.
객체를 만드는 일을
생성(instantiation)이라고 부릅니다.
만들어진 객체 하나하나를
인스턴스(instance)라고도 부릅니다.
코틀린에서 객체를 만드는 방법은 간단합니다.
class Member
fun main() {
val member = Member()
println(member)
}
Member(),
클래스 이름 뒤에 괄호를 붙이면 객체가 만들어집니다.
여기서 자바를 아는 분이라면
빠진 단어를 눈치챘을 것입니다.
바로 new입니다.
// 자바
Member member = new Member();
자바는 객체를 만들 때new 키워드를 꼭 붙여야 했습니다.
코틀린에는 new가 없습니다.
그냥 Member()라고만 쓰면 됩니다.
이렇게 만든 객체는
변수에 담아 두고 쓸 수 있습니다.
여기서는 val member에 담았습니다.
프로퍼티 추가하기
빈 클래스는 아직 아무 정보도 담지 못합니다.
이제 회원에게
이름과 나이를 넣어 보겠습니다.
클래스 안에 값을 담는 자리를
프로퍼티(property)라고 부릅니다.
우리말로는 “속성“이라고 옮기기도 합니다.
클래스가 가지는 값이라고 생각하면 쉽습니다.
class Member {
var name: String = "이름 없음"
var age: Int = 0
}
프로퍼티는 우리가 2장에서 배운 변수와
생김새가 똑같습니다.val이나 var로 선언하고, 타입을 적습니다.
다른 점은 위치입니다.
클래스 안에 들어가 있으면 프로퍼티,
함수 안에 있으면 그냥 지역 변수입니다.
프로퍼티 사용하기
이제 객체를 만들고
프로퍼티에 값을 넣고 읽어 보겠습니다.
프로퍼티에 접근할 때는
객체 이름 뒤에 점(.)을 찍습니다.
class Member {
var name: String = "이름 없음"
var age: Int = 0
}
fun main() {
val member = Member()
member.name = "홍길동"
member.age = 20
println("이름: ${member.name}")
println("나이: ${member.age}")
}
실행하면 이렇게 출력됩니다.
이름: 홍길동
나이: 20
member.name = "홍길동": 프로퍼티에 값을 넣기member.name: 프로퍼티의 값을 읽기
점 하나로 값을 넣고 읽을 수 있어서
직관적입니다.
한 가지 짚어 둘 점이 있습니다.
여기서 member는 val로 선언했는데도member.name은 바꿀 수 있었습니다.
val member는
“member라는 변수에 다른 객체를 다시 담을 수 없다“는 뜻이지,
“객체 안의 값을 못 바꾼다“는 뜻이 아닙니다.
객체 안의 값을 바꾸지 못하게 하는 방법은
7.5에서 자세히 다룹니다.
7.2 생성자
생성자가 필요한 이유
앞의 코드에는 조금 불편한 점이 있었습니다.
객체를 먼저 만들고,
그다음에 값을 하나씩 채워 넣었습니다.
val member = Member()
member.name = "홍길동"
member.age = 20
이렇게 하면
값을 빠뜨릴 위험이 있습니다.
이름만 넣고 나이를 깜빡한다면,age는 처음에 넣어 둔 0인 채로 남습니다.
그래서 더 좋은 방법이 있습니다.
객체를 만드는 바로 그 순간에
필요한 값을 함께 받는 것입니다.
이때 쓰는 것이 생성자(constructor)입니다.
생성자란
“객체를 만들 때 실행되는 특별한 코드“입니다.
주로 프로퍼티에 초기값을 채우는 일을 합니다.
Primary Constructor
코틀린에서 가장 많이 쓰는 생성자는
클래스 이름 바로 옆에 적는 방식입니다.
이것을 주 생성자(primary constructor)라고 부릅니다.
class Member(val name: String, val age: Int)
fun main() {
val member = Member("홍길동", 20)
println("이름: ${member.name}")
println("나이: ${member.age}")
}
클래스 이름 옆 괄호 안에val name: String, val age: Int를 적었습니다.
이 짧은 한 줄이 두 가지 일을 동시에 합니다.
- 객체를 만들 때 이름과 나이를 받는다
- 받은 값으로 프로퍼티를 만든다
즉, 앞에서 여러 줄로 하던 일을
한 줄로 끝낸 것입니다.
객체를 만들 때는Member("홍길동", 20)처럼
괄호 안에 값을 순서대로 넣어 줍니다.
여기서 val을 var로 바꾸면
나중에 값을 바꿀 수 있는 프로퍼티가 됩니다.
class Member(var name: String, var age: Int)
자바로 같은 클래스를 만들면
훨씬 길어집니다.
// 자바
public class Member {
private final String name;
private final int age;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
}
같은 일을 하는데도
자바는 여러 줄이 필요합니다.
코틀린은 이 모든 것을class Member(val name: String, val age: Int) 한 줄로 표현합니다.
init 블록
생성자에서
값을 채우는 것 말고
다른 일도 하고 싶을 때가 있습니다.
예를 들어 “나이가 음수면 안 된다” 같은
검사를 하고 싶을 수 있습니다.
이럴 때 init 블록을 씁니다.
init은 initialize(초기화)의 줄임말입니다.
객체가 만들어질 때
자동으로 한 번 실행되는 코드 묶음입니다.
class Member(val name: String, val age: Int) {
init {
println("$name 회원이 생성되었습니다")
}
}
fun main() {
val member = Member("홍길동", 20)
}
실행하면 이렇게 출력됩니다.
홍길동 회원이 생성되었습니다
객체를 만들자마자init 블록이 스스로 실행된 것입니다.
init 블록은 값 검사에 특히 자주 쓰입니다.
class Member(val name: String, val age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 음수일 수 없습니다")
}
}
}
이렇게 하면
잘못된 나이로 객체를 만드는 순간
바로 오류가 발생합니다.
덕분에 “잘못된 상태의 객체“가
프로그램 안을 돌아다니는 일을 막을 수 있습니다.
(예외를 던지는 throw는 8부에서 자세히 다룹니다.)
이렇게 “만들 때부터 올바른 객체“를 보장하는 생각은
백엔드 도메인 설계에서 아주 중요합니다.
(11부 34장에서 다시 깊게 다룹니다.)
Secondary Constructor
대부분은 주 생성자 하나로 충분합니다.
하지만 가끔
“객체를 만드는 방법을 여러 가지로 열어 두고” 싶을 때가 있습니다.
이럴 때 쓰는 것이
보조 생성자(secondary constructor)입니다.
보조 생성자는 클래스 안에서constructor라는 키워드로 만듭니다.
class Member(val name: String, val age: Int) {
constructor(name: String) : this(name, 0) {
println("나이를 모른 채 회원을 만들었습니다")
}
}
fun main() {
val a = Member("홍길동", 20) // 주 생성자 사용
val b = Member("이순신") // 보조 생성자 사용
println("${b.name}, ${b.age}세")
}
실행하면 이렇게 출력됩니다.
나이를 모른 채 회원을 만들었습니다
이순신, 0세
여기서 눈여겨볼 부분은: this(name, 0)입니다.
이것은
“주 생성자를 대신 불러 달라“는 뜻입니다.
이름만 받아서, 나이는 0으로 채워
주 생성자에게 넘기는 것입니다.
즉, 보조 생성자는
언제나 주 생성자를 거쳐 갑니다.
정리하면 이렇습니다.
| 구분 | 위치 | 개수 |
|---|---|---|
| 주 생성자 | 클래스 이름 옆 | 하나 |
| 보조 생성자 | 클래스 안 constructor | 여러 개 가능 |
한 가지 조언을 덧붙이자면,
코틀린에서는 보조 생성자를 쓸 일이 생각보다 적습니다.
값을 채우는 목적이라면
“기본값(default parameter)“으로 해결되는 경우가 많기 때문입니다.
(기본 인자는 4장에서 배웠습니다.)
// 보조 생성자 대신 기본값으로 해결
class Member(val name: String, val age: Int = 0)
이렇게 하면Member("이순신")처럼 나이를 생략해도
자동으로 0이 들어갑니다.
보조 생성자 없이
같은 편리함을 얻은 것입니다.
7.3 Getter와 Setter
Kotlin Property의 정체
여기서 코틀린 클래스의
숨은 동작 하나를 밝히겠습니다.
우리가 이렇게 값을 읽고 쓸 때,
member.name = "홍길동" // 값 쓰기
println(member.name) // 값 읽기
겉보기에는 프로퍼티에
직접 접근하는 것처럼 보입니다.
하지만 실제로는
숨겨진 함수 두 개가 대신 일하고 있습니다.
- 값을 읽을 때 : 게터(getter)라는 함수가 호출됨
- 값을 쓸 때 : 세터(setter)라는 함수가 호출됨
게터는 “값을 꺼내 주는 함수”,
세터는 “값을 넣어 주는 함수“라고 생각하면 됩니다.
코틀린은 프로퍼티를 만들 때
이 게터와 세터를 자동으로 함께 만들어 줍니다.
그래서 우리는
게터와 세터를 직접 쓰지 않고도
점(.) 하나로 편하게 값을 다룰 수 있었던 것입니다.
겉모습은 값에 직접 접근하는 것 같지만,
속에서는 게터와 세터가 대신 움직입니다.
val로 만든 프로퍼티는
값을 바꿀 수 없으므로 게터만 만들어집니다.var로 만든 프로퍼티는
게터와 세터가 모두 만들어집니다.
Custom Getter
게터가 함수라면,
그 안의 동작을 우리가 직접 정할 수도 있습니다.
이렇게 직접 만든 게터를
커스텀 게터(custom getter)라고 부릅니다.
예를 들어
성과 이름을 따로 받아서,
“전체 이름“을 만들어 주는 프로퍼티를 생각해 봅시다.
class Member(val firstName: String, val lastName: String) {
val fullName: String
get() = "$lastName$firstName"
}
fun main() {
val member = Member("길동", "홍")
println(member.fullName)
}
실행하면 이렇게 출력됩니다.
홍길동
fullName은
따로 값을 저장하지 않습니다.
대신 누군가 member.fullName을 읽을 때마다get() 안의 코드가 실행되어
그 자리에서 값을 만들어 냅니다.
이렇게 “저장하지 않고 계산해서 돌려주는 프로퍼티“는
서로 관련된 값을 조합해 보여 줄 때 아주 유용합니다.
Custom Setter
세터도 직접 만들 수 있습니다.
이것을 커스텀 세터(custom setter)라고 부릅니다.
값을 넣을 때
특별한 처리를 하고 싶을 때 씁니다.
여기서 새 단어 하나가 필요합니다.
바로 field입니다.
field는
프로퍼티가 실제 값을 저장해 두는 자리를 가리킵니다.
“뒷받침하는 저장 공간“이라는 뜻에서
배킹 필드(backing field)라고 부릅니다.
말이 어렵지만 쓰임은 간단합니다.
커스텀 세터 안에서
“진짜 값을 넣을 곳“이 바로 field입니다.
class Member {
var name: String = ""
set(value) {
field = value.trim()
}
}
fun main() {
val member = Member()
member.name = " 홍길동 " // 앞뒤에 공백이 있음
println("[${member.name}]")
}
실행하면 이렇게 출력됩니다.
[홍길동]
세터 안에서value.trim()으로 앞뒤 공백을 지운 뒤field에 넣었습니다.
그래서 공백이 섞인 이름을 넣어도
저장될 때는 깔끔하게 정리됩니다.
여기서 field 대신
실수로 name이라고 쓰면 안 됩니다.
name = value라고 쓰면
세터가 자기 자신을 다시 부르게 되어
끝없이 반복되는 문제가 생깁니다.
그래서 반드시 field를 써야 합니다.
Java Bean과의 차이
자바를 배운 분이라면
게터와 세터라는 말이 익숙할 것입니다.
자바에서는 값을 안전하게 다루기 위해
프로퍼티를 숨기고,getName(), setName() 같은 함수를
일일이 손으로 만들어 왔습니다.
이런 방식으로 만든 객체를
자바 빈(Java Bean)이라고 부릅니다.
자바로 쓰면 이렇습니다.
// 자바
public class Member {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
프로퍼티 하나에
게터와 세터를 직접 써야 해서
코드가 길어집니다.
코틀린은 이 일을 대신 해 줍니다.
class Member {
var name: String = ""
}
이 한 줄이
자바의 게터, 세터를 모두 포함합니다.
정리하면 이렇습니다.
| 구분 | 자바 | 코틀린 |
|---|---|---|
| 게터/세터 작성 | 손으로 직접 | 자동 생성 |
| 값 읽기 | member.getName() | member.name |
| 값 쓰기 | member.setName("홍") | member.name = "홍" |
코드는 짧아졌지만
동작은 자바와 같습니다.
속에서는 여전히 게터와 세터가 움직이고 있으니까요.
이 덕분에 코틀린으로 만든 클래스를
자바에서 불러 쓸 때도 자연스럽게 연결됩니다.
(자바와 코틀린을 함께 쓰는 이야기는 9부에서 다룹니다.)
7.4 접근 제어
접근 제어가 필요한 이유
클래스를 만들다 보면
“이건 밖에서 건드리지 않았으면” 하는 값이 생깁니다.
예를 들어 회원의 비밀번호는
아무나 밖에서 읽거나 바꾸면 곤란합니다.
이렇게 “어디까지 접근을 허용할지“를 정하는 것을
접근 제어(access control)라고 부릅니다.
접근 범위를 정해 주는 키워드를
가시성 변경자(visibility modifier)라고 합니다.
말이 어렵지만
“어디까지 보이게 할까“를 정하는 표시라고
생각하면 쉽습니다.
코틀린에는 네 가지가 있습니다.
| 키워드 | 접근 범위 |
|---|---|
public | 어디서든 접근 가능 (기본값) |
private | 선언한 클래스 안에서만 |
protected | 클래스 자신과 자식 클래스에서만 |
internal | 같은 모듈 안에서만 |
하나씩 살펴보겠습니다.
public
public은
“어디서든 접근할 수 있다“는 뜻입니다.
아무 표시도 하지 않으면
자동으로 public이 됩니다.
class Member {
var name: String = "" // public (기본값)
}
여기서 자바와의 차이가 하나 있습니다.
자바는 아무 표시도 없으면
“같은 패키지에서만 접근” 가능한 상태가 됩니다.
하지만 코틀린은 아무 표시가 없으면public,
즉 어디서든 접근 가능한 상태가 됩니다.
코틀린의 기본값은
public입니다.
private
private은
“선언한 클래스 안에서만 쓸 수 있다“는 뜻입니다.
밖에서는 접근할 수 없습니다.
class Member(val name: String) {
private val password: String = "1234"
fun checkPassword(input: String): Boolean {
return password == input
}
}
fun main() {
val member = Member("홍길동")
println(member.checkPassword("1234")) // 가능
// println(member.password) // 오류: 밖에서 접근 불가
}
password는 private이라
클래스 밖에서는 읽을 수 없습니다.
대신 클래스 안의 checkPassword 함수를 통해서만
간접적으로 확인할 수 있습니다.
이렇게 하면
비밀번호가 밖으로 새어 나가는 것을
막을 수 있습니다.
protected
protected는
“클래스 자신과, 그 클래스를 물려받은 자식 클래스“에서만
접근할 수 있다는 뜻입니다.
여기서 “물려받는다“는 말이 나오는데,
이것을 상속(inheritance)이라고 합니다.
상속은 다음 장인 8장에서 자세히 배웁니다.
지금은 이렇게만 기억해 두면 됩니다.
private은 자기 클래스 안에서만,protected는 자식 클래스까지 허용한다.
open class User {
protected val secret: String = "비밀"
}
private보다는 조금 넓고,public보다는 좁은 범위라고
이해하면 됩니다.
internal
internal은 코틀린에만 있는 개념입니다.
자바에는 없습니다.
“같은 모듈 안에서만 접근 가능“하다는 뜻입니다.
여기서 모듈(module)이란
함께 컴파일되는
“하나의 코드 묶음“을 말합니다.
예를 들어
하나의 프로젝트, 하나의 라이브러리가
각각 하나의 모듈이 됩니다.
internal class InternalHelper {
fun help() {
println("같은 모듈에서만 쓸 수 있어요")
}
}
internal은
“우리 프로젝트 안에서는 자유롭게 쓰되,
남에게 라이브러리로 공개할 때는 감추고 싶은 코드“에
유용합니다.
지금 단계에서는public과 private만 확실히 알아 두어도 충분합니다.
- 밖에서 써야 하면
public(또는 그냥 생략) - 안에서만 써야 하면
private
이 두 가지가
실무에서 가장 많이 쓰입니다.
7.5 불변 객체 만들기
다시 만나는 불변성
2장에서 우리는
“되도록 val을 쓰자“는 생각을 배웠습니다.
바뀌지 않는 성질,
즉 불변성(immutability)이었습니다.
이 생각은 변수 하나에만 해당하는 것이 아닙니다.
객체 전체에도 그대로 적용됩니다.
한 번 만들어지면
내용이 절대 바뀌지 않는 객체를
불변 객체(immutable object)라고 부릅니다.
val 중심의 객체
불변 객체를 만드는 방법은 간단합니다.
모든 프로퍼티를 val로 선언하면 됩니다.
var로 만든 객체와 비교해 보겠습니다.
// 바꿀 수 있는 객체 (var)
class MutableMember(var name: String, var age: Int)
// 바꿀 수 없는 객체 (val)
class Member(val name: String, val age: Int)
fun main() {
val a = MutableMember("홍길동", 20)
a.age = 21 // 가능: var이므로 변경됨
val b = Member("이순신", 30)
// b.age = 31 // 오류: val이라 바꿀 수 없음
}
MutableMember는
만든 뒤에도 나이를 바꿀 수 있습니다.
하지만 Member는
한 번 만들어지면 나이를 바꿀 수 없습니다.
바로 이 Member가 불변 객체입니다.
왜 불변 객체가 좋을까
값을 못 바꾸게 만드는 것이
불편해 보일 수 있습니다.
하지만 백엔드에서는
이 불편함이 오히려 큰 장점이 됩니다.
서버는 수많은 요청을
동시에 처리합니다.
이때 여러 곳에서 같은 객체를
동시에 바꾸려 하면,
값이 뒤엉켜 예측할 수 없는 오류가 생깁니다.
하지만 객체가 불변이라면
아무도 값을 바꿀 수 없으므로
이런 뒤엉킴 자체가 일어나지 않습니다.
아무도 바꿀 수 없는 값은,
누가 언제 읽어도 항상 똑같습니다.
또 다른 장점도 있습니다.
값이 바뀌지 않으니
“이 값이 어디서 바뀌었지?” 하고
추적할 필요가 없습니다.
버그를 찾기가 훨씬 쉬워집니다.
상태를 외부에 노출하지 않기
여기서 한 걸음 더 나아가 봅시다.
객체가 가진 값을
그 객체의 상태(state)라고 부릅니다.
불변 객체를 만들 때는
“밖에서 상태를 함부로 바꾸지 못하게” 하는 것이 핵심입니다.
특히 컬렉션(여러 값을 담는 목록)을 프로퍼티로 가질 때
주의가 필요합니다.
(컬렉션은 5부에서 자세히 배웁니다.)
간단한 예로 살펴보겠습니다.
class Team(names: List<String>) {
private val members: MutableList<String> = names.toMutableList()
val memberNames: List<String>
get() = members.toList()
fun addMember(name: String) {
members.add(name)
}
}
여기서 설계 의도를 하나씩 보겠습니다.
- 실제 목록
members는private으로 감춥니다 - 밖에서 목록을 볼 때는
memberNames로 복사본만 내줍니다 - 값을 추가할 때는 반드시
addMember함수를 거칩니다
이렇게 하면
밖에서는 목록을 마음대로 헤집을 수 없습니다.
오직 addMember라는
정해진 통로로만 값을 넣을 수 있습니다.
만약 목록을 그대로 밖에 공개했다면,
밖에서 몰래 값을 지우거나 바꿔
객체의 상태가 엉망이 될 수 있습니다.
내부의 상태는 감추고,
정해진 통로로만 다루게 한다.
이것이 객체를 안전하게 지키는 기본 원칙입니다.
백엔드 도메인 객체의 상태 관리
이 원칙이 가장 빛나는 곳이
바로 백엔드의 도메인 객체(domain object)입니다.
도메인 객체란
회원, 주문, 상품처럼
그 서비스의 핵심 개념을 담은 객체입니다.
이런 객체는
언제나 “올바른 상태“를 유지해야 합니다.
예를 들어 주문 금액이
갑자기 음수가 되면 안 됩니다.
그래서 좋은 도메인 객체는
세 가지를 함께 지킵니다.
- 만들 때부터 올바른 값만 받는다 (
init검사) - 만든 뒤에는 상태를 함부로 못 바꾼다 (
val중심) - 꼭 바꿔야 하는 변화는 정해진 함수로만 한다
이 세 가지를 담은
간단한 주문 객체를 만들어 보겠습니다.
class Order(
val orderId: String,
val amount: Int,
) {
init {
if (amount <= 0) {
throw IllegalArgumentException("주문 금액은 0보다 커야 합니다")
}
}
}
fun main() {
val order = Order("ORD-001", 15000)
println("주문 ${order.orderId}: ${order.amount}원")
// val wrong = Order("ORD-002", -100) // 만드는 순간 오류
}
이 Order는
잘못된 금액으로는 아예 만들어지지 않습니다.
또 한 번 만들어지면
금액을 바꿀 수도 없습니다.
즉, 세상에 나온 순간부터
항상 올바른 상태를 유지합니다.
이렇게 “믿을 수 있는 객체“를 쌓아 올리는 것이
탄탄한 백엔드 코드의 출발점입니다.
이 주제는 이 책의 마지막을 향해 가며
11부 34장에서 본격적으로 다룹니다.
지금은 “불변 객체가 왜 좋은지“를
느끼는 것만으로 충분합니다.
7장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 클래스는 객체를 만드는 설계도이고,
프로퍼티는 클래스가 값을 담는 자리라는 점 new없이클래스이름()으로
객체를 만든다는 점- 주 생성자로 객체 생성과 프로퍼티 선언을
한 줄에 끝낼 수 있다는 점 init블록으로
만들 때부터 올바른 객체를 보장하는 방법- 코틀린의 프로퍼티는
게터와 세터를 자동으로 만들어 준다는 점 public,private,protected,internal로
접근 범위를 조절하는 방법val중심으로 불변 객체를 만들고,
상태를 감춰 안전하게 지키는 생각
특히 두 가지를 기억해 두면 좋습니다.
“생성자로 한 줄에 담기”, 그리고 “불변 객체로 상태 지키기“입니다.
이 두 가지는
앞으로 객체를 설계하는 내내 함께합니다.
다음 장에서는
클래스끼리 관계를 맺는 방법,
즉 상속과 인터페이스를 배워 보겠습니다.
8장. 상속과 인터페이스
앞 장까지 우리는 클래스로 하나의 대상을 표현하는 법을 배웠습니다.
그런데 프로그램이 커지면
비슷한 클래스가 여럿 생기기 시작합니다.
이때 “공통된 부분은 한 번만 정의하고,
차이 나는 부분만 따로 만들 수는 없을까?” 하는 고민이 생깁니다.
이 고민을 풀어 주는 도구가 바로
상속(inheritance)과 인터페이스(interface)입니다.
이 장에서는 코틀린이 상속을 어떻게 다루는지,
그리고 인터페이스로 코드를 어떻게 유연하게 만드는지 살펴봅니다.
특히 마지막 절에서는
백엔드 개발에서 가장 중요한 습관 하나를 배웁니다.
바로 “구현이 아니라 인터페이스에 기대는” 설계입니다.
8.1 Kotlin 클래스는 기본적으로 final
Java와 다른 기본 정책
상속을 이야기하려면
먼저 코틀린의 독특한 기본 규칙을 알아야 합니다.
코틀린에서 클래스는
기본적으로 상속할 수 없습니다.
무슨 뜻인지 코드로 보겠습니다.
class Animal {
fun breathe() {
println("숨을 쉰다")
}
}
class Dog : Animal() // 오류: Animal은 상속할 수 없음
Dog가 Animal을 물려받으려 했지만
오류가 납니다.
Animal이 상속을 허락하지 않았기 때문입니다.
여기서 “상속할 수 없다“는 성질을
final(파이널)이라고 부릅니다.
final은 “더 이상 바꿀 수 없는, 마지막“이라는 뜻입니다.
코틀린의 모든 클래스는
특별히 열어 주지 않으면 final이다.
자바를 아는 분이라면 이 규칙이 낯설 것입니다.
자바는 정반대이기 때문입니다.
| 언어 | 클래스 기본 상태 | 상속하려면 |
|---|---|---|
| 자바 | 상속 가능 | 막으려면 final |
| 코틀린 | 상속 불가(final) | 열려면 open |
자바는 “일단 열려 있고, 막고 싶으면 막는” 방식입니다.
코틀린은 “일단 닫혀 있고, 열고 싶을 때만 여는” 방식입니다.
왜 기본을 final로 정했을까
조금 불편해 보이는 이 규칙에는
분명한 이유가 있습니다.
상속은 강력하지만 위험하기도 합니다.
누군가 내 클래스를 물려받아
동작을 몰래 바꿔 버리면,
원래 만든 사람의 의도가 깨질 수 있습니다.
이런 상황을 비유하면 이렇습니다.
잘 만들어 둔 기계를 누가 마음대로 뜯어고치면,
원래대로 동작한다고 장담할 수 없습니다.
그래서 코틀린은 이렇게 생각합니다.
상속은 “이 클래스는 물려받아도 안전하다“고
만든 사람이 분명히 허락했을 때만 하자.
이 허락의 표시가 다음에 배울 open입니다.
open 키워드
클래스를 상속할 수 있게 열려면
클래스 앞에 open을 붙입니다.
open class Animal {
fun breathe() {
println("숨을 쉰다")
}
}
class Dog : Animal() // 이제 상속 가능
open은 말 그대로 “열다“라는 뜻입니다.
Animal 앞에 open을 붙였더니
이제 Dog가 문제없이 물려받습니다.
이렇게 코틀린에서는
“상속을 허락한 클래스“만 상속됩니다.
덕분에 코드를 읽는 사람은open이 붙은 클래스를 보고
“아, 이건 물려받으라고 만든 거구나” 하고 바로 알 수 있습니다.
상속의 자세한 규칙은 다음 절에서 이어집니다.
8.2 상속
상속이란 무엇인가
상속(inheritance)은
한 클래스가 다른 클래스의 성질을 물려받는 것입니다.
물려주는 쪽을 부모 클래스(상위 클래스),
물려받는 쪽을 자식 클래스(하위 클래스)라고 부릅니다.
비유하면 이렇습니다.
부모가 가진 것을 자식이 그대로 물려받고,
거기에 자기만의 것을 더 얹는 것입니다.
코드로 보겠습니다.
open class Animal {
fun breathe() {
println("숨을 쉰다")
}
}
class Dog : Animal() {
fun bark() {
println("멍멍")
}
}
여기서 Dog는 Animal을 상속했습니다.
그래서 Dog는
자기가 직접 만든 bark()뿐 아니라
부모에게 물려받은 breathe()도 쓸 수 있습니다.
val dog = Dog()
dog.breathe() // 물려받은 기능
dog.bark() // 자기만의 기능
숨을 쉰다
멍멍
breathe()를 다시 만들지 않았는데도Dog가 쓸 수 있다는 점이 상속의 핵심입니다.
공통 기능을 부모에 한 번만 적어 두면,
자식들은 그것을 그냥 물려받습니다.
상속의 문법과 생성자 호출
코틀린에서 상속은 콜론(:)으로 표현합니다.
class Dog : Animal()
여기서 눈여겨볼 점은
부모 이름 뒤에 붙은 괄호 ()입니다.
이 괄호는 부모의 생성자를 부른다는 뜻입니다.
(생성자는 7장에서 다뤘습니다.)
자식을 만들 때
부모 부분도 함께 준비되어야 하므로,
부모의 생성자를 호출해 주는 것입니다.
부모가 값을 받는다면
이렇게 넘겨줍니다.
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
Dog가 받은 name을
그대로 부모 Animal에게 전달하는 모습입니다.
override로 기능 새로 정의하기
상속을 받으면
부모의 기능을 그대로 쓸 수도 있지만,
자식에 맞게 바꿔 쓰고 싶을 때도 있습니다.
예를 들어 모든 동물은 소리를 내지만,
소리는 저마다 다릅니다.
이렇게 부모의 기능을
자식이 새로 정의하는 것을
재정의(override, 오버라이드)라고 합니다.
여기에도 8.1의 “허락” 규칙이 그대로 적용됩니다.
- 부모는 재정의를 허락하는 함수 앞에
open을 붙인다 - 자식은 새로 정의하는 함수 앞에
override를 붙인다
open class Animal {
open fun sound() {
println("어떤 소리를 낸다")
}
}
class Dog : Animal() {
override fun sound() {
println("멍멍")
}
}
class Cat : Animal() {
override fun sound() {
println("야옹")
}
}
이제 같은 sound()를 불러도
실제 객체에 따라 다르게 동작합니다.
val dog = Dog()
val cat = Cat()
dog.sound() // 멍멍
cat.sound() // 야옹
멍멍
야옹
함수뿐 아니라 클래스와 마찬가지로,
함수도 기본은 final입니다.
그래서 재정의를 허락하려면 반드시 open이 필요합니다.
자바에서는 @Override가 붙여도 그만, 안 붙여도 그만인
“권장 사항“이었습니다.
하지만 코틀린에서 override는
반드시 붙여야 하는 “필수 규칙“입니다.
덕분에 이 함수가 부모의 것을 바꾼 것인지
한눈에 알 수 있습니다.
부모 기능을 함께 쓰기: super
재정의를 하더라도
부모의 원래 기능을 함께 쓰고 싶을 때가 있습니다.
이때는 super를 사용합니다.
super는 “부모“를 가리키는 말입니다.
open class Animal {
open fun sound() {
println("동물이 소리를 낸다")
}
}
class Dog : Animal() {
override fun sound() {
super.sound() // 부모의 기능을 먼저 실행
println("멍멍")
}
}
Dog().sound()
동물이 소리를 낸다
멍멍
super.sound()로 부모 기능을 먼저 실행하고,
그다음 자기만의 동작을 덧붙였습니다.
abstract: 뼈대만 정하는 클래스
이번에는 조금 다른 상황을 생각해 봅시다.
“모든 동물은 소리를 낸다“는 것은 확실합니다.
하지만 “동물 그 자체“의 소리가 무엇인지는 정할 수 없습니다.
개는 멍멍, 고양이는 야옹이지만
그냥 “동물“의 소리는 딱 정해지지 않습니다.
이럴 때 쓰는 것이
추상 클래스(abstract class)입니다.
추상(abstract)은
“구체적이지 않고 뼈대만 있는“이라는 뜻입니다.
abstract class Animal {
abstract fun sound() // 내용 없이 이름만 정함
fun breathe() {
println("숨을 쉰다")
}
}
여기서 sound()에는
중괄호 { }가 없습니다.
“이런 기능이 있어야 한다“고 선언만 하고,
실제 내용은 자식에게 맡기는 것입니다.
이렇게 내용 없이 이름만 있는 함수를
추상 함수(abstract function)라고 합니다.
추상 클래스는 몇 가지 특징이 있습니다.
- 추상 함수를 가질 수 있다(내용 없는 함수)
- 일반 함수도 함께 가질 수 있다(
breathe처럼) - 그 자체로는 객체를 만들 수 없다
마지막 특징이 중요합니다.
val animal = Animal() // 오류: 추상 클래스는 객체를 만들 수 없음
“동물 그 자체“는 실체가 없으니
직접 만들 수 없는 것입니다.
대신 자식이 추상 함수를 채워야 합니다.
class Dog : Animal() {
override fun sound() {
println("멍멍")
}
}
이제 Dog는 실체가 있으므로
객체를 만들 수 있습니다.
val dog = Dog()
dog.sound() // 멍멍
dog.breathe() // 숨을 쉰다
추상 클래스는
“공통 뼈대는 정해 두되,
구체적인 내용은 자식이 채우게 하는” 틀입니다.
참고로 추상 함수는
이미 “채워야만 하는” 것이라open을 따로 붙이지 않아도 재정의됩니다.
8.3 인터페이스
인터페이스란 무엇인가
인터페이스(interface)는
“이런 기능이 있어야 한다“는 약속만 모아 둔 것입니다.
추상 클래스와 비슷해 보이지만,
더 순수하게 “약속“에만 집중한 개념입니다.
비유하면 이렇습니다.
인터페이스는 “해야 할 일의 목록“입니다.
목록을 받은 사람이 실제로 어떻게 할지는 각자 정합니다.
예를 들어 “소리를 낼 수 있다“는 능력을
인터페이스로 만들어 봅시다.
interface Soundable {
fun sound()
}
interface라는 키워드로 정의합니다.
Soundable 안의 sound()는
추상 함수처럼 이름만 있습니다.
즉, “소리를 낼 수 있는 것은
반드시 sound()를 갖춰야 한다“는 약속입니다.
인터페이스 구현하기
인터페이스의 약속을 실제로 지키는 것을
구현(implement, 구현하다)이라고 합니다.
문법은 상속과 똑같이 콜론(:)을 씁니다.
interface Soundable {
fun sound()
}
class Dog : Soundable {
override fun sound() {
println("멍멍")
}
}
여기서 한 가지 차이를 눈여겨보세요.
상속과 달리 인터페이스 이름 뒤에는
괄호 ()가 없습니다.
class Dog : Animal() // 클래스 상속: 괄호 있음(생성자 호출)
class Dog : Soundable // 인터페이스 구현: 괄호 없음
인터페이스는 생성자가 없기 때문입니다.
약속만 있을 뿐, 만들 실체가 없는 것입니다.
인터페이스의 함수를 채울 때도override를 붙입니다.
약속을 지켰다는 표시라고 생각하면 쉽습니다.
여러 인터페이스 구현하기
인터페이스의 진짜 강점은 여기서 나옵니다.
클래스는 부모를 하나만 가질 수 있습니다.
하지만 인터페이스는 여러 개를 동시에 구현할 수 있습니다.
콤마(,)로 나열하면 됩니다.
interface Soundable {
fun sound()
}
interface Swimmable {
fun swim()
}
class Dog : Soundable, Swimmable {
override fun sound() {
println("멍멍")
}
override fun swim() {
println("헤엄친다")
}
}
Dog는 “소리를 낼 수 있고”
동시에 “헤엄칠 수도 있는” 존재가 되었습니다.
val dog = Dog()
dog.sound() // 멍멍
dog.swim() // 헤엄친다
여러 능력을 조합해서 붙일 수 있다는 점이
인터페이스를 강력하게 만듭니다.
상속은 “무엇인가(is-a)“를 표현하고,
인터페이스는 “무엇을 할 수 있는가(can-do)“를 표현합니다.
왜 클래스 상속은 하나만 되고
인터페이스는 여럿이 될까요?
클래스는 실제 데이터와 상태를 가지므로,
여럿을 물려받으면 충돌이 생기기 쉽습니다.
반면 인터페이스는 대체로 “약속“이라
여럿을 모아도 충돌이 적습니다.
기본 구현
인터페이스는 약속만 담는다고 했습니다.
그런데 코틀린에서는 예외가 있습니다.
인터페이스 안에서
함수의 내용을 미리 채워 둘 수 있습니다.
이것을 기본 구현(default implementation)이라고 합니다.
interface Soundable {
fun sound()
fun describe() {
println("나는 소리를 낼 수 있다")
}
}
sound()는 여전히 이름만 있지만,describe()에는 내용이 들어 있습니다.
이렇게 내용이 있는 함수는
구현하는 쪽에서 다시 만들지 않아도 됩니다.
class Dog : Soundable {
override fun sound() {
println("멍멍")
}
// describe()는 만들지 않아도 됨
}
val dog = Dog()
dog.sound() // 멍멍
dog.describe() // 나는 소리를 낼 수 있다
멍멍
나는 소리를 낼 수 있다
describe()를 직접 만들지 않았는데도
기본 구현이 그대로 쓰였습니다.
물론 원하면 자식이 다시 정의할 수도 있습니다.
기본 구현 덕분에
꼭 필요한 것만 강제하고,
공통 동작은 인터페이스가 제공할 수 있습니다.
인터페이스와 추상 클래스, 무엇을 쓸까
둘은 비슷해 보이지만 쓰임이 다릅니다.
표로 정리해 보겠습니다.
| 구분 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 개수 제한 | 하나만 상속 | 여러 개 구현 가능 |
| 상태(데이터) | 가질 수 있음 | 원칙적으로 안 가짐 |
| 표현하는 것 | 무엇인가(is-a) | 할 수 있는가(can-do) |
| 생성자 | 있음 | 없음 |
지금은 이렇게 기억해 두면 충분합니다.
“이것도 저것도 할 수 있다“는 능력을 붙일 때는 인터페이스,
“공통된 뼈대와 데이터를 함께 물려줄” 때는 추상 클래스.
백엔드 실무에서는
인터페이스를 훨씬 자주 씁니다.
그 이유를 다음 절에서 살펴봅니다.
8.4 인터페이스로 의존성 분리하기
의존한다는 것의 의미
이제 이 장에서 가장 중요한 이야기입니다.
먼저 의존(dependency)이라는 말부터 풀어 봅시다.
어떤 코드 A가 동작하기 위해
다른 코드 B가 필요하다면,
“A는 B에 의존한다“고 말합니다.
비유하면 이렇습니다.
요리사(A)가 특정 칼(B)이 없으면
요리를 못 한다면,
요리사는 그 칼에 의존하는 것입니다.
프로그램에서는 이런 의존이 아주 많습니다.
예를 들어 “회원 관련 일을 처리하는 코드“는
“회원 정보를 저장하는 코드“에 의존합니다.
백엔드에서 흔히 쓰는 이름으로 표현하면 이렇습니다.
UserService: 회원 관련 처리를 담당UserRepository: 회원 정보를 저장하고 꺼내는 역할
여기서 서비스(Service)는 “일 처리 담당”,
리포지토리(Repository)는 “저장소 담당” 정도로
지금은 이해하면 됩니다.
(자세한 역할은 나중에 스프링에서 다룹니다.)
이 둘의 의존 관계를 그림으로 보면 이렇습니다.
UserService
↓
UserRepository
↓
MemoryUserRepository
위에서 아래로 내려가는 화살표는
“위가 아래에 의존한다“는 뜻입니다.
UserService는 UserRepository에 기대고,UserRepository는 실제 저장 방식인MemoryUserRepository로 이어집니다.
여기서 핵심 질문이 나옵니다.
UserService는 저장을 “누구“에게 맡겨야 할까?
구체적인 구현일까, 아니면 인터페이스일까?
구현이 아닌 인터페이스에 의존하기
먼저 잘못된 방식을 보겠습니다.
UserService가
구체적인 저장 방식에 직접 기대는 경우입니다.
// 구체적인 구현: 메모리에 저장
class MemoryUserRepository {
private val storage = mutableMapOf<Long, String>()
fun save(id: Long, name: String) {
storage[id] = name
}
fun findName(id: Long): String? {
return storage[id]
}
}
// 서비스가 구현에 직접 의존
class UserService {
private val repository = MemoryUserRepository() // 딱 붙어 버림
fun register(id: Long, name: String) {
repository.save(id, name)
}
}
mutableMapOf는 값을 담아 두는 저장소로,
지금은 “임시로 메모리에 저장한다” 정도로 보면 됩니다.
(자료구조는 6부에서 다룹니다.)
이 코드는 동작은 합니다.
하지만 문제가 있습니다.
UserService가 MemoryUserRepository에
너무 단단히 붙어 버렸습니다.
나중에 저장 방식을
데이터베이스로 바꾸고 싶어지면,UserService 안을 뜯어고쳐야 합니다.
이렇게 딱 붙어 있는 상태를
강한 결합(tight coupling)이라고 합니다.
이제 인터페이스를 도입해 봅시다.
“저장소는 이런 기능을 갖춰야 한다“는
약속부터 인터페이스로 정의합니다.
// 약속: 저장소가 갖춰야 할 기능
interface UserRepository {
fun save(id: Long, name: String)
fun findName(id: Long): String?
}
그리고 실제 구현이
이 약속을 지키게 합니다.
class MemoryUserRepository : UserRepository {
private val storage = mutableMapOf<Long, String>()
override fun save(id: Long, name: String) {
storage[id] = name
}
override fun findName(id: Long): String? {
return storage[id]
}
}
이제 UserService는
구체적인 구현이 아니라
인터페이스에만 기대게 만듭니다.
class UserService(
private val repository: UserRepository // 약속(인터페이스)에만 의존
) {
fun register(id: Long, name: String) {
repository.save(id, name)
}
fun findName(id: Long): String? {
return repository.findName(id)
}
}
여기서 UserService는
자기 안에서 저장소를 직접 만들지 않습니다.
대신 생성자로
“약속을 지키는 저장소“를 받아 옵니다.
이렇게 필요한 것을 밖에서 넣어 주는 방식을
의존성 주입(Dependency Injection)이라고 합니다.
“주입“이란 밖에서 넣어 준다는 뜻입니다.
서비스는 “저장소가 어떻게 생겼는지” 모릅니다.
그저 “약속을 지키는 무언가“라는 것만 압니다.
이것이 “구현이 아니라 인터페이스에 의존하라“는
설계 원칙의 핵심입니다.
구현체를 교체하기
인터페이스에 의존하면
어떤 좋은 점이 생길까요?
가장 큰 이점은
구현체를 자유롭게 갈아 끼울 수 있다는 것입니다.
예를 들어 파일에 저장하는
새로운 구현을 하나 더 만들어 봅시다.
class FileUserRepository : UserRepository {
override fun save(id: Long, name: String) {
println("파일에 저장: $id, $name")
}
override fun findName(id: Long): String? {
println("파일에서 조회: $id")
return "파일에서 찾은 이름"
}
}
UserService는 전혀 건드리지 않았습니다.
그런데도 저장 방식을
자유롭게 바꿀 수 있습니다.
// 메모리 방식으로 사용
val service1 = UserService(MemoryUserRepository())
// 파일 방식으로 교체
val service2 = UserService(FileUserRepository())
넣어 주는 구현체만 바꿨을 뿐,UserService의 코드는 한 줄도 바뀌지 않았습니다.
앞의 다이어그램으로 돌아가 보면
그 의미가 분명해집니다.
UserService
↓
UserRepository (약속: 인터페이스)
↓
MemoryUserRepository (실제 구현, 언제든 교체 가능)
UserService는 가운데의 UserRepository라는
약속만 바라봅니다.
맨 아래의 실제 구현이 무엇으로 바뀌든
위쪽은 영향을 받지 않습니다.
이렇게 서로 느슨하게 연결된 상태를
느슨한 결합(loose coupling)이라고 합니다.
느슨하게 연결될수록
바꾸기 쉽고, 확장하기 쉬운 코드가 됩니다.
테스트 가능한 코드 만들기
인터페이스 분리의 또 다른 큰 장점은
테스트가 쉬워진다는 것입니다.
테스트(test)란
“코드가 제대로 동작하는지 미리 확인하는 것“입니다.
UserService가 잘 동작하는지
확인한다고 생각해 봅시다.
만약 서비스가
진짜 데이터베이스에 딱 붙어 있다면,
테스트할 때마다 데이터베이스가 필요합니다.
데이터베이스를 켜고, 연결하고,
느리고, 준비도 번거롭습니다.
하지만 인터페이스에 의존한다면,
테스트용 가짜 저장소를 만들어 넣으면 됩니다.
이런 가짜 구현을
가짜 객체(fake) 또는 테스트 대역이라고 부릅니다.
// 테스트용 가짜 저장소
class FakeUserRepository : UserRepository {
private val storage = mutableMapOf<Long, String>()
override fun save(id: Long, name: String) {
storage[id] = name
}
override fun findName(id: Long): String? {
return storage[id]
}
}
이제 이 가짜 저장소를 넣어
서비스를 가볍게 확인할 수 있습니다.
fun main() {
val service = UserService(FakeUserRepository())
service.register(1L, "홍길동")
val name = service.findName(1L)
println(name) // 홍길동
}
홍길동
데이터베이스 없이도UserService가 잘 동작하는지 확인했습니다.
빠르고, 간단하고, 어디서나 돌아갑니다.
인터페이스에 의존하면
진짜 대신 가짜를 끼워 넣기 쉬워집니다.
그래서 테스트가 쉬워집니다.
정리해 봅시다.
인터페이스로 의존성을 분리하면 세 가지를 얻습니다.
- 구현체를 자유롭게 교체할 수 있다
- 코드가 느슨하게 연결되어 바꾸기 쉬워진다
- 가짜 구현으로 테스트하기 쉬워진다
이 세 가지는
백엔드 개발 내내 계속 따라다니는 이점입니다.
특히 나중에 스프링을 배울 때,
스프링이 바로 이 “의존성 주입“을
자동으로 해 준다는 것을 알게 될 것입니다.
지금 이 원칙을 몸에 익혀 두면
스프링이 훨씬 쉽게 이해됩니다.
8장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 코틀린 클래스는 기본이 final이며,
상속하려면open으로 열어야 한다는 점 - 상속에서
override로 기능을 다시 정의하고,super로 부모 기능을 함께 쓰는 방법 abstract로 뼈대만 정해 두고
자식이 내용을 채우게 하는 추상 클래스- 인터페이스로 “할 수 있는 능력“을 약속하고,
여러 인터페이스를 동시에 구현하는 방법 - 인터페이스의 기본 구현으로
공통 동작을 미리 제공하는 방법 - 구현이 아니라 인터페이스에 의존해서
교체 가능하고 테스트하기 쉬운 코드를 만드는 설계
특히 마지막 절의 생각은 오래 기억해 주세요.
“구체적인 구현“이 아니라
“약속(인터페이스)“에 기대라.
이 한 문장이
좋은 백엔드 코드의 출발점입니다.
다음 장에서는
코틀린이 자랑하는 편리한 클래스들,
데이터 클래스와 관련 기능들을 살펴보겠습니다.
9장. 데이터를 표현하는 클래스
프로그램은 결국 “데이터를 주고받는 일“입니다.
사용자 정보, 상품 정보, 주문 내역처럼
여러 값을 하나로 묶어서 다뤄야 할 때가 많습니다.
이렇게 “데이터를 담는 것이 주된 목적“인 클래스를
코틀린은 아주 간단하게 만들 수 있습니다.
이 장에서는 데이터를 표현하는 특별한 클래스인
데이터 클래스(data class)를 배웁니다.
그리고 이 데이터 클래스가
백엔드에서 실제로 어떻게 쓰이는지도 함께 살펴봅니다.
자바에서 길고 번거로웠던 코드가
코틀린에서 얼마나 짧아지는지 직접 확인해 봅시다.
9.1 Data Class
데이터를 담는 클래스가 필요한 이유
먼저 하나의 상황을 떠올려 봅시다.
사용자 한 명의 정보를 다룬다고 해 보겠습니다.
이름과 나이, 이메일이 필요합니다.
이 세 값은 따로 떨어져 있으면 관리하기 어렵습니다.
그래서 하나로 묶어 주는 것이 좋습니다.
이렇게 여러 값을 하나로 묶는 도구가
바로 클래스(class)입니다.
(클래스의 기본은 앞 장에서 다뤘습니다.)
일반 클래스로 사용자를 표현하면 이렇습니다.
class User(
val name: String,
val age: Int,
val email: String,
)
이 클래스는 값을 담는 데는 문제가 없습니다.
하지만 실제로 써 보면 불편한 점이 드러납니다.
val user = User("홍길동", 20, "hong@example.com")
println(user) // 내용이 안 보이고 이상한 값이 나옴
출력 결과는 이렇게 나옵니다.
User@1b6d3586
우리가 기대한 것은 “이름은 홍길동, 나이는 20“인데,
알 수 없는 기호만 찍혔습니다.
값이 같은지 비교해도 문제가 생깁니다.
val a = User("홍길동", 20, "hong@example.com")
val b = User("홍길동", 20, "hong@example.com")
println(a == b) // false
두 사용자는 내용이 완전히 똑같은데도
“다르다(false)“고 나옵니다.
내용이 같으면 같다고 나오길 바라는데
일반 클래스는 그렇게 동작하지 않습니다.
data class 만들기
이 문제를 한 번에 해결해 주는 것이
데이터 클래스(data class)입니다.
데이터 클래스는 “데이터를 담는 것이 주 목적인 클래스“입니다.
만드는 방법은 아주 간단합니다.
앞에 data라는 단어 하나만 붙이면 됩니다.
data class User(
val name: String,
val age: Int,
val email: String,
)
class 앞에 data를 붙인 것이 전부입니다.
이 단어 하나를 붙이면
코틀린이 유용한 기능들을 자동으로 만들어 줍니다.
방금 불편했던 문제들이
모두 알아서 해결됩니다.
하나씩 확인해 보겠습니다.
toString: 내용을 보기 좋게 출력하기
먼저 출력부터 확인해 봅시다.
val user = User("홍길동", 20, "hong@example.com")
println(user)
이번에는 결과가 이렇게 나옵니다.
User(name=홍길동, age=20, email=hong@example.com)
클래스 이름과 함께
안에 담긴 값이 그대로 보입니다.
이렇게 객체를 사람이 읽기 좋은 글자로 바꿔 주는 기능을toString이라고 부릅니다.
이름 그대로 “문자열(string)로 바꾼다(to)“는 뜻입니다.
일반 클래스라면 이 기능을 직접 만들어야 했지만,
데이터 클래스는 자동으로 만들어 줍니다.
디버깅이나 로그를 남길 때
값을 바로 눈으로 확인할 수 있어 아주 편리합니다.
equals: 내용이 같은지 비교하기
이번에는 비교를 확인해 봅시다.
val a = User("홍길동", 20, "hong@example.com")
val b = User("홍길동", 20, "hong@example.com")
println(a == b) // true
이번에는 “같다(true)“가 나옵니다.
내용이 같으면 같다고 판단하는 이 기능을equals라고 부릅니다.
(equals는 “같다“는 뜻의 영어 단어입니다.)
2.5절에서 코틀린의 ==가
“값이 같은가“를 비교한다고 배웠습니다.
데이터 클래스는 이 == 비교가
“안에 담긴 값이 모두 같은가“를 보도록 만들어 줍니다.
즉, 이름과 나이와 이메일이 모두 같으면
같은 사용자로 취급합니다.
데이터 클래스는
“겉모습이 아니라 내용으로” 같음을 판단합니다.
hashCode: 값을 빠르게 찾기 위한 번호
equals와 늘 짝을 이루는 기능이hashCode입니다.
hashCode는 객체를 대표하는 “번호표” 같은 것입니다.
비유하자면 이렇습니다.
도서관에서 책마다 청구기호가 붙어 있으면
원하는 책을 훨씬 빨리 찾을 수 있습니다.hashCode는 그 청구기호에 해당합니다.
컴퓨터가 수많은 데이터 중에서
원하는 값을 빠르게 찾을 때 이 번호를 활용합니다.
여기서 중요한 규칙이 하나 있습니다.
내용이 같으면(
equals가 true면)hashCode도 반드시 같아야 합니다.
이 규칙을 어기면
데이터를 저장하고 찾는 과정에서 오류가 생깁니다.
자바에서는 이 규칙을 지키려고equals와 hashCode를 항상 함께 신경 써야 했습니다.
코틀린의 데이터 클래스는
이 둘을 규칙에 맞게 자동으로 만들어 줍니다.
우리가 실수할 여지 자체를 없애 주는 것입니다.
자바의 POJO와 비교하기
자바를 조금 아는 분이라면
지금 이 편리함이 더 크게 느껴질 것입니다.
자바에서는 데이터를 담는 클래스를
POJO라고 불렀습니다.
POJO는 Plain Old Java Object,
우리말로 “평범하고 오래된 자바 객체“입니다.
그냥 값을 담는 단순한 클래스라는 뜻입니다.
문제는 이 단순한 클래스를 제대로 만들려면
코드가 아주 길어졌다는 점입니다.
같은 User를 자바로 쓰면 대략 이렇습니다.
// 자바
public class User {
private final String name;
private final int age;
private final String email;
public User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
public String getName() { return name; }
public int getAge() { return age; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) {
// ... 값 하나하나 비교하는 긴 코드
}
@Override
public int hashCode() {
// ... 번호를 계산하는 긴 코드
}
@Override
public String toString() {
// ... 문자열로 바꾸는 긴 코드
}
}
값은 세 개뿐인데
코드는 수십 줄에 달합니다.
코틀린은 같은 일을 이 한 줄로 끝냅니다.
data class User(val name: String, val age: Int, val email: String)
무엇이 자동으로 만들어지는지 정리하면 이렇습니다.
| 기능 | 하는 일 |
|---|---|
toString | 값을 보기 좋은 문자열로 바꿔 준다 |
equals | 내용이 같은지 비교해 준다 |
hashCode | 값을 빠르게 찾기 위한 번호를 만들어 준다 |
copy | 기존 객체를 복사해 새 객체를 만들어 준다 |
componentN | 값을 하나씩 꺼낼 수 있게 해 준다 |
이 표의 아래 두 가지,copy와 componentN은 다음 절들에서 자세히 다룹니다.
9.2 copy
기존 객체를 기반으로 새로운 객체 만들기
데이터를 다루다 보면
“거의 같은데 한 부분만 다른” 객체가 필요할 때가 많습니다.
예를 들어 사용자의 나이만 한 살 늘리고 싶다고 해 봅시다.
나머지 정보는 그대로 두고 싶습니다.
이럴 때 값을 전부 다시 적으면 번거롭습니다.
val user = User("홍길동", 20, "hong@example.com")
// 나이만 바꾸고 싶은데 전부 다시 적어야 함
val older = User("홍길동", 21, "hong@example.com")
이름과 이메일은 똑같은데
다시 적느라 실수하기도 쉽습니다.
이럴 때 쓰는 것이 copy입니다.
copy는 이름 그대로
“기존 객체를 복사해서 새 객체를 만드는” 기능입니다.
바꾸고 싶은 값만 콕 집어서 적으면 됩니다.
val user = User("홍길동", 20, "hong@example.com")
val older = user.copy(age = 21)
println(older)
// User(name=홍길동, age=21, email=hong@example.com)
age = 21만 적었더니
나이만 바뀌고 나머지는 그대로 복사되었습니다.
적지 않은 값은 원래 객체의 값을
그대로 가져옵니다.
여러 값을 한 번에 바꿀 수도 있습니다.
val changed = user.copy(age = 21, email = "new@example.com")
println(changed)
// User(name=홍길동, age=21, email=new@example.com)
바꾸고 싶은 것만 적으면 되니
코드가 짧고 실수가 줄어듭니다.
불변 객체와 copy
여기서 한 가지 짚고 넘어갈 점이 있습니다.
copy는 원래 객체를 바꾸지 않습니다.
항상 새로운 객체를 만들어 돌려줍니다.
직접 확인해 봅시다.
val user = User("홍길동", 20, "hong@example.com")
val older = user.copy(age = 21)
println(user) // User(name=홍길동, age=20, ...) ← 그대로
println(older) // User(name=홍길동, age=21, ...) ← 새 객체
older를 만들었지만
원래 user는 전혀 바뀌지 않았습니다.
이 방식은 2장에서 배운
불변성(immutability)과 잘 어울립니다.
2장에서 이렇게 배웠습니다.
값은 되도록 바뀌지 않게 만들자.
데이터 클래스의 값을 val로 만들면
객체를 만든 뒤에는 내용을 바꿀 수 없습니다.
그런데 값을 조금 바꿔야 할 때가 있습니다.
이때 원래 객체를 건드리는 대신,copy로 “바뀐 새 객체“를 만드는 것입니다.
바뀌지 않는 객체를
불변 객체(immutable object)라고 부릅니다.
정리하면 이렇습니다.
- 원래 객체는 그대로 둔다 (안전하다)
- 바뀐 값은 새 객체로 표현한다 (
copy)
이 방식의 장점은 분명합니다.
어떤 객체가 갑자기 바뀔까 걱정할 필요가 없습니다.
특히 여러 곳에서 같은 객체를 함께 쓸 때,
불변 객체는 예상치 못한 오류를 크게 줄여 줍니다.
copy는 “안전하게 값을 바꾸는” 방법입니다.
원본은 지키고, 변화는 새 객체에 담습니다.
9.3 구조 분해
값을 하나씩 꺼내기
데이터 클래스에 담긴 값을
따로따로 꺼내 쓰고 싶을 때가 있습니다.
보통은 이렇게 하나씩 꺼냅니다.
val user = User("홍길동", 20, "hong@example.com")
val name = user.name
val age = user.age
val email = user.email
틀린 방법은 아니지만
세 줄이나 필요합니다.
코틀린에는 이것을 한 줄로 줄여 주는
편리한 기능이 있습니다.
바로 구조 분해(destructuring)입니다.
이름이 어렵지만 뜻은 간단합니다.
하나로 묶인 객체를 “풀어서”
여러 변수에 한 번에 나눠 담는 것입니다.
Data Class 구조 분해
데이터 클래스를 구조 분해하면 이렇습니다.
val user = User("홍길동", 20, "hong@example.com")
val (name, age, email) = user
println(name) // 홍길동
println(age) // 20
println(email) // hong@example.com
괄호 안에 변수 이름을 나열했더니
값이 순서대로 하나씩 담겼습니다.
세 줄이 한 줄로 줄었습니다.
여기서 중요한 것은 순서입니다.
데이터 클래스에 적은 순서대로 값이 담깁니다.
data class User(
val name: String, // 첫 번째
val age: Int, // 두 번째
val email: String, // 세 번째
)
그래서 위 예시에서name에는 이름이, age에는 나이가,email에는 이메일이 들어간 것입니다.
일부만 꺼내고 싶다면
필요 없는 자리는 밑줄(_)로 건너뛸 수 있습니다.
val user = User("홍길동", 20, "hong@example.com")
val (name, _, email) = user // 나이는 건너뜀
println(name) // 홍길동
println(email) // hong@example.com
componentN: 구조 분해가 동작하는 원리
구조 분해는 어떻게 값을 순서대로 꺼낼까요?
그 비밀은 componentN이라는 기능에 있습니다.
데이터 클래스를 만들면 코틀린이
값마다 번호가 붙은 함수를 자동으로 만들어 줍니다.
component1(): 첫 번째 값component2(): 두 번째 값component3(): 세 번째 값
N 자리에 순서 번호가 들어간다고 생각하면 됩니다.
앞의 구조 분해 코드는
사실 다음과 같이 동작합니다.
val name = user.component1() // 첫 번째 값
val age = user.component2() // 두 번째 값
val email = user.component3() // 세 번째 값
우리가 val (name, age, email) = user라고 적으면,
코틀린이 속으로 이 componentN 함수들을 대신 불러 줍니다.
이 함수들도 데이터 클래스가
자동으로 만들어 주는 기능 중 하나입니다.
(9.1절 표의 componentN이 바로 이것입니다.)
원리까지 외울 필요는 없습니다.
“순서대로 꺼내진다“는 점만 기억하면 충분합니다.
9.4 백엔드 DTO 만들기
이제 데이터 클래스가
백엔드에서 실제로 어떻게 쓰이는지 살펴봅시다.
여기서 아주 자주 등장하는 개념이
DTO입니다.
DTO는 Data Transfer Object,
우리말로 “데이터 전송 객체“입니다.
이름 그대로
“데이터를 실어 나르는 상자“라고 생각하면 됩니다.
서버는 바깥세상(브라우저, 앱 등)과
끊임없이 데이터를 주고받습니다.
이때 데이터를 담아 보내고 받는 상자가
바로 DTO입니다.
데이터를 담는 것이 목적이므로
DTO는 데이터 클래스로 만들기에 딱 맞습니다.
DTO는 “데이터를 실어 나르는 상자“이고,
데이터 클래스는 그 상자를 만드는 가장 좋은 도구입니다.
Request DTO
먼저 서버가 요청을 “받을 때” 쓰는 상자입니다.
사용자가 회원가입을 한다고 해 봅시다.
브라우저에서 이름과 이메일, 비밀번호를 보냅니다.
서버는 이 값을 담을 상자가 필요합니다.
이것을 요청 DTO(Request DTO)라고 부릅니다.
Request는 “요청”,
즉 “바깥에서 서버로 들어오는 데이터“를 뜻합니다.
data class SignUpRequest(
val name: String,
val email: String,
val password: String,
)
들어온 요청은 이 상자에 담깁니다.
val request = SignUpRequest(
name = "홍길동",
email = "hong@example.com",
password = "1234",
)
이렇게 하면 흩어진 값이
하나로 깔끔하게 묶입니다.
Response DTO
이번에는 서버가 결과를 “돌려줄 때” 쓰는 상자입니다.
회원가입이 끝나면 서버는
결과를 바깥으로 돌려줍니다.
이때 쓰는 상자를
응답 DTO(Response DTO)라고 부릅니다.
Response는 “응답”,
즉 “서버에서 바깥으로 나가는 데이터“를 뜻합니다.
data class SignUpResponse(
val id: Long,
val name: String,
val email: String,
)
여기서 한 가지 눈여겨볼 점이 있습니다.
응답에는 비밀번호가 빠져 있습니다.
받을 때는 비밀번호가 필요했지만,
돌려줄 때 비밀번호를 함께 보내면 위험합니다.
이렇게 상황에 맞게
“필요한 값만 담은 상자“를 따로 만들 수 있습니다.
이것이 요청과 응답을
서로 다른 DTO로 나누는 이유 중 하나입니다.
Domain Object와 DTO 구분하기
여기서 한 걸음 더 나아가 봅시다.
서버 안에는 DTO 말고도
비슷하게 생긴 객체가 하나 더 있습니다.
바로 도메인 객체(Domain Object)입니다.
도메인 객체는
“우리 서비스의 진짜 알맹이가 되는 데이터“입니다.
예를 들어 우리 서비스에서 다루는
“진짜 사용자“를 표현한 것입니다.
// 도메인 객체: 서비스의 핵심 데이터
data class User(
val id: Long,
val name: String,
val email: String,
val password: String,
)
언뜻 보면 DTO와 비슷해 보입니다.
그런데 왜 굳이 나눌까요?
역할이 다르기 때문입니다.
- 도메인 객체 : 서비스 안에서 다루는 핵심 데이터
- DTO : 바깥과 데이터를 주고받는 상자
비유하자면 이렇습니다.
도메인 객체는 “집 안의 물건“이고,
DTO는 “택배로 보낼 때 쓰는 포장 상자“입니다.
물건 그대로를 택배에 던지지 않고,
보낼 것만 골라 상자에 담아 보냅니다.
이렇게 나누면 좋은 점이 있습니다.
첫째, 안전합니다.
비밀번호처럼 민감한 값을
실수로 바깥에 내보내는 일을 막을 수 있습니다.
둘째, 자유롭습니다.
바깥에 보내는 모양(DTO)과
안에서 다루는 모양(도메인 객체)을
서로 눈치 보지 않고 바꿀 수 있습니다.
요청 DTO를 도메인 객체로 바꾸는 흐름은
대략 이렇게 그릴 수 있습니다.
요청 DTO → 도메인 객체 → 응답 DTO
(바깥 → 서버) (서버 내부) (서버 → 바깥)
SignUpRequest → User → SignUpResponse
copy를 배웠으니
변환의 느낌도 살짝 맛볼 수 있습니다.
val request = SignUpRequest("홍길동", "hong@example.com", "1234")
// 요청 DTO의 값을 이용해 도메인 객체를 만든다
val user = User(
id = 1L,
name = request.name,
email = request.email,
password = request.password,
)
// 도메인 객체에서 필요한 값만 골라 응답 DTO를 만든다
val response = SignUpResponse(
id = user.id,
name = user.name,
email = user.email, // 비밀번호는 담지 않음
)
지금은 이 흐름을
완벽히 이해하지 못해도 괜찮습니다.
들어올 때는 요청 DTO,
안에서는 도메인 객체,
나갈 때는 응답 DTO.
이 큰 그림만 기억해 두면 충분합니다.
DTO를 실제 스프링 서버에서
어떻게 주고받는지는 33장에서 깊이 다룹니다.
지금은 “데이터 클래스가 DTO를 만드는 데
아주 잘 어울린다“는 점만 알아 두면 됩니다.
9장을 마치며
이 장에서 우리는 다음을 배웠습니다.
class앞에data를 붙이면
데이터를 담는 클래스가 된다는 점- 데이터 클래스는
toString,equals,hashCode를 자동으로 만들어 준다는 점 - 자바의 POJO에서 길게 써야 했던 코드를
코틀린은 한 줄로 끝낸다는 점 copy로 원본을 지키면서
값이 바뀐 새 객체를 안전하게 만드는 방법- 구조 분해와
componentN으로
값을 한 번에 꺼내는 방법 - 백엔드에서 요청 DTO, 응답 DTO,
도메인 객체를 나누어 쓰는 이유
특히 두 가지는 앞으로 계속 나옵니다.
“데이터는 data class로”, 그리고 “DTO와 도메인 객체는 나눈다“입니다.
다음 장에서는
값을 여러 개 묶어 다루는 방법을 이어서 배워 보겠습니다.
10장. Enum과 Sealed Type
프로그램에는 “정해진 몇 가지 중 하나“인 값이 자주 등장합니다.
주문의 상태는 “결제 대기, 결제 완료, 배송 중, 배송 완료” 중 하나입니다.
요일은 “월, 화, 수, 목, 금, 토, 일” 중 하나입니다.
이렇게 값의 종류가 미리 정해져 있는 경우가 많습니다.
이런 값을 문자열이나 숫자로 대충 표현하면
오타나 잘못된 값이 슬그머니 끼어들기 쉽습니다.
이 장에서는 “정해진 종류“를 안전하게 표현하는 두 도구,
Enum과 Sealed Type을 배웁니다.
그리고 성공과 실패 같은 “결과“를
타입으로 깔끔하게 표현하는 방법까지 함께 살펴봅니다.
10.1 Enum: 상태를 Enum으로 표현하기
상태를 문자열로 표현하면 생기는 문제
먼저 Enum이 왜 필요한지부터 느껴 봅시다.
주문 상태를 문자열로 표현한다고 해 보겠습니다.
var status = "PAID" // 결제 완료
status = "SHIPPING" // 배송 중
언뜻 보면 문제가 없어 보입니다.
하지만 이 방식에는 위험이 숨어 있습니다.
status = "Paid" // 대소문자가 다름
status = "결제완료" // 표기가 제각각
status = "PADI" // 오타
이 코드는 전부 “정상적으로 실행“됩니다.
컴파일러가 잘못을 잡아 주지 못하기 때문입니다.
문자열은 어떤 글자든 담을 수 있으므로
“허용된 값“이라는 개념 자체가 없습니다.
즉, 상태의 종류가 정해져 있는데도
문자열은 그 약속을 강제해 주지 못합니다.
Enum이란 무엇인가
이럴 때 쓰는 것이 Enum입니다.
Enum은 enumeration(열거)의 줄임말로,
우리말로는 “열거형“이라고 부릅니다.
이름은 어렵지만 뜻은 간단합니다.
Enum은 “정해진 값들의 목록“을
하나의 타입으로 만든 것입니다.
비유하자면 이렇습니다.
문자열이 “아무 글자나 쓸 수 있는 빈칸“이라면,
Enum은 “정답이 미리 적힌 객관식 보기“입니다.
보기에 없는 값은 애초에 고를 수 없습니다.
코틀린에서는 enum class로 만듭니다.
enum class OrderStatus {
PENDING, // 결제 대기
PAID, // 결제 완료
SHIPPING, // 배송 중
DELIVERED // 배송 완료
}
이제 주문 상태는 이 네 가지 중 하나로만
표현할 수 있습니다.
각각의 값(PENDING, PAID 등)을
Enum 상수(enum constant)라고 부릅니다.
Enum을 사용하면 안전해지는 이유
Enum으로 만든 값은 이렇게 씁니다.
var status = OrderStatus.PAID
status = OrderStatus.SHIPPING
여기서 목록에 없는 값을 넣어 보겠습니다.
status = OrderStatus.PADI // 오류: 그런 값은 없음
이 코드는 실행되지 않습니다.
컴파일 단계에서 오류로 걸러집니다.
문자열이었다면 그냥 넘어갔을 오타를,
Enum은 프로그램 실행 전에 막아 줍니다.
정리하면 이렇습니다.
| 방식 | 잘못된 값 | 오타 방지 | 자동 완성 |
|---|---|---|---|
| 문자열 | 가능 | 안 됨 | 안 됨 |
| Enum | 불가능 | 됨 | 됨 |
Enum을 쓰면 IntelliJ가
가능한 값을 자동으로 보여 주기까지 합니다.
값을 외울 필요도, 오타를 걱정할 필요도 없습니다.
Enum Property와 Method
Enum은 단순한 값의 목록에서 그치지 않습니다.
각 값에 추가 정보를 함께 담을 수 있습니다.
이 추가 정보를 프로퍼티(property)라고 합니다.
프로퍼티는 “그 값이 가진 속성“이라고 생각하면 됩니다.
(프로퍼티는 클래스와 함께 4부에서 자세히 다룹니다.)
예를 들어 각 상태마다
“사용자에게 보여 줄 한글 이름“을 붙여 보겠습니다.
enum class OrderStatus(val label: String) {
PENDING("결제 대기"),
PAID("결제 완료"),
SHIPPING("배송 중"),
DELIVERED("배송 완료")
}
이제 각 상태에서 한글 이름을 꺼낼 수 있습니다.
val status = OrderStatus.PAID
println(status.label)
결제 완료
Enum 안에는 메서드(method)도 넣을 수 있습니다.
메서드는 “그 값이 할 수 있는 일“입니다.
쉽게 말해 Enum 안에 정의한 함수입니다.
예를 들어 “이 상태에서 취소가 가능한가“를
알려 주는 메서드를 만들어 보겠습니다.
enum class OrderStatus(val label: String) {
PENDING("결제 대기"),
PAID("결제 완료"),
SHIPPING("배송 중"),
DELIVERED("배송 완료");
fun canCancel(): Boolean {
return this == PENDING || this == PAID
}
}
여기서 한 가지 규칙이 있습니다.
상수 목록 뒤에 메서드를 이어 쓸 때는
목록 끝에 세미콜론(;)을 붙여야 합니다.
코틀린에서 세미콜론이 필요한
드문 경우 중 하나입니다.
사용하는 모습은 이렇습니다.
println(OrderStatus.PAID.canCancel()) // true
println(OrderStatus.SHIPPING.canCancel()) // false
Enum이 기본으로 제공하는 기능
Enum은 몇 가지 편리한 기능을
자동으로 갖추고 있습니다.
자주 쓰는 것만 정리하면 이렇습니다.
| 기능 | 설명 |
|---|---|
name | 상수의 이름을 문자열로 반환 |
ordinal | 상수의 순서(0부터 시작)를 반환 |
values() | 모든 상수를 배열로 반환 |
valueOf("이름") | 이름에 해당하는 상수를 반환 |
간단히 확인해 보겠습니다.
val status = OrderStatus.PAID
println(status.name) // PAID
println(status.ordinal) // 1
for (s in OrderStatus.values()) {
println(s.label)
}
PAID
1
결제 대기
결제 완료
배송 중
배송 완료
이렇게 Enum 하나로
“정해진 값 + 관련 정보 + 관련 동작“을
한곳에 깔끔하게 모을 수 있습니다.
10.2 Sealed Class: 제한된 타입 계층
Enum만으로는 부족한 순간
Enum은 아주 편리하지만 한계가 있습니다.
값마다 “서로 다른 모양의 데이터“를 담기 어렵습니다.
예를 들어 결제 결과를 생각해 봅시다.
- 성공했다면: 결제 승인 번호가 필요합니다
- 실패했다면: 실패 사유가 필요합니다
- 처리 중이라면: 아무 정보도 필요 없습니다
성공과 실패가 담아야 할 정보의 모양이
서로 다릅니다.
Enum은 모든 상수가 같은 프로퍼티를 공유하므로
이런 “제각각 다른 데이터“를 표현하기 불편합니다.
Sealed Class란 무엇인가
이럴 때 쓰는 것이 Sealed Class입니다.
sealed는 “봉인된, 밀봉된“이라는 뜻입니다.
우리말로는 “봉인 클래스“라고 부릅니다.
뜻을 풀어 보면 이렇습니다.
Sealed Class는
“정해진 자식들만 가질 수 있는,
봉인된 타입“입니다.
비유하자면 이렇습니다.
Enum이 “정해진 값 목록“이라면,
Sealed Class는 “정해진 종류 목록“입니다.
각 종류가 저마다 다른 데이터를
담을 수 있다는 점이 큰 차이입니다.
여기서 클래스(class)와 상속(inheritance)이
등장하는데, 이 개념은 4부에서 자세히 배웁니다.
지금은 아래 그림 정도로만 이해하면 충분합니다.
부모 타입 하나가 있고,
그 아래에 “미리 정해진 자식 타입“만
존재할 수 있다.
결제 결과를 Sealed Class로 만들어 보겠습니다.
sealed class PaymentResult {
data class Success(val approvalNo: String) : PaymentResult()
data class Failure(val reason: String) : PaymentResult()
object Pending : PaymentResult()
}
한 줄씩 뜯어보겠습니다.
sealed class PaymentResult: 봉인된 부모 타입Success: 성공, 승인 번호를 담음Failure: 실패, 사유를 담음Pending: 처리 중, 담을 데이터가 없음
data class는 데이터를 담기 좋은 클래스이고,object는 데이터가 없는 단 하나뿐인 값입니다.
(둘 다 4부에서 자세히 다룹니다.)
핵심은 이렇습니다.
PaymentResult의 종류는
이 세 가지가 전부다.
다른 종류는 만들 수 없다.
이것이 “봉인“의 의미입니다.
값 만들고 데이터 꺼내기
각 종류는 이렇게 만듭니다.
val r1 = PaymentResult.Success("APV-12345")
val r2 = PaymentResult.Failure("잔액 부족")
val r3 = PaymentResult.Pending
성공에는 승인 번호가,
실패에는 사유가 함께 담겨 있습니다.
Enum과 달리 종류마다
다른 데이터를 가질 수 있다는 점이 보입니다.
when과 Exhaustive Check
Sealed Class의 진짜 힘은when과 함께 쓸 때 나옵니다.
when은 값에 따라 갈래를 나누는 문법입니다.
(when의 기본은 3장에서 다뤘습니다.)
결제 결과에 따라
다른 메시지를 만들어 보겠습니다.
fun describe(result: PaymentResult): String {
return when (result) {
is PaymentResult.Success -> "성공: ${result.approvalNo}"
is PaymentResult.Failure -> "실패: ${result.reason}"
is PaymentResult.Pending -> "처리 중입니다"
}
}
여기서 is는 “이 타입인가?“를 확인하는 표현입니다.
그리고 각 갈래 안에서는
그 타입이 가진 데이터를 바로 꺼내 씁니다.Success로 확인되면 approvalNo를,Failure로 확인되면 reason을 쓸 수 있습니다.
이렇게 타입을 확인한 뒤
그 타입으로 자동 취급되는 것을
스마트 캐스트(smart cast)라고 합니다.
(스마트 캐스트는 3부에서 다시 만납니다.)
이제 가장 중요한 특징을 보겠습니다.
위 코드에는 else 갈래가 없습니다.
그런데도 오류가 나지 않습니다.
왜냐하면 PaymentResult의 종류가
세 가지뿐이라는 사실을
컴파일러가 이미 알기 때문입니다.
세 가지를 모두 처리했으니
“빠뜨린 경우가 없다“고 판단하는 것입니다.
이렇게 “모든 경우를 빠짐없이 처리했는지”
컴파일러가 확인해 주는 것을
Exhaustive Check(빠짐없음 검사)라고 합니다.
exhaustive는 “빠짐없는, 철저한“이라는 뜻입니다.
만약 한 가지를 빠뜨리면 어떻게 될까요?
fun describe(result: PaymentResult): String {
return when (result) {
is PaymentResult.Success -> "성공"
is PaymentResult.Failure -> "실패"
// Pending을 빠뜨림
}
}
오류: 'when' expression must be exhaustive,
add necessary 'Pending' branch
컴파일러가 “Pending 갈래가 빠졌다“고
정확히 알려 줍니다.
이 기능이 왜 강력할까요?
나중에 새로운 종류를 추가하면,
그것을 처리하지 않은 모든when이
자동으로 컴파일 오류가 됩니다.
즉, 빠뜨린 곳을 컴파일러가
전부 찾아 줍니다.
문자열이나 Enum + else 방식에서는
이런 안전장치를 얻기 어렵습니다.
Enum의 when과 비교하기
Enum도 when과 함께 쓸 수 있고,
Exhaustive Check도 동작합니다.
fun labelOf(status: OrderStatus): String {
return when (status) {
OrderStatus.PENDING -> "결제 대기"
OrderStatus.PAID -> "결제 완료"
OrderStatus.SHIPPING -> "배송 중"
OrderStatus.DELIVERED -> "배송 완료"
}
}
Enum도 else 없이 모든 경우를 처리하면
빠짐없음 검사를 받습니다.
차이는 데이터에 있습니다.
- Enum: 값의 목록, 모두 같은 모양
- Sealed Class: 종류의 목록, 각각 다른 모양
“단순한 상태 값“이면 Enum,
“종류마다 다른 데이터“가 필요하면 Sealed Class.
이렇게 기억하면 쉽습니다.
10.3 Sealed Interface: 상태보다 타입 자체를 모델링하기
Sealed Interface란 무엇인가
코틀린에는 Sealed Class와 짝을 이루는
Sealed Interface도 있습니다.
인터페이스(interface)는
“이런 기능을 갖춰야 한다“는 약속입니다.
(인터페이스는 4부에서 자세히 배웁니다.)
Sealed Interface는
그 약속을 “봉인된 형태“로 만든 것입니다.
쓰는 방법과 효과는
Sealed Class와 거의 같습니다.
sealed interface PaymentResult {
data class Success(val approvalNo: String) : PaymentResult
data class Failure(val reason: String) : PaymentResult
object Pending : PaymentResult
}
앞 절의 Sealed Class 예제와
거의 똑같아 보입니다.
when과 함께 쓸 때
Exhaustive Check가 되는 것도 동일합니다.
Sealed Class와 무엇이 다른가
그렇다면 언제 어느 쪽을 쓸까요?
가장 큰 차이는 이렇습니다.
클래스는 부모를 하나만 가질 수 있지만,
인터페이스는 여러 개를 동시에 구현할 수 있다.
즉, Sealed Interface를 쓰면
한 타입이 여러 봉인 계층에
동시에 속할 수 있습니다.
정리하면 이렇습니다.
| 구분 | Sealed Class | Sealed Interface |
|---|---|---|
| 공통 데이터 저장 | 담기 좋음 | 담을 수 없음 |
| 여러 계층에 속하기 | 하나만 가능 | 여러 개 가능 |
| 유연함 | 보통 | 더 유연함 |
실무에서는 이렇게 생각하면 무난합니다.
- 모든 자식이 공유할 데이터가 있다 → Sealed Class
- 그냥 “종류만” 나누면 된다 → Sealed Interface
특별한 이유가 없다면
가벼운 Sealed Interface를
기본으로 골라도 좋습니다.
상태가 아니라 타입을 나눈다
이 절의 제목을 다시 읽어 봅시다.
“상태보다 타입 자체를 모델링하기“입니다.
무슨 뜻인지 예로 풀어 보겠습니다.
어떤 화면이 보여 줄 수 있는 모습을
생각해 보겠습니다.
- 데이터를 잘 불러온 화면
- 아무것도 없는 빈 화면
- 오류가 난 화면
이것을 Enum으로 표현하면
“상태 이름“만 남습니다.
enum class ScreenState {
CONTENT, EMPTY, ERROR
}
하지만 실제로는 각 모습이
서로 다른 데이터를 가집니다.
- 데이터 화면: 보여 줄 목록
- 빈 화면: 안내 문구
- 오류 화면: 오류 메시지
이럴 때 Sealed Interface가 잘 맞습니다.
sealed interface ScreenState {
data class Content(val items: List<String>) : ScreenState
data class Empty(val message: String) : ScreenState
data class Error(val message: String) : ScreenState
}
이제 상태 “이름“만 있는 것이 아니라,
각 상태가 무엇을 담는지까지
타입 하나로 표현됩니다.
이것이 “상태보다 타입 자체를 모델링한다“의
의미입니다.
값의 이름만 나누는 것을 넘어,
“그 종류가 무엇을 담는가“까지
타입으로 그려 내는 것입니다.
10.4 결과를 타입으로 표현하기
결과를 어떻게 돌려줄까
함수는 종종 “결과“를 돌려줍니다.
그런데 결과에는 성공만 있는 것이 아닙니다.
- 잘 처리됐다 (성공)
- 처리에 실패했다 (실패)
- 아직 처리 중이다 (진행 중)
이 서로 다른 결과를
하나의 함수가 어떻게 돌려줄 수 있을까요?
전통적인 방법 두 가지를 먼저 보겠습니다.
첫째, 예외(exception)를 던지는 방법입니다.
실패하면 오류를 발생시키는 방식입니다.
(예외는 8부에서 자세히 다룹니다.)
둘째, null을 돌려주는 방법입니다.
실패하면 “값 없음“을 뜻하는 null을 주는 방식입니다.
(null은 3부에서 다룹니다.)
두 방법 모두 쓰이지만 아쉬움이 있습니다.
“왜 실패했는지“를 함께 전달하기 어렵거나,
호출한 쪽이 처리를 깜빡하기 쉽습니다.
성공, 실패, 처리 중을 타입으로
이럴 때 Sealed Type이 빛을 발합니다.
결과의 종류 자체를 타입으로 만드는 것입니다.
sealed interface SignUpResult {
data class Success(val userId: Long) : SignUpResult
data class Failure(val message: String) : SignUpResult
object Processing : SignUpResult
}
회원 가입 함수가
이 타입을 돌려준다고 해 보겠습니다.
fun signUp(email: String): SignUpResult {
if (email.isBlank()) {
return SignUpResult.Failure("이메일을 입력해 주세요")
}
// ... 실제 가입 처리 ...
return SignUpResult.Success(userId = 1001)
}
호출하는 쪽은 이렇게 씁니다.
val result = signUp("test@example.com")
val message = when (result) {
is SignUpResult.Success -> "가입 완료! id=${result.userId}"
is SignUpResult.Failure -> "가입 실패: ${result.message}"
is SignUpResult.Processing -> "처리 중입니다"
}
println(message)
이 방식의 장점은 분명합니다.
- 성공과 실패를 모두 “정상적인 값“으로 다룬다
- 실패 이유를 함께 담아 전달할 수 있다
- 모든 경우를 처리했는지 컴파일러가 검사해 준다
특히 마지막 장점이 큽니다.
호출한 쪽이 실패 처리를 깜빡하면
Exhaustive Check가 이를 잡아냅니다.
오류 코드와 결과 객체
실무에서는 실패 사유를
단순한 문자열보다 오류 코드로 다룰 때가 많습니다.
오류 코드(error code)란
“실패의 종류를 구분하는 정해진 값“입니다.
오류 코드야말로 Enum이 딱 맞는 자리입니다.
enum class ErrorCode(val message: String) {
DUPLICATE_EMAIL("이미 사용 중인 이메일입니다"),
INVALID_EMAIL("이메일 형식이 올바르지 않습니다"),
SERVER_ERROR("잠시 후 다시 시도해 주세요")
}
이제 실패 결과 안에
오류 코드를 담을 수 있습니다.
sealed interface SignUpResult {
data class Success(val userId: Long) : SignUpResult
data class Failure(val code: ErrorCode) : SignUpResult
}
여기서 두 도구가
어떻게 역할을 나누는지 보입니다.
- Sealed Type: 결과의 큰 갈래(성공 / 실패)
- Enum: 실패 안에서의 세부 종류(오류 코드)
둘을 함께 쓰면 이렇게 깔끔해집니다.
fun signUp(email: String): SignUpResult {
if (!email.contains("@")) {
return SignUpResult.Failure(ErrorCode.INVALID_EMAIL)
}
return SignUpResult.Success(userId = 1001)
}
val result = signUp("wrong-email")
when (result) {
is SignUpResult.Success ->
println("가입 성공: ${result.userId}")
is SignUpResult.Failure ->
println("가입 실패: ${result.code.message}")
}
가입 실패: 이메일 형식이 올바르지 않습니다
이렇게 성공과 실패를 담아 전달하는 값을
결과 객체(result object)라고 부릅니다.
결과를 타입으로 표현하는 이 방식은
코틀린 백엔드에서 아주 자주 쓰입니다.
이 주제는 함수형 스타일의 오류 처리와도
이어지며, 24장에서 더 깊이 다룹니다.
10.5 Enum과 Sealed Type 선택하기
이제 정리해 봅시다.
Enum과 Sealed Type은 언제 골라야 할까요?
예제 세 가지로 비교해 보겠습니다.
예제 1: 주문 상태 → Enum
주문 상태를 다시 봅시다.
- 결제 대기, 결제 완료, 배송 중, 배송 완료
- 종류가 딱 정해져 있다
- 각 상태가 담을 별도 데이터가 없다
이런 경우는 Enum이 정답입니다.
enum class OrderStatus {
PENDING, PAID, SHIPPING, DELIVERED
}
모든 값이 같은 모양이고,
그저 “어떤 상태인가“만 구분하면 되기 때문입니다.
예제 2: 결제 결과 → Sealed Type
결제 결과는 조금 다릅니다.
- 성공: 승인 번호가 필요
- 실패: 실패 사유가 필요
- 처리 중: 담을 데이터가 없음
종류마다 담는 데이터가 다릅니다.
이럴 때는 Sealed Type이 맞습니다.
sealed interface PaymentResult {
data class Success(val approvalNo: String) : PaymentResult
data class Failure(val reason: String) : PaymentResult
object Pending : PaymentResult
}
예제 3: API 처리 결과 → 함께 쓰기
실제 서버의 API 응답은
두 도구를 섞어 쓰는 경우가 많습니다.
큰 갈래는 Sealed Type으로,
실패의 세부 종류는 Enum으로 나눕니다.
enum class ApiErrorCode(val message: String) {
NOT_FOUND("대상을 찾을 수 없습니다"),
UNAUTHORIZED("권한이 없습니다"),
SERVER_ERROR("서버 오류가 발생했습니다")
}
sealed interface ApiResult {
data class Ok(val body: String) : ApiResult
data class Error(val code: ApiErrorCode) : ApiResult
}
큰 흐름(성공 / 실패)은 타입으로 나누고,
실패의 이유는 오류 코드로 세분화합니다.
두 도구는 경쟁 관계가 아니라
서로를 보완하는 사이입니다.
한눈에 보는 선택 기준
마지막으로 표로 정리하겠습니다.
| 질문 | 그렇다면 |
|---|---|
| 값의 종류가 정해져 있는가 | 둘 다 후보 |
| 각 값이 같은 모양인가 | Enum |
| 종류마다 다른 데이터를 담는가 | Sealed Type |
| 공통 데이터를 함께 저장하고 싶은가 | Sealed Class |
| 그냥 종류만 나누면 되는가 | Sealed Interface |
| 실패의 세부 사유를 구분해야 하는가 | Enum(오류 코드) |
기억하기 쉽게 한 문장으로 줄이면 이렇습니다.
단순한 값 목록이면 Enum,
종류마다 데이터가 다르면 Sealed Type.
10장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- Enum은 “정해진 값들의 목록“을
하나의 타입으로 만든 것이라는 점 - Enum에 프로퍼티와 메서드를 붙여
값과 정보와 동작을 한곳에 모을 수 있다는 점 - Sealed Class는 “정해진 자식만 가지는
봉인된 타입“이며, 종류마다 다른 데이터를 담는다는 점 when과 함께 쓰면 Exhaustive Check로
빠뜨린 경우를 컴파일러가 잡아 준다는 점- Sealed Interface로 상태의 이름을 넘어
타입 자체를 모델링할 수 있다는 점 - 성공과 실패를 타입으로 표현하면
결과를 더 안전하게 다룰 수 있다는 점
두 가지만 기억해도 충분합니다.
“단순한 값이면 Enum”,
그리고 “종류마다 다르면 Sealed Type“입니다.
다음 장에서는
지금까지 배운 것들을 바탕으로
한 걸음 더 나아가 봅니다.
11장. Object와 Companion Object
지금까지 우리는 클래스로 객체를 “찍어내는” 법을 배웠습니다.
클래스는 붕어빵 틀이고,
그 틀로 붕어빵(객체)을 여러 개 만들 수 있었습니다.
그런데 세상에는 이런 경우도 있습니다.
- 프로그램 전체에서 딱 하나만 있으면 되는 것
- 클래스에 딸려 있지만, 객체 하나하나와는 상관없는 것
이 장에서는 이런 상황을 코틀린이 어떻게 다루는지 배웁니다.
바로 object와 Companion Object입니다.
자바를 조금 아는 분이라면,
자바의 static이 자꾸 떠오를 텐데요.
그 비교도 이 장에서 함께 정리하겠습니다.
11.1 object: Singleton 객체
하나만 존재하는 객체
먼저 상황을 하나 떠올려 봅시다.
프로그램 전체에서 설정 값을 관리하는 무언가가 필요합니다.
그런데 이런 관리자는 여러 개일 이유가 없습니다.
관리자가 두 명이면 오히려 헷갈립니다.
“진짜 설정은 누가 들고 있지?” 하는 문제가 생깁니다.
이렇게 프로그램 전체에서
딱 하나만 존재해야 하는 객체를
싱글턴(singleton)이라고 부릅니다.
single(하나) + ton →
“단 하나뿐인 것“이라는 뜻입니다.
자바에서 싱글턴을 만들려면 꽤 번거로웠습니다.
생성자를 막고, 정적 필드를 두고,
그 필드를 돌려주는 메서드를 따로 만들어야 했습니다.
코틀린은 이 과정을 단어 하나로 끝냅니다.
바로 object입니다.
object로 Singleton 만들기
object는 클래스를 정의하는 동시에
그 객체를 딱 하나 만들어 줍니다.
말로만 들으면 헷갈리니 코드로 보겠습니다.
object AppConfig {
val appName = "주문 시스템"
var version = "1.0.0"
fun printInfo() {
println("$appName ($version)")
}
}
class가 아니라 object라고 적은 점에 주목해 주세요.
여기서 중요한 차이가 있습니다.
- 클래스는
AppConfig()처럼 만들어서 써야 한다 object는 만들 필요 없이 이름 그대로 바로 쓴다
사용법을 보겠습니다.
fun main() {
AppConfig.printInfo() // 주문 시스템 (1.0.0)
AppConfig.version = "1.0.1"
AppConfig.printInfo() // 주문 시스템 (1.0.1)
}
주문 시스템 (1.0.0)
주문 시스템 (1.0.1)
AppConfig()라고 괄호를 붙이지 않았습니다.object는 이미 만들어진 하나의 객체이기 때문입니다.
프로그램 어디에서 AppConfig를 불러도
언제나 똑같은 그 객체 하나를 가리킵니다.
자바의 Singleton과 비교하기
자바에서 같은 싱글턴을 만들면 이렇게 됩니다.
// 자바
public class AppConfig {
public static final AppConfig INSTANCE = new AppConfig();
private AppConfig() { } // 밖에서 못 만들게 막기
public void printInfo() {
System.out.println("주문 시스템");
}
}
밖에서 새로 만들지 못하도록 생성자를 막고,INSTANCE라는 정적 필드를 하나 둡니다.
쓸 때는 AppConfig.INSTANCE.printInfo()처럼INSTANCE를 거쳐야 합니다.
코틀린은 이 모든 준비를object 한 단어로 대신합니다.
object AppConfig {
fun printInfo() {
println("주문 시스템")
}
}
정리하면 이렇습니다.
| 구분 | 자바 | 코틀린 |
|---|---|---|
| 선언 | class + static INSTANCE | object |
| 생성자 막기 | 직접 private으로 막음 | 자동으로 막힘 |
| 사용 | AppConfig.INSTANCE.xxx | AppConfig.xxx |
자바가 여러 줄로 하던 일을,
코틀린은 단어 하나로 표현합니다.
전역 상태와 Singleton의 위험성
object는 편리하지만,
편리한 만큼 조심해서 써야 합니다.
이유는 object 안의 var 때문입니다.
앞의 예제에서 version은 var였습니다.
그리고 프로그램 어디에서든 바꿀 수 있었습니다.
AppConfig.version = "2.0.0" // 어디서든 이렇게 바꿀 수 있음
이렇게 프로그램 어디에서나 접근하고
바꿀 수 있는 값을
전역 상태(global state)라고 부릅니다.
전역 상태는 왜 위험할까요?
2장에서 만난 고민이 그대로 되살아나기 때문입니다.
- 이 값이 대체 어디서 바뀐 거지?
- 지금 이 값이 맞는 값이 맞나?
싱글턴은 딱 하나뿐이라,
여러 곳에서 동시에 손을 댈 수 있습니다.
값이 꼬이면 원인을 찾기가 아주 어렵습니다.
특히 서버는 여러 요청을 동시에 처리합니다.
싱글턴의 var를 여러 요청이 함께 바꾸면
예상치 못한 오류로 이어질 수 있습니다.
그래서 object를 쓸 때는 이렇게 기억해 둡시다.
object에는 되도록 바뀌지 않는 값(val)만 담자.
자주 바뀌는 상태는 싱글턴에 두지 말자.
object가 잘 어울리는 곳은
값이 거의 바뀌지 않는 경우입니다.
- 고정된 설정 값
- 여러 곳에서 함께 쓰는 상수 모음
- 상태 없이 계산만 해 주는 도구
정리하면,object는 “하나만 있으면 되는, 상태 없는 것“에
가장 잘 맞습니다.
11.2 Companion Object
클래스와 연결된 객체
이번에는 조금 다른 상황입니다.
클래스는 여전히 객체를 여러 개 만들어야 합니다.
그런데 그중에는 “객체 하나하나“가 아니라
“클래스 전체“에 속하는 것도 있습니다.
예를 들어 회원 클래스를 생각해 봅시다.
class Member(val name: String)
Member("홍길동"), Member("이순신")처럼
회원 객체는 여러 개 만들어집니다.
name은 회원마다 다릅니다.
홍길동의 이름과 이순신의 이름은 다르니까요.
그런데 이런 값은 어떨까요?
- 회원 이름의 최대 길이 제한
- 지금까지 만들어진 회원 수
이런 값은 회원 한 명의 것이 아닙니다.
“회원이라는 개념 전체“에 속하는 값입니다.
이렇게 특정 객체가 아니라
클래스 자체에 딸려 있는 것을 담는 공간이
바로 컴패니언 오브젝트(companion object)입니다.
companion은 “동반자, 짝꿍“이라는 뜻입니다.
클래스와 짝을 이루어 함께 다니는 객체라고
생각하면 쉽습니다.
companion object 사용하기
클래스 안에 companion object 블록을 넣습니다.
class Member(val name: String) {
companion object {
const val MAX_NAME_LENGTH = 20
fun describe() {
println("이름은 최대 ${MAX_NAME_LENGTH}자입니다.")
}
}
}
컴패니언 오브젝트 안의 값과 함수는
객체를 만들지 않고 클래스 이름으로 바로 씁니다.
fun main() {
println(Member.MAX_NAME_LENGTH) // 20
Member.describe() // 이름은 최대 20자입니다.
}
20
이름은 최대 20자입니다.
Member()로 회원을 만들지 않고도Member.MAX_NAME_LENGTH를 바로 썼습니다.
이 값은 회원 한 명의 것이 아니라
회원 클래스 전체의 것이기 때문입니다.
정리하면 이렇습니다.
- 객체마다 다른 값 → 클래스의 프로퍼티 (
name) - 클래스 전체에 하나뿐인 값 → 컴패니언 오브젝트
Factory Method
컴패니언 오브젝트가 가장 빛나는 곳은
객체를 만들어 주는 함수를 담을 때입니다.
이렇게 객체를 만들어서 돌려주는 함수를
팩토리 메서드(factory method)라고 부릅니다.
factory는 “공장“이라는 뜻입니다.
객체를 찍어내 주는 작은 공장이라고
생각하면 됩니다.
왜 굳이 이런 함수가 필요할까요?
예제로 이유를 만나 봅시다.
문자열 하나로 회원을 만들고 싶다고 해 봅시다."홍길동,20"처럼 이름과 나이가
쉼표로 붙어 있는 문자열입니다.
생성자에 이 로직을 다 넣으면 지저분해집니다.
이럴 때 팩토리 메서드를 씁니다.
class Member(val name: String, val age: Int) {
companion object {
fun from(text: String): Member {
val parts = text.split(",")
val name = parts[0]
val age = parts[1].toInt()
return Member(name, age)
}
}
}
from은 문자열을 받아서
그 안에서 회원 객체를 만들어 돌려줍니다.
사용하는 쪽은 아주 깔끔해집니다.
fun main() {
val member = Member.from("홍길동,20")
println("${member.name} / ${member.age}")
}
홍길동 / 20
팩토리 메서드의 장점을 정리해 보겠습니다.
- 이름을 붙일 수 있다 (
from,of,create등) - 만드는 과정이 복잡해도 밖에서는 간단하게 쓴다
- 상황에 따라 다른 방식으로 만들 수 있다
예를 들어 만드는 방법이 여러 가지라면
이름이 다른 함수를 여러 개 둘 수 있습니다.
class Member(val name: String, val age: Int) {
companion object {
fun of(name: String, age: Int): Member {
return Member(name, age)
}
fun newborn(name: String): Member {
return Member(name, 0) // 나이는 0으로 고정
}
}
}
생성자만으로는 이렇게
“이름 있는 여러 방식“을 표현하기 어렵습니다.
생성자의 이름은 언제나 클래스 이름 하나뿐이니까요.
만드는 방법이 여러 가지이거나,
만드는 과정이 복잡할 때
팩토리 메서드를 떠올리면 됩니다.
이 방식은 나중에 DTO를 만들거나
문자열/데이터를 객체로 바꿀 때 자주 씁니다.
(DTO 변환은 33장에서 자세히 다룹니다.)
11.3 Java의 static과 비교
정적 메서드와 Companion Object
자바를 배운 분이라면
지금까지의 이야기가 익숙할 것입니다.
자바에서는 이런 것들을static이라는 키워드로 표현했습니다.
정적(static)이란
“특정 객체가 아니라 클래스에 속한다“는 뜻입니다.
자바 코드로 보겠습니다.
// 자바
public class Member {
public static final int MAX_NAME_LENGTH = 20;
public static Member from(String text) {
String[] parts = text.split(",");
return new Member(parts[0]);
}
private final String name;
public Member(String name) {
this.name = name;
}
}
static이 붙은 것들은Member.MAX_NAME_LENGTH, Member.from(...)처럼
객체 없이 클래스 이름으로 바로 씁니다.
여기서 중요한 차이가 하나 있습니다.
코틀린에는 static이라는 키워드가 아예 없습니다.
그 자리를 컴패니언 오브젝트가 대신합니다.
두 언어를 나란히 비교하면 이렇습니다.
| 자바 | 코틀린 |
|---|---|
static 필드 | companion object의 프로퍼티 |
static 메서드 | companion object의 함수 |
Member.from(...) 호출 | Member.from(...) 호출 (동일) |
부르는 방식(Member.from)은 똑같습니다.
다만 코틀린은 그것을 static이 아니라
“클래스와 짝을 이루는 객체“로 표현합니다.
이 차이가 중요한 이유가 있습니다.
코틀린의 컴패니언 오브젝트는 진짜 “객체“라서,
인터페이스를 구현하는 등 더 많은 일을 할 수 있습니다.
(인터페이스는 8장에서 다뤘습니다.)
지금은 이렇게만 기억해 둡시다.
자바의
static이 하던 일을,
코틀린은companion object로 한다.
const val
앞의 예제에서 이런 코드를 봤습니다.
companion object {
const val MAX_NAME_LENGTH = 20
}
여기서 val 앞에 const가 붙었습니다.
이 const가 무엇인지 짚고 넘어가겠습니다.
2장에서 val은
“한 번 정하면 바꿀 수 없는 값“이라고 배웠습니다.
const val은 거기서 한 걸음 더 나갑니다.
“컴파일하는 시점에 이미 정해지는 상수“입니다.
상수(constant)란
프로그램 내내 절대 바뀌지 않는 고정된 값입니다.
val과 const val의 차이를 정리하면 이렇습니다.
val: 프로그램이 실행되면서 값이 정해질 수 있다const val: 코드를 쓰는 순간 값이 이미 정해져 있다
그래서 const val에는 제약이 있습니다.
- 숫자, 문자열,
true/false같은
단순한 값만 담을 수 있다 - 함수 호출의 결과 같은 것은 담을 수 없다
object나companion object,
또는 파일 맨 바깥(톱레벨)에만 놓을 수 있다
간단한 예를 보겠습니다.
const val PI = 3.14 // 가능: 단순한 숫자
const val APP_NAME = "주문 시스템" // 가능: 단순한 문자열
const val NOW = System.currentTimeMillis()
// 오류: 실행해 봐야 아는 값은 const가 될 수 없음
그럼 언제 const val을 쓸까요?
“절대 바뀌지 않는 고정 값“을 이름 붙일 때입니다.
class Order {
companion object {
const val MAX_ITEM_COUNT = 100
const val STATUS_PAID = "PAID"
}
}
이렇게 이름을 붙여 두면
숫자 100이나 문자열 "PAID"를
코드 여기저기에 흩뿌리지 않아도 됩니다.
뜻 없는 숫자나 문자열 대신
이름 있는 상수를 쓰면
코드가 훨씬 읽기 쉬워집니다.
참고로 자바의 public static final이
코틀린의 const val과 가장 가까운 짝입니다.
11.4 익명 객체
이름 없이 그 자리에서 만드는 객체
지금까지 본 object에는 모두 이름이 있었습니다.AppConfig, Member.Companion처럼요.
그런데 때로는
이름을 붙일 필요조차 없는 객체가 있습니다.
딱 한 번, 그 자리에서만 쓰고 버릴 객체입니다.
이런 것을 익명 객체(anonymous object)라고 부릅니다.
익명(匿名)은 “이름을 숨긴다”,
즉 “이름이 없다“는 뜻입니다.
익명 객체는 주로
인터페이스를 그 자리에서 간단히 구현할 때 씁니다.
object : Interface로 구현체 만들기
먼저 간단한 인터페이스를 하나 준비하겠습니다.
(인터페이스는 8장에서 배웠습니다.)
interface Greeter {
fun greet(): String
}
이 인터페이스를 쓰려면greet()를 실제로 채운 구현체가 필요합니다.
보통은 이렇게 클래스를 따로 만듭니다.
class KoreanGreeter : Greeter {
override fun greet(): String {
return "안녕하세요"
}
}
그런데 이 구현이
딱 한 곳에서 한 번만 필요하다면 어떨까요?
클래스를 따로 만드는 것이 오히려 번거롭습니다.
이럴 때 익명 객체를 씁니다.object와 콜론(:)을 이어 붙여
그 자리에서 바로 구현합니다.
fun main() {
val greeter = object : Greeter {
override fun greet(): String {
return "안녕하세요"
}
}
println(greeter.greet())
}
안녕하세요
object : Greeter { ... } 부분을 봐 주세요.
object: 객체를 하나 만든다: Greeter: 이 객체는 Greeter를 구현한다{ ... }: 그 자리에서 내용을 채운다
클래스에 이름을 붙여 따로 선언하지 않았습니다.
필요한 곳에서 바로 만들어 썼을 뿐입니다.
익명 객체는 언제 쓰는가
익명 객체가 어울리는 상황을 정리하면 이렇습니다.
- 구현이 아주 짧다
- 그 자리에서 딱 한 번만 쓴다
- 이름을 붙여 재사용할 일이 없다
반대로, 같은 구현을 여러 곳에서 쓴다면
이름 있는 클래스로 만드는 편이 낫습니다.
그래야 재사용할 수 있으니까요.
자바를 아는 분이라면
익명 객체가 자바의 “익명 클래스“와
닮았다고 느낄 것입니다.
// 자바의 익명 클래스
Greeter greeter = new Greeter() {
@Override
public String greet() {
return "안녕하세요";
}
};
역할은 거의 같습니다.
자바의 익명 클래스가 하던 일을
코틀린은 object : ... 표현으로 합니다.
한 가지만 미리 언급해 두겠습니다.
함수가 하나뿐인 인터페이스라면,
익명 객체보다 더 짧은 방법도 있습니다.
바로 람다(lambda)입니다.
(람다는 14장에서 자세히 배웁니다.)
지금은 이렇게 기억해 두면 충분합니다.
이름 붙일 필요 없는
짧은 일회용 구현이 필요할 때object : 인터페이스를 떠올린다.
11장을 마치며
이 장에서 우리는 다음을 배웠습니다.
object로 만드는 싱글턴,
즉 프로그램에 하나뿐인 객체- 싱글턴의
var가 전역 상태가 되어
위험해질 수 있다는 점 - 클래스와 짝을 이루는 컴패니언 오브젝트와,
그 안에 두는 팩토리 메서드 - 자바의
static을 코틀린은companion object로 대신한다는 점 - 고정된 값을 이름 붙이는
const val - 이름 없이 그 자리에서 만드는 익명 객체
이 장의 도구들은
“객체 하나하나“가 아니라
“클래스나 프로그램 전체“를 다루는 도구였습니다.
특히 두 가지를 기억해 두면 좋습니다.
“하나만 필요하면 object”,
그리고 “만드는 일을 맡기려면 팩토리 메서드“입니다.
이것으로 4부, 객체를 설계하는 코틀린을 마칩니다.
다음 부에서는 컬렉션과 함수형 프로그래밍으로
데이터를 다루는 세계로 넘어갑니다.
12장. Kotlin Collection
프로그램을 만들다 보면
값 하나가 아니라 “여러 값“을 한꺼번에 다뤄야 할 때가 많습니다.
회원 목록, 장바구니에 담긴 상품들,
설정값들의 모음처럼요.
이렇게 여러 값을 하나로 묶어서 담는 그릇을
컬렉션(collection)이라고 부릅니다.
이 장에서는 코틀린의 대표적인 컬렉션인
List, Set, Map을 하나씩 살펴봅니다.
그리고 코틀린이 특히 강조하는
“읽기 전용“이라는 생각도 여기서 만나게 됩니다.
컬렉션을 만드는 방법과
그 안에서 값을 꺼내 보는 방법까지 익히고 나면,
백엔드 코드의 절반은 눈에 들어오기 시작합니다.
12.1 List, Set, Map
값을 여러 개 담는 그릇에도 종류가 있습니다.
담는 방식과 목적이 조금씩 다릅니다.
코틀린에서 가장 많이 쓰는 컬렉션은 세 가지입니다.
List, Set, Map입니다.
먼저 셋을 한눈에 비교해 봅시다.
| 종류 | 특징 | 언제 쓰나 |
|---|---|---|
| List | 순서가 있고 중복을 허용 | 순서대로 줄 세운 목록 |
| Set | 순서가 없고 중복을 허용하지 않음 | 겹치지 않는 값들의 모음 |
| Map | 키(key)와 값(value)의 짝 | 이름표를 붙여 저장 |
이제 하나씩 자세히 보겠습니다.
List: 순서가 있는 목록
리스트(List)는 값을 “줄 세워” 담는 그릇입니다.
값을 넣은 순서가 그대로 유지되고,
같은 값을 여러 번 넣어도 됩니다.
val fruits = listOf("사과", "바나나", "사과")
println(fruits)
[사과, 바나나, 사과]
“사과“가 두 번 들어가도 문제없습니다.
순서도 넣은 그대로 유지됩니다.
각 값에는 자리 번호가 있습니다.
이 번호를 인덱스(index)라고 부릅니다.
인덱스는 “몇 번째 자리인가“를 뜻하는 번호입니다.
중요한 점은, 0부터 시작한다는 것입니다.
val fruits = listOf("사과", "바나나", "포도")
println(fruits[0]) // 첫 번째 값
println(fruits[1]) // 두 번째 값
사과
바나나
첫 번째 값의 인덱스가 1이 아니라 0이라는 점을
꼭 기억해 두세요.
일상에서는 “첫째, 둘째“라고 세지만,
프로그래밍에서는 “0번째, 1번째“라고 셉니다.
리스트는 순서가 중요한 목록에 잘 맞습니다.
예를 들어 “먼저 가입한 순서대로 정렬된 회원 목록” 같은 것입니다.
Set: 중복 없는 모음
셋(Set)은 “겹치지 않는 값들“을 담는 그릇입니다.
같은 값을 여러 번 넣어도
결국 하나만 남습니다.
val numbers = setOf(1, 2, 2, 3, 3, 3)
println(numbers)
[1, 2, 3]
2와 3을 여러 번 넣었지만
중복이 자동으로 제거되었습니다.
Set은 순서를 보장하지 않습니다.
“어떤 값이 들어 있는가“에만 관심이 있고,
“몇 번째에 있는가“에는 관심이 없습니다.
그래서 Set에는 인덱스로 접근하지 않습니다.
대신 “이 값이 들어 있는지“를 물어봅니다.
val numbers = setOf(1, 2, 3)
println(2 in numbers) // 2가 들어 있는가?
println(5 in numbers) // 5가 들어 있는가?
true
false
in은 “안에 있는가“를 묻는 표현입니다.
사람 말과 순서가 비슷해서 읽기도 편합니다.
Set은 중복을 허용하지 않아야 할 때 잘 맞습니다.
예를 들어 “이 글에 좋아요를 누른 사용자 목록“처럼,
한 사람이 여러 번 세어지면 안 되는 경우입니다.
Map: 키와 값의 짝
맵(Map)은 조금 다른 방식으로 값을 담습니다.
리스트가 번호(인덱스)로 값을 찾았다면,
맵은 “이름표“로 값을 찾습니다.
이 이름표를 키(key),
이름표에 붙은 실제 값을 값(value)이라고 합니다.
Map은 “키 → 값“의 짝을 모아 둔 그릇입니다.
사전(dictionary)을 떠올리면 쉽습니다.
단어(키)를 찾으면 뜻(값)이 나오니까요.
val scores = mapOf(
"홍길동" to 90,
"이순신" to 85
)
println(scores["홍길동"])
90
여기서 to는 키와 값을 이어 붙이는 표현입니다."홍길동" to 90은 “홍길동이라는 키에 90이라는 값“이라는 뜻입니다.
값을 꺼낼 때는 대괄호 안에 키를 넣습니다.scores["홍길동"]처럼요.
Map은 이름표를 붙여 저장할 때 잘 맞습니다.
예를 들어 “회원 아이디로 회원 정보를 찾는” 경우입니다.
세 가지를 언제 고를까
정리하면 이렇게 고르면 됩니다.
- 순서가 중요하고 중복도 괜찮다면: List
- 중복 없이 모아 두고 싶다면: Set
- 이름표로 값을 찾고 싶다면: Map
처음에는 대부분 List만 써도 충분합니다.
그러다 “중복을 없애고 싶다”, “이름으로 찾고 싶다“는
필요가 생길 때 Set과 Map을 떠올리면 됩니다.
12.2 읽기 전용과 Mutable Collection
여기서 코틀린의 중요한 생각이 하나 나옵니다.
2장에서 만난 “불변성“과 이어지는 이야기입니다.
코틀린의 컬렉션은 두 부류로 나뉩니다.
“읽기 전용“과 “바꿀 수 있는” 컬렉션입니다.
읽기 전용 컬렉션
앞 절에서 만든 listOf, setOf, mapOf는
모두 읽기 전용(read-only) 컬렉션입니다.
읽기 전용이란,
“값을 꺼내 볼 수는 있지만 넣거나 뺄 수는 없다“는 뜻입니다.
val fruits = listOf("사과", "바나나")
println(fruits[0]) // 꺼내 보기: 가능
fruits.add("포도") // 오류: 값을 추가할 수 없음
add처럼 내용을 바꾸는 기능이
아예 제공되지 않습니다.
그래서 위 코드는 실행조차 되지 않습니다.
컴파일 단계에서 오류로 걸러집니다.
Mutable Collection
값을 넣거나 빼야 할 때도 분명히 있습니다.
그럴 때는 뮤터블(mutable) 컬렉션을 씁니다.
뮤터블은 “바꿀 수 있는“이라는 뜻입니다.
이름 앞에 Mutable이 붙습니다.
- List → MutableList
- Set → MutableSet
- Map → MutableMap
MutableList는 값을 추가하거나 삭제할 수 있습니다.
val fruits = mutableListOf("사과", "바나나")
fruits.add("포도") // 값 추가
fruits.remove("사과") // 값 삭제
println(fruits)
[바나나, 포도]
MutableMap도 마찬가지로
키와 값을 넣거나 뺄 수 있습니다.
val scores = mutableMapOf("홍길동" to 90)
scores["이순신"] = 85 // 새 짝 추가
scores.remove("홍길동") // 짝 삭제
println(scores)
{이순신=85}
정리하면 이렇습니다.
| 읽기 전용 | 바꿀 수 있음 |
|---|---|
| listOf | mutableListOf |
| setOf | mutableSetOf |
| mapOf | mutableMapOf |
읽기 전용과 불변성의 차이
여기서 한 가지 헷갈리기 쉬운 점을 짚겠습니다.
“읽기 전용(read-only)“과
“불변(immutable)“은 비슷해 보이지만 다릅니다.
읽기 전용은
“이 통로로는 내용을 바꿀 수 없다“는 뜻입니다.
값 자체가 절대 안 바뀐다는 보장은 아닙니다.
말이 조금 어렵지요.
코드로 보면 이해가 쉽습니다.
val mutable = mutableListOf("사과") // 바꿀 수 있는 리스트
val readOnly: List<String> = mutable // 읽기 전용으로 바라보기
mutable.add("바나나") // 원본을 바꾸면
println(readOnly) // 읽기 전용 쪽에도 반영됨
[사과, 바나나]
readOnly는 값을 추가할 수 없습니다.
그래서 “읽기 전용“입니다.
하지만 같은 리스트를 가리키는mutable 쪽에서 값을 바꾸면,readOnly로 봐도 그 변화가 보입니다.
비유하자면 이렇습니다.
읽기 전용은 “유리창 너머로 보기“입니다.
내가 유리창을 통해 물건을 바꿀 수는 없지만,
안에 있는 사람이 물건을 옮기면
유리창 너머로 그 변화가 보입니다.
정리하면 이렇게 기억해 두면 됩니다.
- 읽기 전용: 이 변수로는 못 바꾼다 (완전한 불변은 아님)
- 뮤터블: 이 변수로 바꿀 수 있다
그래도 실무에서는
“바꿀 필요가 없으면 읽기 전용을 쓴다“가 좋은 습관입니다.
2장에서 “일단 val“이라고 배운 것과
같은 맥락입니다.
되도록 읽기 전용으로 시작하고,
꼭 바꿔야 할 때만 Mutable을 쓴다.
Java의 컬렉션과 비교하기
자바를 조금 아는 분이라면
이 구분이 낯설 수 있습니다.
자바의 List, Set, Map은
기본적으로 값을 넣고 뺄 수 있습니다.
// 자바
List<String> fruits = new ArrayList<>();
fruits.add("사과"); // 언제든 추가 가능
읽기 전용으로 만들려면Collections.unmodifiableList 같은
별도의 처리를 해 줘야 했습니다.
코틀린은 이 구분을 타입 자체로 나눴습니다.List는 읽기 전용, MutableList는 바꿀 수 있는 것으로요.
덕분에 “실수로 값을 바꾸는” 일을
문법 차원에서 미리 막아 줍니다.
12.3 Collection 생성하기
이제 컬렉션을 직접 만들어 보겠습니다.
만드는 함수들은 이름이 규칙적이라 외우기 쉽습니다.
읽기 전용 컬렉션 만들기
읽기 전용 컬렉션은 ~Of 함수로 만듭니다.
val fruits = listOf("사과", "바나나", "포도")
val numbers = setOf(1, 2, 3)
val scores = mapOf("홍길동" to 90, "이순신" to 85)
listOf(...): 읽기 전용 ListsetOf(...): 읽기 전용 SetmapOf(...): 읽기 전용 Map
Map을 만들 때만 to가 등장합니다.
키와 값을 짝지어야 하기 때문입니다.
val scores = mapOf(
"홍길동" to 90,
"이순신" to 85,
"강감찬" to 95
)
읽기 편하도록
한 줄에 하나씩 적는 경우가 많습니다.
빈 컬렉션 만들기
값 없이 빈 컬렉션을 만들 수도 있습니다.empty~Of 함수를 쓰거나, 타입을 명시합니다.
val emptyList = emptyList<String>()
val emptySet = emptySet<Int>()
val emptyMap = emptyMap<String, Int>()
여기서 꺾쇠괄호 안의 타입(<String>)은
“이 컬렉션이 어떤 값을 담을지” 알려 주는 표시입니다.
값이 비어 있으면 코틀린이 타입을 추론할 수 없어서,
이렇게 직접 적어 줘야 합니다.
꺾쇠괄호 안의 타입을 제네릭(generic)이라고 부릅니다.
“이 그릇에는 String만 담는다“고 약속하는 표시입니다.
제네릭은 나중에 더 자세히 다룹니다.
바꿀 수 있는 컬렉션 만들기
바꿀 수 있는 컬렉션은 mutable~Of 함수로 만듭니다.
val fruits = mutableListOf("사과", "바나나")
val numbers = mutableSetOf(1, 2, 3)
val scores = mutableMapOf("홍길동" to 90)
값을 넣지 않고
빈 상태로 시작할 수도 있습니다.
val fruits = mutableListOf<String>()
fruits.add("사과")
fruits.add("바나나")
println(fruits)
[사과, 바나나]
처음에는 비어 있다가
필요할 때 하나씩 채워 넣는 방식입니다.
val인데 값이 추가된다?
여기서 한 가지 궁금증이 생길 수 있습니다.
val fruits = mutableListOf("사과")
fruits.add("바나나") // val인데 추가가 되네?
val은 값을 바꿀 수 없다고 배웠는데,
어떻게 리스트에 값이 추가될까요?
핵심은 “무엇이 고정되는가“입니다.
val이 고정하는 것은
“이 변수가 어떤 리스트를 가리키는가“입니다.
리스트 “안의 내용“까지
고정하는 것은 아닙니다.
val fruits = mutableListOf("사과")
fruits.add("바나나") // 가능: 리스트 내용 변경
fruits = mutableListOf("포도") // 오류: 다른 리스트로 교체 불가
비유하자면 이렇습니다.
val은 “이 바구니를 계속 쓴다“는 약속입니다.
바구니 자체를 다른 바구니로 바꿀 수는 없지만,
바구니 안에 과일을 더 넣는 것은 됩니다.
내용까지 못 바꾸게 하고 싶다면mutableListOf 대신 listOf를 쓰면 됩니다.
생성 함수 정리
지금까지 나온 생성 함수를
표로 정리해 봅시다.
| 만들 것 | 읽기 전용 | 바꿀 수 있음 |
|---|---|---|
| List | listOf | mutableListOf |
| Set | setOf | mutableSetOf |
| Map | mapOf | mutableMapOf |
| 빈 것 | emptyList 등 | mutableListOf<타입>() 등 |
이름 규칙만 기억하면
필요한 함수를 자연스럽게 떠올릴 수 있습니다.
12.4 Collection 조회하기
컬렉션을 만들었다면
이제 그 안의 값을 꺼내 봐야 합니다.
값을 꺼내는 것을 조회(lookup)라고 부릅니다.
특히 List에서 값을 찾는
대표적인 함수들을 살펴보겠습니다.
first: 첫 번째 값 꺼내기
first는 리스트의 첫 번째 값을 돌려줍니다.
val fruits = listOf("사과", "바나나", "포도")
println(fruits.first())
사과
fruits[0]과 같은 결과이지만,first()가 더 읽기 쉽습니다.
반대로 마지막 값은 last()로 꺼냅니다.
val fruits = listOf("사과", "바나나", "포도")
println(fruits.last())
포도
그런데 여기에는 함정이 하나 있습니다.
리스트가 비어 있으면 어떻게 될까요?
val empty = listOf<String>()
println(empty.first()) // 오류 발생!
값이 하나도 없는데
“첫 번째 값“을 달라고 했으니,first()는 오류를 냅니다.
이런 상황을 대비한 함수가 따로 있습니다.
firstOrNull: 없으면 null
firstOrNull은 첫 번째 값을 돌려주되,
값이 없으면 오류 대신 null을 돌려줍니다.
이름 그대로 “first, 없으면(Or) null“입니다.
val empty = listOf<String>()
println(empty.firstOrNull()) // 오류 없이 null
null
값이 있을 때는
평범하게 첫 번째 값을 돌려줍니다.
val fruits = listOf("사과", "바나나")
println(fruits.firstOrNull())
사과
여기서 2장에서 만난 ?를 떠올려 봅시다.null이 나올 수 있다는 것은,
결과 타입이 String?이라는 뜻입니다.
이름 끝에
OrNull이 붙은 함수는
“값이 없으면 오류 대신 null을 준다“는 신호입니다.
리스트가 비어 있을 가능성이 있다면first()보다 firstOrNull()이 더 안전합니다.
마찬가지로 lastOrNull()도 있습니다.
마지막 값을 안전하게 꺼낼 때 씁니다.
find: 조건에 맞는 값 찾기
지금까지는 “첫 번째, 마지막“처럼
자리로 값을 꺼냈습니다.
find는 조건에 맞는 값을 찾아 줍니다.
“이런 조건을 만족하는 첫 번째 값“을 돌려줍니다.
val numbers = listOf(1, 3, 5, 8, 10)
val firstEven = numbers.find { it % 2 == 0 }
println(firstEven)
8
여기서 중괄호 안의 코드가
“찾을 조건“입니다.
it은 리스트의 각 값을 가리키는 이름입니다.
값들을 하나씩 대입해 보면서 조건을 확인합니다.
it은 “지금 검사 중인 값“을 뜻하는 이름입니다.it % 2 == 0은 “이 값이 짝수인가“를 묻는 조건입니다.
즉 위 코드는
“짝수인 첫 번째 값을 찾아라“라는 뜻입니다.
1, 3, 5는 홀수라 지나치고, 8에서 멈춥니다.
이렇게 중괄호로 조건을 넘기는 방식은
람다(lambda)라고 부릅니다.
람다는 뒤에서 더 자세히 다룹니다.
find도 조건에 맞는 값이 하나도 없으면null을 돌려줍니다.
val numbers = listOf(1, 3, 5)
val firstEven = numbers.find { it % 2 == 0 }
println(firstEven) // 짝수가 없으므로 null
null
그래서 find의 결과도
“없을 수 있는 값“으로 다뤄야 합니다.
getOrNull: 안전하게 인덱스로 꺼내기
앞에서 대괄호로 값을 꺼냈습니다.fruits[0]처럼요.
그런데 없는 자리를 꺼내려 하면
오류가 납니다.
val fruits = listOf("사과", "바나나")
println(fruits[5]) // 오류: 5번 자리는 없음
리스트에는 0번과 1번 자리밖에 없는데
5번 자리를 달라고 했기 때문입니다.
이럴 때 getOrNull을 쓰면
오류 대신 null을 돌려줍니다.
val fruits = listOf("사과", "바나나")
println(fruits.getOrNull(0)) // 있는 자리
println(fruits.getOrNull(5)) // 없는 자리
사과
null
이름 그대로
“get, 없으면(Or) null“입니다.
인덱스가 범위를 벗어날 수 있는 상황이라면[ ]보다 getOrNull()이 더 안전합니다.
안전한 조회를 기본으로
지금까지 나온 조회 함수를
정리해 봅시다.
| 함수 | 하는 일 | 값이 없으면 |
|---|---|---|
first() | 첫 번째 값 | 오류 발생 |
firstOrNull() | 첫 번째 값 | null |
find { 조건 } | 조건에 맞는 첫 값 | null |
getOrNull(인덱스) | 그 자리의 값 | null |
규칙이 하나 보입니다.
이름 끝에
OrNull이 붙거나find처럼 null을 돌려주는 함수는
“값이 없을 때 오류 대신 null“을 준다.
백엔드에서는 “값이 없는 경우“가 정말 흔합니다.
검색 결과가 비어 있거나,
찾는 회원이 존재하지 않는 경우처럼요.
그래서 오류로 프로그램이 멈추기보다,null을 받아 차분히 처리하는 편이
훨씬 안전할 때가 많습니다.
null을 안전하게 다루는 방법은
3부에서 집중적으로 배웁니다.
한 가지 덧붙이면,
컬렉션의 값을 걸러 내고 변형하는
더 강력한 가공 함수들이 있습니다.
filter, map, sortedBy 같은 것들입니다.
이 함수들은 13장에서 본격적으로 다룹니다.
12장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 여러 값을 담는 그릇인 컬렉션에는
List, Set, Map이 있다는 점 - List는 순서와 중복을 허용하고,
Set은 중복을 없애며,
Map은 키와 값의 짝으로 저장한다는 점 - 읽기 전용 컬렉션과
바꿀 수 있는(Mutable) 컬렉션의 차이 listOf,setOf,mapOf로 만들고,
앞에mutable을 붙이면 바꿀 수 있다는 규칙first,firstOrNull,find,getOrNull로
값을 안전하게 꺼내는 방법
특히 두 가지를 기억해 두면 좋습니다.
“되도록 읽기 전용”, 그리고 “값이 없을 땐 OrNull“입니다.
다음 장에서는
컬렉션을 자유자재로 가공하는
함수형 스타일을 다뤄 보겠습니다.
13장. Collection을 가공하는 함수
백엔드 개발은 결국 “데이터를 다루는 일“입니다.
데이터베이스에서 목록을 꺼내 오고,
그중 필요한 것만 고르고,
원하는 모양으로 바꿔서 화면이나 다른 서버로 넘깁니다.
이런 작업의 대부분은 컬렉션(collection)을 가공하는 일입니다.
리스트, 맵 같은 “값들의 묶음“을 손질하는 것입니다.
코틀린은 이 손질 작업을 위한 함수를 아주 많이 준비해 두었습니다.
이 함수들만 잘 익혀 두면,
반복문으로 길게 쓰던 코드를 한 줄로 줄일 수 있습니다.
이 장에서는 백엔드에서 매일 쓰는
컬렉션 가공 함수들을 하나씩 살펴봅니다.
이 장의 함수들은 대부분 람다(lambda)를 인자로 받습니다.
람다 문법 자체는 14장에서 자세히 다루므로,
여기서는 “중괄호 안에 처리 규칙을 적는다” 정도로
편하게 읽어도 괜찮습니다.
또 하나, 자바를 조금 아는 분이라면
“이거 자바 스트림(Stream)이랑 비슷한데?“라고 느낄 수 있습니다.
맞습니다. 비슷한 일을 합니다.
다만 코틀린은 .stream()을 붙이는 과정 없이
컬렉션에 바로 함수를 부를 수 있어 훨씬 간결합니다.
(둘의 차이는 절마다 짚어 보겠습니다.)
13.1 선택하고 변환하기
컬렉션 가공에서 가장 기본은 두 가지입니다.
- 필요한 것만 고르기 (선택)
- 원하는 모양으로 바꾸기 (변환)
이 절에서 배울 filter와 map이
바로 그 두 가지를 담당하는 대표 함수입니다.
filter
filter는 “조건에 맞는 것만 걸러내는” 함수입니다.
이름 그대로 체(filter)에 걸러내는 모습을 떠올리면 쉽습니다.
조건을 통과한 값만 남고, 나머지는 버려집니다.
중괄호 안에는 “남길 조건“을 적습니다.
조건이 참(true)인 값만 새 리스트에 담깁니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evens = numbers.filter { it % 2 == 0 }
println(evens)
[2, 4, 6]
여기서 it은
“지금 검사 중인 값 하나“를 가리키는 이름입니다.
람다에서 인자가 하나일 때 자동으로 쓸 수 있습니다.
(자세한 규칙은 14장에서 다룹니다.)
원래 리스트는 그대로 남습니다.filter는 결과를 새 리스트로 돌려줄 뿐,
기존 리스트를 바꾸지 않습니다.
2장에서 말한 불변성과 잘 어울리는 방식입니다.
map
map은 “각 값을 다른 값으로 바꾸는” 함수입니다.
리스트의 값 하나하나를
중괄호 안의 규칙대로 변환해 새 리스트를 만듭니다.
val numbers = listOf(1, 2, 3)
val doubled = numbers.map { it * 2 }
println(doubled)
[2, 4, 6]
문자열로 바꾸거나,
객체에서 원하는 값만 꺼낼 때도 자주 씁니다.
val names = listOf("kim", "lee", "park")
val upper = names.map { it.uppercase() }
println(upper)
[KIM, LEE, PARK]
filter와 map은 짝을 이뤄 자주 함께 쓰입니다.
“거르고 나서 바꾼다“는 흐름입니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers
.filter { it % 2 == 0 } // 짝수만 남기고
.map { it * 10 } // 10을 곱한다
println(result)
[20, 40, 60]
자바에서는 같은 일을
list.stream().filter(...).map(...).collect(...)처럼 씁니다.
코틀린은.stream()도.collect()도 필요 없습니다.
mapNotNull
map을 쓰다 보면
“바꾼 결과 중 null은 빼고 싶다“는 상황이 생깁니다.
null은 “값이 없음“을 뜻합니다.
(null에 대해서는 3부에서 자세히 다뤘습니다.)
mapNotNull은 변환한 뒤,
결과가 null인 것은 자동으로 버려 줍니다.
val texts = listOf("1", "2", "삼", "4")
val numbers = texts.mapNotNull { it.toIntOrNull() }
println(numbers)
[1, 2, 4]
toIntOrNull()은
숫자로 바꿀 수 없으면 null을 돌려주는 함수입니다."삼"은 숫자가 아니라 null이 되고,mapNotNull이 그 null을 걸러 냅니다.
map 다음에 filter로 null을 지우는 것보다
한 번에 처리해 주니 훨씬 깔끔합니다.
flatMap
flatMap은 조금 특별합니다.
“리스트 안에 리스트가 있을 때,
이를 하나로 펼쳐 주는” 함수입니다.
flat은 “평평하게“라는 뜻입니다.
겹겹이 쌓인 리스트를 평평하게 펴 준다고 생각하면 쉽습니다.
val nested = listOf(
listOf(1, 2),
listOf(3, 4),
listOf(5)
)
val flat = nested.flatMap { it }
println(flat)
[1, 2, 3, 4, 5]
각 요소가 리스트를 품고 있을 때 특히 유용합니다.
예를 들어 “주문마다 상품 목록이 들어 있고,
전체 상품을 하나의 목록으로 모으고 싶을 때“입니다.
data class Order(val id: Int, val items: List<String>)
val orders = listOf(
Order(1, listOf("사과", "바나나")),
Order(2, listOf("우유")),
Order(3, listOf("빵", "계란"))
)
val allItems = orders.flatMap { it.items }
println(allItems)
[사과, 바나나, 우유, 빵, 계란]
map으로 했다면[[사과, 바나나], [우유], [빵, 계란]]처럼
리스트가 겹쳐진 결과가 나왔을 것입니다.flatMap은 이를 한 겹으로 펴서 돌려줍니다.
13.2 데이터를 찾고 검사하기
목록을 다루다 보면 이런 질문을 자주 하게 됩니다.
- 조건에 맞는 값이 있나?
- 특정 값이 들어 있나?
- 전부 다 조건을 만족하나?
이 절의 함수들이 그 질문에 답해 줍니다.
대부분 결과로 값 하나나 참/거짓(Boolean)을 돌려줍니다.
find
find는 “조건에 맞는 첫 번째 값을 찾아 주는” 함수입니다.
val numbers = listOf(1, 2, 3, 4, 5)
val firstEven = numbers.find { it % 2 == 0 }
println(firstEven)
2
조건에 맞는 값이 하나도 없으면find는 null을 돌려줍니다.
val numbers = listOf(1, 3, 5)
val firstEven = numbers.find { it % 2 == 0 }
println(firstEven)
null
그래서 find의 결과는
“값이 없을 수도 있음(nullable)“으로 다뤄야 합니다.
any
any는 “조건에 맞는 값이 하나라도 있는가“를 묻습니다.
결과는 참 또는 거짓입니다.
val numbers = listOf(1, 2, 3)
val hasEven = numbers.any { it % 2 == 0 }
println(hasEven)
true
조건 없이 그냥 any()만 부르면
“비어 있지 않은가“를 확인합니다.
val empty = emptyList<Int>()
println(empty.any()) // false (하나도 없음)
all
all은 “모든 값이 조건을 만족하는가“를 묻습니다.
하나라도 어긋나면 거짓입니다.
val numbers = listOf(2, 4, 6)
val allEven = numbers.all { it % 2 == 0 }
println(allEven)
true
none
none은 all의 반대입니다.
“조건에 맞는 값이 하나도 없는가“를 묻습니다.
val numbers = listOf(1, 3, 5)
val noEven = numbers.none { it % 2 == 0 }
println(noEven)
true
any, all, none은 헷갈리기 쉬우니
표로 정리해 두겠습니다.
| 함수 | 참(true)이 되는 경우 |
|---|---|
any | 조건에 맞는 값이 하나라도 있을 때 |
all | 모든 값이 조건을 만족할 때 |
none | 조건에 맞는 값이 하나도 없을 때 |
contains
contains는 “특정 값이 들어 있는가“를 묻습니다.
조건이 아니라 값 자체를 넘긴다는 점이 다릅니다.
val fruits = listOf("사과", "바나나", "포도")
println(fruits.contains("바나나")) // true
println(fruits.contains("수박")) // false
in 연산자로도 같은 확인을 할 수 있습니다.
읽기에는 이쪽이 더 자연스러울 때가 많습니다.
println("바나나" in fruits) // true
13.3 데이터를 묶기
데이터를 “종류별로 묶는” 일도 백엔드에서 흔합니다.
- 사용자를 등급별로 묶기
- 주문을 상태별로 묶기
- ID를 열쇠(key)로 삼아 빠르게 찾을 수 있게 만들기
이 절의 함수들은 리스트를
맵(Map) 형태로 바꿔 주는 경우가 많습니다.
맵(Map)은 “열쇠(key)와 값(value)의 짝“으로 이뤄진 자료구조입니다.
사전에서 단어(key)로 뜻(value)을 찾듯,
key로 value를 빠르게 찾을 수 있습니다.
groupBy
groupBy는 “같은 기준을 가진 것끼리 묶는” 함수입니다.
결과는 “기준 → 해당하는 값들의 리스트” 형태의 맵입니다.
val words = listOf("apple", "banana", "avocado", "blueberry", "cherry")
val grouped = words.groupBy { it.first() }
println(grouped)
{a=[apple, avocado], b=[banana, blueberry], c=[cherry]}
it.first()는 단어의 첫 글자입니다.
첫 글자가 같은 단어끼리 리스트로 묶였습니다.
숫자를 짝수/홀수로 묶는 것도 간단합니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val grouped = numbers.groupBy { if (it % 2 == 0) "짝수" else "홀수" }
println(grouped)
{홀수=[1, 3, 5], 짝수=[2, 4, 6]}
associate
associate는 “리스트를 맵으로 바꾸는” 함수입니다.
각 값에서 “key와 value의 짝“을 직접 만들어 줍니다.
짝은 key to value 형태로 적습니다.to는 두 값을 짝으로 묶어 주는 표현입니다.
val names = listOf("kim", "lee", "park")
val lengthMap = names.associate { it to it.length }
println(lengthMap)
{kim=3, lee=3, park=4}
이름을 key로, 이름 길이를 value로 하는 맵이 만들어졌습니다.
associateBy
associateBy는 associate보다 더 자주 씁니다.
value는 원래 값 그대로 두고,
key만 정해 주면 되기 때문입니다.
data class User(val id: Long, val name: String)
val users = listOf(
User(1, "kim"),
User(2, "lee"),
User(3, "park")
)
val userMap = users.associateBy { it.id }
println(userMap)
{1=User(id=1, name=kim), 2=User(id=2, name=lee), 3=User(id=3, name=park)}
이제 userMap[2]처럼
ID만으로 사용자를 바로 꺼낼 수 있습니다.
이 패턴은 13.7에서 다시 자세히 다룹니다.
groupBy와associateBy는 헷갈리기 쉽습니다.groupBy는 하나의 key에 값이 여러 개(리스트)이고,associateBy는 하나의 key에 값이 딱 하나입니다.
key가 겹치면associateBy는 나중 값으로 덮어씁니다.
partition
partition은 “조건에 따라 딱 두 덩어리로 나누는” 함수입니다.
- 조건을 만족하는 것들
- 만족하지 않는 것들
결과는 두 리스트의 짝(Pair)으로 돌아옵니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val (evens, odds) = numbers.partition { it % 2 == 0 }
println(evens)
println(odds)
[2, 4, 6]
[1, 3, 5]
val (evens, odds) = ... 부분은
짝으로 온 두 값을 한 번에 나눠 받는 문법입니다.
(구조 분해 선언이라고 하며, 나중에 다시 만납니다.)
filter를 두 번 쓸 수도 있지만,partition은 목록을 한 번만 훑어 두 덩어리로 나눠 줍니다.
13.4 정렬하고 중복 제거하기
목록을 보기 좋게 다듬는 작업입니다.
- 순서대로 줄 세우기 (정렬)
- 겹치는 값 없애기 (중복 제거)
sorted
sorted는 “작은 것부터 큰 순서로 줄 세우는” 함수입니다.
숫자나 문자열처럼 크기를 비교할 수 있는 값에 씁니다.
val numbers = listOf(3, 1, 4, 1, 5, 9, 2)
println(numbers.sorted())
[1, 1, 2, 3, 4, 5, 9]
반대로 큰 것부터 줄 세우려면sortedDescending()을 씁니다.
println(numbers.sortedDescending())
[9, 5, 4, 3, 2, 1, 1]
sortedBy
sortedBy는 “무엇을 기준으로 정렬할지” 정해 줍니다.
객체를 특정 항목 기준으로 정렬할 때 씁니다.
data class User(val name: String, val age: Int)
val users = listOf(
User("kim", 30),
User("lee", 20),
User("park", 25)
)
val byAge = users.sortedBy { it.age }
println(byAge)
[User(name=lee, age=20), User(name=park, age=25), User(name=kim, age=30)]
나이 기준으로 어린 순서대로 정렬됐습니다.
큰 순서로 하려면 sortedByDescending을 씁니다.
val oldestFirst = users.sortedByDescending { it.age }
sortedWith
정렬 기준이 두 개 이상일 때가 있습니다.
예를 들어 “나이 순으로, 나이가 같으면 이름 순으로“입니다.
이럴 때 sortedWith와 compareBy를 씁니다.
data class User(val name: String, val age: Int)
val users = listOf(
User("park", 25),
User("kim", 25),
User("lee", 20)
)
val sorted = users.sortedWith(
compareBy({ it.age }, { it.name })
)
println(sorted)
[User(name=lee, age=20), User(name=kim, age=25), User(name=park, age=25)]
compareBy에 기준을 순서대로 나열하면,
앞의 기준이 같을 때 다음 기준으로 비교합니다.
나이가 같은 park와 kim이 이름 순으로 정렬됐습니다.
정렬 함수들을 표로 정리하면 이렇습니다.
| 함수 | 쓰임 |
|---|---|
sorted | 값 자체를 기본 순서로 정렬 |
sortedBy | 기준 하나를 정해 정렬 |
sortedWith | 기준 여러 개를 조합해 정렬 |
distinct
distinct는 “중복을 없애는” 함수입니다.
같은 값이 여러 번 있으면 하나만 남깁니다.
val numbers = listOf(1, 2, 2, 3, 3, 3, 4)
println(numbers.distinct())
[1, 2, 3, 4]
distinctBy
distinctBy는 “무엇을 기준으로 중복을 볼지” 정해 줍니다.
객체에서 특정 항목이 같으면 중복으로 봅니다.
data class User(val id: Long, val name: String)
val users = listOf(
User(1, "kim"),
User(1, "kim"),
User(2, "lee")
)
val unique = users.distinctBy { it.id }
println(unique)
[User(id=1, name=kim), User(id=2, name=lee)]
ID가 같은 첫 번째 사용자만 남고,
중복된 ID는 제거됐습니다.
13.5 집계하기
집계(aggregation)란
“여러 값을 하나의 결과로 요약하는” 것입니다.
- 개수 세기
- 합계 구하기
- 가장 큰 값, 가장 작은 값 찾기
주문 금액 합계, 사용자 수 같은
백엔드 통계에서 늘 쓰는 작업입니다.
count
count는 “개수를 세는” 함수입니다.
그냥 부르면 전체 개수를 셉니다.
val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.count()) // 5
조건을 주면 “조건에 맞는 것의 개수“를 셉니다.
val evenCount = numbers.count { it % 2 == 0 }
println(evenCount) // 2
sum
sum은 “숫자를 모두 더하는” 함수입니다.
숫자 리스트에 바로 쓸 수 있습니다.
val numbers = listOf(10, 20, 30)
println(numbers.sum()) // 60
sumOf
sumOf는 “무엇을 더할지” 정해 줍니다.
객체에서 특정 숫자를 꺼내 더할 때 씁니다.
data class Order(val product: String, val price: Int)
val orders = listOf(
Order("사과", 1000),
Order("우유", 2500),
Order("빵", 1500)
)
val total = orders.sumOf { it.price }
println(total) // 5000
map으로 가격만 뽑은 뒤 sum을 부를 수도 있지만,sumOf가 한 번에 처리해 더 깔끔합니다.
minOrNull
minOrNull은 “가장 작은 값을 찾는” 함수입니다.
이름 끝에 OrNull이 붙은 이유가 있습니다.
리스트가 비어 있으면 최솟값이 없으므로,
그럴 때 null을 돌려주기 때문입니다.
val numbers = listOf(3, 1, 4, 1, 5)
println(numbers.minOrNull()) // 1
val empty = emptyList<Int>()
println(empty.minOrNull()) // null
기준을 정해 최솟값을 찾으려면minByOrNull을 씁니다.
data class User(val name: String, val age: Int)
val users = listOf(
User("kim", 30),
User("lee", 20),
User("park", 25)
)
val youngest = users.minByOrNull { it.age }
println(youngest) // User(name=lee, age=20)
maxOrNull
maxOrNull은 반대로 “가장 큰 값“을 찾습니다.
사용법은 minOrNull과 똑같습니다.
val numbers = listOf(3, 1, 4, 1, 5)
println(numbers.maxOrNull()) // 5
기준이 필요하면 maxByOrNull을 씁니다.
val oldest = users.maxByOrNull { it.age }
println(oldest) // User(name=kim, age=30)
OrNull이 붙은 함수는
“결과가 없을 수도 있음(null)“을 뜻합니다.
빈 리스트를 다룰 때 오류 없이 안전하게 처리할 수 있습니다.
13.6 누적 계산
앞 절의 집계 함수는
합계나 최댓값처럼 정해진 계산만 해 줬습니다.
그런데 “내가 원하는 방식으로
값을 하나씩 누적하고 싶을 때“가 있습니다.
이때 쓰는 것이 fold와 reduce입니다.
두 함수는 값을 하나씩 훑으며 결과를 쌓아 갑니다.
fold
fold는 “시작값을 정하고,
값을 하나씩 누적해 가는” 함수입니다.
두 가지를 넘깁니다.
- 시작값 (초깃값)
- 누적 규칙 (지금까지의 결과 + 다음 값)
val numbers = listOf(1, 2, 3, 4)
val sum = numbers.fold(0) { acc, num -> acc + num }
println(sum) // 10
여기서 acc는 accumulator,
즉 “지금까지 쌓인 결과“입니다.num은 “지금 처리 중인 값“입니다.
계산 과정을 풀어 보면 이렇습니다.
시작값 0
0 + 1 = 1
1 + 2 = 3
3 + 3 = 6
6 + 4 = 10
fold의 장점은 시작값을 정할 수 있다는 것입니다.
그래서 결과 타입을 자유롭게 바꿀 수 있습니다.
예를 들어 숫자 리스트를 하나의 문자열로 이어 붙일 수 있습니다.
val numbers = listOf(1, 2, 3)
val text = numbers.fold("숫자:") { acc, num -> "$acc $num" }
println(text) // 숫자: 1 2 3
reduce
reduce는 fold와 비슷하지만
시작값이 없습니다.
첫 번째 값을 시작값으로 삼고,
두 번째 값부터 누적합니다.
val numbers = listOf(1, 2, 3, 4)
val sum = numbers.reduce { acc, num -> acc + num }
println(sum) // 10
간단해 보이지만 주의할 점이 있습니다.
리스트가 비어 있으면 시작값도 없으므로reduce는 오류를 냅니다.
시작값이 필요하거나
빈 리스트가 들어올 수 있다면fold를 쓰세요.
리스트가 비어 있지 않다고 확신할 때만reduce를 쓰는 편이 안전합니다.
fold와 reduce를 표로 비교하면 이렇습니다.
| 구분 | fold | reduce |
|---|---|---|
| 시작값 | 직접 정함 | 첫 번째 값을 사용 |
| 결과 타입 | 자유롭게 바꿀 수 있음 | 원소와 같은 타입 |
| 빈 리스트 | 안전 (시작값 반환) | 오류 발생 |
13.7 백엔드 데이터 가공 예제
이제 앞에서 배운 함수들을
실제 백엔드 상황에 적용해 봅시다.
여기 나오는 패턴은
스프링으로 서버를 만들 때 매일 마주치는 것들입니다.
지금 손에 익혀 두면 큰 도움이 됩니다.
먼저 예제에서 공통으로 쓸 데이터를 정의하겠습니다.
data class User(val id: Long, val name: String, val grade: String)
data class Order(
val id: Long,
val userId: Long,
val product: String,
val amount: Int
)
사용자 ID를 기준으로 Map 만들기
서버에서는 목록을 받은 뒤
“ID로 특정 항목을 빠르게 찾고 싶을 때“가 아주 많습니다.
리스트에서 매번 find로 찾으면
항목이 많을수록 느려집니다.
그래서 미리 맵으로 만들어 두면 편리합니다.
associateBy가 바로 이때 쓰입니다.
val users = listOf(
User(1, "kim", "VIP"),
User(2, "lee", "BASIC"),
User(3, "park", "VIP")
)
val userMap = users.associateBy { it.id }
// 이제 ID로 바로 꺼낼 수 있다
println(userMap[2]?.name) // lee
userMap[2] 뒤의 ?는
“해당 ID가 없을 수도 있음“을 대비한 것입니다.
(null 안전 호출은 3부에서 다뤘습니다.)
주문별 상품 그룹화
“사용자마다 어떤 주문을 했는지” 묶고 싶을 때가 있습니다.
한 사용자가 주문을 여러 개 할 수 있으므로,
key 하나에 값이 여러 개 필요합니다.
이럴 때는 groupBy를 씁니다.
val orders = listOf(
Order(1, userId = 1, product = "사과", amount = 1000),
Order(2, userId = 1, product = "우유", amount = 2500),
Order(3, userId = 2, product = "빵", amount = 1500)
)
val ordersByUser = orders.groupBy { it.userId }
println(ordersByUser[1]?.size) // 2 (사용자 1의 주문 수)
사용자 ID별로 상품 이름만 모으고 싶다면groupBy에 두 번째 인자를 더할 수 있습니다.
val productsByUser = orders.groupBy(
keySelector = { it.userId },
valueTransform = { it.product }
)
println(productsByUser)
{1=[사과, 우유], 2=[빵]}
keySelector는 묶는 기준(key),valueTransform은 담을 값의 모양을 정합니다.
결제 금액 집계
통계는 백엔드의 단골 업무입니다.
“오늘 총 매출은 얼마인가” 같은 질문에 답해야 합니다.
전체 매출은 sumOf로 간단히 구합니다.
val totalAmount = orders.sumOf { it.amount }
println(totalAmount) // 5000
“사용자별 매출“처럼
그룹마다 합계를 내고 싶을 때는groupBy와 mapValues를 함께 씁니다.
val amountByUser = orders
.groupBy { it.userId }
.mapValues { (_, orderList) -> orderList.sumOf { it.amount } }
println(amountByUser)
{1=3500, 2=1500}
mapValues는 맵의 값(value)만 바꿔 주는 함수입니다.
여기서는 “주문 리스트“를 “합계 금액“으로 바꿨습니다.(_, orderList)에서 _는
“key는 쓰지 않겠다“는 표시입니다.
조건을 건 집계도 흔합니다.
예를 들어 “2000원 이상 주문의 개수“입니다.
val bigOrderCount = orders.count { it.amount >= 2000 }
println(bigOrderCount) // 1
외부 API 결과 변환하기
백엔드는 다른 서버(외부 API)에서
데이터를 받아 오는 일이 잦습니다.
그런데 외부에서 온 데이터의 모양이
우리 서비스에 딱 맞지 않을 때가 많습니다.
그래서 “우리가 쓰기 좋은 형태로 바꾸는” 작업이 필요합니다.
외부에서 이런 응답이 왔다고 해 봅시다.
data class ExternalUser(
val user_id: String, // 문자열로 온 ID
val user_name: String,
val status: String // "ACTIVE" 또는 "INACTIVE"
)
우리 서비스는 이런 형태를 쓴다고 합시다.
data class Member(val id: Long, val name: String, val active: Boolean)
이제 filter, map, mapNotNull을 조합해
필요한 것만 골라 우리 형태로 바꿔 보겠습니다.
val externalUsers = listOf(
ExternalUser("1", "kim", "ACTIVE"),
ExternalUser("2", "lee", "INACTIVE"),
ExternalUser("abc", "park", "ACTIVE") // ID가 숫자가 아님
)
val members = externalUsers
.filter { it.status == "ACTIVE" } // 활성 사용자만
.mapNotNull { external ->
val id = external.user_id.toLongOrNull()
?: return@mapNotNull null // ID 변환 실패 시 제외
Member(
id = id,
name = external.user_name,
active = true
)
}
println(members)
[Member(id=1, name=kim, active=true)]
무슨 일이 벌어졌는지 정리해 봅시다.
- lee는 INACTIVE라서
filter에서 걸러졌습니다 - park는 ID
"abc"가 숫자로 안 바뀌어mapNotNull에서 제외됐습니다 - kim만 우리 형태(
Member)로 변환돼 남았습니다
?: 부분은 엘비스 연산자입니다.
“왼쪽이 null이면 오른쪽을 실행하라“는 뜻입니다.
(자세한 내용은 3부에서 다뤘습니다.)
이렇게 컬렉션 함수를 엮으면
“거르고, 변환하고, 다듬는” 일련의 과정을
반복문 없이 읽기 쉽게 표현할 수 있습니다.
이 예제들은 처리할 데이터가 많아지면
성능을 더 신경 써야 할 때가 옵니다.
그때는 중간 리스트를 만들지 않는 시퀀스(Sequence)가 유용합니다.
시퀀스는 16장에서 다룹니다.
13장을 마치며
이 장에서 우리는
컬렉션을 가공하는 여러 함수를 배웠습니다.
- 선택과 변환:
filter,map,mapNotNull,flatMap - 찾기와 검사:
find,any,all,none,contains - 묶기:
groupBy,associate,associateBy,partition - 정렬과 중복 제거:
sorted,sortedBy,sortedWith,distinct,distinctBy - 집계:
count,sum,sumOf,minOrNull,maxOrNull - 누적 계산:
fold,reduce
함수가 많아 보이지만 걱정하지 마세요.
전부 외울 필요는 없습니다.
중요한 것은
“이런 일을 하는 함수가 있다“는 감각입니다.
필요할 때 이 장을 다시 펼쳐 찾아 쓰면 됩니다.
이 함수들의 공통점은
원본을 바꾸지 않고 새 결과를 돌려준다는 것입니다.
2장에서 배운 불변성과 자연스럽게 이어집니다.
다음 장에서는
이 함수들의 중괄호 안에 들어가던
람다(lambda)를 제대로 파헤쳐 봅니다.
람다를 이해하면 이 장의 함수들이
어떻게 동작하는지 훨씬 선명하게 보일 것입니다.
14장. Lambda와 함수 타입
지금까지 우리는 함수를 “어떤 일을 하는 코드 묶음“으로 배웠습니다.
그런데 코틀린에서는 함수를 조금 다르게도 볼 수 있습니다.
바로 함수를 “값처럼” 다루는 것입니다.
숫자를 변수에 담듯이,
함수도 변수에 담을 수 있습니다.
함수를 다른 함수에 넘겨줄 수도 있고,
함수가 함수를 돌려줄 수도 있습니다.
이 장에서는 그 출발점인 람다(Lambda)를 배웁니다.
그리고 함수를 값으로 다루는 여러 방법을 익힙니다.
13장에서 배운 컬렉션 함수들이
사실은 모두 이 개념 위에서 동작합니다.
이번 장을 지나면 그 코드들이 훨씬 선명하게 보일 것입니다.
14.1 Lambda Expression
Lambda 문법
먼저 람다(Lambda)라는 말부터 풀어 봅시다.
람다는 어렵게 말하면 “람다 표현식”,
쉽게 말하면 이름 없는 함수입니다.
우리가 지금까지 만든 함수에는 항상 이름이 있었습니다.
fun add(a: Int, b: Int): Int {
return a + b
}
이 함수의 이름은 add입니다.
그런데 어떤 함수는 딱 한 번만 쓰고 버립니다.
그럴 때마다 이름을 붙이는 것은 번거롭습니다.
이럴 때 이름 없이 “함수 그 자체“만 적는 방법이 람다입니다.
람다는
“이름표를 떼고 알맹이만 남긴 함수“입니다.
람다는 중괄호({ })로 감쌉니다.
기본 모양은 이렇습니다.
{ 매개변수 -> 실행할 코드 }
화살표(->)를 기준으로
왼쪽은 “받는 값”, 오른쪽은 “할 일“입니다.
앞의 add 함수를 람다로 바꾸면 이렇게 됩니다.
val add = { a: Int, b: Int -> a + b }
a와 b를 받아서a + b를 돌려주는 람다입니다.
여기서 눈여겨볼 점이 하나 있습니다.return을 쓰지 않았습니다.
람다에서는 마지막 줄의 값이
자동으로 결과가 됩니다.
val greet = { name: String ->
println("준비 중...")
"안녕하세요, $name 님" // 이 마지막 값이 결과가 됨
}
방금 만든 add 람다는
일반 함수처럼 괄호를 붙여 실행할 수 있습니다.
val add = { a: Int, b: Int -> a + b }
println(add(2, 3)) // 5
it: 매개변수가 하나일 때
매개변수가 하나뿐인 람다는 아주 자주 나옵니다.
그래서 코틀린은 편의 장치를 하나 준비해 두었습니다.
매개변수가 하나면 이름을 생략하고it이라는 정해진 이름으로 부를 수 있습니다.
먼저 이름을 직접 붙인 모습입니다.
val square = { x: Int -> x * x }
println(square(4)) // 16
이때 매개변수가 하나이므로x를 it으로 바꿔 쓸 수 있습니다.
이 축약은 람다를 함수의 인자로 넘길 때 특히 빛납니다.
13장에서 본 컬렉션 함수를 예로 들어 봅시다.
val numbers = listOf(1, 2, 3, 4)
val doubled = numbers.map { it * 2 }
println(doubled) // [2, 4, 6, 8]
여기서 it은
리스트의 각 원소를 하나씩 가리킵니다.
it은
“매개변수가 하나일 때만 쓸 수 있는 자동 이름“입니다.
편하다고 항상 it만 쓰는 것은 아닙니다.
의미가 헷갈릴 것 같으면
차라리 이름을 직접 붙이는 편이 읽기 좋습니다.
val users = listOf("홍길동", "이순신")
// it보다 이름이 더 분명한 경우
users.forEach { name -> println("환영합니다, $name") }
여러 매개변수
매개변수가 둘 이상이면it은 쓸 수 없습니다.
이때는 이름을 직접 적고
쉼표(,)로 구분합니다.
val sum = { a: Int, b: Int -> a + b }
val max = { a: Int, b: Int -> if (a > b) a else b }
println(sum(10, 20)) // 30
println(max(10, 20)) // 20
받는 값이 아예 없는 람다도 만들 수 있습니다.
이때는 화살표 왼쪽을 비워 두거나,
화살표 자체를 생략합니다.
val hello = { println("안녕하세요") }
hello() // 안녕하세요
자바를 조금 아는 분이라면
자바의 람다가 떠오를 수 있습니다.
자바에서는 람다를 이렇게 씁니다.
// 자바
Runnable r = () -> System.out.println("안녕하세요");
코틀린은 이렇습니다.
val r = { println("안녕하세요") }
큰 흐름은 비슷합니다.
다만 코틀린은 중괄호로 감싸고,
매개변수를 화살표 왼쪽에 둔다는 점이 다릅니다.
14.2 함수도 값이다
함수 타입
앞 절에서 우리는 람다를 변수에 담았습니다.
val add = { a: Int, b: Int -> a + b }
여기서 잠깐 멈춰 생각해 봅시다.
변수에는 늘 타입이 있었습니다. (2장을 떠올려 주세요.)
val name = "홍길동"에서name의 타입은 String이었습니다.
그렇다면 함수를 담은 add의 타입은 무엇일까요?
바로 함수 타입(function type)입니다.
“이 변수에는 함수가 들어 있다“를 나타내는 타입입니다.
함수 타입은 이렇게 적습니다.
(매개변수 타입들) -> 반환 타입
- 화살표 왼쪽 괄호 : 어떤 값을 받는가
- 화살표 오른쪽 : 어떤 값을 돌려주는가
add는 Int 두 개를 받아 Int를 돌려줍니다.
그래서 타입은 (Int, Int) -> Int입니다.
타입을 직접 적으면 이렇게 됩니다.
val add: (Int, Int) -> Int = { a, b -> a + b }
타입을 밝혀 두면
람다 안에서 a, b의 타입을 생략할 수 있습니다.
왼쪽 타입만 봐도 코틀린이 알아채기 때문입니다.
몇 가지 함수 타입을 더 살펴봅시다.
| 함수 타입 | 뜻 |
|---|---|
() -> Unit | 받는 값 없음, 돌려줄 값 없음 |
(Int) -> Int | 정수 하나 받고 정수 하나 돌려줌 |
(String) -> Boolean | 문자열 받고 참/거짓 돌려줌 |
(Int, Int) -> Int | 정수 둘 받고 정수 하나 돌려줌 |
여기서 Unit은 2장에서 배운
“돌려줄 값이 없음“을 뜻하는 타입입니다.
함수를 변수에 저장하기
이제 함수를 값처럼 다루는 그림이 그려집니다.
숫자를 변수에 담고 나중에 꺼내 쓰듯이,
함수도 변수에 담고 나중에 꺼내 실행합니다.
val discount: (Int) -> Int = { price -> price - 1000 }
val result = discount(5000)
println(result) // 4000
discount라는 상자 안에
“1000원을 빼는 동작“을 담아 둔 것입니다.
값이니까 다른 함수에도 담을 수 있습니다.
var policy: (Int) -> Int = { price -> price - 1000 }
// 나중에 다른 동작으로 바꿀 수도 있음 (var이므로)
policy = { price -> price / 2 }
println(policy(5000)) // 2500
함수를 값으로 다룬다는 것은
“동작 자체를 변수에 담아 옮긴다“는 뜻입니다.
이 발상이 이번 장 전체의 핵심입니다.
지금까지 우리는 값을 넘겼지만,
이제부터는 동작을 넘길 수 있습니다.
14.3 고차 함수
고차 함수란
함수가 값이라면,
함수를 다른 함수에 넘길 수도 있어야 합니다.
이렇게 함수를 인자로 받거나
함수를 결과로 돌려주는 함수를
고차 함수(higher-order function)라고 합니다.
말은 거창하지만 뜻은 간단합니다.
고차 함수는
“함수를 다루는 함수“입니다.
사실 우리는 이미 고차 함수를 써 봤습니다.
13장의 map, filter, forEach가 모두 고차 함수입니다.
이제 그 안이 어떻게 생겼는지 직접 만들어 봅시다.
함수를 인자로 받기
먼저 함수를 인자로 받는 고차 함수를 만들어 보겠습니다.
“두 숫자에 어떤 계산을 적용할지“를 밖에서 정하게 하는 함수입니다.
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
세 번째 매개변수 operation을 봅시다.
타입이 (Int, Int) -> Int입니다.
즉 operation은 값이 아니라
“정수 둘을 받아 정수를 돌려주는 함수“입니다.
이제 이 함수에 서로 다른 동작을 넘겨 봅시다.
val sum = calculate(3, 4) { a, b -> a + b }
val product = calculate(3, 4) { a, b -> a * b }
println(sum) // 7
println(product) // 12
같은 calculate인데
넘긴 동작에 따라 결과가 달라집니다.
여기서 코틀린만의 편의를 하나 짚고 갑시다.
함수의 마지막 매개변수가 함수 타입이면,
그 람다를 괄호 밖으로 뺄 수 있습니다.
이것을 후행 람다(trailing lambda)라고 합니다.
// 괄호 안에 넣은 모습
calculate(3, 4, { a, b -> a + b })
// 괄호 밖으로 뺀 모습 (더 자주 쓰는 방식)
calculate(3, 4) { a, b -> a + b }
13장에서 본 list.map { it * 2 }가
바로 이 후행 람다 문법이었습니다.
함수를 반환하기
이번에는 반대로,
함수를 결과로 돌려주는 고차 함수를 봅시다.
“할인율을 정해 두면, 그 할인을 적용하는 함수를 만들어 주는 함수“를
예로 들어 보겠습니다.
fun createDiscount(rate: Int): (Int) -> Int {
return { price -> price - (price * rate / 100) }
}
반환 타입을 봅시다.(Int) -> Int, 즉 함수를 돌려줍니다.
return 뒤에 람다가 통째로 놓여 있습니다.
이제 이 함수로 “10% 할인기“와 “20% 할인기“를
각각 만들어 낼 수 있습니다.
val discount10 = createDiscount(10)
val discount20 = createDiscount(20)
println(discount10(10000)) // 9000
println(discount20(10000)) // 8000
createDiscount(10)은
“10%를 빼는 함수“를 만들어서 돌려줍니다.
그 함수를 discount10에 담아 두고
필요할 때마다 꺼내 쓰는 것입니다.
고차 함수를 쓰면
“동작을 찍어내는 틀“을 만들 수 있습니다.
14.4 Closure
외부 변수 참조
앞 절의 createDiscount를 다시 봅시다.
fun createDiscount(rate: Int): (Int) -> Int {
return { price -> price - (price * rate / 100) }
}
여기서 신기한 점이 하나 있습니다.
돌려주는 람다가 rate를 쓰고 있습니다.
그런데 rate는 람다의 매개변수가 아닙니다.
바깥 함수 createDiscount의 매개변수입니다.
createDiscount는 함수를 돌려주고 나면 끝납니다.
그런데도 돌려준 람다는
그때의 rate 값을 계속 기억합니다.
이렇게 람다가 자기 바깥의 변수를
붙잡아 기억하는 성질을
클로저(Closure)라고 합니다.
우리말로는 “감싸 안는다” 정도의 느낌입니다.
클로저는
“람다가 태어난 곳의 변수를
함께 챙겨서 데리고 다니는 것“입니다.
비유하자면 이렇습니다.
여행을 떠난 사람이
고향에서 챙긴 물건을 배낭에 넣고
어디를 가든 그대로 쓰는 것과 같습니다.
간단한 예를 하나 더 보겠습니다.
val prefix = "[알림] "
val notify = { message: String -> prefix + message }
println(notify("서버 시작")) // [알림] 서버 시작
notify 람다는
바깥 변수 prefix를 붙잡아 쓰고 있습니다.
이것이 클로저입니다.
자바에서는 이런 바깥 변수를 사실상 바꿀 수 없었지만,
코틀린은 다음에서 보듯 조금 더 자유롭습니다.
상태 변경 시 주의점
코틀린의 클로저는
바깥 변수를 읽기만 하는 것이 아니라
값을 바꿀 수도 있습니다.
var로 만든 변수라면
람다 안에서 그 값을 고칠 수 있습니다.
var count = 0
val click = { count++ }
click()
click()
click()
println(count) // 3
click 람다가 실행될 때마다
바깥의 count가 하나씩 늘어납니다.
편리해 보이지만 여기에는 함정이 있습니다.
값이 바깥에서 조용히 바뀌기 때문입니다.
특히 여러 작업이 동시에 도는 서버에서는
이런 공유 변수가 위험합니다.
두 요청이 같은
count를 동시에 건드리면
값이 꼬일 수 있습니다.
이 문제를 동시성 문제라고 부릅니다.
(자세한 내용은 훨씬 뒤에서 다룹니다.)
지금은 원칙 하나만 기억해 두면 충분합니다.
클로저 안에서는
되도록 바깥 변수를 바꾸지 말자.
2장에서 배운 “일단 val” 습관이
여기서도 그대로 통합니다.
값을 바꾸기보다
새 값을 계산해서 돌려주는 편이 훨씬 안전합니다.
// 바깥 변수를 바꾸는 방식 (주의)
var total = 0
listOf(1, 2, 3).forEach { total += it }
// 새 값을 계산해서 돌려받는 방식 (권장)
val total2 = listOf(1, 2, 3).sum()
14.5 함수 참조
::function
람다가 이미 이름이 있는 함수와
똑같은 일을 할 때가 있습니다.
예를 들어 이런 함수가 있다고 합시다.
fun isEven(n: Int): Boolean {
return n % 2 == 0
}
이 함수를 filter에 넘기고 싶습니다.
람다로 감싸면 이렇게 됩니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evens = numbers.filter { isEven(it) }
println(evens) // [2, 4, 6]
그런데 { isEven(it) }은
“그냥 isEven을 부르는 것“뿐입니다.
이렇게 감싸는 것이 조금 번거롭습니다.
이때 함수 참조(function reference)를 쓸 수 있습니다.
함수 이름 앞에 콜론 두 개(::)를 붙이면
그 함수 자체를 값으로 가리킬 수 있습니다.
val evens = numbers.filter(::isEven)
println(evens) // [2, 4, 6]
::isEven은
“isEven이라는 함수 그 자체“를 뜻합니다.
::는
“이 함수를 실행하지 말고,
함수 자체를 값으로 건네라“는 표시입니다.
자바에도 비슷한 문법이 있습니다.
자바의 Integer::parseInt가
코틀린의 ::function과 같은 자리입니다.
Member Reference
함수 참조는
클래스 안에 속한 함수나 속성에도 쓸 수 있습니다.
이렇게 어떤 객체에 속한 것을 가리키는 참조를
멤버 참조(member reference)라고 합니다.
간단한 데이터 클래스를 하나 두겠습니다.
(클래스와 data class는 4부에서 자세히 다룹니다.)
data class User(val name: String, val age: Int)
val users = listOf(
User("홍길동", 30),
User("이순신", 45),
User("강감찬", 28),
)
각 유저의 이름만 뽑고 싶다고 합시다.
람다로는 이렇게 씁니다.
val names = users.map { it.name }
여기서 it.name은
“유저의 name 속성을 꺼내는 일“입니다.
이 동작을 멤버 참조로 바꾸면클래스::속성 형태가 됩니다.
val names = users.map(User::name)
println(names) // [홍길동, 이순신, 강감찬]
User::name은
“User의 name을 꺼내는 동작“을 값으로 가리킵니다.
정렬 같은 곳에서 특히 자주 쓰입니다.
val sorted = users.sortedBy(User::age)
println(sorted.map(User::name)) // [강감찬, 홍길동, 이순신]
Constructor Reference
객체를 새로 만드는 생성자(constructor)도
참조로 가리킬 수 있습니다.
생성자는 “객체를 찍어내는 특별한 함수“라고
생각하면 됩니다. (4부에서 자세히 배웁니다.)
생성자 참조는 클래스::클래스이름 형태입니다.
정확히는 클래스 이름 앞에 ::를 붙입니다.
이름 목록을 받아User 객체 목록으로 바꾸는 예를 보겠습니다.
data class User(val name: String)
val names = listOf("홍길동", "이순신")
// 람다로 만드는 방식
val users1 = names.map { name -> User(name) }
// 생성자 참조로 만드는 방식
val users2 = names.map(::User)
println(users2) // [User(name=홍길동), User(name=이순신)]
::User는
“이름을 받아 User를 만드는 생성자“를
값으로 가리킨 것입니다.
정리하면 이렇습니다.
| 참조 종류 | 문법 예시 | 가리키는 대상 |
|---|---|---|
| 함수 참조 | ::isEven | 톱레벨 함수 |
| 멤버 참조 | User::name | 클래스의 속성이나 메서드 |
| 생성자 참조 | ::User | 객체를 만드는 생성자 |
세 가지 모두 뿌리는 같습니다.
“동작이나 대상을 값으로 건넨다“는 것입니다.
14.6 백엔드 로직을 함수로 분리하기
지금까지 배운 것을
실제 서버 코드에 가깝게 엮어 보겠습니다.
핵심 생각은 하나입니다.
자주 바뀌는 “정책“을 함수로 빼두면,
큰 흐름은 그대로 두고 정책만 갈아 끼울 수 있다.
할인 정책
쇼핑몰에는 여러 할인 정책이 있습니다.
회원 등급마다, 이벤트마다 할인 방식이 다릅니다.
할인 방식이 바뀔 때마다
결제 코드 전체를 고치는 것은 힘듭니다.
그래서 “할인하는 동작“을 함수 타입으로 빼둡니다.
// 원래 가격을 받아 할인된 가격을 돌려주는 함수 타입
typealias DiscountPolicy = (Int) -> Int
여기서 typealias는
길고 복잡한 타입에 별명을 붙이는 기능입니다.
이제 (Int) -> Int 대신DiscountPolicy라고 부를 수 있어 읽기 좋아집니다.
정책들을 미리 만들어 둡니다.
val noDiscount: DiscountPolicy = { price -> price }
val tenPercent: DiscountPolicy = { price -> price - price / 10 }
val flat2000: DiscountPolicy = { price -> price - 2000 }
이제 결제 함수는
어떤 정책을 쓸지 인자로 받기만 하면 됩니다.
fun checkout(price: Int, policy: DiscountPolicy): Int {
val finalPrice = policy(price)
println("결제 금액: ${finalPrice}원")
return finalPrice
}
호출하는 쪽에서 정책만 바꿔 끼웁니다.
checkout(10000, noDiscount) // 결제 금액: 10000원
checkout(10000, tenPercent) // 결제 금액: 9000원
checkout(10000, flat2000) // 결제 금액: 8000원
checkout의 큰 흐름은 그대로입니다.
바뀌는 것은 넘기는 정책뿐입니다.
검증 정책
입력값 검증도 같은 방식으로 정리할 수 있습니다.
검증이란
“이 값이 규칙에 맞는가“를 확인하는 일입니다.
“값을 받아 참/거짓을 돌려주는 함수”,
즉 (String) -> Boolean 타입으로 표현할 수 있습니다.
val isNotBlank: (String) -> Boolean = { it.isNotBlank() }
val isEmail: (String) -> Boolean = { it.contains("@") }
val isShort: (String) -> Boolean = { it.length <= 20 }
이제 여러 규칙을 한꺼번에 검사하는
고차 함수를 만들어 봅시다.
fun validate(value: String, rules: List<(String) -> Boolean>): Boolean {
return rules.all { rule -> rule(value) }
}
rules는 검증 함수들의 목록입니다.all은 13장에서 배운 컬렉션 함수로,
“모든 원소가 조건을 만족하는가“를 확인합니다.
여기서는 “모든 규칙을 통과하는가“를 묻는 셈입니다.
val email = "test@example.com"
val result = validate(email, listOf(isNotBlank, isEmail, isShort))
println(result) // true
규칙을 추가하거나 빼고 싶으면
목록만 바꾸면 됩니다.validate 함수 자체는 손대지 않습니다.
상태별 처리 전략
마지막으로 주문 상태에 따라
다른 처리를 하는 경우를 봅시다.
주문에는 여러 상태가 있습니다.
“결제됨”, “배송중”, “취소됨” 같은 것입니다.
상태마다 할 일이 다릅니다.
이럴 때 if나 when을 길게 늘어놓기 쉽습니다.
대신 “상태 이름“과 “처리 동작“을
Map으로 짝지어 두면 깔끔해집니다.
val handlers: Map<String, (String) -> Unit> = mapOf(
"PAID" to { orderId -> println("$orderId: 상품을 준비합니다") },
"SHIPPING" to { orderId -> println("$orderId: 배송을 시작합니다") },
"CANCELED" to { orderId -> println("$orderId: 환불을 진행합니다") },
)
handlers는
“상태 이름 → 그 상태에서 할 동작“을 담은 Map입니다.
값 자리에 함수가 들어 있다는 점이 핵심입니다.
이제 상태에 맞는 동작을 꺼내 실행합니다.
fun handleOrder(status: String, orderId: String) {
val handler = handlers[status]
if (handler != null) {
handler(orderId)
} else {
println("$orderId: 알 수 없는 상태입니다")
}
}
실행해 보면 이렇게 동작합니다.
handleOrder("PAID", "ORD-001")
handleOrder("SHIPPING", "ORD-002")
handleOrder("CANCELED", "ORD-003")
ORD-001: 상품을 준비합니다
ORD-002: 배송을 시작합니다
ORD-003: 환불을 진행합니다
새로운 상태가 생기면handlers에 한 줄만 추가하면 됩니다.
handleOrder의 흐름은 그대로 둔 채
동작만 늘려 가는 것입니다.
함수를 값으로 다루면
“무엇을 할지“를 데이터처럼 관리할 수 있습니다.
이것이 백엔드에서 람다와 함수 타입이
힘을 발휘하는 지점입니다.
14장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 람다는 이름 없는 함수이고,
{ 매개변수 -> 코드 }모양으로 쓴다는 점 - 매개변수가 하나면
it으로 줄여 쓸 수 있다는 점 - 함수도 값이며,
(Int) -> Int같은 함수 타입을 가진다는 점 - 함수를 인자로 받거나 돌려주는
고차 함수를 만드는 방법 - 람다가 바깥 변수를 기억하는 클로저,
그리고 상태 변경의 위험성 ::function, 멤버 참조, 생성자 참조로
함수를 값처럼 건네는 방법- 할인, 검증, 상태 처리 같은 정책을
함수로 분리해 갈아 끼우는 방법
핵심은 하나입니다.
이제 우리는 값뿐 아니라 “동작“도 넘길 수 있습니다.
이 발상은 13장의 컬렉션 함수를 다시 보게 만들고,
다음 15장의 Scope Function에서
한 번 더 확장됩니다.
동작을 값으로 다루는 감각을 익혔으니,
이제 그 동작을 더 우아하게 쓰는 방법으로 넘어가 봅시다.
15장. Scope Function
코틀린 코드를 읽다 보면
낯선 모양의 함수들을 자주 만나게 됩니다.
user?.let { println(it.name) }
val car = Car().apply {
color = "red"
price = 3000
}
let, run, with, apply, also.
바로 이 다섯 함수를 스코프 함수(scope function)라고 부릅니다.
스코프(scope)란 “범위“라는 뜻입니다.
이 함수들은 어떤 객체를 하나 받아서,
그 객체를 다루는 “잠깐의 작업 범위“를 만들어 줍니다.
말이 어렵지만, 하는 일은 단순합니다.
어떤 객체를 잠깐 데려와서,
그 안에서 편하게 일 처리를 하고,
결과를 돌려주는 것.
문제는 다섯 함수가 서로 너무 비슷하다는 점입니다.
생김새가 닮아서 초보자에게는 다 똑같아 보입니다.
실제로 코틀린을 배우는 사람들이
“가장 헷갈린다“고 꼽는 주제가 바로 이것입니다.
이 장에서는 다섯 함수를 하나씩 뜯어보고,
차이를 표로 정리한 다음,
“언제 무엇을 쓸지“까지 정해 보겠습니다.
이 함수들은 반드시 외워야 하는 문법이 아닙니다.
“코드를 더 깔끔하게 만들어 주는 도구“입니다.
그러니 부담 없이 읽어 나가 봅시다.
15.1 let
객체를 받아 결과를 돌려주는 함수
let은 가장 먼저 익혀 두면 좋은 스코프 함수입니다.
let은 어떤 객체를 하나 받아서,
그 객체를 가지고 무언가를 한 다음,
마지막 줄의 결과를 돌려줍니다.
먼저 코드로 보겠습니다.
val name = "kotlin"
val length = name.let {
println("문자열: $it")
it.length
}
println(length) // 5
여기서 it은 let이 받은 객체,
즉 name을 가리킵니다.
it은 “그것“이라는 뜻입니다.
“방금 넘긴 그 객체“라고 생각하면 됩니다.
let 블록 안의 마지막 줄이 it.length이므로,let은 그 값(5)을 돌려줍니다.
let은 블록의 “마지막 줄“을 결과로 돌려준다.
이 규칙만 기억해도 절반은 이해한 것입니다.
null 안전 처리에서의 활용
let이 진짜 빛나는 순간은 따로 있습니다.
바로 null일 수 있는 값을 다룰 때입니다.
앞에서 타입 뒤의 ?가
“null이 들어올 수 있음“을 뜻한다고 배웠습니다. (2.4)
null일 수 있는 값은?.let { } 형태로 자주 처리합니다.
val name: String? = "홍길동"
name?.let {
println("이름 길이: ${it.length}")
}
여기서 ?.는
“값이 null이 아닐 때만 다음을 실행하라“는 뜻입니다.
즉 위 코드는 이렇게 동작합니다.
name이 null이면 →let블록을 아예 실행하지 않음name이 null이 아니면 →let블록을 실행
덕분에 블록 안에서는it이 null이 아님이 보장됩니다.
자바에서는 같은 일을if (name != null)로 감싸야 했습니다.
// 자바
if (name != null) {
System.out.println("이름 길이: " + name.length());
}
코틀린은 ?.let { } 한 줄로 대신합니다.
“값이 있을 때만 이 일을 하라“를
짧게 표현하는 것이?.let이다.
null 안전성은 3부에서 더 깊이 다룹니다.
지금은 “let이 null 처리에 잘 어울린다” 정도로 충분합니다.
15.2 run
계산 후 결과를 반환할 때
run도 let과 아주 비슷합니다.
객체를 받아서, 마지막 줄을 결과로 돌려줍니다.
딱 하나 다른 점이 있습니다.
객체를 부르는 방식입니다.
let은 객체를 it으로 부르지만,run은 객체를 this로 부릅니다.
this는 “이 객체 자신“을 뜻합니다.
그리고 this는 대개 생략할 수 있습니다.
val name = "kotlin"
val result = name.run {
println("문자열: $this")
length // this.length 인데 this 생략
}
println(result) // 5
it.length 대신 그냥 length라고 쓴 점에 주목합시다.run 블록 안에서는 객체가 곧 this이므로,
그 객체의 기능을 바로 부를 수 있습니다.
run은 객체의 기능을 자기 것처럼 쓰면서,
마지막 줄을 결과로 돌려준다.
this와 it의 차이는 15.6에서 따로 정리합니다.
지금은 “run은 this, let은 it” 정도만 기억해 둡시다.
객체 없이 블록을 실행하는 run
run에는 조금 다른 사용법도 있습니다.
객체에 붙이지 않고, 그냥 단독으로 쓰는 방식입니다.
val value = run {
val a = 3
val b = 4
a + b
}
println(value) // 7
이 경우 run은
“여러 줄을 묶어서 하나의 결과를 만드는” 용도입니다.
임시로 쓸 변수(a, b)를run 블록 안에 가둬 둘 수 있다는 장점이 있습니다.
블록이 끝나면 a, b는 사라지므로,
바깥 코드가 깔끔하게 유지됩니다.
15.3 with
하나의 객체에 여러 작업을 수행할 때
with는 하나의 객체를 놓고
그 객체의 기능을 여러 번 부를 때 편합니다.
with는 조금 다르게 생겼습니다.
객체를 뒤에 붙이지 않고, 괄호 안에 넣습니다.
val builder = StringBuilder()
with(builder) {
append("Hello")
append(", ")
append("World")
}
println(builder) // Hello, World
StringBuilder는 문자열을 조금씩 이어 붙일 때 쓰는 도구입니다.append는 뒤에 글자를 덧붙이는 기능입니다.
with 블록 안에서는 객체가 this이므로,builder.append(...)를 그냥 append(...)로 쓸 수 있습니다.
같은 객체 이름을 반복하지 않아도 되니
코드가 훨씬 짧아집니다.
with를 안 쓰면 이렇게 됩니다.
builder.append("Hello")
builder.append(", ")
builder.append("World")
builder가 세 번이나 반복됩니다.with는 이 반복을 없애 줍니다.
with는 “이 객체를 가지고 이런 일들을 하라“를
한 묶음으로 표현한다.
run과 비슷하지만 다른 점
여기까지 보면 with는 run과 거의 같아 보입니다.
실제로 둘 다 객체를 this로 다룹니다.
차이는 “부르는 형태“에 있습니다.
| 함수 | 부르는 방법 |
|---|---|
run | 객체.run { ... } |
with | with(객체) { ... } |
이 작은 차이가 실제로는 꽤 중요합니다.
바로 null 처리에서 갈립니다.
run은 객체 뒤에 붙기 때문에객체?.run { }처럼 null 안전 호출과 잘 어울립니다.
하지만 with는 그렇게 쓰기 어색합니다.
그래서 다음과 같이 정리해 두면 편합니다.
- null일 수 있는 객체 →
run - null이 아닌 게 확실한 객체 →
with
15.4 apply
객체 설정 후 자기 자신을 반환
apply는 이름 그대로
“객체에 설정을 적용(apply)“할 때 씁니다.
지금까지 본 함수들은
블록의 마지막 줄을 결과로 돌려줬습니다.
apply는 다릅니다.
블록이 끝나면 “객체 자기 자신“을 돌려줍니다.
간단한 클래스를 하나 두고 보겠습니다.
(클래스는 4부에서 배우니 지금은 가볍게 봅시다.)
class Car {
var color: String = ""
var price: Int = 0
}
val car = Car().apply {
color = "red"
price = 3000
}
println("${car.color}, ${car.price}") // red, 3000
Car()로 자동차를 하나 만들고,apply 블록 안에서 색과 가격을 정합니다.
블록 안에서는 객체가 this이므로,car.color 대신 그냥 color라고 씁니다.
그리고 apply는 설정을 끝낸 그 객체를 돌려주므로,
바로 car에 담을 수 있습니다.
apply는 “객체를 만들면서 값도 채워 넣는”
상황에 딱 맞는다.
초기화에 자주 쓰이는 패턴
apply는 객체를 만들자마자
초기 설정을 몰아서 할 때 특히 자주 쓰입니다.
apply를 안 쓰면 이렇게 됩니다.
val car = Car()
car.color = "red"
car.price = 3000
car라는 이름이 계속 반복됩니다.apply는 이 반복을 없애 줍니다.
val car = Car().apply {
color = "red"
price = 3000
}
한눈에 “이 자동차를 이렇게 설정한다“가 읽힙니다.
객체를 만들고 값을 채우는 코드는
백엔드에서 정말 자주 나옵니다.
그래서 apply는
실무에서 가장 많이 쓰는 스코프 함수 중 하나입니다.
15.5 also
부수적인 작업을 수행할 때
also는 “게다가, 또한“이라는 뜻입니다.
이름처럼 “본래 하던 일에 무언가를 덧붙일 때” 씁니다.
also도 객체 자기 자신을 돌려줍니다.apply와 반환 규칙이 같습니다.
대신 객체를 it으로 부릅니다.apply가 this인 것과 반대입니다.
val numbers = mutableListOf(1, 2, 3)
numbers
.also { println("추가 전: $it") }
.add(4)
println(numbers) // [1, 2, 3, 4]
also 블록은 객체를 그대로 돌려주므로,
그 뒤에 .add(4)를 이어서 부를 수 있습니다.
즉 also는 흐름을 끊지 않고
“잠깐 곁다리 작업“을 끼워 넣습니다.
대표적인 곁다리 작업이 로그 출력입니다.
val user = createUser()
.also { println("사용자 생성됨: ${it.name}") }
객체를 만드는 흐름은 그대로 두고,
“만들어졌다“는 기록만 살짝 추가한 것입니다.
apply는 객체를 “바꾸는” 느낌,also는 객체를 “구경하며 곁들이는” 느낌.
로그, 검증 등 곁다리 작업
also가 잘 어울리는 곁다리 작업을 모아 보면 이렇습니다.
- 값이 어떻게 흘러가는지 로그로 찍기
- 값이 올바른지 잠깐 검사하기
- 만들어진 객체를 다른 곳에 슬쩍 넘겨 두기
이런 작업들의 공통점이 있습니다.
“객체 자체는 바꾸지 않는다“는 점입니다.
그래서 코드를 읽는 사람은also를 보면 이렇게 이해합니다.
“여기는 본 흐름이 아니라 곁다리구나.”
이렇게 의도가 드러나는 것도
스코프 함수를 쓰는 이유 중 하나입니다.
15.6 this와 it (수신 객체 접근 방식 차이)
수신 객체란 무엇인가
지금까지 두 단어가 계속 나왔습니다.
바로 this와 it입니다.
이 둘은 모두 “스코프 함수가 받은 객체“를 가리킵니다.
이 객체를 어려운 말로 수신 객체(receiver)라고 합니다.
수신 객체란,
“이 스코프 함수가 데려온 대상 객체“입니다.
다섯 함수는 이 수신 객체를this로 부르거나 it으로 부릅니다.
바로 이 차이가 다섯 함수를 가르는 핵심 축 중 하나입니다.
this로 접근하는 함수
run, with, apply는
수신 객체를 this로 부릅니다.
this는 생략할 수 있다는 점이 특징입니다.
그래서 객체의 기능을 자기 것처럼 바로 쓸 수 있습니다.
val car = Car().apply {
color = "red" // this.color 인데 this 생략
price = 3000 // this.price 인데 this 생략
}
color, price가 사실은 this.color, this.price입니다.this를 생략하니 마치 그 객체 “안에서” 코드를 쓰는 느낌이 납니다.
this방식은 객체의 속성을 여러 개 다룰 때 편하다.
(설정, 초기화 같은 작업)
it으로 접근하는 함수
let과 also는
수신 객체를 it으로 부릅니다.
it은 생략할 수 없습니다.
대신 이름을 바꿔 줄 수 있습니다.
val name: String? = "홍길동"
name?.let { user ->
println("이름: $user")
}
it 대신 user라는 이름을 붙였습니다.
이렇게 하면 무엇을 다루는지 더 분명해집니다.
특히 스코프 함수를 겹쳐 쓸 때it이 무엇을 가리키는지 헷갈리기 쉬운데,
이름을 붙이면 그 혼란을 막을 수 있습니다.
it방식은 객체를 “하나의 값처럼” 다룰 때 편하다.
(넘기고, 검사하고, 변환하는 작업)
정리: 접근 방식 비교
두 방식을 표로 정리하면 이렇습니다.
| 접근 방식 | 해당 함수 | 특징 |
|---|---|---|
this | run, with, apply | 생략 가능, 속성 다루기 편함 |
it | let, also | 이름 바꾸기 가능, 값 다루기 편함 |
여기서 한 가지 감을 잡을 수 있습니다.
- 객체의 여러 속성을 만질 것 같다 →
this계열 - 객체를 하나의 값으로 넘길 것 같다 →
it계열
이 감각이 다음 절의 “선택 기준“으로 이어집니다.
15.7 어떤 Scope Function을 선택할 것인가
두 개의 기준으로 나누기
다섯 함수가 헷갈리는 이유는
너무 비슷하기 때문이라고 했습니다.
하지만 사실 딱 두 가지 질문만 하면
대부분 구분됩니다.
첫째 질문: 무엇을 돌려주는가?
- 블록의 마지막 줄을 돌려준다 →
let,run,with - 객체 자기 자신을 돌려준다 →
apply,also
둘째 질문: 객체를 무엇으로 부르는가?
this로 부른다 →run,with,applyit으로 부른다 →let,also
이 두 질문의 답을 조합하면
다섯 함수가 정확히 자리를 찾아갑니다.
다섯 함수 비교표
두 기준으로 다섯 함수를 한 표에 정리하면 이렇습니다.
| 함수 | 객체 접근 | 반환하는 것 | 대표 용도 |
|---|---|---|---|
let | it | 마지막 줄 | null 처리, 값 변환 |
run | this | 마지막 줄 | 계산 후 결과 반환 |
with | this | 마지막 줄 | 한 객체에 여러 작업 |
apply | this | 객체 자신 | 객체 초기화, 설정 |
also | it | 객체 자신 | 로그, 검증 등 곁다리 |
이 표 하나면
다섯 함수의 차이가 거의 다 담깁니다.
처음에는 이 표를 옆에 두고 코드를 써도 좋습니다.
쓰다 보면 자연스럽게 외워집니다.
상황별 선택 가이드
표를 실제 상황으로 바꿔 보면 이렇게 정리됩니다.
- null일 수 있는 값을 다룬다 →
let - 객체를 만들면서 값을 채운다 →
apply - 중간에 로그만 살짝 남긴다 →
also - 한 객체로 여러 작업을 하고 결과를 받는다 →
with - 여러 줄을 묶어 하나의 결과를 만든다 →
run
물론 경계가 애매한 경우도 있습니다.
그럴 때는 “가독성이 더 좋은 쪽“을 고르면 됩니다.
스코프 함수의 목적은 “짧게 쓰기“가 아니라
“읽기 쉽게 쓰기“입니다.
더 자세한 선택표는 부록 C에 실어 두었습니다.
코드를 쓰다 헷갈릴 때 부록 C를 펼쳐 보면 됩니다.
15.8 중첩된 Scope Function 피하기
Kotlin다운 코드란 무엇인가
스코프 함수는 코드를 깔끔하게 만드는 도구입니다.
그래서 잘 쓰면 코드가 정말 읽기 좋아집니다.
이렇게 코틀린의 장점을 살린 코드를
흔히 “코틀린다운(Kotlin-idiomatic) 코드“라고 부릅니다.
예를 들어 이런 코드는 코틀린답습니다.
val user = User().apply {
name = "홍길동"
age = 20
}
객체를 만들고 설정하는 의도가
한눈에 명확하게 읽힙니다.
과도한 Kotlin 코드의 함정
그런데 좋은 도구도 지나치면 독이 됩니다.
스코프 함수도 마찬가지입니다.
특히 스코프 함수를
겹겹이 겹쳐 쓰면(중첩하면) 오히려 읽기 어려워집니다.
아래는 나쁜 예입니다.
user.let { u ->
u.address.let { a ->
a.city.let { c ->
println(c.name)
}
}
}
let이 세 번 겹쳤습니다.
그리고 it을 u, a, c로 바꿨는데도
무엇이 무엇인지 따라가기 벅찹니다.
블록이 깊어질수록
“지금 이 it은 누구지?” 하는 혼란이 커집니다.
스코프 함수를 겹쳐 쓸수록
코드는 짧아지지만 읽기는 어려워진다.
이렇게 “코틀린 기능을 과하게 쓴” 코드는
코틀린다운 코드가 아니라
오히려 “과도한 코틀린 코드“입니다.
가독성을 되살리는 방법
방금 본 나쁜 예는
사실 스코프 함수 없이 쓰는 게 더 낫습니다.
val cityName = user.address.city.name
println(cityName)
?가 붙은 null 가능 값이 섞여 있다면
안전 호출(?.)로 이어 붙이면 됩니다.
val cityName = user?.address?.city?.name
println(cityName)
훨씬 짧고, 훨씬 잘 읽힙니다.
정리하면 이렇습니다.
- 스코프 함수는 한 번에 하나만 쓰는 것이 기본이다
- 겹쳐 써야 할 것 같으면, 먼저 다른 방법을 의심한다
- 중간 결과에 이름을 붙여 변수로 나누는 것도 좋은 방법이다
마지막 방법을 코드로 보면 이렇습니다.
val address = user.address
val city = address.city
println(city.name)
줄 수는 늘었지만
읽는 사람은 훨씬 편합니다.
코드는 “쓰는 나“보다
“나중에 읽는 사람“을 위해 쓰는 것입니다.
짧은 코드가 항상 좋은 코드는 아니라는 점,
이것이 이 장에서 가장 기억해야 할 교훈입니다.
15장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 스코프 함수는 객체를 잠깐 데려와
그 안에서 편하게 일하게 해 주는 도구라는 점 let은it으로 받고 마지막 줄을 돌려주며,
null 처리에 잘 어울린다는 점run과with는this로 받고
마지막 줄을 돌려준다는 점apply와also는 객체 자기 자신을 돌려주며,
각각 설정과 곁다리 작업에 쓰인다는 점- 다섯 함수는 “무엇을 돌려주는가“와
“this냐it이냐” 두 기준으로 구분된다는 점 - 스코프 함수를 겹쳐 쓰면
오히려 가독성이 나빠진다는 점
다섯 함수가 아직 헷갈려도 괜찮습니다.
15.7의 비교표와 부록 C의 선택표를 곁에 두고
직접 코드를 쓰다 보면 몸에 익습니다.
기억할 한 문장은 이것입니다.
스코프 함수는 코드를 짧게가 아니라,
읽기 쉽게 만들기 위한 도구다.
다음 장에서는
또 다른 편리한 문법을 이어서 배워 보겠습니다.
16장. Sequence와 지연 연산
앞 장에서 우리는 컬렉션을 다루는 함수들을 배웠습니다.
filter로 걸러 내고, map으로 바꾸고,sum으로 합치는 방식이었습니다.
이 함수들은 대부분 편하고 잘 동작합니다.
하지만 데이터가 아주 많아지면 이야기가 달라집니다.
이 장에서는 “연산을 미뤄 두었다가
꼭 필요할 때만 실행하는” 방식을 배웁니다.
이것을 지연 연산(lazy evaluation)이라고 부르고,
코틀린에서는 Sequence로 구현합니다.
이름은 낯설지만 개념은 어렵지 않습니다.
천천히 그림과 함께 따라가 봅시다.
16.1 Collection 연산은 어떻게 실행되는가
즉시 실행되는 연산
13장에서 배운 컬렉션 함수를 다시 떠올려 봅시다.
아래 코드는 리스트에서 짝수만 골라 두 배로 만듭니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers
.filter { it % 2 == 0 }
.map { it * 2 }
println(result)
// 출력: [4, 8, 12]
코드는 잘 동작합니다.
그런데 안에서 어떤 일이 벌어지는지 생각해 본 적 있나요?
코틀린의 컬렉션 함수는 즉시 연산(eager evaluation) 방식입니다.
즉시 연산이란 이름 그대로
“그 자리에서 바로 계산하는” 방식입니다.
즉, filter가 호출되는 순간
리스트 전체를 훑어서 결과를 만듭니다.
중간 결과가 계속 생긴다
여기서 중요한 점이 있습니다.
각 함수는 계산이 끝나면
그 결과를 담을 새 리스트를 하나씩 만듭니다.
앞의 코드가 실제로 어떻게 흘러가는지
단계별로 그려 보겠습니다.
원본: [1, 2, 3, 4, 5, 6]
│
▼ filter { 짝수만 }
새 리스트1: [2, 4, 6] ← 리스트 하나 새로 생성
│
▼ map { 두 배 }
새 리스트2: [4, 8, 12] ← 리스트 또 하나 생성
함수를 두 번 이어 썼을 뿐인데
중간에 임시 리스트가 두 개나 만들어졌습니다.
filter가 만든 [2, 4, 6]은map에 넘겨주고 나면 곧 버려집니다.
잠깐 쓰고 버릴 리스트를
매번 새로 만드는 셈입니다.
데이터가 적을 때는 문제없다
데이터가 여섯 개뿐이라면
임시 리스트 두 개쯤은 아무 문제도 아닙니다.
컴퓨터에게 이 정도 일은 눈 깜짝할 사이입니다.
문제는 데이터가 아주 많아질 때입니다.
100만 개짜리 리스트를
filter→map하면,
중간에 수십만 개짜리 임시 리스트가 통째로 생깁니다.
그리고 함수를 이어 쓸 때마다
그런 임시 리스트가 하나씩 더 늘어납니다.
이렇게 쓰고 버리는 리스트가 쌓이면
메모리를 낭비하게 됩니다.
바로 이 지점에서
Sequence라는 새로운 도구가 등장합니다.
16.2 Sequence란 무엇인가
값을 하나씩 흘려보내는 통로
Sequence(시퀀스)는 우리말로 “연속된 것“이라는 뜻입니다.
컬렉션이 “값을 모아 담아 둔 상자“라면,
Sequence는 “값이 하나씩 지나가는 통로“에 가깝습니다.
비유로 이해해 봅시다.
컬렉션은 물이 가득 찬 양동이입니다.
Sequence는 물이 흐르는 호스입니다.
양동이는 물을 전부 담아 두고 한꺼번에 봅니다.
호스는 물을 한 방울씩 흘려보냅니다.
이 차이가 왜 중요할까요?
Sequence는 값을 미리 다 만들어 두지 않습니다.
필요한 순간에 값을 하나씩 꺼내 옵니다.
미리 계산하지 않는다
앞 절의 즉시 연산은filter 단계에서 짝수를 전부 골라 두었습니다.
Sequence는 그렇게 하지 않습니다.
“일단 계산하지 말고 미뤄 두자.
진짜 결과가 필요해질 때 그때 계산하자.”
이렇게 계산을 뒤로 미루는 성질을
지연 연산(lazy evaluation)이라고 합니다.
lazy는 영어로 “게으른“이라는 뜻입니다.
“시키기 전까지는 일하지 않는다“는 느낌입니다.
이 게으름이 오히려 장점이 됩니다.
쓸데없는 중간 리스트를 만들지 않기 때문입니다.
자바의 Stream과 닮았다
자바를 조금 아는 분이라면
이 개념이 익숙할 수 있습니다.
자바 8부터 등장한 Stream(스트림)이
바로 이 지연 연산 방식입니다.
| 구분 | 자바 | 코틀린 |
|---|---|---|
| 즉시 연산 컬렉션 | List, Set 등 | List, Set 등 |
| 지연 연산 | Stream | Sequence |
| 통로로 바꾸기 | .stream() | .asSequence() |
이름만 다를 뿐 생각은 같습니다.
코틀린의 Sequence는
자바의 Stream과 같은 역할을 합니다.
다만 코틀린은 컬렉션 함수(13장)가
워낙 편하게 잘 되어 있어서,
Sequence는 필요할 때만 골라 씁니다.
이 점은 뒤에서 자세히 다루겠습니다.
16.3 asSequence
컬렉션을 Sequence로 바꾸기
이미 가지고 있는 리스트를
Sequence로 바꾸는 방법은 간단합니다.
asSequence()를 붙이면 됩니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers
.asSequence() // 통로로 바꾸기
.filter { it % 2 == 0 }
.map { it * 2 }
.toList() // 다시 리스트로 받기
16.1의 코드와 거의 똑같습니다.asSequence()와 toList()만 추가되었습니다.
asSequence(): 리스트를 Sequence(통로)로 바꾼다toList(): Sequence를 다시 리스트로 모은다
여기서 toList()가 왜 필요한지
궁금할 수 있습니다.
Sequence는 “흐르는 통로“라고 했습니다.
통로 자체는 최종 결과가 아닙니다.
그래서 마지막에
“흘러나온 값들을 리스트로 모아 줘“라고
말해 주어야 합니다.
그 역할을 하는 것이 toList()입니다.
처음부터 Sequence로 만들기
리스트를 거치지 않고
바로 Sequence를 만들 수도 있습니다.
sequenceOf를 쓰면 됩니다.
val seq = sequenceOf(1, 2, 3, 4, 5)
listOf와 사용법이 똑같습니다.
값을 나열해 주기만 하면 됩니다.
값을 규칙에 따라 계속 만들어 내는generateSequence라는 함수도 있습니다.
// 1부터 시작해 1씩 커지는 무한한 흐름
val infinite = generateSequence(1) { it + 1 }
val firstFive = infinite
.take(5) // 앞에서 다섯 개만
.toList()
println(firstFive)
// 출력: [1, 2, 3, 4, 5]
여기서 놀라운 점이 있습니다.
generateSequence는 끝이 없는
“무한한 값의 흐름“을 만듭니다.
그런데도 프로그램이 멈추지 않고
잘 동작합니다.
즉시 연산이었다면
무한한 리스트를 만들다가 프로그램이 멈췄을 것입니다.
하지만 Sequence는 게으르기 때문에take(5)가 요청한 다섯 개만 만들고 멈춥니다.
이것이 지연 연산의 힘입니다.
그 원리를 이제 자세히 살펴봅시다.
16.4 Intermediate Operation과 Terminal Operation
두 종류의 연산
Sequence의 연산은 두 종류로 나뉩니다.
이 구분을 알아야 지연 연산을 이해할 수 있습니다.
- 중간 연산(intermediate operation)
- 최종 연산(terminal operation)
이름이 어렵게 들리지만 뜻은 단순합니다.
중간 연산은 “통로를 잇는” 연산이고,
최종 연산은 “결과를 뽑아내는” 연산입니다.
중간 연산은 실행되지 않는다
중간 연산은 값을 실제로 계산하지 않습니다.
그저 “이런 작업을 하겠다“는 계획만 세워 둡니다.
대표적인 중간 연산은 이렇습니다.
filter: 걸러 내기map: 바꾸기take: 앞에서 몇 개만 가져오기
이 함수들은 아무리 이어 써도
실제 계산은 일어나지 않습니다.
다음 코드를 봅시다.
val seq = listOf(1, 2, 3)
.asSequence()
.filter {
println("filter 실행: $it")
it > 1
}
println("아직 아무것도 출력되지 않음")
이 코드를 실행하면filter 실행은 한 번도 출력되지 않습니다.
아직 아무것도 출력되지 않음
filter를 분명히 적었는데
안쪽 코드가 실행되지 않은 것입니다.
중간 연산은 계획만 세울 뿐,
아직 일하지 않기 때문입니다.
최종 연산이 방아쇠를 당긴다
그럼 언제 실제로 계산이 일어날까요?
바로 최종 연산이 호출될 때입니다.
최종 연산은 “결과를 내놔“라고
요구하는 연산입니다.
대표적인 최종 연산은 이렇습니다.
toList: 리스트로 모으기sum: 전부 더하기count: 개수 세기first: 첫 번째 값 가져오기forEach: 하나씩 꺼내 무언가 하기
앞의 코드에 최종 연산을 붙여 봅시다.
val result = listOf(1, 2, 3)
.asSequence()
.filter {
println("filter 실행: $it")
it > 1
}
.toList() // 최종 연산 추가
println("결과: $result")
이제 실행하면 이렇게 출력됩니다.
filter 실행: 1
filter 실행: 2
filter 실행: 3
결과: [2, 3]
toList()를 붙이는 순간
비로소 filter가 실제로 동작했습니다.
정리하면 이렇습니다.
중간 연산은 계획을 세우고,
최종 연산이 그 계획을 실행시킨다.
이 “실행시킨다“는 표현을
방아쇠를 당긴다고도 말합니다.
16.5 Lazy Evaluation
값 하나가 통로를 끝까지 지나간다
이제 이 장의 핵심에 도착했습니다.
즉시 연산과 지연 연산의 진짜 차이는
“값이 흐르는 순서“에 있습니다.
같은 코드를 두 방식으로 비교해 봅시다.
val numbers = listOf(1, 2, 3, 4)
numbers
.filter { it % 2 == 0 }
.map { it * 10 }
먼저 즉시 연산(컬렉션)의 흐름입니다.
단계별로 전체를 처리합니다.
[즉시 연산: 단계별로 전부 처리]
1단계 filter: 1→X 2→O 3→X 4→O → [2, 4]
2단계 map: 2→20 4→40 → [20, 40]
(filter를 네 번 다 한 뒤, map을 시작한다)
이번엔 같은 코드를 Sequence로 바꾼 흐름입니다.
값 하나가 통로 끝까지 갔다가 다음 값이 출발합니다.
[지연 연산: 값 하나씩 끝까지]
값 1 → filter(홀수라 탈락) → 버림
값 2 → filter(통과) → map(20) → 결과에 담음
값 3 → filter(홀수라 탈락) → 버림
값 4 → filter(통과) → map(40) → 결과에 담음
(값 하나가 filter와 map을 모두 거친 뒤,
다음 값이 출발한다)
차이가 보이시나요?
즉시 연산은 “가로줄“로 처리합니다.
filter를 전부 끝내고 map을 전부 합니다.
지연 연산은 “세로줄“로 처리합니다.
값 하나가 모든 단계를 통과한 뒤
다음 값이 출발합니다.
중간 리스트가 생기지 않는다
이 세로줄 방식의 장점은 분명합니다.
값이 하나씩 흘러가므로
중간에 임시 리스트를 만들 필요가 없습니다.
16.1에서 즉시 연산은[2, 4]라는 임시 리스트를 만들었습니다.
하지만 지연 연산에서는
그런 임시 리스트가 아예 생기지 않습니다.
값 하나가 통로를 지나가고 사라질 뿐,
중간에 값을 모아 두지 않는다.
데이터가 많을수록
이 차이는 크게 벌어집니다.
필요한 만큼만 계산한다
지연 연산의 또 다른 장점은
“필요한 만큼만” 계산한다는 점입니다.
앞에서 필요한 개수를 정해 두는 경우를 봅시다.
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8)
val result = numbers
.asSequence()
.map {
println("map 실행: $it")
it * 2
}
.first { it > 4 } // 조건에 맞는 첫 값 하나만
println("결과: $result")
실행 결과는 이렇습니다.
map 실행: 1
map 실행: 2
map 실행: 3
결과: 6
원본에는 값이 여덟 개나 있는데map은 딱 세 번만 실행됐습니다.
first가 조건에 맞는 값을
찾자마자 통로를 멈췄기 때문입니다.
- 값 1 → 2 (조건 미달)
- 값 2 → 4 (조건 미달)
- 값 3 → 6 (조건 통과, 여기서 끝)
나머지 4, 5, 6, 7, 8은map을 거치지도 않았습니다.
만약 즉시 연산이었다면
여덟 개 전부에 map을 실행한 뒤
그중에서 첫 값을 찾았을 것입니다.
지연 연산은 답을 찾으면 곧바로 멈춘다.
그래서 헛일을 하지 않는다.
이것이 16.3에서 본
“무한한 흐름“이 멈추지 않았던 이유이기도 합니다.
16.6 Collection과 Sequence 선택하기
언제 무엇을 쓸까
지금까지 보면
Sequence가 무조건 좋아 보일 수 있습니다.
하지만 그렇지 않습니다.
상황에 맞게 골라 써야 합니다.
기준을 간단히 정리하면 이렇습니다.
| 상황 | 추천 |
|---|---|
| 데이터 개수가 적다 (수백~수천 개) | Collection |
| 데이터 개수가 아주 많다 (수십만 개 이상) | Sequence |
| 연산을 여러 단계 이어 붙인다 | Sequence |
| 연산이 한두 단계로 짧다 | Collection |
조건에 맞는 일부만 필요하다 (first 등) | Sequence |
| 무한한 흐름을 다룬다 | Sequence |
기본은 Collection
여기서 오해하지 말아야 할 점이 있습니다.
평소에는 그냥 컬렉션 함수(13장)를 쓰면 됩니다.
Sequence는 “특별한 경우“에 꺼내 드는 도구입니다.
대부분의 백엔드 코드는
데이터베이스에서 가져온
수십, 수백 개짜리 목록을 다룹니다.
이 정도 크기에서는
즉시 연산이든 지연 연산이든
속도 차이를 느끼기 어렵습니다.
오히려 컬렉션 함수가
더 읽기 쉽고 익숙합니다.
그래서 실무의 기본 원칙은 이렇습니다.
일단 컬렉션 함수로 쓴다.
데이터가 아주 많거나 단계가 길 때만
Sequence를 고려한다.
2장에서 “일단 val로 시작하라“고 했던 것처럼,
여기서는 “일단 컬렉션으로 시작하라“고
기억해 두면 됩니다.
16.7 Sequence가 항상 빠른 것은 아니다
통로를 여는 비용
Sequence가 지연 연산이라
언제나 빠를 것 같지만 사실은 다릅니다.
Sequence를 만들고
값을 하나씩 흘려보내는 데에도
나름의 비용이 듭니다.
값 하나가 지나갈 때마다
“다음 값 줘, 다음 값 줘” 하고
통로에 계속 요청을 보내야 하기 때문입니다.
데이터가 적으면
이 요청 비용이 오히려 더 클 수 있습니다.
작은 데이터에서는 손해일 수 있다
예를 들어 값이 열 개뿐인 리스트를filter 한 번만 한다고 해 봅시다.
val small = listOf(1, 2, 3, 4, 5)
// 이 경우엔 그냥 컬렉션이 낫다
val a = small.filter { it > 2 }
// 굳이 Sequence로 감쌀 필요가 없다
val b = small.asSequence().filter { it > 2 }.toList()
두 코드는 결과가 같습니다.
하지만 아래쪽은 통로를 만들었다가
다시 리스트로 모으는 수고가 더 듭니다.
작은 데이터에서는
이 수고가 이득보다 클 수 있습니다.
정리하면 이렇습니다.
Sequence는 “많은 데이터“와
“긴 연산 단계“에서 빛난다.
작고 짧으면 오히려 손해다.
직접 재 보는 습관
무엇이 더 빠른지는
상황마다 다를 수 있습니다.
그래서 성능이 정말 중요한 자리라면
“느낌“으로 판단하지 말고
직접 시간을 재 보는 것이 가장 정확합니다.
코틀린에는 실행 시간을 재 주는measureTimeMillis라는 함수가 있습니다.
import kotlin.system.measureTimeMillis
val time = measureTimeMillis {
// 여기에 시간을 재고 싶은 코드
(1..1_000_000).filter { it % 2 == 0 }.count()
}
println("걸린 시간: ${time}ms")
이렇게 두 방식을 각각 재서 비교하면
어느 쪽이 빠른지 눈으로 확인할 수 있습니다.
성능 판단은 추측이 아니라
측정으로 하는 것이 안전합니다.
16.8 대량 데이터 처리와 Sequence의 한계
메모리에 다 올릴 때의 문제
Sequence는 중간 리스트를 만들지 않아
메모리를 아낀다고 배웠습니다.
하지만 여기에는 조건이 하나 있습니다.
원본 데이터가 이미 메모리에 다 올라와 있다면,
그 원본만큼의 메모리는 어차피 써야 합니다.
예를 들어 봅시다.
// 이미 100만 개가 메모리에 올라와 있음
val huge = (1..1_000_000).toList()
val result = huge
.asSequence()
.filter { it % 2 == 0 }
.map { it * 2 }
.toList()
이 경우 Sequence는
중간 리스트는 아껴 줍니다.
그러나 huge 자체가
이미 100만 개짜리 리스트입니다.
즉, 애초에 100만 개를
전부 메모리에 담아 둔 상태입니다.
Sequence는 이 원본의 크기까지
줄여 주지는 못합니다.
진짜 대량 데이터는 흘려보내야 한다
그렇다면 데이터가
메모리에 다 담기지 못할 만큼
거대하다면 어떻게 할까요?
예를 들어
수 기가바이트짜리 로그 파일을
한 줄씩 처리해야 한다고 해 봅시다.
이런 경우엔 파일 전체를
리스트로 읽어 오면 안 됩니다.
메모리가 터지기 때문입니다.
대신 한 줄씩 읽어서
흘려보내며 처리해야 합니다.
이럴 때 Sequence가 제 역할을 합니다.
import java.io.File
File("big-log.txt")
.useLines { lines -> // 한 줄씩 흐르는 Sequence
lines
.filter { it.contains("ERROR") }
.forEach { println(it) }
}
useLines는 파일을
한 번에 다 읽지 않습니다.
한 줄씩 읽어서
Sequence로 흘려보냅니다.
그래서 파일이 아무리 커도
메모리에는 한 줄씩만 올라옵니다.
파일 전체를 양동이에 퍼 담는 대신,
호스로 한 줄씩 흘려보내는 것입니다.
이것이 대량 데이터 처리에서
Sequence가 꼭 필요한 이유입니다.
한계도 기억하기
마지막으로 한계를 짚어 둡시다.
Sequence는 만능이 아닙니다.
- 값을 한 방향으로만 흘려보낸다
(뒤로 돌아가 다시 볼 수 없다) - 한 번 흘려보낸 Sequence는
상황에 따라 다시 쓰기 어렵다 - 정렬(
sorted)처럼
전체를 다 봐야 하는 연산은
지연 연산의 이점이 사라진다
특히 sorted가 중요합니다.
정렬은 값을 전부 모아 봐야
순서를 정할 수 있습니다.
그래서 이런 연산이 끼면
“게으르게 하나씩” 흘려보내는
Sequence의 장점이 그 지점에서 멈춥니다.
이런 한계까지 알고 나면
Sequence를 언제 써야 할지
더 분명하게 판단할 수 있습니다.
16장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 컬렉션 함수는 즉시 연산이라
단계마다 중간 리스트를 만든다는 점 - Sequence는 지연 연산이라
값을 하나씩 통로로 흘려보낸다는 점 - 중간 연산은 계획만 세우고,
최종 연산이 방아쇠를 당긴다는 점 - 값 하나가 모든 단계를 통과한 뒤
다음 값이 출발하는 “세로줄” 흐름 - 필요한 만큼만 계산하고,
답을 찾으면 곧바로 멈춘다는 점 - Sequence가 항상 빠른 것은 아니며,
작은 데이터에서는 오히려 손해라는 점 - 진짜 대량 데이터는 리스트가 아니라
Sequence로 흘려보내야 한다는 점
가장 중요한 원칙은 하나입니다.
평소에는 컬렉션 함수를 쓰고,
데이터가 많거나 단계가 길 때만
Sequence를 꺼내 든다.
즉시 연산과 지연 연산,
이 두 방식의 차이를 이해했다면
이 장의 목표는 충분히 이룬 것입니다.
다음 장에서는 또 다른 주제로
코틀린을 더 깊이 파고들어 봅시다.
17장. 확장 함수와 확장 프로퍼티
우리는 String, List, Int 같은 타입을 매일 씁니다.
그런데 이런 타입에는 “내가 원하는 기능“이 늘 있는 것은 아닙니다.
예를 들어 “이 문자열이 올바른 이메일인가?“를 확인하고 싶다고 해 봅시다.
String에는 그런 함수가 없습니다.
이럴 때 자바에서는 보통 StringUtils 같은 도우미 클래스를 따로 만들었습니다.
하지만 코틀린에는 더 자연스러운 방법이 있습니다.
바로 이 장의 주인공,
확장 함수(extension function)와 확장 프로퍼티(extension property)입니다.
기존 타입에 마치 원래 있던 기능처럼
새 함수와 새 값을 덧붙이는 방법을 배워 보겠습니다.
17.1 Extension Function: 기존 클래스에 함수 추가하기
확장 함수란 무엇인가
확장 함수(extension function)는 이름 그대로
“기존 타입에 함수를 확장해서 붙이는” 기능입니다.
내가 만들지 않은 타입,
심지어 코드를 고칠 수 없는 타입에도
새로운 함수를 추가할 수 있습니다.
비유하자면 이렇습니다.
이미 완성된 스마트폰에
케이스를 끼워 새 기능(카드 수납, 거치대)을 더하는 것과 같습니다.
스마트폰 자체를 뜯어고치지는 않습니다.
먼저 간단한 예를 보겠습니다.
String에 “마지막 글자를 돌려주는” 함수를 붙여 보겠습니다.
fun String.lastChar(): Char {
return this[this.length - 1]
}
이제 이렇게 쓸 수 있습니다.
val word = "kotlin"
println(word.lastChar()) // 출력: n
word는 평범한 문자열입니다.
그런데 word.lastChar()처럼,
마치 String에 원래 있던 함수처럼 불렀습니다.
이것이 확장 함수의 매력입니다.
수신 객체와 this
확장 함수 선언을 다시 뜯어보겠습니다.
fun String.lastChar(): Char {
return this[this.length - 1]
}
여기서 함수 이름 앞에 붙은 String. 부분이 핵심입니다.
이 String을
수신 객체 타입(receiver type)이라고 부릅니다.
말이 어렵지만 뜻은 간단합니다.
“이 함수를 어떤 타입에 붙일 것인가“를 정하는 자리입니다.
String.lastChar()는
“String에 lastChar라는 함수를 붙이겠다“는 뜻입니다.
그리고 함수 안의 this는
그 함수를 호출한 바로 그 값을 가리킵니다.
val word = "kotlin"
word.lastChar()
이때 this는 word,
즉 "kotlin"이 됩니다.
이 this를
수신 객체(receiver object)라고 부릅니다.
정리하면 이렇습니다.
- 수신 객체 타입 : 함수를 붙일 대상 타입 (
String) - 수신 객체 : 실제로 함수를 호출한 값 (
"kotlin")
참고로 this는 생략할 수도 있습니다.
멤버에 바로 접근하면 코틀린이 알아서 this로 이해합니다.
fun String.lastChar(): Char {
return this[length - 1] // this 생략 가능
}
확장 함수를 직접 만들어 보기
이번에는 조금 더 쓸모 있는 함수를 만들어 보겠습니다.
Int에 “짝수인지” 알려 주는 함수를 붙여 보겠습니다.
fun Int.isEven(): Boolean {
return this % 2 == 0
}
사용은 이렇게 합니다.
println(10.isEven()) // 출력: true
println(7.isEven()) // 출력: false
10이라는 숫자 값에.isEven()을 바로 붙여 부르는 모습이
꽤 자연스럽습니다.
한 가지 더,
확장 함수도 매개변수를 받을 수 있습니다.
fun String.repeatWith(separator: String, times: Int): String {
val parts = List(times) { this }
return parts.joinToString(separator)
}
println("hi".repeatWith("-", 3))
// 출력: hi-hi-hi
여기서 this는 문자열 "hi"이고,separator와 times는 우리가 넘긴 값입니다.
Java 유틸리티 클래스와 비교하기
자바에서는 이런 기능을 어떻게 만들었을까요?
String에 함수를 직접 추가할 수 없으니,
보통 별도의 도우미 클래스를 만들었습니다.
이런 클래스를
유틸리티 클래스(utility class)라고 부릅니다.
값을 가지지 않고,
도우미 함수만 잔뜩 모아 둔 클래스입니다.
// 자바
public class StringUtils {
public static char lastChar(String s) {
return s.charAt(s.length() - 1);
}
}
사용할 때는 이렇게 씁니다.
// 자바
char c = StringUtils.lastChar("kotlin");
값이 앞이 아니라StringUtils.lastChar(값)처럼 뒤에 들어갑니다.
읽는 순서가 조금 어색합니다.
코틀린의 확장 함수는 이 순서를 뒤집습니다.
val c = "kotlin".lastChar()
값이 먼저 오고,
그다음에 하고 싶은 일이 옵니다.
우리가 말로 표현할 때와 순서가 같습니다.
“이 문자열의 마지막 글자”
이 자연스러운 순서가
확장 함수가 유틸리티 클래스보다 편한 이유입니다.
(자바 유틸리티 클래스를 확장 함수로 바꾸는 실전 이야기는
13부 38.5에서 다시 다룹니다.)
17.2 Extension Property
함수가 아니라 값처럼 쓰고 싶을 때
확장 함수가 “함수를 붙이는 것“이라면,
확장 프로퍼티(extension property)는 “값을 붙이는 것“입니다.
프로퍼티(property)는
“객체가 가진 값“을 뜻합니다.
(프로퍼티는 4부 7장에서 자세히 다룹니다.)
앞에서 만든 lastChar를 떠올려 봅시다.
"kotlin".lastChar() // 함수 호출: 괄호가 붙음
그런데 마지막 글자는
어떤 “동작“이라기보다
그 문자열이 “가진 값“에 가깝습니다.
그래서 괄호 없이,
값처럼 쓰고 싶을 수 있습니다.
"kotlin".lastChar // 이렇게 값처럼 쓰고 싶다
이럴 때 확장 프로퍼티를 씁니다.
확장 프로퍼티 만들기
lastChar를 확장 프로퍼티로 바꿔 보겠습니다.
val String.lastChar: Char
get() = this[length - 1]
이제 괄호 없이 쓸 수 있습니다.
val word = "kotlin"
println(word.lastChar) // 출력: n
코드를 뜯어보겠습니다.
val String.lastChar: String에lastChar라는 값을 붙인다: Char: 그 값의 타입은 Char다get() = ...: 이 값을 읽을 때 무엇을 돌려줄지 정한다
여기서 get() 부분을
게터(getter)라고 부릅니다.
게터는 “이 프로퍼티를 읽으면 이 값을 줘“라고
알려 주는 코드입니다.
(게터는 7장 7.3에서 자세히 배웁니다.)
확장 프로퍼티에는
반드시 이 게터를 적어 줘야 합니다.
확장 프로퍼티에 값을 저장할 수는 없다
여기서 중요한 규칙이 하나 있습니다.
확장 프로퍼티는 값을 따로 저장할 수 없습니다.
무슨 뜻일까요?
일반 프로퍼티는 값을 담아 둘 공간을 가집니다.
하지만 확장 프로퍼티는
기존 타입에 “끼워 넣은” 것일 뿐이라,
값을 담을 자기만의 공간이 없습니다.
그래서 아래처럼 초깃값을 주는 것은 안 됩니다.
val String.lastChar: Char = ' ' // 오류: 값을 저장할 수 없음
확장 프로퍼티는
“이미 있는 값들로부터 계산해서 돌려주는” 용도입니다.
그래서 항상 게터가 필요한 것입니다.
정리하면 이렇습니다.
| 구분 | 값을 저장 | 계산해서 돌려주기 |
|---|---|---|
| 일반 프로퍼티 | 가능 | 가능 |
| 확장 프로퍼티 | 불가능 | 가능 |
언제 함수, 언제 프로퍼티로 만들까
확장 함수와 확장 프로퍼티는 비슷해 보입니다.
어느 쪽으로 만드는 게 좋을까요?
간단한 기준은 이렇습니다.
- 그 대상의 “성질“이나 “상태“를 나타낸다 → 프로퍼티
- 어떤 “동작“이나 “일 처리“를 한다 → 함수
예를 들어 봅시다.
// 성질에 가깝다 → 프로퍼티
val String.lastChar: Char
get() = this[length - 1]
// 동작에 가깝다 → 함수
fun String.repeatWith(separator: String, times: Int): String {
return List(times) { this }.joinToString(separator)
}
애매하면 함수로 만들면 됩니다.
함수는 매개변수를 받을 수 있어
더 유연하기 때문입니다.
17.3 확장 함수의 실제 동작
확장 함수는 편리하지만,
“어떻게 동작하는지“를 오해하기 쉽습니다.
여기서 한번 정확히 짚고 넘어가겠습니다.
이 내용을 알아 두면 나중에 헷갈릴 일이 줄어듭니다.
클래스가 실제로 바뀌는 것은 아니다
String.lastChar()를 만들었다고 해서,
String 클래스 안에 진짜로 함수가 추가되는 것은 아닙니다.
String은 코틀린과 자바가
공유하는 기본 타입입니다.
우리가 함부로 뜯어고칠 수 없습니다.
그렇다면 확장 함수는 무엇일까요?
사실 확장 함수는
겉모습만 멤버 함수처럼 보이는
“편의 문법“에 가깝습니다.
컴파일러는 확장 함수를
아래와 같은 평범한 함수 호출로 바꿔서 처리합니다.
// 우리가 쓴 코드
"kotlin".lastChar()
// 컴파일러가 이해하는 방식 (개념적으로)
lastChar("kotlin") // "kotlin"을 첫 번째 인자로 넘긴 것과 같음
즉, 확장 함수는
“값을 첫 번째로 받는 일반 함수“를
점(.)으로 예쁘게 부를 수 있게 해 준 것입니다.
앞에서 본 자바 유틸리티 클래스와
속을 들여다보면 거의 같은 구조입니다.
겉으로는 멤버 함수처럼 보이지만,
속으로는 값을 넘겨 받는 일반 함수입니다.
정적 디스패치: 변수의 타입으로 결정된다
여기서 한 가지 중요한 성질이 나옵니다.
확장 함수는
“변수에 담긴 실제 값“이 아니라
“변수의 타입“을 보고 어떤 함수를 부를지 정합니다.
이것을
정적 디스패치(static dispatch)라고 부릅니다.
- 디스패치(dispatch) : 여러 함수 중 무엇을 부를지 고르는 일
- 정적(static) : 프로그램 실행 전, 컴파일 시점에 미리 정해짐
말로만 들으면 어렵습니다.
예제로 확인해 보겠습니다.
open class Animal
class Dog : Animal()
fun Animal.sound() = "어떤 동물의 소리"
fun Dog.sound() = "멍멍"
fun main() {
val animal: Animal = Dog()
println(animal.sound())
}
animal 변수에는
실제로는 Dog 값이 담겨 있습니다.
그러면 "멍멍"이 나올 것 같지만,
결과는 다릅니다.
어떤 동물의 소리
왜 그럴까요?
animal 변수의 타입이Animal로 선언되어 있기 때문입니다.
확장 함수는 변수에 담긴 실제 값(Dog)이 아니라,
변수의 타입(Animal)을 기준으로 고릅니다.
확장 함수는 “실제 값“이 아니라
“선언된 타입“을 보고 결정됩니다.
이 점은 다음 절에서 배울
멤버 함수와 정반대입니다.
꼭 기억해 두면 좋습니다.
(멤버 함수가 실제 값을 기준으로 동작하는 원리,
즉 오버라이딩은 4부 8장에서 자세히 다룹니다.)
17.4 Member Function과 Extension Function
멤버 함수가 무엇인가
지금까지는 확장 함수만 봤습니다.
이제 그 짝인 멤버 함수와 비교해 보겠습니다.
멤버 함수(member function)는
클래스 안에 직접 정의된 함수입니다.
즉, 그 클래스를 처음 만들 때부터
“원래 들어 있던” 함수입니다.
class Member(val name: String) {
// 클래스 안에 직접 정의 → 멤버 함수
fun greeting(): String {
return "안녕하세요, ${name}님"
}
}
반면 확장 함수는
클래스 밖에서 나중에 덧붙인 함수입니다.
// 클래스 밖에서 붙임 → 확장 함수
fun Member.shortName(): String {
return name.take(1)
}
둘 다 member.함수()처럼 똑같이 부릅니다.
겉으로는 구분이 안 됩니다.
val member = Member("홍길동")
println(member.greeting()) // 멤버 함수
println(member.shortName()) // 확장 함수
이름이 겹치면 멤버 함수가 이긴다
만약 멤버 함수와 확장 함수의
이름이 똑같으면 어떻게 될까요?
이 경우 항상 멤버 함수가 우선합니다.
class Member(val name: String) {
fun hello(): String {
return "멤버 함수 hello"
}
}
fun Member.hello(): String {
return "확장 함수 hello"
}
fun main() {
val member = Member("홍길동")
println(member.hello())
}
결과는 이렇습니다.
멤버 함수 hello
확장 함수는 조용히 무시됩니다.
클래스 원래 주인의 함수가 이기는 셈입니다.
같은 이름이면 멤버 함수가 언제나 우선합니다.
이 규칙에는 좋은 이유가 있습니다.
만약 확장 함수가 멤버 함수를 이긴다면,
누군가 확장 함수를 하나 추가한 것만으로
기존 클래스의 동작이 몰래 바뀔 수 있습니다.
그건 아주 위험합니다.
그래서 코틀린은 원래 주인을 보호합니다.
확장 함수의 한계
확장 함수는 밖에서 붙인 것이라
할 수 없는 일도 있습니다.
바로
클래스의 숨겨진(private) 내부 값에는
접근할 수 없다는 점입니다.
class Account {
private var balance: Int = 0 // 외부에 숨긴 값
fun deposit(amount: Int) {
balance += amount
}
}
fun Account.show() {
println(balance) // 오류: private 값에 접근 불가
}
balance는 클래스가 감춰 둔 값입니다.
(감추기, 즉 접근 제어는 7장 7.4에서 다룹니다.)
멤버 함수는 같은 집 안에 있으니
이 값을 볼 수 있습니다.
하지만 확장 함수는 밖에서 붙인 손님이라
감춰진 값까지는 볼 수 없습니다.
언제 멤버로, 언제 확장으로 만들까
두 가지를 어떻게 나눠 쓰면 좋을까요?
간단한 기준을 정리해 보겠습니다.
| 상황 | 추천 |
|---|---|
| 그 타입의 핵심 기능이다 | 멤버 함수 |
| 내부의 숨겨진 값을 다뤄야 한다 | 멤버 함수 |
| 내가 고칠 수 없는 남의 타입에 기능을 더한다 | 확장 함수 |
| 부가적인 편의 기능이다 | 확장 함수 |
| 공통 로직을 여러 곳에서 쓰고 싶다 | 확장 함수 |
핵심은 이렇습니다.
그 타입의 “본질“이면 멤버로,
“편의를 위한 덤“이면 확장으로 만듭니다.
17.5 백엔드 공통 로직 분리하기
이제 확장 함수를 백엔드에서 어떻게 쓰는지
실전에 가까운 예로 살펴보겠습니다.
백엔드 코드에는
여러 곳에서 반복되는 자잘한 처리가 많습니다.
이런 공통 로직을 확장 함수로 모아 두면
코드가 훨씬 깔끔해집니다.
문자열 검증
서버는 사용자가 보낸 값을 늘 검증해야 합니다.
그중에서도 이메일 형식 검사는 아주 흔합니다.
먼저 검증 로직을 확장 함수로 만들어 보겠습니다.
fun String.isValidEmail(): Boolean {
val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return this.matches(emailRegex)
}
Regex는
정규식(regular expression)을 다루는 타입입니다.
“이런 형태의 글자인지” 검사하는 규칙이라고
이해하면 됩니다.
이제 사용하는 쪽은 아주 읽기 쉬워집니다.
val email = "user@example.com"
if (email.isValidEmail()) {
println("올바른 이메일입니다")
} else {
println("이메일 형식이 아닙니다")
}
올바른 이메일입니다
정규식이라는 복잡한 코드를isValidEmail()이라는 이름 뒤에 숨겼습니다.
읽는 사람은 규칙의 속을 몰라도
“이메일이 올바른지 검사하는구나“를
바로 알 수 있습니다.
날짜 변환
백엔드에서는 날짜와 시간을 자주 다룹니다.
자바의 LocalDate, LocalDateTime 타입을 많이 씁니다.
이 타입들을 특정 형식의 문자열로 바꾸는 일이
아주 자주 반복됩니다.
이 변환도 확장 함수로 정리할 수 있습니다.
import java.time.LocalDate
import java.time.format.DateTimeFormatter
fun LocalDate.toKoreanFormat(): String {
val formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일")
return this.format(formatter)
}
LocalDate는 우리가 만든 타입이 아닙니다.
자바가 제공하는 타입입니다.
그런데도 확장 함수 덕분에
마치 원래 있던 기능처럼 함수를 붙일 수 있습니다.
val today = LocalDate.of(2026, 8, 8)
println(today.toKoreanFormat())
2026년 8월 8일
여러 곳에서 날짜를 한국식으로 보여 줘야 할 때,
매번 DateTimeFormatter를 만들 필요가 없습니다.toKoreanFormat() 한 번이면 됩니다.
DTO 변환
백엔드에서 확장 함수가 가장 빛나는 곳은
DTO 변환입니다.
DTO는 Data Transfer Object,
우리말로 “데이터 전달 객체“입니다.
(DTO는 4부 9장에서 자세히 배웁니다.)
간단히 말하면 이렇습니다.
서버 안에서 쓰는 객체와,
바깥으로 내보내는 객체는 보통 다릅니다.
그래서 둘 사이를 변환하는 코드가 늘 필요합니다.
예를 들어 봅시다.
서버 내부에서 쓰는 회원 객체와,
화면으로 내보내는 응답 객체가 있다고 하겠습니다.
// 서버 내부에서 쓰는 객체
data class Member(
val id: Long,
val name: String,
val email: String,
val password: String
)
// 바깥으로 내보내는 응답 객체
data class MemberResponse(
val id: Long,
val name: String,
val email: String
)
응답 객체에는password가 빠져 있습니다.
비밀번호를 밖으로 내보내면 안 되기 때문입니다.
이제 Member를 MemberResponse로 바꾸는
변환 함수를 확장 함수로 만들어 보겠습니다.
fun Member.toResponse(): MemberResponse {
return MemberResponse(
id = this.id,
name = this.name,
email = this.email
)
}
사용하는 쪽은 이렇게 깔끔합니다.
val member = Member(1L, "홍길동", "hong@example.com", "secret")
val response = member.toResponse()
println(response)
MemberResponse(id=1, name=홍길동, email=hong@example.com)
변환 로직이 toResponse() 안에 모여 있어,
여러 곳에서 같은 방식으로 안전하게 변환할 수 있습니다.
만약 나중에 응답에 담을 값이 바뀌더라도
이 함수 한 곳만 고치면 됩니다.
(이 DTO 변환 패턴은 11부 33.8에서
더 본격적으로 다룹니다.)
확장 함수를 어디에 둘까
확장 함수는 클래스 밖,
파일의 톱레벨에 둘 수 있습니다.
(톱레벨 함수는 1장 1.4에서 설명했습니다.)
관련된 확장 함수들을
하나의 파일에 모아 두면 관리하기 좋습니다.
예를 들어 이런 식으로 나눕니다.
StringExtensions.kt: 문자열 관련 확장 함수DateExtensions.kt: 날짜 관련 확장 함수MemberMapper.kt: 회원 DTO 변환 함수
다른 파일에서 이 확장 함수를 쓰려면import로 가져오면 됩니다.
IntelliJ IDEA가 대부분 자동으로 넣어 줍니다.
한 가지 주의할 점이 있습니다.
확장 함수를 너무 많이 만들면
오히려 코드가 어디 있는지 찾기 어려워집니다.
정말 여러 곳에서 반복되는 로직만
확장 함수로 뽑아내는 것이 좋습니다.
17장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 확장 함수는 기존 타입에
마치 원래 있던 것처럼 함수를 붙이는 기능이라는 점 - 함수 이름 앞의 수신 객체 타입과,
함수 안에서 자신을 가리키는this의 의미 - 확장 프로퍼티는 값을 저장하지 못하고,
계산해서 돌려주는 게터가 필요하다는 점 - 확장 함수는 클래스를 실제로 바꾸지 않으며,
변수의 타입으로 결정되는 정적 디스패치를 따른다는 점 - 이름이 겹치면 멤버 함수가 언제나 우선하고,
확장 함수는 숨겨진 값에 접근할 수 없다는 점 - 문자열 검증, 날짜 변환, DTO 변환 같은
백엔드 공통 로직을 확장 함수로 깔끔하게 분리하는 방법
확장 함수는 자바의 유틸리티 클래스를
훨씬 자연스러운 모습으로 바꿔 줍니다.
값을 먼저 쓰고, 하고 싶은 일을 뒤에 붙이는
이 순서가 코틀린다운 코드의 시작입니다.
다음 장에서는
연산자와 코틀린의 관례(convention)를 다뤄 보겠습니다.
18장. 연산자와 Kotlin Convention
우리는 지금까지 +, -, ==, < 같은 기호를 자연스럽게 써 왔습니다.
숫자를 더하고, 값을 비교하고,
문자열을 이어 붙였습니다.
그런데 이런 기호들을
내가 만든 클래스에도 붙여 쓸 수 있다면 어떨까요?
예를 들어 두 개의 “돈” 객체를 money1 + money2처럼
더할 수 있다면 코드가 훨씬 읽기 편해질 것입니다.
이 장에서는 코틀린이 연산자(기호)를
어떻게 우리 마음대로 정의하게 해 주는지 배웁니다.
이것을 연산자 오버로딩(operator overloading)이라고 부릅니다.
그리고 코틀린이 미리 정해 둔 약속,
즉 컨벤션(convention)이 무엇인지도 함께 살펴봅니다.
마지막으로 이 편리한 기능을
언제 쓰고 언제 참아야 하는지도 이야기합니다.
18.1 Operator Overloading (연산자 오버로딩이란)
연산자도 사실은 함수다
먼저 놀라운 사실 하나부터 짚고 가겠습니다.
우리가 쓰던 + 기호는
사실 함수를 부르는 것과 똑같습니다.
val sum = 1 + 2
이 코드는 겉으로는 기호를 쓴 것 같지만,
코틀린 내부에서는 이렇게 동작합니다.
val sum = 1.plus(2)
즉, 1 + 2는 1이라는 숫자에게
“2를 더해 줘(plus)“라고 부탁하는 것입니다.
기호는 그저 함수를 예쁘게 부르는
겉모습일 뿐입니다.
연산자(기호)는
약속된 이름의 함수를 부르는 지름길이다.
오버로딩이라는 말의 뜻
오버로딩(overloading)이라는 단어를
풀어 보겠습니다.
여기서는 “원래 있던 기호에
새로운 의미를 얹는다“는 뜻으로 이해하면 됩니다.
+는 원래 숫자를 더하는 기호였습니다.
그런데 이 +에 “내 클래스끼리 더하는 의미“를
새로 얹는 것입니다.
비유하자면 이렇습니다.
“더하기“라는 말은 원래 숫자에만 썼지만,
“장바구니에 상품을 더한다“처럼
새로운 대상에도 쓸 수 있게 넓혀 주는 것이다.
operator 키워드로 정의하기
내 클래스에 +를 붙여 쓰려면plus라는 이름의 함수를 만들면 됩니다.
단, 함수 앞에 operator라는 표시를
꼭 붙여야 합니다.
돈을 표현하는 간단한 클래스를 만들어 보겠습니다.
class Money(val amount: Int) {
operator fun plus(other: Money): Money {
return Money(this.amount + other.amount)
}
}
operator: 이 함수를 연산자로 쓰겠다는 표시plus:+기호에 연결되는 약속된 이름other:+오른쪽에 오는 값
이제 두 개의 Money를
기호 하나로 더할 수 있습니다.
val a = Money(1000)
val b = Money(2000)
val total = a + b // a.plus(b)와 같음
println(total.amount) // 3000
a + b라고 썼지만
실제로는 a.plus(b)가 불린 것입니다.
기호 덕분에 코드가
훨씬 사람의 말처럼 읽힙니다.
대표적인 산술 연산자 이름
기호마다 연결되는 함수 이름은
코틀린이 미리 정해 두었습니다.
이 “미리 정해 둔 약속“이 바로
컨벤션(convention)입니다.
자주 쓰는 산술 연산자를 표로 정리해 보겠습니다.
| 기호 | 함수 이름 | 의미 |
|---|---|---|
+ | plus | 더하기 |
- | minus | 빼기 |
* | times | 곱하기 |
/ | div | 나누기 |
% | rem | 나머지 |
예를 들어 a * b라고 쓰면
코틀린은 a.times(b)를 찾아 부릅니다.
우리가 할 일은 그저
약속된 이름으로 함수를 만드는 것뿐입니다.
Java와 비교하면
자바를 아는 분이라면 이 기능이
꽤 새롭게 느껴질 것입니다.
자바에는 연산자 오버로딩이 없습니다.
그래서 객체끼리 더하려면 이렇게 써야 합니다.
// 자바
Money total = a.plus(b);
기호를 못 쓰니
항상 함수 이름을 직접 불러야 합니다.
코틀린은 여기서 한 걸음 더 나아가,
그 함수를 기호로도 부를 수 있게 열어 준 것입니다.
val total = a + b
같은 동작이지만
읽는 느낌이 사뭇 다릅니다.
18.2 비교 연산 (compareTo, 부등호)
크고 작음을 비교하고 싶을 때
이번에는 부등호를 다뤄 보겠습니다.<, >, <=, >= 같은 기호입니다.
숫자는 당연히 크고 작음을 비교할 수 있습니다.
println(3 < 5) // true
그런데 우리가 만든 Money끼리도
“어느 쪽이 더 큰지” 비교하고 싶을 수 있습니다.
val a = Money(1000)
val b = Money(2000)
println(a < b) // 이게 가능할까?
기본 상태로는 불가능합니다.
코틀린은 Money의 크기를 알지 못하기 때문입니다.
compareTo라는 약속
부등호에 연결된 함수 이름은compareTo입니다.
이 함수 하나만 만들면<, >, <=, >=가 모두 동작합니다.
compareTo는 두 값을 비교한 결과를
정수(Int)로 돌려주기로 약속되어 있습니다.
- 나 자신이 더 크면 : 양수(예:
1) - 서로 같으면 :
0 - 나 자신이 더 작으면 : 음수(예:
-1)
Money에 적용해 보겠습니다.
class Money(val amount: Int) {
operator fun compareTo(other: Money): Int {
return this.amount.compareTo(other.amount)
}
}
여기서는 직접 계산하지 않고,
Int가 이미 가진 compareTo에게 일을 맡겼습니다.
이제 부등호를 자유롭게 쓸 수 있습니다.
val a = Money(1000)
val b = Money(2000)
println(a < b) // true
println(a > b) // false
println(a >= b) // false
부등호는 어떻게 판단할까
한 가지 궁금증이 생길 수 있습니다.
우리는 compareTo 하나만 만들었는데,
어떻게 <와 >가 둘 다 동작할까요?
비밀은 간단합니다.
코틀린이 부등호를 만나면compareTo의 결과를 0과 비교합니다.
예를 들어 a < b는
내부에서 이렇게 바뀝니다.
a.compareTo(b) < 0
a가 더 작으면 compareTo가 음수를 돌려주고,
음수는 0보다 작으므로 a < b가 true가 됩니다.
부등호 하나하나를 따로 만들 필요가 없다.
compareTo하나가 모든 부등호를 책임진다.
Comparable과 정렬
값의 크기를 비교할 수 있는 클래스는Comparable이라는 이름을 붙여 표현합니다.
정확히는 Comparable<T> 인터페이스를
구현(implements)한다고 말합니다.
(인터페이스는 5부에서 자세히 다룹니다.)
class Money(val amount: Int) : Comparable<Money> {
override operator fun compareTo(other: Money): Int {
return this.amount.compareTo(other.amount)
}
}
이렇게 해 두면 좋은 점이 하나 있습니다.
목록을 정렬(sort)할 때 그대로 쓸 수 있습니다.
val list = listOf(Money(3000), Money(1000), Money(2000))
val sorted = list.sorted()
sorted.forEach { println(it.amount) }
// 1000
// 2000
// 3000
sorted()는 내부에서 compareTo를 이용해
작은 값부터 차례로 줄을 세웁니다.
우리가 크기 기준만 한 번 정해 주면,
정렬은 코틀린이 알아서 해 주는 것입니다.
18.3 contains (in 연산자)
“안에 들어 있나?“를 묻는 in
이번에는 in이라는 조금 특별한 기호를 봅니다.
in은 “어떤 값이 그 안에 들어 있는가“를
묻는 연산자입니다.
리스트에서 자주 써 봤을 것입니다.
val numbers = listOf(1, 2, 3)
println(2 in numbers) // true
println(5 in numbers) // false
2 in numbers는
“2가 numbers 안에 있니?“라는 뜻입니다.
읽는 그대로 의미가 통해서
아주 자연스럽습니다.
contains라는 약속
in에 연결된 함수 이름은contains입니다.
contains는 영어로 “포함한다“는 뜻이고,
결과를 참/거짓(Boolean)으로 돌려줍니다.
여기서 방향을 눈여겨봐야 합니다.
2 in numbers
이 코드는 내부에서 이렇게 바뀝니다.
numbers.contains(2)
즉, in 오른쪽에 있는 대상(numbers)이contains의 주인이 됩니다.
찾는 값(2)은 괄호 안으로 들어갑니다.
값 in 대상은대상.contains(값)으로 바뀐다.
직접 만들어 보는 contains
좌석 범위를 표현하는
간단한 클래스를 만들어 보겠습니다.
1번부터 10번까지의 좌석이 있다고 합시다.
class SeatRange(val start: Int, val end: Int) {
operator fun contains(seat: Int): Boolean {
return seat in start..end
}
}
이제 어떤 좌석 번호가
이 범위 안에 있는지 기호로 물을 수 있습니다.
val range = SeatRange(1, 10)
println(5 in range) // true
println(15 in range) // false
5 in range는range.contains(5)를 부른 결과입니다.
숫자 5는 1부터 10 사이에 있으므로true가 나옵니다.
!in도 함께 따라온다
contains 하나를 만들면
반대 표현인 !in도 자동으로 쓸 수 있습니다.
!in은 “안에 없다“는 뜻입니다.
println(15 !in range) // true (범위 밖이다)
부등호에서 봤던 원리와 같습니다.
코틀린이 contains의 결과를 뒤집어!in을 처리해 주는 것입니다.
18.4 get과 set (인덱스 접근 [])
대괄호로 값을 꺼내고 넣기
리스트에서 특정 위치의 값을 꺼낼 때
대괄호([])를 써 봤을 것입니다.
val list = listOf("a", "b", "c")
println(list[0]) // a
list[0]은 “0번째 값을 꺼내 줘“라는 뜻입니다.
이 대괄호 접근도
사실은 약속된 함수를 부르는 것입니다.
값을 꺼내는 get
값을 꺼내는 대괄호에 연결된 이름은get입니다.
list[0]
이 코드는 내부에서 이렇게 바뀝니다.
list.get(0)
대괄호 안의 숫자가get의 괄호 안으로 들어갑니다.
간단한 좌석 배치판을
직접 만들어 보겠습니다.
class SeatMap(private val seats: List<String>) {
operator fun get(index: Int): String {
return seats[index]
}
}
이제 대괄호로 좌석을 꺼낼 수 있습니다.
val map = SeatMap(listOf("김철수", "이영희", "박민수"))
println(map[0]) // 김철수
println(map[1]) // 이영희
값을 넣는 set
값을 꺼내는 것이 get이라면,
값을 넣는 것은 set입니다.
list[0] = "z"
이 코드는 내부에서 이렇게 바뀝니다.
list.set(0, "z")
여기서 순서를 잘 봐야 합니다.
- 대괄호 안의 값(
0) →set의 첫 번째 자리 - 등호 오른쪽의 값(
"z") →set의 마지막 자리
좌석 배치판에 set을 추가해 보겠습니다.
이번에는 값을 바꿀 수 있어야 하므로
안쪽을 MutableList로 바꿉니다.
class SeatMap(private val seats: MutableList<String>) {
operator fun get(index: Int): String {
return seats[index]
}
operator fun set(index: Int, name: String) {
seats[index] = name
}
}
이제 대괄호로 값을 넣을 수 있습니다.
val map = SeatMap(mutableListOf("김철수", "이영희", "박민수"))
map[1] = "정수진" // set(1, "정수진")
println(map[1]) // 정수진
map[1] = "정수진"은map.set(1, "정수진")을 부른 것입니다.
인덱스가 여러 개일 때
get과 set은 대괄호 안에
값을 여러 개 받을 수도 있습니다.
바둑판처럼 가로, 세로 두 좌표가
필요한 경우에 유용합니다.
class Board {
operator fun get(row: Int, col: Int): String {
return "($row, $col) 칸"
}
}
val board = Board()
println(board[2, 3]) // (2, 3) 칸
대괄호 안에 값을 쉼표로 나눠 적으면
그대로 get의 자리들로 전달됩니다.
18.5 Iterator (for에서 순회 가능하게)
for 루프가 도는 원리
우리는 for로 리스트를
하나씩 훑어 봤습니다.
val list = listOf("a", "b", "c")
for (item in list) {
println(item)
}
여기서도 in이라는 기호가 보입니다.
하지만 18.3에서 본 in과는 역할이 다릅니다.
for 안의 in은
“하나씩 순서대로 꺼내라“는 뜻입니다.
이렇게 처음부터 끝까지
차례대로 훑는 것을 순회(iteration)라고 합니다.
iterator라는 약속
for (x in 대상)이 동작하려면
그 대상에 iterator라는 함수가 있어야 합니다.
이터레이터(iterator)란
“다음 값을 하나씩 꺼내 주는 도구“입니다.
비유하자면 이렇습니다.
이터레이터는 사탕이 든 통에서
사탕을 하나씩 꺼내 주는 손과 같다.
“다음 사탕 있니?” 물어보고,
“있으면 하나 꺼내“를 반복한다.
이 “손“은 두 가지 능력을 갖춰야 합니다.
hasNext(): 다음 값이 남아 있는지 (참/거짓)next(): 다음 값을 하나 꺼내기
직접 순회 가능하게 만들기
세 명의 손님을 담는
간단한 대기열 클래스를 만들어 보겠습니다.
iterator 함수가 이터레이터를 돌려주게 하면for에서 바로 쓸 수 있습니다.
class WaitingLine(private val names: List<String>) {
operator fun iterator(): Iterator<String> {
return names.iterator()
}
}
여기서는 직접 만들지 않고,
리스트가 이미 가진 iterator를 빌려 왔습니다.
이제 대기열을 for로 훑을 수 있습니다.
val line = WaitingLine(listOf("김철수", "이영희", "박민수"))
for (name in line) {
println("$name 님 대기 중")
}
출력은 이렇습니다.
김철수 님 대기 중
이영희 님 대기 중
박민수 님 대기 중
line은 리스트가 아니지만,iterator를 갖췄기 때문에
리스트처럼 for로 돌 수 있게 된 것입니다.
for가 도는 조건은 단 하나,
“iterator를 가지고 있는가“이다.
18.6 구조 분해의 동작 원리 (componentN)
구조 분해를 다시 만나다
3장에서 구조 분해(destructuring)를
잠깐 만난 적이 있습니다.
여러 값을 한 번에
여러 변수로 나눠 받는 문법이었습니다.
val (name, age) = Person("홍길동", 20)
println(name) // 홍길동
println(age) // 20
한 줄로 두 변수를 동시에 만들었습니다.
편리해 보이지만, 어떻게 동작하는 걸까요?
이 장에서 그 원리를 밝혀 보겠습니다.
비밀은 componentN 함수
구조 분해는 사실component1, component2 같은 함수를 부르는 것입니다.
이름 끝의 숫자가
몇 번째 값인지를 나타냅니다.
val (name, age) = person
이 코드는 내부에서 이렇게 바뀝니다.
val name = person.component1()
val age = person.component2()
component1(): 첫 번째 값component2(): 두 번째 값
즉, 왼쪽 괄호에 적은 변수 순서대로component1, component2가 차례로 불립니다.
직접 만들어 보는 componentN
일반 클래스에componentN 함수를 직접 붙여 보겠습니다.
물론 operator 표시를 붙여야 합니다.
class Person(val name: String, val age: Int) {
operator fun component1(): String = name
operator fun component2(): Int = age
}
이제 이 클래스도
구조 분해로 나눠 받을 수 있습니다.
val person = Person("홍길동", 20)
val (name, age) = person
println(name) // 홍길동
println(age) // 20
component1이 이름을,component2가 나이를 돌려준 것입니다.
data class가 편한 이유
여기서 중요한 사실이 하나 있습니다.
우리가 방금 손으로 적은 componentN 함수를,
data class는 자동으로 만들어 줍니다.
(data class는 4부에서 자세히 다룹니다.)
data class Person(val name: String, val age: Int)
이 한 줄이면component1, component2가
자동으로 생겨납니다.
그래서 data class는
별다른 준비 없이 바로 구조 분해가 됩니다.
val (name, age) = Person("홍길동", 20)
앞에서 손으로 길게 적었던 코드가,
data class에서는 공짜로 따라오는 것입니다.
Map을 훑을 때도 같은 원리
구조 분해가 실제로 빛나는 순간은
Map을 for로 훑을 때입니다.
val scores = mapOf("김철수" to 90, "이영희" to 85)
for ((name, score) in scores) {
println("$name: $score")
}
출력은 이렇습니다.
김철수: 90
이영희: 85
(name, score) 부분이 바로 구조 분해입니다.
Map의 각 항목이 component1(키)과component2(값)를 갖추고 있어서 가능한 일입니다.
이제 왜 이 문법이 동작하는지
그 속을 이해하게 되었습니다.
18.7 필요한 경우에만 연산자 오버로딩하기 (남용 경계)
편리함에는 대가가 따른다
지금까지 배운 기능은 정말 강력합니다.
내가 만든 클래스에 기호를 붙일 수 있으니까요.
하지만 강력한 도구일수록
조심해서 써야 합니다.
연산자 오버로딩의 가장 큰 위험은
“읽는 사람이 오해할 수 있다“는 점입니다.
기호는 짧습니다.
짧은 만큼 뜻이 모호해지기 쉽습니다.
나쁜 예: 기호가 거짓말을 할 때
예를 들어 사용자를 표현하는 클래스에+를 이렇게 정의했다고 합시다.
class User(val name: String) {
// 나쁜 예: +가 무엇을 하는지 알 수 없다
operator fun plus(other: User): User {
return User(this.name + "와 " + other.name)
}
}
val result = User("철수") + User("영희")
이 코드를 처음 본 사람은 당황합니다.
- 사용자를 “더한다“는 게 무슨 뜻이지?
- 친구를 맺는 건가?
- 계정을 합치는 건가?
기호는 짧아서 편했지만,
정작 의미는 하나도 전해지지 않았습니다.
기호가 상식과 다른 일을 하면,
읽는 사람은 매번 정의를 찾아봐야 한다.
좋은 예: 이름 있는 함수가 나을 때
이럴 때는 차라리
이름이 분명한 함수를 쓰는 편이 낫습니다.
class User(val name: String) {
fun makeFriendWith(other: User): Friendship {
return Friendship(this, other)
}
}
val friendship = user1.makeFriendWith(user2)
makeFriendWith라는 이름을 보면
무엇을 하는지 한눈에 알 수 있습니다.
코드가 조금 길어졌지만,
읽는 사람은 고민할 필요가 없습니다.
언제 연산자 오버로딩을 쓸까
그렇다면 언제 기호를 붙여도 좋을까요?
판단 기준은 의외로 간단합니다.
기호의 원래 뜻과
내가 하려는 일이 자연스럽게 맞아떨어질 때만 쓴다.
좋은 예와 나쁜 예를
표로 정리해 보겠습니다.
| 상황 | 판단 | 이유 |
|---|---|---|
| 돈 + 돈 = 돈 | 좋음 | 더한다는 뜻이 그대로 통한다 |
| 좌표 + 좌표 = 좌표 | 좋음 | 위치를 합친다는 의미가 명확하다 |
| 날짜 < 날짜 | 좋음 | 날짜의 앞뒤 비교는 자연스럽다 |
| 사용자 + 사용자 | 나쁨 | “더한다“의 뜻이 모호하다 |
| 주문 * 상품 | 나쁨 | “곱한다“가 무슨 일인지 알 수 없다 |
핵심은 “상식“입니다.
누가 봐도 자연스럽게 이해되면 기호를 쓰고,
조금이라도 갸웃하게 되면 이름 있는 함수를 씁니다.
코드는 결국 사람이 읽는다
이 장의 교훈을 한 문장으로 줄이면 이렇습니다.
짧은 코드보다
오해 없는 코드가 더 좋다.
연산자 오버로딩은
코드를 짧고 예쁘게 만들어 줍니다.
하지만 그 목적은 “짧음“이 아니라
“더 잘 읽히는 코드“여야 합니다.
기호를 붙였을 때 정말로
더 읽기 쉬워지는지 스스로 물어보세요.
그 답이 “그렇다“일 때만
연산자 오버로딩을 쓰면 됩니다.
18장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 연산자(기호)는 사실
약속된 이름의 함수를 부르는 지름길이라는 점 operator키워드로
내 클래스에+같은 기호를 붙이는 방법compareTo하나로
모든 부등호가 동작하고 정렬까지 되는 원리contains로in연산자를,get과set으로 대괄호 접근을 만드는 법iterator를 갖추면
내 클래스도for로 순회할 수 있다는 점- 구조 분해가 사실
component1,component2를 부르는 것이라는 사실 - 이 편리한 기능을
상식에 맞을 때만 조심스럽게 써야 한다는 태도
연산자 오버로딩은 코틀린이 준
강력한 표현력입니다.
이 힘을 “짧게“가 아니라
“더 잘 읽히게“에 쓰는 것,
그것이 좋은 코틀린 개발자의 습관입니다.
19장. 위임
일을 혼자 다 하지 않고,
잘하는 누군가에게 대신 맡기는 방법이 있습니다.
회사에서 팀장이 모든 일을 직접 하지 않는 것과 같습니다.
자료 정리는 정리 잘하는 팀원에게,
발표는 발표 잘하는 팀원에게 맡깁니다.
이렇게 “내 일을 다른 사람에게 대신 맡기는 것“을
위임(delegation)이라고 합니다.
코틀린은 이 위임을 문법으로 아주 쉽게 지원합니다.by라는 짧은 키워드 하나면 됩니다.
이 장에서는 위임이 무엇인지,
그리고 코틀린에서 위임을 어떻게 쓰는지 배웁니다.
처음에는 낯설 수 있지만,
알고 나면 반복 코드를 크게 줄여 주는 아주 편리한 도구입니다.
19.1 Delegation이란 무엇인가
일을 대신 맡긴다는 것
위임(delegation)이란
“내가 할 일을 다른 대상에게 대신 맡기는 것“입니다.
일상에서도 우리는 위임을 자주 합니다.
이사할 때 짐을 내가 다 나르지 않고
이삿짐센터에 맡기는 것,
이것이 위임입니다.
나는 “이사한다“는 책임만 지고,
실제 일은 전문가에게 넘기는 것입니다.
프로그래밍에서도 똑같습니다.
어떤 객체가 자기 일을 직접 처리하지 않고,
그 일을 잘하는 다른 객체에게 넘깁니다.
위임하는 쪽과 위임받는 쪽
위임에는 두 역할이 있습니다.
- 위임하는 쪽 : 일을 맡기는 대상
- 위임받는 쪽 : 일을 대신 처리하는 대상
코드로 아주 간단하게 표현하면 이렇습니다.
class 음악플레이어 {
private val 스피커 = 스피커()
fun 소리내기() {
스피커.출력() // 실제 일은 스피커에게 맡긴다
}
}
음악 플레이어는 소리를 직접 만들지 않습니다.스피커라는 객체에게 그 일을 넘깁니다.
이렇게 어떤 객체가
자기 안에 다른 객체를 두고,
그 객체에게 일을 넘기는 방식이 위임의 기본 모습입니다.
왜 위임을 쓸까
위임을 쓰면 좋은 점은 크게 두 가지입니다.
첫째, 잘 만들어진 기능을 그대로 재사용합니다.
이미 있는 객체에게 일을 맡기면,
같은 코드를 다시 만들 필요가 없습니다.
둘째, 역할을 나눌 수 있습니다.
각 객체가 자기가 잘하는 일에만 집중하게 됩니다.
이런 생각은 상속(8장)과도 자주 비교됩니다.
상속이 “부모의 기능을 물려받는 것“이라면,
위임은 “다른 객체에게 일을 넘기는 것“입니다.
둘 중 어느 쪽이 나은지는
이 장 마지막(19.7)에서 다시 정리하겠습니다.
지금은 이렇게만 기억해 둡시다.
위임은
“직접 하지 않고 대신 맡기는 것“이다.
19.2 Interface Delegation과 by
반복되는 전달 코드
먼저 자바에서 흔히 겪는 불편함부터 봅시다.
인터페이스(interface)란
“이런 기능을 갖춰야 한다“고 정해 놓은 약속입니다.
(인터페이스는 8장에서 배웠습니다.)
예를 들어 소리를 내는 기능을
인터페이스로 정해 봅시다.
interface Speaker {
fun playSound()
fun stopSound()
}
그리고 이 약속을 지키는 기본 스피커를 만듭니다.
class BasicSpeaker : Speaker {
override fun playSound() {
println("소리를 냅니다")
}
override fun stopSound() {
println("소리를 멈춥니다")
}
}
이제 음악 플레이어도Speaker처럼 동작하게 만들고 싶습니다.
하지만 소리 내는 기능은
이미 BasicSpeaker가 잘 만들어 두었습니다.
그러니 그 객체에게 맡기면 됩니다.
자바 방식으로 쓰면 이렇게 됩니다.
class MusicPlayer(
private val speaker: Speaker
) : Speaker {
override fun playSound() {
speaker.playSound() // 그냥 넘겨주기만 함
}
override fun stopSound() {
speaker.stopSound() // 그냥 넘겨주기만 함
}
}
여기서 문제가 보입니다.playSound, stopSound가 하는 일은
그냥 speaker에게 그대로 넘기는 것뿐입니다.
메서드가 열 개, 스무 개라면?
이런 “넘기기만 하는 코드“를
그 수만큼 반복해서 써야 합니다.
이런 코드를 전달 코드(forwarding code)라고 합니다.
지루하고, 실수하기도 쉽습니다.
by로 위임하기
코틀린은 이 반복을 by 키워드 하나로 해결합니다.
class MusicPlayer(
speaker: Speaker
) : Speaker by speaker
이게 전부입니다.
앞의 길었던 코드와 완전히 같은 일을 합니다.
여기서 by speaker의 뜻은 이렇습니다.
Speaker가 해야 할 일은speaker에게 맡겨라(by).
이렇게 인터페이스의 기능을
다른 객체에게 통째로 넘기는 것을
인터페이스 위임(interface delegation)이라고 부릅니다.
코틀린 컴파일러가
앞에서 우리가 손으로 쓰던 전달 코드를
자동으로 만들어 줍니다.
일부만 바꾸기
위임하면서 일부 기능만
직접 바꾸고 싶을 때도 있습니다.
그럴 때는 그 메서드만 override로 다시 적으면 됩니다.
class MusicPlayer(
speaker: Speaker
) : Speaker by speaker {
override fun playSound() {
println("음악을 재생합니다") // 이 기능만 새로 정의
}
// stopSound()는 speaker에게 그대로 위임됨
}
playSound는 새로 만든 내용대로 동작하고,stopSound는 여전히 speaker에게 맡겨집니다.
실행해 보면 이렇습니다.
fun main() {
val player = MusicPlayer(BasicSpeaker())
player.playSound()
player.stopSound()
}
음악을 재생합니다
소리를 멈춥니다
필요한 부분만 바꾸고,
나머지는 그대로 재사용하는 것입니다.
이것이 인터페이스 위임의 큰 매력입니다.
19.3 Property Delegation
프로퍼티도 위임할 수 있다
앞에서는 인터페이스(기능)를 위임했습니다.
코틀린은 프로퍼티(property)도 위임할 수 있습니다.
프로퍼티란
클래스가 가진 값, 즉 변수를 말합니다.
(프로퍼티는 4부에서 다뤘습니다.)
보통 프로퍼티는 이렇게 씁니다.
class User {
var name: String = "이름 없음"
}
이 name은 값을 그냥 자기 안에 저장합니다.
그런데 값을 읽고 쓰는 그 동작 자체를
다른 객체에게 맡길 수 있습니다.
이것이 프로퍼티 위임(property delegation)입니다.
by로 프로퍼티 위임하기
프로퍼티에서도 똑같이 by를 씁니다.
class User {
var name: String by 어떤위임객체
}
by 뒤에 오는 객체가name의 값을 읽고 쓰는 일을 대신 처리합니다.
즉, name을 읽으면
그 위임 객체가 대신 “값을 꺼내 주고”,name에 값을 넣으면
그 위임 객체가 대신 “값을 저장해” 줍니다.
말로만 들으면 어렵습니다.
그래서 코틀린은 자주 쓰는 위임 객체를
미리 만들어 두었습니다.
대표적인 것이 다음 절에서 배울 lazy입니다.
위임 객체가 갖춰야 할 약속
프로퍼티 위임을 이해하는 핵심은 이것입니다.
위임 객체는
“값을 읽어 주는 방법“과
“값을 저장하는 방법“을 알고 있어야 한다.
값을 읽어 주는 방법을 getValue,
값을 저장하는 방법을 setValue라고 합니다.
val(읽기 전용)이면 getValue만,var(읽고 쓰기)이면 둘 다 필요합니다.
이 약속을 직접 만드는 방법은
19.6 커스텀 위임에서 다룹니다.
지금은 코틀린이 미리 만들어 준
편리한 위임들을 먼저 써 보겠습니다.
19.4 lazy
필요할 때 딱 한 번만 만들기
lazy는 가장 많이 쓰이는 프로퍼티 위임입니다.
사실 우리는 5장에서 이미 잠깐 만났습니다.
lazy는 우리말로 “게으른“이라는 뜻입니다.
이름처럼 “미루는” 성질을 가집니다.
값을 미리 만들어 두지 않고,
실제로 그 값이 처음 필요해질 때
그제야 딱 한 번 만든다.
이것을 지연 초기화(lazy initialization)라고 합니다.
초기화(값을 처음 만드는 일)를 뒤로 미룬다는 뜻입니다.
lazy 사용법
by lazy { } 형태로 씁니다.
class Database {
val connection: String by lazy {
println("연결을 만듭니다")
"DB 연결 완료"
}
}
lazy의 중괄호 { } 안에는
값을 만드는 코드를 적습니다.
이 코드는 connection을
처음 읽는 순간에만 실행됩니다.
실행해 봅시다.
fun main() {
val db = Database()
println("아직 연결 안 함")
println(db.connection) // 여기서 처음 만들어짐
println(db.connection) // 이미 만든 값을 재사용
}
아직 연결 안 함
연결을 만듭니다
DB 연결 완료
DB 연결 완료
결과를 잘 봅시다.
db를 만든 시점에는
“연결을 만듭니다“가 출력되지 않습니다.connection을 처음 읽을 때
비로소 값을 만듭니다.- 두 번째로 읽을 때는
다시 만들지 않고 이미 만든 값을 씁니다.
lazy는 언제 쓸까
lazy는 이런 경우에 유용합니다.
- 만드는 데 시간이 오래 걸리는 값
- 항상 쓰이지는 않는 값
- 무거운 자원(데이터베이스 연결 등)
만들어 두고 안 쓰면 낭비입니다.lazy를 쓰면 정말 필요할 때만 만들므로
자원을 아낄 수 있습니다.
한 가지 기억할 점이 있습니다.
lazy는val에만 쓸 수 있습니다.
한 번 만든 값을 계속 재사용하는 것이 목적이라,
값을 바꿀 수 있는 var에는 어울리지 않습니다.
19.5 Observable Property
값이 바뀌는 순간을 지켜보기
lazy 말고도 코틀린이 미리 만들어 둔
편리한 위임이 있습니다.
그중 하나가
관찰 가능한 프로퍼티(observable property)입니다.
관찰(observe)이란 “지켜본다“는 뜻입니다.
값이 바뀌는 순간을 지켜보다가,
바뀔 때마다 원하는 코드를 실행할 수 있습니다.
값이 바뀔 때마다
알림을 받는 것과 같습니다.
Delegates.observable
Delegates.observable을 씁니다.
import kotlin.properties.Delegates
class User {
var name: String by Delegates.observable("이름 없음") { property, old, new ->
println("이름이 '$old' 에서 '$new' 로 바뀜")
}
}
조금 길어 보이니 하나씩 봅시다.
"이름 없음": 처음에 넣어 둘 값(초깃값){ property, old, new -> ... }: 값이 바뀔 때 실행할 코드old: 바뀌기 전 값new: 바뀐 후 값
실행해 봅시다.
fun main() {
val user = User()
user.name = "홍길동"
user.name = "이순신"
}
이름이 '이름 없음' 에서 '홍길동' 로 바뀜
이름이 '홍길동' 에서 '이순신' 로 바뀜
값을 넣을 때마다
중괄호 안의 코드가 자동으로 실행됩니다.
값이 언제 바뀌는지 기록을 남기거나,
바뀔 때 화면을 갱신하는 데 쓰기 좋습니다.
Delegates.vetoable
값이 바뀌는 것을
막아야 할 때도 있습니다.
이때는 Delegates.vetoable을 씁니다.
vetoable은 “거부할 수 있는“이라는 뜻입니다.
import kotlin.properties.Delegates
class Account {
var balance: Int by Delegates.vetoable(0) { property, old, new ->
new >= 0 // true면 변경 허용, false면 거부
}
}
중괄호 안에서true를 돌려주면 값 변경을 허용하고,false를 돌려주면 변경을 막습니다.
여기서는 새 값이 0 이상일 때만 허용합니다.
잔액이 음수가 되는 것을 막는 것입니다.
fun main() {
val account = Account()
account.balance = 1000
println(account.balance) // 1000 (허용됨)
account.balance = -500
println(account.balance) // 1000 (거부되어 그대로)
}
1000
1000
-500을 넣으려 했지만
조건에 맞지 않아 거부되었습니다.
그래서 값은 이전 그대로 1000입니다.
잘못된 값이 들어오는 것을
프로퍼티 차원에서 막을 수 있는 것입니다.
19.6 Custom Delegate
위임을 직접 만들어 보기
지금까지는 코틀린이 미리 만든lazy, observable 같은 위임을 썼습니다.
이제 위임 객체를 직접 만들어 보겠습니다.
이것을 커스텀 위임(custom delegate)이라고 합니다.
19.3에서 위임 객체는
“값을 읽는 방법“과 “값을 저장하는 방법“을
알고 있어야 한다고 했습니다.
- 읽는 방법 :
getValue - 저장하는 방법 :
setValue
이 두 함수를 직접 만들면
나만의 위임 객체가 됩니다.
읽기 전용 위임 만들기
먼저 값을 읽기만 하는
간단한 위임을 만들어 봅시다.
import kotlin.reflect.KProperty
class UpperCaseDelegate {
private val value = "hello"
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value.uppercase() // 항상 대문자로 돌려줌
}
}
낯선 부분을 짚어 봅시다.
operator: 위임 규칙을 따르겠다는 표시getValue: 값을 읽을 때 불리는 함수thisRef: 이 프로퍼티를 가진 객체property: 프로퍼티 자신의 정보
지금은 thisRef와 property를
꼭 쓰지 않아도 됩니다.
정해진 형식이라 적어 두는 것입니다.
이제 이 위임을 프로퍼티에 붙여 봅시다.
class Message {
val text: String by UpperCaseDelegate()
}
fun main() {
val message = Message()
println(message.text)
}
HELLO
text를 읽으면UpperCaseDelegate의 getValue가 실행되어
값을 대문자로 바꿔 돌려줍니다.
읽고 쓰기 위임 만들기
var처럼 값을 저장도 하려면setValue도 함께 만들면 됩니다.
값을 넣을 때 앞뒤 공백을 없애 주는
위임을 만들어 보겠습니다.
import kotlin.reflect.KProperty
class TrimDelegate {
private var value = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.trim() // 앞뒤 공백을 없애서 저장
}
}
setValue는
값을 저장할 때 불리는 함수입니다.
마지막 자리(newValue)로 넣으려는 값이 전달됩니다.
여기서는 trim()으로
앞뒤 공백을 없앤 뒤 저장합니다.
class Form {
var name: String by TrimDelegate()
}
fun main() {
val form = Form()
form.name = " 홍길동 "
println("[${form.name}]")
}
[홍길동]
공백이 잔뜩 붙은 값을 넣었지만,
저장될 때 공백이 사라졌습니다.
이렇게 “값을 다루는 규칙“을
위임 객체 안에 한번 만들어 두면,
여러 프로퍼티에서 재사용할 수 있습니다.
표준 인터페이스로 정리하기
getValue와 setValue를
매번 손으로 적는 대신,
코틀린이 준비한 약속을 쓸 수도 있습니다.
ReadOnlyProperty: 읽기 전용(val)용ReadWriteProperty: 읽고 쓰기(var)용
이 약속을 따르면
함수 형식을 실수 없이 맞출 수 있습니다.
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimDelegate : ReadWriteProperty<Any?, String> {
private var value = ""
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Form {
var name: String by TrimDelegate()
}
동작은 앞의 예와 똑같습니다.
다만 정해진 약속을 따르므로
형식이 더 안전하고 분명해집니다.
지금 이 내용이 어렵게 느껴져도 괜찮습니다.
“위임은 이렇게 직접 만들 수도 있구나”
정도만 기억해 두면 충분합니다.
19.7 상속보다 위임이 적절한 경우
상속의 한계
마지막으로 위임과 상속(8장)을
나란히 놓고 비교해 봅시다.
상속(inheritance)은
부모 클래스의 기능을 자식이 물려받는 것입니다.
강력하지만, 문제도 있습니다.
첫째, 부모에 강하게 묶입니다.
부모 코드가 바뀌면
자식이 뜻하지 않게 영향을 받습니다.
둘째, 필요 없는 기능까지 물려받습니다.
부모가 가진 모든 것을
자식이 그대로 떠안게 됩니다.
셋째, 부모는 하나만 가질 수 있습니다.
코틀린에서 클래스 상속은
부모가 오직 하나뿐입니다.
위임의 장점
위임은 이 한계들을 부드럽게 풉니다.
첫째, 느슨하게 연결됩니다.
필요한 기능을 가진 객체를
그냥 “빌려” 쓰는 방식이기 때문입니다.
둘째, 필요한 기능만 골라 씁니다.
원하는 인터페이스만 위임하면 됩니다.
셋째, 여러 대상에게 나눠 맡길 수 있습니다.
소리는 스피커에게,
화면은 디스플레이에게 각각 맡길 수 있습니다.
interface Speaker {
fun playSound()
}
interface Display {
fun showScreen()
}
class TV(
speaker: Speaker,
display: Display
) : Speaker by speaker, Display by display
TV는 소리와 화면 기능을
서로 다른 두 객체에게 나눠 맡깁니다.
상속으로는 이렇게
두 부모의 기능을 함께 물려받기 어렵습니다.
하지만 위임으로는 자연스럽습니다.
기준 정리하기
그렇다면 언제 무엇을 써야 할까요?
간단한 기준을 표로 정리해 봅시다.
| 상황 | 어울리는 방식 |
|---|---|
| “~는 ~의 한 종류다” 관계 | 상속 |
| “~는 ~기능을 빌려 쓴다” 관계 | 위임 |
| 부모 코드에 강하게 묶여도 괜찮음 | 상속 |
| 느슨하게 연결하고 싶음 | 위임 |
| 여러 기능을 조합하고 싶음 | 위임 |
관계를 말로 대 보면 쉽습니다.
“고양이는 동물의 한 종류다”
→ 이런 관계는 상속.“자동차는 엔진 기능을 빌려 쓴다”
→ 이런 관계는 위임.
프로그래밍 세계에는 오래된 조언이 있습니다.
상속보다 조합(위임)을 먼저 고려하라.
무조건 위임이 옳다는 뜻은 아닙니다.
다만 상속부터 떠올리기 쉬우니,
“위임으로 풀 수는 없을까?“를
한 번 더 생각해 보라는 조언입니다.
코틀린은 이 위임을by 한 단어로 아주 쉽게 만들어 줍니다.
그래서 위임을 고르는 것이
전혀 부담스럽지 않습니다.
19장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 위임은 “일을 다른 대상에게 대신 맡기는 것“이라는 점
by키워드로
인터페이스 기능을 통째로 위임하는 방법- 프로퍼티의 읽고 쓰기 동작도
다른 객체에게 위임할 수 있다는 점 - 값을 미뤄 두었다가 처음 쓸 때 만드는
lazy - 값이 바뀌는 순간을 지켜보는
observable,
값 변경을 막는vetoable getValue와setValue로
위임을 직접 만드는 방법- 상속과 위임을 비교하고,
어느 쪽이 어울리는지 고르는 기준
특히 by와 lazy는
앞으로 코틀린 코드에서 자주 만나게 됩니다.
위임을 잘 쓰면
반복 코드를 줄이고,
유연한 구조를 만들 수 있습니다.
다음 장에서는
또 다른 코틀린의 기능을 이어서 배워 보겠습니다.
20장. Generic
상자 하나를 떠올려 봅시다.
어떤 상자는 사과만 담고,
어떤 상자는 책만 담습니다.
그런데 “무엇이든 담을 수 있는 상자“를 만들되,
“이 상자에는 사과만 담기로 한다“처럼
담을 종류를 나중에 정할 수 있다면 어떨까요?
제네릭(Generic)이 바로 그런 도구입니다.
타입을 미리 못 박지 않고,
“쓸 때 정하는” 방법입니다.
이 장에서는 제네릭으로 클래스와 함수를 만드는 법,
그리고 타입에 조건을 거는 법을 배웁니다.
마지막에는 백엔드에서 자주 쓰는
API 응답 객체를 제네릭으로 만들어 봅니다.
이 장부터는 난이도가 한 단계 높은 심화 파트(7부)입니다.
처음 읽을 때는 어렵게 느껴질 수 있습니다.
지금 다 이해하지 못해도 괜찮습니다.
일단 가볍게 훑고 넘어간 뒤,
실제로 필요할 때 다시 돌아와 읽어도 충분합니다.
20.1 Generic Class
타입을 나중에 정하는 상자
먼저 제네릭이 없는 세상을 상상해 봅시다.
“값 하나를 담는 상자“를 만들고 싶습니다.
그런데 담을 값의 종류마다
상자를 따로 만들어야 한다면 어떨까요?
class IntBox(val value: Int) // 정수 전용 상자
class StringBox(val value: String) // 문자열 전용 상자
하는 일은 똑같은데,
타입만 다른 상자를 계속 만들게 됩니다.
이건 너무 번거롭습니다.
이럴 때 제네릭을 씁니다.
class Box<T>(val value: T)
여기서 T가 바로 제네릭의 핵심입니다.
T는 “아직 정해지지 않은 타입“을 뜻하는 이름표입니다.
이런 이름표를 타입 파라미터(type parameter)라고 부릅니다.
파라미터(parameter)라는 말이 붙은 이유가 있습니다.
함수에 값을 나중에 넘기듯,
클래스에 타입을 나중에 넘기기 때문입니다.
값을 넘기는 자리가 함수의 파라미터라면,
타입을 넘기는 자리가 바로 타입 파라미터입니다.
만든 상자 사용하기
이제 이 상자를 써 봅시다.
상자 이름 뒤 꺾쇠(< >) 안에
담을 타입을 적어 주면 됩니다.
val intBox = Box<Int>(10)
val stringBox = Box<String>("안녕")
println(intBox.value) // 10
println(stringBox.value) // 안녕
Box<Int>라고 적는 순간
그 상자의 T는 Int로 정해집니다.Box<String>이라면 T는 String이 됩니다.
하나의 클래스로
정수 상자도, 문자열 상자도 만든 것입니다.
타입 추론도 그대로 동작합니다.
값만 봐도 타입을 알 수 있다면
꺾쇠 부분을 생략할 수 있습니다.
val intBox = Box(10) // Box<Int>로 추론됨
val stringBox = Box("안녕") // Box<String>으로 추론됨
타입 파라미터 이름 규칙
타입 파라미터 이름은 아무렇게나 지어도 됩니다.
하지만 관례처럼 자주 쓰는 대문자 한 글자가 있습니다.
| 이름 | 주로 쓰는 곳 |
|---|---|
| T | 타입(Type) 하나 |
| E | 목록의 원소(Element) |
| K | 맵의 키(Key) |
| V | 맵의 값(Value) |
꼭 이 글자를 써야 하는 것은 아닙니다.
다만 다른 개발자와 코드를 나눌 때
이 관례를 따르면 서로 알아보기 쉽습니다.
타입 파라미터는 여러 개도 가능합니다.
쉼표로 나열하면 됩니다.
class Pair<A, B>(val first: A, val second: B)
val pair = Pair("나이", 20)
println(pair.first) // 나이
println(pair.second) // 20
여기서는 A는 String으로,B는 Int로 각각 정해졌습니다.
자바의 제네릭과 비교하기
자바에도 제네릭이 있습니다.
자바를 조금 아는 분이라면 낯익을 것입니다.
// 자바
class Box<T> {
private final T value;
Box(T value) { this.value = value; }
T getValue() { return value; }
}
// 코틀린
class Box<T>(val value: T)
기본 생각은 똑같습니다.<T>로 타입을 나중에 정한다는 점에서 다르지 않습니다.
다만 코틀린은 생성자와 속성을 한 줄로 적을 수 있어
훨씬 짧습니다.
(이 짧은 문법은 이미 클래스를 다룬 장에서 배웠습니다.)
20.2 Generic Function
함수에도 타입을 넘긴다
제네릭은 클래스에만 쓰는 것이 아닙니다.
함수 하나에도 적용할 수 있습니다.
예를 들어 “값을 그대로 돌려주는 함수“를 만들어 봅시다.
어떤 타입이 들어오든
그 타입 그대로 돌려주고 싶습니다.
fun <T> echo(value: T): T {
return value
}
여기서 위치를 잘 봐 둡시다.
타입 파라미터 <T>가fun과 함수 이름 사이에 놓여 있습니다.
fun <T> 함수이름(파라미터: T): T
▲
여기에 타입 파라미터를 선언
이 <T> 선언이 있어야
파라미터와 반환 타입에서 T를 쓸 수 있습니다.
사용할 때는 이렇게 합니다.
val a = echo<Int>(10) // T는 Int
val b = echo<String>("hi") // T는 String
println(a) // 10
println(b) // hi
함수도 타입 추론이 됩니다.
넘긴 값으로 타입을 알 수 있으니
꺾쇠를 생략해도 됩니다.
val a = echo(10) // T가 Int로 추론됨
val b = echo("hi") // T가 String으로 추론됨
실제로 쓸모 있는 예
좀 더 그럴듯한 예를 봅시다.
“목록의 첫 번째 값을 돌려주는 함수“입니다.
목록 안에 무엇이 들었든
그 타입 그대로 돌려주고 싶습니다.
fun <T> firstOrNull(list: List<T>): T? {
if (list.isEmpty()) {
return null
}
return list[0]
}
val numbers = listOf(1, 2, 3)
val words = listOf("가", "나", "다")
println(firstOrNull(numbers)) // 1
println(firstOrNull(words)) // 가
정수 목록을 넣으면 정수가,
문자열 목록을 넣으면 문자열이 나옵니다.
함수 하나로 모든 타입의 목록을 처리한 것입니다.
이것이 제네릭 함수의 힘입니다.
반환 타입에 붙은 ?가 궁금할 수 있습니다.
목록이 비어 있으면 돌려줄 값이 없어null을 돌려주기 때문입니다.
제네릭과 null의 관계는 20.4에서 자세히 다룹니다.
20.3 Type Parameter 제한 (상한 경계)
아무 타입이나 받으면 생기는 문제
제네릭은 “어떤 타입이든” 받습니다.
그런데 이 자유로움이 가끔 문제가 됩니다.
예를 들어 “두 값 중 큰 값을 돌려주는 함수“를 만든다고 합시다.
fun <T> max(a: T, b: T): T {
if (a > b) { // 오류!
return a
}
return b
}
이 코드는 오류가 납니다.
왜냐하면 T가 “아무 타입“이기 때문입니다.
T에 어떤 타입이 올지 모르는데,
그 타입이 >로 크기 비교를 할 수 있는지
코틀린은 알 방법이 없습니다.
담을 수 있는 종류를 좁혀 줘야 합니다.
상한 경계로 조건 걸기
이럴 때 타입 파라미터에 조건을 겁니다.
“이런 타입만 받겠다“고 제한하는 것입니다.
T 뒤에 콜론(:)을 쓰고
조건이 될 타입을 적습니다.
fun <T : Comparable<T>> max(a: T, b: T): T {
if (a > b) {
return a
}
return b
}
여기서 Comparable은
“크기를 비교할 수 있는 타입“을 뜻합니다.Int, String, Double 같은 타입이 여기에 해당합니다.
이렇게 T : Comparable<T>라고 적으면
“크기를 비교할 수 있는 타입만 T로 받겠다“는 뜻이 됩니다.
이 조건을 상한 경계(upper bound)라고 부릅니다.
말이 어렵지만 뜻은 간단합니다.
상한 경계란,
“T는 적어도 이 타입은 되어야 한다“는 자격 조건입니다.
이제 함수 안에서 > 비교를 안심하고 쓸 수 있습니다.T가 반드시 비교 가능한 타입이라고
약속되어 있기 때문입니다.
println(max(3, 7)) // 7
println(max("가", "나")) // 나
비유로 이해하기
상한 경계는 놀이기구의 키 제한과 비슷합니다.
키가 120cm 이상인 사람만 탈 수 있다.
이 조건이 있으면
탈 수 있는 사람은 좁아지지만,
대신 “탄 사람은 모두 120cm 이상“이라고
믿을 수 있습니다.
제네릭도 똑같습니다.
조건을 걸면 받을 수 있는 타입은 좁아지지만,
그 대신 그 타입이 무엇을 할 수 있는지
확실히 알게 됩니다.
조건이 여러 개일 때 (where)
조건을 두 개 이상 걸고 싶을 때도 있습니다.
“비교도 가능하고, 복제도 가능한 타입“처럼요.
이럴 때는 where를 사용합니다.
fun <T> process(value: T): T
where T : Comparable<T>,
T : Cloneable {
// T는 비교도 되고 복제도 되는 타입
return value
}
where는 함수 선언 끝에 붙여서
여러 조건을 쉼표로 나열합니다.
조건이 하나뿐이라면
앞에서 본 T : Comparable<T> 방식이 더 간단합니다.
조건이 둘 이상일 때만 where를 떠올리면 됩니다.
정리하면 이렇습니다.
- 조건 하나 :
<T : 타입> - 조건 여럿 :
<T> ... where T : 타입1, T : 타입2
20.4 Nullable Generic
타입 파라미터와 null
3부에서 배웠듯,
코틀린은 null을 아주 조심스럽게 다룹니다.
타입 뒤에 ?가 붙어야만 null이 들어올 수 있었습니다.
그런데 제네릭에서는 한 가지 헷갈리는 점이 있습니다.
타입 파라미터 T 자체가
이미 null을 허용할 수도 있다는 점입니다.
무슨 말인지 코드로 봅시다.
class Box<T>(val value: T)
val box = Box<String?>(null) // 가능!
println(box.value) // null
T에 String?을 넣었습니다.
그러니 value는 null을 담을 수 있게 됩니다.
즉, 아무 표시가 없는 T라도T에 String? 같은 nullable 타입이 들어오면
그 안에는 null이 담길 수 있습니다.
T의 기본 상한은 Any?
여기서 한 걸음 더 들어가 봅시다.
class Box<T>(val value: T)
이렇게 아무 조건 없이 T라고만 적으면,
사실 코틀린은 속으로 이렇게 이해합니다.
class Box<T : Any?>(val value: T)
Any?는 2장에서 잠깐 봤습니다.
“모든 타입의 최상위이면서, null도 될 수 있는 타입“입니다.
그래서 조건 없는 T의 상한 경계는 Any?입니다.
즉, 기본적으로 T에는
nullable 타입까지 들어올 수 있는 것입니다.
조건을 안 걸면
T는Any?,
즉 null까지 포함한 “무엇이든“이 됩니다.
null을 막고 싶다면 Any로 제한
만약 “이 상자에는 절대 null을 담지 못하게” 하고 싶다면
상한 경계를 Any로 걸어 주면 됩니다.
(?가 없는 Any입니다.)
class Box<T : Any>(val value: T)
val box = Box("안녕") // 가능
// val bad = Box<String?>(null) // 오류! null 타입은 못 받음
T : Any라고 적으면T에는 null이 될 수 없는 타입만 들어옵니다.
20.3에서 배운 상한 경계를
null 제어에 활용한 것입니다.
함수 안에서 null 돌려주기
앞서 20.2에서 본 함수를 다시 봅시다.
fun <T> firstOrNull(list: List<T>): T? {
if (list.isEmpty()) {
return null
}
return list[0]
}
반환 타입이 T가 아니라 T?인 점에 주목합시다.
T가 무엇이든,T?라고 적으면
“그 타입이거나 null“이라는 뜻이 됩니다.
목록이 비어 있어 돌려줄 값이 없을 때
안전하게 null을 돌려주기 위한 장치입니다.
정리하면 이렇습니다.
T: 넘어온 타입 그대로 (null 여부는 T에 달림)T?: 그 타입이거나 null
제네릭이라고 해서 null 규칙이 사라지는 것은 아닙니다.
오히려 ?를 어디에 붙이는지를
더 신경 써서 봐야 합니다.
20.5 Generic API 응답 객체 만들기
왜 필요할까
이제 배운 것을 백엔드다운 예제에 써 봅시다.
서버는 클라이언트에게 응답을 돌려줍니다.
그런데 응답의 겉모양은 대체로 비슷합니다.
- 성공했는지 여부
- 메시지
- 실제 데이터
여기서 “실제 데이터“만
요청마다 종류가 다릅니다.
사용자 정보를 돌려줄 때도 있고,
주문 목록을 돌려줄 때도 있습니다.
바로 이 “겉모양은 같고 알맹이만 다른” 상황이
제네릭을 쓰기에 딱 좋습니다.
응답 객체 만들기
응답을 감싸는 상자를 하나 만들어 봅시다.
알맹이 데이터의 타입을 T로 둡니다.
data class ApiResponse<T>(
val success: Boolean,
val message: String,
val data: T,
)
data class는 값을 담는 데 특화된 클래스입니다.
(이미 앞 장에서 배웠습니다.)
여기서 중요한 것은 data: T 부분입니다.
알맹이의 타입을 미리 정하지 않고T로 열어 둔 것입니다.
이제 데이터 종류가 무엇이든
같은 ApiResponse로 감쌀 수 있습니다.
다양한 데이터로 채워 보기
사용자 정보와 주문 목록이
각각 아래처럼 있다고 합시다.
data class UserResponse(val id: Long, val name: String)
data class OrderResponse(val orderId: Long, val amount: Int)
이제 ApiResponse로 감싸 봅시다.
val userResult = ApiResponse(
success = true,
message = "조회 성공",
data = UserResponse(1, "홍길동"),
)
val orderResult = ApiResponse(
success = true,
message = "조회 성공",
data = listOf(
OrderResponse(100, 5000),
OrderResponse(101, 8000),
),
)
여기서 두 응답의 타입을 적어 보면 이렇게 됩니다.
ApiResponse<UserResponse>
ApiResponse<List<OrderResponse>>
첫 번째는 알맹이가 사용자 하나이고,
두 번째는 알맹이가 주문 목록입니다.
T 자리에 List<OrderResponse>처럼
그 자체가 제네릭인 타입도 들어갈 수 있습니다.
상자 안에 또 다른 상자를 넣은 셈입니다.
클래스를 한 번만 만들었는데
사용자 응답, 주문 목록 응답을
모두 표현할 수 있게 되었습니다.
성공과 실패를 함수로 나누기
실무에서는 성공 응답과 실패 응답을
더 편하게 만들도록 함수로 감싸 두기도 합니다.
여기서 제네릭 함수가 쓰입니다.
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(
success = true,
message = "성공",
data = data,
)
}
val userResult = success(UserResponse(1, "홍길동"))
// 타입: ApiResponse<UserResponse>
val orderResult = success(listOf(OrderResponse(100, 5000)))
// 타입: ApiResponse<List<OrderResponse>>
success에 넘긴 데이터의 타입에 따라
결과의 T가 자동으로 정해집니다.
20.2에서 배운 타입 추론이 그대로 동작하는 것입니다.
이렇게 제네릭을 쓰면
응답 규격은 하나로 통일하면서도
데이터 종류는 자유롭게 바꿀 수 있습니다.
겉모양은 하나로,
알맹이는 자유롭게.
이것이 백엔드에서 제네릭을 쓰는
가장 흔하고 실용적인 이유입니다.
20장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 제네릭은 타입을 미리 정하지 않고
쓸 때 정하는 방법이라는 점 class Box<T>처럼
클래스에 타입 파라미터를 다는 법fun <T> ...처럼
함수에도 제네릭을 적용하는 법- 상한 경계(
T : 타입)와where로
타입에 조건을 거는 법 - 조건 없는
T의 기본 상한은Any?이며,T : Any로 null을 막을 수 있다는 점 ApiResponse<T>처럼
실무에서 쓰는 제네릭 응답 객체를 만드는 법
제네릭은 처음에는 낯설지만,
“타입을 나중에 넘기는 파라미터“라는
한 가지 생각만 잡으면 훨씬 쉬워집니다.
아직 남은 이야기가 있습니다.
Box<String>을 Box<Any>처럼
다룰 수 있는지에 대한 문제는
변성(variance)이라고 부르며 21장에서 다룹니다.
또 실행 중에 T가 무엇인지 알아내는reified 키워드는 22장에서 이어집니다.
지금은 “타입도 넘길 수 있다“는 감각만
챙겨 두면 충분합니다.
21장. Variance
20장에서 제네릭을 배웠습니다.List<String>, Box<Int>처럼
타입을 담는 상자를 만들 수 있었습니다.
그런데 여기서 까다로운 질문이 하나 생깁니다.
List<고양이>는List<동물>로 취급해도 될까요?
고양이는 동물입니다.
그렇다면 고양이 목록도 동물 목록일까요?
이 질문에 답하는 것이 바로 변성(Variance)입니다.
제네릭 타입 사이의 “상하 관계“를 다루는 규칙입니다.
이 장도 7부(심화)에 속합니다.
처음에는 큰 흐름만 잡고,
필요할 때 다시 돌아와도 좋습니다.
변성은 코틀린에서 가장 헷갈리는 주제입니다.
하지만 실무에서는 대부분 “코드를 읽기 위해” 필요합니다.
그러니 완벽히 외우기보다
“왜 이런 규칙이 있는지“를 이해하는 데 집중합시다.
21.1 왜 List는 List가 아닌가
먼저 간단한 상속 관계를 만들어 봅시다.
open class Animal
class Cat : Animal()
Cat은 Animal을 상속합니다.
그래서 이런 대입은 당연히 됩니다.
val animal: Animal = Cat() // OK: 고양이는 동물이다
그렇다면 목록도 될까요?
val cats: MutableList<Cat> = mutableListOf(Cat())
val animals: MutableList<Animal> = cats // 컴파일 오류!
직관적으로는 될 것 같은데,
코틀린은 이를 막습니다.
이유를 상상해 봅시다.
만약 위 대입이 허용된다면 이런 일이 벌어집니다.
animals.add(Animal()) // animals는 동물 목록이니 동물 추가 가능
val c: Cat = cats[0] // 그런데 cats에는 Animal이 들어가 버림!
cats는 분명 고양이 목록인데
그 안에 일반 동물이 섞이게 됩니다.
이런 사고를 막기 위해
코틀린은 기본적으로 이 대입을 금지합니다.
상속 관계가 있어도,
그 타입을 담은 제네릭까지
자동으로 상속 관계가 되지는 않는다.
21.2 Invariance (무공변)
방금 본 것이 바로 무공변(Invariance)입니다.
무공변이란Cat과 Animal이 부모-자식 관계여도Box<Cat>과 Box<Animal>은
아무 관계도 아니라는 뜻입니다.
class Box<T>(val value: T)
val catBox: Box<Cat> = Box(Cat())
val animalBox: Box<Animal> = catBox // 오류: 서로 무관한 타입
코틀린의 제네릭은 아무 표시가 없으면
전부 무공변입니다.
무공변은 안전합니다.
하지만 때로는 너무 엄격해서 불편합니다.
그래서 “이 경우엔 관계를 인정해도 안전하다“고
알려 주는 장치가 필요합니다.
그것이 다음에 나오는 out과 in입니다.
21.3 Covariance와 out (공변)
다시 생각해 봅시다.
문제가 생긴 건 목록에 값을 “추가“할 때였습니다.
만약 값을 꺼내기만 하고
절대 추가하지 않는다면 어떨까요?
고양이만 들어 있는 목록에서
값을 꺼내면 항상 고양이가 나옵니다.
고양이는 동물이므로,
이를 동물 목록처럼 “읽는” 것은 안전합니다.
이렇게 “꺼내기(생산)만 하는” 경우
부모-자식 관계를 인정해 주는 것을
공변(Covariance)이라고 합니다.
공변은 타입 앞에 out을 붙여 표시합니다.
class Box<out T>(val value: T)
val catBox: Box<Cat> = Box(Cat())
val animalBox: Box<Animal> = catBox // OK! out 덕분에 허용
out은 “이 타입은 밖으로 내보내기만 한다“는 약속입니다.
실제로 코틀린의 읽기 전용 List가
바로 이렇게 정의되어 있습니다.
interface List<out E> { ... }
그래서 이런 코드는 문제없이 동작합니다.
val cats: List<Cat> = listOf(Cat())
val animals: List<Animal> = cats // OK: List는 공변
읽기 전용이라 값을 추가할 수 없으니
21.1에서 본 사고가 일어나지 않습니다.
21.4 Contravariance와 in (반공변)
이번엔 반대 상황입니다.
값을 “받아서 처리(소비)만” 하는 경우입니다.
동물을 처리할 수 있는 도구가 있다고 합시다.
class Processor<in T> {
fun process(item: T) {
println("처리: $item")
}
}
동물을 처리할 수 있다면,
고양이도 당연히 처리할 수 있습니다.
고양이는 동물이니까요.
그래서 “동물 처리기“를 “고양이 처리기” 자리에
넣는 것이 안전합니다.
val animalProcessor: Processor<Animal> = Processor()
val catProcessor: Processor<Cat> = animalProcessor // OK! in 덕분에 허용
이렇게 “받아서 소비만 하는” 경우
관계를 반대로 인정해 주는 것을
반공변(Contravariance)이라고 합니다.
반공변은 타입 앞에 in을 붙입니다.
in은 “이 타입은 안으로 받기만 한다“는 약속입니다.
21.5 Star Projection
가끔은 타입 파라미터가 무엇인지
전혀 신경 쓰지 않을 때가 있습니다.
“무슨 타입인지는 모르겠지만
아무튼 어떤 리스트“라고만 말하고 싶을 때입니다.
이때 별표(*)를 씁니다.
이를 스타 프로젝션(Star Projection)이라고 합니다.
fun printSize(list: List<*>) {
println("크기: ${list.size}")
}
printSize(listOf(1, 2, 3)) // OK
printSize(listOf("a", "b")) // OK
List<*>는 “무언가의 리스트“라는 뜻입니다.
크기를 세는 것처럼
타입과 무관한 작업에만 안전하게 쓸 수 있습니다.
다만 *로 받은 값은
꺼낼 때 타입을 알 수 없으므로Any?로 취급됩니다.
21.6 읽는 타입과 쓰는 타입
여기까지 오면 핵심이 보입니다.out과 in은 결국 “방향“입니다.
기억하기 쉬운 한 문장으로 정리하면 이렇습니다.
out = 밖으로 내보낸다 = 생산자(Producer) = 읽기
in = 안으로 받는다 = 소비자(Consumer) = 쓰기
| 키워드 | 의미 | 역할 | 관계 |
|---|---|---|---|
| (없음) | 무공변 | 읽기+쓰기 | 관계 없음 |
| out | 공변 | 생산(읽기) | 자식→부모 허용 |
| in | 반공변 | 소비(쓰기) | 부모→자식 허용 |
이 방향 규칙은 종종
“PECS“라는 말로도 불립니다.
Producer는 out, Consumer는 in.
(Producer-Extends, Consumer-Super)
21.7 실무 코드를 읽기 위한 Variance
솔직히 말하면,
직접 out/in을 붙여 클래스를 설계하는 일은
초보 단계에서는 드뭅니다.
대신 라이브러리나 표준 API를 읽을 때
이 표시들을 자주 만나게 됩니다.
List<out E>: 읽기 전용, 공변Comparable<in T>: 비교 대상으로 소비, 반공변(T) -> R함수 타입도 내부적으로 in/out 규칙을 따름
그러니 지금은 이 정도만 기억하면 충분합니다.
out이 보이면 “여기서 값을 꺼내는구나”,in이 보이면 “여기에 값을 넣는구나”
라고 읽으면 된다.
자바에도 비슷한 개념이 있습니다.? extends T가 코틀린의 out,? super T가 코틀린의 in에 해당합니다.
코틀린은 이 표시를
클래스를 정의할 때 한 번만 붙일 수 있어서
자바보다 훨씬 깔끔합니다.
21장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 상속 관계가 있어도 제네릭까지 자동으로 상속되지 않는다는 점(무공변)
- 값을 꺼내기만 하면
out으로 공변을 허용한다는 점 - 값을 받기만 하면
in으로 반공변을 허용한다는 점 - 타입을 신경 쓰지 않을 땐
*(스타 프로젝션)를 쓴다는 점 - out=생산/읽기, in=소비/쓰기라는 방향 감각
변성은 완벽히 외우는 개념이 아니라
“방향으로 읽는” 감각에 가깝습니다.
다음 장에서는 7부의 마지막,inline과 reified를 다룹니다.
22장. Inline과 Reified
7부의 마지막 장입니다.
14장에서 우리는 함수를 값처럼 다루는 법을 배웠습니다.
함수를 인자로 넘기는 고차 함수도 익혔습니다.
그런데 함수를 인자로 넘기면
눈에 보이지 않는 작은 비용이 생깁니다.
이 장에서는 그 비용을 줄이는 inline과,
제네릭의 한계를 넘는 reified를 배웁니다.
이 장도 심화 내용입니다.
inline과reified가
“왜 필요한지“만 이해해도 충분합니다.
22.1 고차 함수와 함수 객체
고차 함수를 하나 만들어 봅시다.
fun repeatAction(times: Int, action: () -> Unit) {
for (i in 1..times) {
action()
}
}
repeatAction(3) {
println("안녕")
}
편리해 보입니다.
그런데 내부에서는 무슨 일이 일어날까요?
{ println("안녕") }이라는 람다는
사실 하나의 “객체“로 만들어집니다.
함수를 넘길 때마다
이런 함수 객체(function object)가 생성됩니다.
한두 번이면 문제없지만,
아주 많이 호출되는 코드에서는
이 작은 비용이 쌓일 수 있습니다.
22.2 inline
이 비용을 없애는 방법이 inline입니다.
inline을 붙이면
컴파일러가 함수 호출을 없애고,
그 자리에 함수 내용을 직접 붙여 넣습니다.
inline fun repeatAction(times: Int, action: () -> Unit) {
for (i in 1..times) {
action()
}
}
비유하자면 이렇습니다.
함수를 “호출“하는 대신,
코드를 그 자리에 “복사해 붙여넣기” 합니다.
그래서 위 호출은 컴파일 후
대략 이런 코드로 바뀝니다.
for (i in 1..3) {
println("안녕") // 람다 객체 없이 직접 실행
}
함수 객체가 만들어지지 않으므로
람다를 넘기는 비용이 사라집니다.
단, 무조건 좋은 것은 아닙니다.
코드를 복사해 붙이므로
함수가 크면 결과물 크기가 커집니다.
inline은 람다를 받는
작은 고차 함수에 쓰는 것이 적절합니다.
22.3 noinline
inline 함수가 람다를 여러 개 받을 때,
그중 일부만 인라인하고 싶을 수 있습니다.
인라인하고 싶지 않은 람다에는noinline을 붙입니다.
inline fun process(
action1: () -> Unit,
noinline action2: () -> Unit
) {
action1() // 인라인됨
val saved = action2 // noinline이라 객체로 다룰 수 있음
saved()
}
인라인된 람다는 객체가 아니라서
변수에 저장하거나 다른 곳에 넘길 수 없습니다.
그런 조작이 필요하면 noinline을 씁니다.
22.4 crossinline
인라인된 람다는 특별한 능력이 있습니다.
바깥 함수를 통째로 종료하는return을 쓸 수 있다는 점입니다.
이를 비지역 반환(non-local return)이라고 합니다.
inline fun run(action: () -> Unit) {
action()
}
fun test() {
run {
return // test() 자체를 종료함
}
println("여기는 실행 안 됨")
}
그런데 람다를 다른 곳에서 실행하는 경우
이 비지역 반환이 위험해질 수 있습니다.
이를 막으려면 crossinline을 붙입니다.
inline fun runLater(crossinline action: () -> Unit) {
val runnable = Runnable {
action() // 여기서 return 금지
}
runnable.run()
}
crossinline은
“인라인은 유지하되, 비지역 반환은 금지한다“는 뜻입니다.
22.5 JVM의 Type Erasure
이제 reified로 넘어가기 위한
배경 지식을 하나 봅시다.
JVM에는 타입 소거(Type Erasure)라는 특징이 있습니다.
제네릭의 타입 정보가
실행 시점(runtime)에는 지워진다는 뜻입니다.
즉, List<String>과 List<Int>는
컴파일 후에는 둘 다 그냥 List가 됩니다.
그래서 이런 코드는 아예 작성할 수 없습니다.
fun <T> isType(value: Any): Boolean {
return value is T // 오류! 실행 시점에 T를 알 수 없음
}
T가 무엇이었는지
실행할 때는 정보가 사라졌기 때문입니다.
22.6 reified
reified는 이 한계를 넘게 해 줍니다.
inline 함수 안에서는
코드를 그 자리에 복사해 붙이므로,T 자리에 실제 타입이 그대로 들어갑니다.
그래서 타입 정보가 사라지지 않습니다.
이때 타입 파라미터에 reified를 붙이면
실행 시점에도 그 타입을 쓸 수 있습니다.
inline fun <reified T> isType(value: Any): Boolean {
return value is T // OK! reified 덕분에 가능
}
println(isType<String>("hello")) // true
println(isType<Int>("hello")) // false
reified는 반드시 inline과 함께 써야 합니다.
인라인이 되어야 타입이 실제로 채워지기 때문입니다.
22.7 런타임 타입을 사용하는 Generic 함수
reified가 실무에서 빛나는 순간은
“타입을 기준으로 무언가를 만들거나 변환할 때“입니다.
예를 들어 JSON 문자열을
원하는 타입의 객체로 바꾸는 함수를 상상해 봅시다.
inline fun <reified T> parse(json: String): T {
// 실제로는 라이브러리가 T 타입 정보를 활용해 변환
return jsonLibrary.readValue(json, T::class.java)
}
val user: User = parse<User>(jsonText)
reified 덕분에
함수 안에서 T::class처럼
타입 자체를 직접 다룰 수 있습니다.
스프링이나 각종 라이브러리에서
이런 패턴을 자주 만나게 됩니다.
그래서 지금 원리를 알아 두면
나중에 코드가 훨씬 잘 읽힙니다.
22장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 고차 함수는 람다를 함수 객체로 만들어 작은 비용이 생긴다는 점
inline은 코드를 그 자리에 붙여 넣어 그 비용을 없앤다는 점noinline과crossinline으로 인라인 동작을 세밀하게 조절한다는 점- JVM은 타입 소거 때문에 실행 시점에 제네릭 타입을 모른다는 점
reified(+inline)로 실행 시점에도 타입을 쓸 수 있다는 점
이것으로 7부(타입 시스템 심화)를 마칩니다.
다음 8부에서는 예외와 오류를 설계합니다.
23장. 예외 처리
프로그램은 늘 예상대로만 흐르지 않습니다.
파일이 없을 수도 있고,
숫자가 들어와야 할 자리에 글자가 올 수도 있고,
네트워크가 끊길 수도 있습니다.
이렇게 “정상적이지 않은 상황“을
예외(Exception)라고 부릅니다.
이 장에서는 예외를 다루는 기본 문법과,
백엔드에서 오류를 어떻게 설계하면 좋은지 살펴봅니다.
23.1 try, catch, finally
예외가 발생할 수 있는 코드는try 블록으로 감쌉니다.
예외가 발생하면 catch 블록이 대신 실행됩니다.
fun parseNumber(text: String) {
try {
val number = text.toInt()
println("숫자: $number")
} catch (e: NumberFormatException) {
println("숫자로 바꿀 수 없습니다: $text")
}
}
parseNumber("123") // 숫자: 123
parseNumber("abc") // 숫자로 바꿀 수 없습니다: abc
구조를 정리하면 이렇습니다.
try: 예외가 생길 수 있는 코드catch: 예외가 생겼을 때 실행할 코드finally: 예외와 상관없이 항상 실행할 코드
finally는 뒷정리에 씁니다.
try {
println("작업 시작")
throw RuntimeException("문제 발생")
} catch (e: Exception) {
println("예외 처리: ${e.message}")
} finally {
println("항상 실행되는 정리 코드")
}
finally는 예외가 나든 안 나든
반드시 실행됩니다.
23.2 Expression으로서의 try
3장에서 if와 when이
값을 돌려주는 표현식이라고 배웠습니다.
코틀린에서는 try도 표현식입니다.
즉, 결과를 값으로 받을 수 있습니다.
val number = try {
text.toInt()
} catch (e: NumberFormatException) {
0 // 실패하면 기본값 0
}
try가 성공하면 그 결과가,
실패하면 catch의 결과가 number에 담깁니다.
이 방식은 코드를 훨씬 간결하게 만들어 줍니다.
23.3 throw
예외를 직접 발생시키려면 throw를 씁니다.
fun checkAge(age: Int) {
if (age < 0) {
throw IllegalArgumentException("나이는 음수일 수 없습니다: $age")
}
println("나이: $age")
}
여기서 흥미로운 점이 있습니다.
코틀린에서는 throw도 표현식입니다.
그래서 엘비스 연산자(?:)와 함께
자주 쓰입니다.
val name = user.name ?: throw IllegalStateException("이름이 없습니다")
user.name이 null이면
바로 예외를 던지는 코드입니다.
(엘비스 연산자는 5장에서 배웠습니다.)
23.4 Kotlin에는 Checked Exception이 없다
자바를 아는 분이라면
이 부분이 반가울 것입니다.
자바에는 검사 예외(Checked Exception)가 있습니다.
특정 예외는 반드시 try-catch로 처리하거나throws로 선언해야 했습니다.
// 자바
public void readFile() throws IOException {
// ...
}
이 규칙은 안전을 위한 것이었지만,
실제로는 의미 없는 try-catch를 양산하기도 했습니다.
코틀린에는 검사 예외가 없습니다.
모든 예외를 강제로 처리하지 않아도 됩니다.
fun readFile() {
// throws 선언이 필요 없음
}
덕분에 코드가 깔끔해집니다.
다만 그만큼 “어떤 예외를 처리할지“는
개발자가 스스로 판단해야 합니다.
23.5 Custom Exception
기본 예외만으로 부족할 때가 있습니다.
우리 서비스에 맞는 예외를 직접 만들 수 있습니다.
Exception을 상속하면 됩니다.
class UserNotFoundException(
val userId: Long
) : RuntimeException("회원을 찾을 수 없습니다: $userId")
이렇게 만든 예외는 이렇게 사용합니다.
fun findUser(id: Long): User {
return userRepository.find(id)
?: throw UserNotFoundException(id)
}
직접 만든 예외의 장점은
“무엇이 잘못됐는지“가 이름만으로 드러난다는 점입니다.
UserNotFoundException은RuntimeException보다 훨씬 명확합니다.
23.6 예외 메시지와 오류 정보 설계
예외를 던질 때는
“나중에 이 메시지를 볼 사람“을 생각해야 합니다.
좋은 예외 메시지는 두 가지를 담습니다.
- 무엇이 잘못됐는가
- 어떤 값 때문에 잘못됐는가
나쁜 예와 좋은 예를 비교해 봅시다.
// 나쁜 예: 원인을 알 수 없음
throw IllegalArgumentException("잘못된 값")
// 좋은 예: 원인과 값이 드러남
throw IllegalArgumentException("주문 수량은 1 이상이어야 합니다. 입력값: $quantity")
또한 예외에 필요한 정보를
프로퍼티로 담아 두면
나중에 코드로 활용하기 좋습니다.
class PaymentException(
val orderId: Long,
val reason: String
) : RuntimeException("결제 실패 [주문 $orderId]: $reason")
예외는 단순히 “터뜨리는 것“이 아니라,
“무슨 일이 있었는지 남기는 기록“입니다.
이렇게 설계해 두면
오류를 추적하고 대응하기가 훨씬 쉬워집니다.
23장을 마치며
이 장에서 우리는 다음을 배웠습니다.
try,catch,finally의 기본 구조try와throw가 값을 돌려주는 표현식이라는 점- 코틀린에는 검사 예외가 없어 코드가 간결하다는 점
- 서비스에 맞는 예외를 직접 만드는 방법
- 원인과 값을 담은 좋은 예외 메시지 설계
예외는 “실패를 표현하는 한 가지 방법“입니다.
다음 장에서는 예외 말고도
실패를 표현하는 여러 방법을 비교해 봅니다.
24장. 실패를 표현하는 여러 방법
23장에서 예외를 배웠습니다.
하지만 실패를 표현하는 방법이
예외 하나만 있는 것은 아닙니다.
- null로 “값이 없음“을 표현할 수도 있고
- 예외를 던질 수도 있고
Result라는 특별한 상자에 담을 수도 있고- Sealed Type으로 결과를 모델링할 수도 있습니다.
이 장에서는 이 방법들을 하나씩 살펴보고,
“어떤 상황에 무엇을 쓰면 좋은지“를 정리합니다.
24.1 Nullable로 실패 표현하기
가장 간단한 방법은 null입니다.
“찾지 못했다“는 실패를
null로 돌려줄 수 있습니다.
fun findUser(id: Long): User? {
return users[id] // 없으면 null
}
val user = findUser(1) ?: return
이 방법은 “실패 이유가 하나뿐“일 때 좋습니다.
그저 “없다“만 표현하면 될 때입니다.
하지만 한계가 있습니다.
null은 “왜 실패했는지“를 말해 주지 못합니다.
회원이 없어서인지,
권한이 없어서인지 구분할 수 없습니다.
24.2 Exception으로 실패 표현하기
실패 이유가 여러 가지이고,
그 실패가 “정말 예외적인 상황“이라면
예외가 적절합니다.
fun withdraw(account: Account, amount: Int) {
if (amount > account.balance) {
throw IllegalStateException("잔액 부족")
}
account.balance -= amount
}
예외의 장점은
“정상 흐름“과 “실패 흐름“을
확실히 분리한다는 점입니다.
단, 예외는 비용이 있고
흐름을 끊어 버립니다.
“자주 일어나는, 평범한 실패“에는
예외가 과할 수 있습니다.
24.3 Result
코틀린에는 Result라는 타입이 있습니다.
성공이면 값을,
실패면 예외를 담는 특별한 상자입니다.
val result: Result<Int> = runCatching {
"123".toInt()
}
Result는 실패를 “값처럼” 다룰 수 있게 해 줍니다.
예외처럼 흐름을 끊지 않고,
결과를 손에 쥔 채로 이어서 처리할 수 있습니다.
24.4 runCatching
Result를 만드는 가장 쉬운 방법이runCatching입니다.
runCatching 블록 안에서
예외가 나면 자동으로 실패 Result가 됩니다.
val result = runCatching {
riskyOperation()
}
result
.onSuccess { println("성공: $it") }
.onFailure { println("실패: ${it.message}") }
try-catch를 직접 쓰지 않아도
성공과 실패를 깔끔하게 나눠 처리할 수 있습니다.
24.5 getOrNull, getOrElse, getOrThrow
Result에서 값을 꺼내는 방법은 여러 가지입니다.
val result = runCatching { "abc".toInt() }
result.getOrNull() // 실패면 null
result.getOrElse { -1 } // 실패면 기본값 -1
result.getOrThrow() // 실패면 예외를 다시 던짐
| 함수 | 실패했을 때 동작 |
|---|---|
getOrNull | null 반환 |
getOrElse | 기본값 반환 |
getOrThrow | 예외를 다시 던짐 |
상황에 맞게 골라 쓰면 됩니다.
24.6 Sealed Type으로 결과 표현하기
가장 표현력이 좋은 방법은
Sealed Type입니다. (10장 참고)
실패의 “종류“까지 타입으로 나눌 수 있습니다.
sealed interface PaymentResult {
data class Success(val paymentId: Long) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
when으로 처리하면
모든 경우를 빠짐없이 다루게 됩니다.
fun handle(result: PaymentResult) = when (result) {
is PaymentResult.Success -> "결제 완료: ${result.paymentId}"
is PaymentResult.Failed -> "결제 실패: ${result.reason}"
PaymentResult.Pending -> "처리 중"
}
else 없이도
컴파일러가 “빠진 경우가 없는지” 검사해 줍니다.
24.7 어떤 방식을 선택해야 하는가
이제 정리해 봅시다.
아래 상황들을 예로 비교해 보겠습니다.
회원 없음
결제 실패
외부 API 오류
잘못된 요청
일시적인 시스템 오류
각 상황에 어울리는 방식을 정리하면 이렇습니다.
| 상황 | 어울리는 방식 |
|---|---|
| 회원 없음 | Nullable |
| 잘못된 요청 | Exception |
| 결제 실패(여러 사유) | Sealed Type |
| 외부 API 오류 | Result |
| 일시적 시스템 오류 | Exception |
큰 기준은 이렇게 잡을 수 있습니다.
실패 이유가 하나면 Nullable,
정말 예외적이면 Exception,
여러 결과를 구분해야 하면 Sealed Type,
실패를 값으로 이어서 다루고 싶으면 Result.
정답이 하나로 정해져 있지는 않습니다.
중요한 것은 “실패도 설계 대상“이라는 생각입니다.
24장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- null, 예외,
Result, Sealed Type으로 실패를 표현하는 네 가지 방법 runCatching으로 예외를 값으로 감싸는 법getOrNull,getOrElse,getOrThrow의 차이- 상황별로 적절한 실패 표현을 고르는 기준
이것으로 8부(예외와 오류 설계)를 마칩니다.
다음 9부에서는
자바 생태계와 코틀린을 함께 쓰는 법을 다룹니다.
25장. Java와 Kotlin 함께 사용하기
1장에서 코틀린과 자바가
같은 JVM 위에서 돌아간다고 배웠습니다.
그 덕분에 한 프로젝트 안에서
자바와 코틀린을 섞어 쓸 수 있습니다.
실무에서는 이런 상황이 아주 흔합니다.
기존 자바 코드가 잔뜩 있고,
새 코드만 코틀린으로 짜는 경우가 많기 때문입니다.
이 장에서는 코틀린에서
자바 코드를 불러 쓰는 방법을 살펴봅니다.
25.1 Java 코드를 Kotlin에서 호출하기
자바 클래스가 하나 있다고 합시다.
// Java
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
코틀린에서는 마치 코틀린 클래스처럼
그대로 쓸 수 있습니다.
val calc = Calculator()
val sum = calc.add(1, 2)
println(sum) // 3
특별한 변환이나 설정이 필요 없습니다.
그냥 부르면 됩니다.
25.2 Java Getter와 Setter
자바에서는 필드를 감추고
게터/세터로 접근하는 관례가 있습니다.
// Java
public class Person {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
코틀린은 이 게터/세터를
프로퍼티처럼 자연스럽게 바꿔 줍니다.
val person = Person()
person.name = "홍길동" // setName() 호출과 같음
println(person.name) // getName() 호출과 같음
getName()을 직접 부를 필요 없이person.name으로 접근할 수 있습니다.
(코틀린 프로퍼티는 7장에서 배웠습니다.)
25.3 Java Collection
자바의 컬렉션도 그대로 쓸 수 있습니다.
다만 한 가지 주의점이 있습니다.
자바 컬렉션에는
“읽기 전용 / 변경 가능“의 구분이 없습니다.
(12장에서 코틀린의 이 구분을 배웠습니다.)
그래서 자바에서 넘어온 List는
코틀린에서 변경 가능한 것으로 취급될 수 있습니다.
val list = javaObject.getItems() // 자바에서 온 List
list.add("새 항목") // 가능할 수 있음(주의)
자바에서 온 컬렉션을 다룰 때는
“내가 이걸 바꿔도 되는가“를
한 번 더 생각하는 습관이 좋습니다.
25.4 SAM Conversion
자바에는 “메서드가 하나뿐인 인터페이스“가 많습니다.Runnable, Comparator 같은 것들입니다.
이런 인터페이스를
SAM 인터페이스라고 합니다.
(SAM = Single Abstract Method)
코틀린에서는 이런 인터페이스를
람다로 간단히 넘길 수 있습니다.
이를 SAM 변환(SAM Conversion)이라고 합니다.
// 익명 객체 대신
val runnable = Runnable {
println("실행")
}
// 자바 메서드에 람다를 바로 넘김
executor.submit {
println("작업 실행")
}
new Runnable() { ... } 같은
긴 코드를 쓰지 않아도 됩니다.
25.5 Checked Exception
23장에서 코틀린에는
검사 예외(Checked Exception)가 없다고 했습니다.
그런데 자바 코드를 부를 때는
그 자바 메서드가 검사 예외를 던질 수 있습니다.
// Java
public String readFile(String path) throws IOException {
// ...
}
자바라면 try-catch가 강제되지만,
코틀린에서는 강제되지 않습니다.
val content = readFile("data.txt") // try-catch 강제 없음
편하긴 하지만,
“이 자바 메서드가 실패할 수 있다“는 사실은
스스로 기억하고 있어야 합니다.
25.6 Platform Type 다시 살펴보기
6장에서 배운 플랫폼 타입을
여기서 다시 만납니다.
자바 메서드가 돌려주는 값은
null일 수도, 아닐 수도 있습니다.
코틀린은 그것을 확실히 알 수 없습니다.
val name = person.name // 타입: String! (플랫폼 타입)
String!는 “null일지 아닐지 코틀린이 모른다“는 표시입니다.
이런 값은 곧바로 쓰기보다
안전하게 다루는 것이 좋습니다.
val safeName: String = person.name ?: "이름 없음"
자바에서 온 값에는?:나 ?.를 적절히 붙여
방어하는 습관을 들이면 좋습니다.
25장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 자바 클래스를 코틀린에서 그대로 부르는 법
- 자바 게터/세터가 코틀린 프로퍼티처럼 보인다는 점
- 자바 컬렉션에는 읽기 전용 구분이 없다는 점
- SAM 변환으로 자바 인터페이스에 람다를 넘기는 법
- 자바의 검사 예외가 코틀린에서는 강제되지 않는다는 점
- 자바에서 온 값(플랫폼 타입)을 방어적으로 다루기
다음 장에서는 반대 방향,
코틀린 코드를 자바에서 잘 쓰게 만드는 법을 다룹니다.
26장. Kotlin 코드를 Java에 공개하기
지금까지 우리는 코틀린 코드를 자유롭게 써 왔습니다.
그런데 현실의 회사 프로젝트에는
자바 코드와 코틀린 코드가 함께 섞여 있는 경우가 많습니다.
이럴 때 자바 쪽에서
우리가 만든 코틀린 코드를 불러 쓰게 될 수 있습니다.
문제는, 코틀린에서는 자연스러웠던 문법이
자바에서 보면 어색하게 보일 때가 있다는 점입니다.
이 장에서는 코틀린 코드가
자바에서 어떤 모습으로 보이는지 살펴봅니다.
그리고 몇 가지 어노테이션(annotation)을 붙여서
자바 쪽에서도 편하게 쓰도록 다듬는 방법을 배웁니다.
25장이 “자바 코드를 코틀린에서 쓰는” 방향이었다면,
이 장은 그 반대인 “코틀린 코드를 자바에 공개하는” 방향입니다.
26.1 Kotlin 코드가 JVM에서 보이는 모습
코틀린도 결국 바이트코드다
1장에서 배운 내용을 잠깐 떠올려 봅시다.
코틀린 코드는 컴파일을 거쳐
바이트코드(bytecode)로 번역된 뒤 JVM에서 실행됩니다.
자바 코드도 마찬가지로
같은 바이트코드로 번역됩니다.
그래서 자바 입장에서 코틀린 코드는
결국 “또 하나의 자바 클래스“처럼 보입니다.
자바는 코틀린 문법을 모릅니다.
자바가 보는 것은 번역이 끝난 바이트코드뿐입니다.
바로 이 점이 이 장의 출발점입니다.
우리가 편하게 쓴 코틀린 문법이
번역되면 어떤 자바 모습으로 바뀌는지 알아야
자바 쪽에서 잘 쓰도록 다듬을 수 있습니다.
어노테이션이란
이 장에서는 @ 기호로 시작하는
어노테이션(annotation)을 많이 씁니다.
어노테이션은 코드에 붙이는 “쪽지“라고 생각하면 쉽습니다.
컴파일러에게
“이 코드를 이렇게 번역해 줘“라고
부탁하는 쪽지입니다.
예를 들어 @JvmStatic이라는 쪽지를 붙이면
“자바에서 static처럼 보이게 번역해 줘“라는 뜻이 됩니다.
이런 쪽지들은 코틀린 코드의 동작을 바꾸지 않습니다.
오직 “자바에서 어떻게 보일지“만 바꿉니다.
이 장에서 다루는 쪽지들
앞으로 나올 어노테이션을 미리 표로 정리해 둡니다.
지금 외울 필요는 없습니다.
| 어노테이션 | 하는 일 |
|---|---|
@JvmStatic | 자바에서 static 멤버처럼 보이게 함 |
@JvmField | 자바에서 필드에 바로 접근하게 함 |
@JvmOverloads | 기본값 조합마다 자바 메서드를 만들어 줌 |
@JvmName | 자바에서 보이는 이름을 바꿔 줌 |
“이런 게 있구나” 정도로만 읽고
아래에서 하나씩 자세히 보겠습니다.
26.2 @JvmStatic
companion object 복습
먼저 11장에서 배운
컴패니언 오브젝트(companion object)를 떠올려 봅시다.
코틀린에는 자바의 static이 없습니다.
대신 클래스 안에 companion object를 두어
“클래스 이름으로 바로 부르는 멤버“를 만듭니다.
class Calculator {
companion object {
fun add(a: Int, b: Int): Int {
return a + b
}
}
}
코틀린에서는 이렇게 부릅니다.
val result = Calculator.add(1, 2)
클래스 이름 뒤에 바로 함수를 부르니
마치 자바의 static 메서드처럼 보입니다.
자바에서는 어떻게 보일까
문제는 자바에서 이 코드를 부를 때 생깁니다.
companion object는 사실Companion이라는 이름의 숨은 객체입니다.
그래서 자바에서는 이렇게 길게 써야 합니다.
// 자바
int result = Calculator.Companion.add(1, 2);
Companion이라는 낯선 단어가
중간에 끼어드는 것이 보입니다.
자바 개발자 입장에서는
“이게 왜 이렇게 되지?” 하고 당황하게 됩니다.
@JvmStatic으로 해결하기
이때 함수 위에 @JvmStatic을 붙입니다.
class Calculator {
companion object {
@JvmStatic
fun add(a: Int, b: Int): Int {
return a + b
}
}
}
이제 자바에서는
진짜 static 메서드처럼 깔끔하게 부를 수 있습니다.
// 자바
int result = Calculator.add(1, 2);
Companion이 사라진 것이 보이시나요?
정리하면 이렇습니다.
| 상황 | 자바에서 부르는 모습 |
|---|---|
@JvmStatic 없음 | Calculator.Companion.add(1, 2) |
@JvmStatic 붙임 | Calculator.add(1, 2) |
코틀린 쪽에서 부르는 방법은
둘 다 Calculator.add(1, 2)로 똑같습니다.
즉, @JvmStatic은
코틀린 사용자에게는 아무 영향이 없고
자바 사용자에게만 편리함을 줍니다.
26.3 @JvmField
코틀린의 프로퍼티 복습
7장에서 배운 프로퍼티(property)를 떠올려 봅시다.
코틀린에서 클래스에 값을 하나 두면,
겉으로는 단순한 변수처럼 보입니다.
class User {
var name: String = "홍길동"
}
하지만 실제로는
보이지 않는 두 함수가 함께 만들어집니다.
- 값을 읽는 함수: getter
- 값을 바꾸는 함수: setter
게터(getter)는 값을 꺼내 오는 함수,
세터(setter)는 값을 넣어 주는 함수입니다.
코틀린에서는 이 둘을 자동으로 만들어 주기 때문에
우리는 그냥 user.name처럼 편하게 씁니다.
자바에서는 어떻게 보일까
자바에서 이 프로퍼티를 쓰면
숨어 있던 게터와 세터가 그대로 드러납니다.
// 자바
User user = new User();
user.setName("이순신"); // 값 넣기
String name = user.getName(); // 값 읽기
user.name처럼 바로 못 쓰고getName(), setName()을 거쳐야 합니다.
대부분의 경우 이 방식이 오히려 안전합니다.
그래서 보통은 그대로 두는 편이 좋습니다.
@JvmField로 필드 직접 열기
하지만 가끔은
게터/세터 없이 값에 바로 접근하고 싶을 때가 있습니다.
이때 프로퍼티 위에 @JvmField를 붙입니다.
class User {
@JvmField
var name: String = "홍길동"
}
그러면 게터/세터가 만들어지지 않고,
자바에서 필드에 바로 접근하게 됩니다.
// 자바
User user = new User();
user.name = "이순신"; // 필드에 바로 넣기
String name = user.name; // 필드에서 바로 읽기
두 방식을 비교하면 이렇습니다.
| 상황 | 자바에서 접근하는 모습 |
|---|---|
@JvmField 없음 | user.getName() / user.setName(...) |
@JvmField 붙임 | user.name |
@JvmField는 게터/세터를 없애고
필드를 그대로 노출합니다.
한 가지 주의할 점이 있습니다.@JvmField를 붙이면 게터/세터가 사라지므로,
나중에 값을 검사하는 로직을 게터에 넣기 어려워집니다.
그래서 꼭 필요한 상수 같은 경우가 아니면
기본 방식(게터/세터)을 그대로 두는 편이 안전합니다.
26.4 @JvmOverloads
코틀린의 기본값 매개변수 복습
4장에서 배운 기본값(default value)을 떠올려 봅시다.
코틀린에서는 매개변수에
기본값을 미리 정해 둘 수 있습니다.
fun greet(name: String = "손님", greeting: String = "안녕하세요") {
println("$greeting, $name 님")
}
덕분에 인자를 생략하고 부를 수 있습니다.
greet() // 안녕하세요, 손님 님
greet("홍길동") // 안녕하세요, 홍길동 님
greet("홍길동", "반갑습니다") // 반갑습니다, 홍길동 님
인자를 몇 개 넣든
코틀린이 알아서 빈자리를 기본값으로 채워 줍니다.
자바에는 기본값이 없다
그런데 자바에는
이런 “매개변수 기본값” 기능이 아예 없습니다.
그래서 자바에서 위 함수를 부르면,
기본값을 쓰지 못하고 항상 두 인자를 모두 넣어야 합니다.
// 자바
greet("홍길동", "반갑습니다"); // 두 개 다 넣어야 함
greet("홍길동"); // 오류: 이런 메서드 없음
greet(); // 오류: 이런 메서드 없음
코틀린에서 편했던 “생략하고 부르기“가
자바에서는 통하지 않는 것입니다.
@JvmOverloads로 여러 버전 만들기
이때 함수 위에 @JvmOverloads를 붙입니다.
@JvmOverloads
fun greet(name: String = "손님", greeting: String = "안녕하세요") {
println("$greeting, $name 님")
}
그러면 컴파일러가
인자 개수별로 여러 버전의 메서드를 자동으로 만들어 줍니다.
여기서 오버로드(overload)란
“이름은 같지만 매개변수가 다른 메서드를 여러 개 두는 것“을 뜻합니다.
만들어지는 자바 메서드는 이렇게 세 개입니다.
// 자바에서 보이는 모습 (자동 생성)
void greet();
void greet(String name);
void greet(String name, String greeting);
이제 자바에서도
인자를 생략하며 부를 수 있습니다.
// 자바
greet(); // 안녕하세요, 손님 님
greet("홍길동"); // 안녕하세요, 홍길동 님
greet("홍길동", "반갑습니다"); // 반갑습니다, 홍길동 님
정리하면 이렇습니다.
@JvmOverloads는
기본값 조합마다 자바 메서드를 만들어 주어
자바에서도 인자를 생략할 수 있게 해 줍니다.
26.5 @JvmName
이름이 부딪히는 경우
가끔 코틀린에서는 문제가 없는데
자바에서는 이름이 충돌하는 경우가 있습니다.
대표적인 예가 시그니처 충돌입니다.
시그니처(signature)란
“함수의 이름 + 매개변수 목록“을 합친,
함수를 구분하는 이름표라고 생각하면 됩니다.
아래 두 함수를 봅시다.
fun List<Int>.sum(): Int {
// 정수 합계
return this.fold(0) { acc, n -> acc + n }
}
fun List<String>.sum(): String {
// 문자열 이어 붙이기
return this.joinToString("")
}
코틀린에서는 이 둘을 구분할 수 있습니다.List<Int>인지 List<String>인지 알기 때문입니다.
하지만 자바로 번역되면
제네릭 정보가 흐려져서 두 함수의 이름표가 똑같아집니다.
그래서 “이름이 겹친다“는 오류가 납니다.
(제네릭은 20장에서 자세히 다룹니다.)
@JvmName으로 이름 바꾸기
이때 @JvmName으로
자바에서 보이는 이름만 다르게 지정합니다.
@JvmName("sumOfInt")
fun List<Int>.sum(): Int {
return this.fold(0) { acc, n -> acc + n }
}
@JvmName("sumOfString")
fun List<String>.sum(): String {
return this.joinToString("")
}
이제 자바에서는
서로 다른 이름으로 부를 수 있습니다.
// 자바
int total = MyUtilsKt.sumOfInt(numbers);
String joined = MyUtilsKt.sumOfString(words);
한편 코틀린에서는
여전히 원래 이름 sum으로 부릅니다.
val total = numbers.sum()
val joined = words.sum()
정리하면 이렇습니다.
@JvmName은
코틀린 이름은 그대로 두고,
자바에서 보이는 이름만 바꿔 줍니다.
@JvmName은 다음 절에서 볼
파일 단위 이름을 바꿀 때도 쓰입니다.
26.6 Top-level Function (파일명Kt 클래스)
톱레벨 함수 복습
1장과 4장에서 배운
톱레벨 함수(top-level function)를 떠올려 봅시다.
코틀린에서는 클래스 밖,
즉 파일에 함수를 바로 놓을 수 있었습니다.
// 파일 이름: MathUtils.kt
fun square(x: Int): Int {
return x * x
}
코틀린에서는 아주 자연스럽게 부릅니다.
val result = square(5)
클래스 없이 함수만 덜렁 있으니
코드가 무척 간결합니다.
자바에는 클래스 밖 함수가 없다
그런데 자바에는
“클래스 밖의 함수“라는 개념이 없습니다.
자바의 모든 메서드는
반드시 어떤 클래스 안에 들어 있어야 합니다.
그래서 코틀린 컴파일러는
톱레벨 함수를 담을 클래스를 하나 몰래 만들어 줍니다.
규칙은 간단합니다.
“파일 이름 + Kt“가 클래스 이름이 됩니다.
MathUtils.kt 파일에 있던 함수는MathUtilsKt라는 클래스의 static 메서드가 됩니다.
그래서 자바에서는 이렇게 부릅니다.
// 자바
int result = MathUtilsKt.square(5);
파일 이름에서 온 MathUtilsKt가
클래스 이름으로 등장하는 것이 보입니다.
@JvmName으로 클래스 이름 바꾸기
~Kt라는 이름이 어색하게 느껴질 수 있습니다.
이때 파일 맨 위에서 @file:JvmName을 쓰면
그 클래스 이름을 우리가 원하는 대로 바꿀 수 있습니다.
@file:JvmName("MathUtils")
package com.example.util
fun square(x: Int): Int {
return x * x
}
맨 앞의 @file:은
“이 어노테이션은 파일 전체에 적용한다“는 표시입니다.
이제 자바에서는
깔끔한 이름으로 부를 수 있습니다.
// 자바
int result = MathUtils.square(5);
정리하면 이렇습니다.
| 상황 | 자바에서 부르는 모습 |
|---|---|
기본 (파일명 MathUtils.kt) | MathUtilsKt.square(5) |
@file:JvmName("MathUtils") | MathUtils.square(5) |
26.7 Java 친화적인 Kotlin API 만들기
API란 무엇인가
지금까지 배운 것을 하나로 묶어 봅시다.
여기서 API(Application Programming Interface)란
“남이 가져다 쓰라고 열어 둔 코드의 겉모습“입니다.
내가 만든 코틀린 함수와 클래스를
자바 개발자가 불러 쓴다면,
그것은 그 사람에게 하나의 API가 됩니다.
좋은 API는
쓰는 사람이 헷갈리지 않게 만든 API입니다.
그래서 자바에서도 쓸 코틀린 코드라면,
자바 쪽에서 어떻게 보일지 미리 챙겨 두어야 합니다.
체크리스트
이 장에서 배운 어노테이션을
“자바에서 어색할 때 붙이는 처방“으로 정리해 봅시다.
| 자바에서 어색한 점 | 붙일 처방 |
|---|---|
Companion이 끼어들어 지저분함 | @JvmStatic |
getX() / setX()가 번거로움 | @JvmField |
| 기본값을 못 써서 인자를 다 넣어야 함 | @JvmOverloads |
| 이름이 겹치거나 어색함 | @JvmName |
이 표 하나만 기억해 두어도
대부분의 상황에 대응할 수 있습니다.
하나로 모아 보기
여러 처방을 함께 쓴 예를 봅시다.
@file:JvmName("UserService")
package com.example.user
class User {
companion object {
@JvmStatic
fun createGuest(): User {
return User()
}
}
@JvmOverloads
fun greet(greeting: String = "안녕하세요") {
println(greeting)
}
}
이 코드를 자바에서 쓰면 이렇게 됩니다.
// 자바
User guest = User.createGuest(); // @JvmStatic 덕분에 깔끔
guest.greet(); // @JvmOverloads 덕분에 생략 가능
guest.greet("반갑습니다"); // 인자를 넣어도 됨
어노테이션이 없었다면User.Companion.createGuest()처럼 지저분했을 것입니다.
언제 신경 쓰고, 언제 넘어갈까
마지막으로 균형을 잡아 봅시다.
- 코틀린만 쓰는 프로젝트라면
이 어노테이션들은 대부분 필요 없습니다. - 자바와 코틀린을 섞어 쓰거나,
자바에서 쓸 라이브러리를 만든다면
이 어노테이션들이 큰 도움이 됩니다.
즉, 무조건 다 붙일 필요는 없습니다.
“이 코드를 자바에서도 부를까?”
이 질문에 “그렇다“일 때만 처방을 꺼내면 됩니다.
이렇게 자바 쪽을 배려하는 습관이
두 언어가 섞인 실무 프로젝트에서
당신을 신뢰받는 개발자로 만들어 줍니다.
26장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 코틀린 코드도 결국 바이트코드로 번역되어,
자바에서는 “또 하나의 자바 클래스“로 보인다는 점 @JvmStatic으로 companion object 멤버를
자바의 static처럼 깔끔하게 부르게 하는 방법@JvmField로 게터/세터 없이
필드에 바로 접근하게 하는 방법@JvmOverloads로 기본값 조합마다
자바 메서드를 만들어 주는 방법@JvmName으로 자바에서 보이는
함수 이름과 클래스 이름을 바꾸는 방법- 톱레벨 함수가
파일명Kt클래스로
번역된다는 점과 그 이름을 바꾸는 방법
핵심은 하나입니다.
“이 코드를 자바에서도 부를까?“를 먼저 묻는 것입니다.
그 답이 “그렇다“일 때,
이 장의 처방들을 하나씩 꺼내 쓰면 됩니다.
다음 장에서는
코틀린을 더 깊이 있게 다루는 주제로 넘어갑니다.
27장. Annotation
코드를 짜다 보면@Override, @Test 같은
골뱅이(@)가 붙은 표시를 보게 됩니다.
이것을 애너테이션(Annotation)이라고 합니다.
애너테이션은 코드에 붙이는 “표식“입니다.
코드의 동작을 직접 바꾸지는 않지만,
“이 코드는 이런 성격이다“라는 정보를 남깁니다.
비유하자면 이렇습니다.
애너테이션은 코드에 붙이는 “포스트잇“입니다.
내용물은 그대로지만,
보는 사람(또는 도구)에게 메모를 남깁니다.
스프링을 배우면@Controller, @Service 같은 애너테이션을
아주 많이 쓰게 됩니다.
그 기초를 여기서 다집니다.
27.1 Annotation 사용하기
애너테이션은 @와 이름으로 씁니다.
대상이 되는 코드 바로 앞에 붙입니다.
class Example {
@Deprecated("이제 newMethod()를 사용하세요")
fun oldMethod() {
println("옛날 방식")
}
fun newMethod() {
println("새 방식")
}
}
@Deprecated는 “이 코드는 이제 권장하지 않는다“는
표식입니다.
IDE는 이 표식을 보고oldMethod()에 취소선을 그어 줍니다.
이처럼 애너테이션은
사람과 도구에게 정보를 전달합니다.
27.2 Annotation 정의하기
애너테이션도 직접 만들 수 있습니다.annotation class로 선언합니다.
annotation class Loggable
이렇게 만든 애너테이션은
바로 붙여 쓸 수 있습니다.
@Loggable
fun processOrder() {
// ...
}
값을 담는 애너테이션도 만들 수 있습니다.
annotation class Role(val name: String)
@Role("ADMIN")
fun deleteUser() {
// ...
}
애너테이션이 담은 값은
나중에 리플렉션으로 읽습니다. (28장)
27.3 Target과 Retention
애너테이션을 정의할 때
두 가지 성질을 정할 수 있습니다.
첫째, Target입니다.
“이 애너테이션을 어디에 붙일 수 있는가“입니다.
@Target(AnnotationTarget.FUNCTION)
annotation class OnlyForFunction
AnnotationTarget.FUNCTION은
“함수에만 붙일 수 있다“는 뜻입니다.
클래스, 프로퍼티 등 다른 대상도 지정할 수 있습니다.
둘째, Retention입니다.
“이 애너테이션이 언제까지 남아 있는가“입니다.
| Retention | 의미 |
|---|---|
| SOURCE | 컴파일 후 사라짐 (도구/검사용) |
| BINARY | 클래스 파일에는 남지만 실행 시 못 읽음 |
| RUNTIME | 실행 중에도 읽을 수 있음 |
@Retention(AnnotationRetention.RUNTIME)
annotation class Loggable
실행 중에 리플렉션으로 읽으려면
반드시 RUNTIME이어야 합니다.
27.4 Kotlin의 Use-site Target
여기서 코틀린만의 특징이 나옵니다.
7장에서 배웠듯,
코틀린의 프로퍼티 하나는
사실 여러 요소로 이루어집니다.
- 뒤에서 값을 저장하는 필드(field)
- 값을 읽는 게터(getter)
- 값을 쓰는 세터(setter)
- 생성자 매개변수(parameter)
프로퍼티에 애너테이션을 붙이면
“이 중 어디에 붙일 것인가“가 문제가 됩니다.
그래서 코틀린은
붙일 위치를 콕 집어 줄 수 있게 했습니다.
이를 use-site target이라고 합니다.
class User(
@field:NotBlank val name: String, // 필드에
@get:JvmName("getEmail") val email: String // 게터에
)
주요 표식은 다음과 같습니다.
@field:: 필드에 붙임@get:: 게터에 붙임@set:: 세터에 붙임@param:: 생성자 매개변수에 붙임
27.5 DTO Validation을 예로 이해하기
이 개념이 실무에서 왜 중요한지
검증(Validation)을 예로 봅시다.
DTO의 값이 올바른지 검사할 때
애너테이션을 자주 씁니다. (DTO는 9장·33장 참고)
data class SignUpRequest(
@field:NotBlank
val name: String,
@field:Email
val email: String
)
여기서 @field:를 붙인 이유가 있습니다.
검증 라이브러리는 대개
객체의 “필드“를 검사합니다.
그래서 애너테이션이 필드에 붙어야
제대로 동작합니다.
만약 위치를 지정하지 않으면
애너테이션이 엉뚱한 곳(예: 생성자 매개변수)에만 붙어
검증이 동작하지 않을 수 있습니다.
이것이 바로
“자바와 다르게 동작하는 이유“입니다.
자바는 필드에 직접 애너테이션을 붙이지만,
코틀린은 프로퍼티가 여러 요소로 나뉘기 때문에
위치를 명시해 줘야 하는 것입니다.
코틀린에서 검증이 안 될 때는
@field:가 빠지지 않았는지 먼저 의심해 보세요.
27장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 애너테이션은 코드에 붙이는 표식이라는 점
annotation class로 직접 정의하는 법- Target(어디에)과 Retention(언제까지)의 의미
- 코틀린 프로퍼티는 여러 요소로 나뉘어 use-site target이 필요하다는 점
- DTO 검증에서
@field:가 중요한 이유
애너테이션이 담은 정보는
“리플렉션“으로 읽어야 실제로 활용됩니다.
다음 장에서 리플렉션을 배웁니다.
28장. Reflection
보통 우리는 코드를 이렇게 씁니다.user.name처럼 이름을 직접 적어 값을 꺼냅니다.
그런데 가끔은
“이 객체에 어떤 프로퍼티가 있는지“조차
실행 중에 알아내야 할 때가 있습니다.
이렇게 프로그램이
실행 중에 자기 자신의 구조를 들여다보는 것을
리플렉션(Reflection)이라고 합니다.
비유하자면 이렇습니다.
리플렉션은 프로그램이
거울로 자기 모습을 비춰 보는 것입니다.
“나는 어떤 클래스지? 어떤 함수를 가졌지?”
스프링 같은 프레임워크는
이 리플렉션 위에서 동작합니다.
그 원리를 여기서 엿봅니다.
28.1 Reflection이란
리플렉션을 쓰면
클래스의 이름, 프로퍼티, 함수 등을
실행 중에 알아낼 수 있습니다.
class User(val name: String, val age: Int)
val user = User("홍길동", 20)
println(user::class) // class User
println(user::class.simpleName) // User
::class는 “이 객체의 클래스 정보“를 가져옵니다.
이름을 직접 적지 않고도
클래스 정보를 손에 쥘 수 있습니다.
28.2 Java Reflection과 Kotlin Reflection
리플렉션에는 두 종류가 있습니다.
- 자바 리플렉션 :
Class,Field,Method - 코틀린 리플렉션 :
KClass,KProperty,KFunction
코틀린 리플렉션은
코틀린만의 특징(프로퍼티, null 여부 등)까지
이해합니다.
두 세계를 오갈 수도 있습니다.
val kClass = User::class // 코틀린 KClass
val javaClass = User::class.java // 자바 Class
코틀린 코드에서는
대개 코틀린 리플렉션을 씁니다.
28.3 KClass
KClass는 코틀린에서
“클래스 그 자체“를 나타내는 타입입니다.
::class로 얻습니다.
val kClass = User::class
println(kClass.simpleName) // User
println(kClass.isData) // false (data class 여부)
KClass를 통해
그 클래스의 프로퍼티, 함수, 애너테이션 등
거의 모든 정보에 접근할 수 있습니다.
28.4 Property Reflection
클래스가 가진 프로퍼티들을
목록으로 훑을 수 있습니다.
val user = User("홍길동", 20)
for (prop in User::class.memberProperties) {
println("${prop.name} = ${prop.get(user)}")
}
// name = 홍길동
// age = 20
프로퍼티 이름을 미리 몰라도
모든 값을 꺼낼 수 있습니다.
이런 능력 덕분에
객체를 자동으로 JSON으로 바꾸거나,
로그로 남기는 도구를 만들 수 있습니다.
28.5 Function Reflection
함수도 같은 방식으로 다룰 수 있습니다.
class Greeter {
fun hello(name: String) = "안녕, $name"
}
val greeter = Greeter()
val func = Greeter::hello
println(func.call(greeter, "코틀린")) // 안녕, 코틀린
함수 이름을 문자열이 아닌
실제 참조로 다루고,call로 실행할 수 있습니다.
28.6 Annotation 조회하기
27장에서 붙인 애너테이션은
리플렉션으로 읽어야 활용됩니다.
이때 그 애너테이션은RUNTIME으로 유지되어야 합니다.
@Retention(AnnotationRetention.RUNTIME)
annotation class Role(val name: String)
@Role("ADMIN")
class AdminService
val role = AdminService::class.annotations
.filterIsInstance<Role>()
.firstOrNull()
println(role?.name) // ADMIN
“이 클래스에 어떤 표식이 붙어 있나“를
실행 중에 확인하는 것입니다.
프레임워크는 바로 이 방식으로@Controller, @Service 같은 표식을 찾아냅니다.
28.7 Reflection의 비용과 사용 시 주의점
리플렉션은 강력하지만 공짜가 아닙니다.
- 일반 호출보다 느립니다
- 컴파일 시점의 안전성이 약해집니다 (오타가 나도 실행 중에야 오류가 드러남)
- 코드가 이해하기 어려워질 수 있습니다
그래서 원칙은 이렇습니다.
평범한 코드는 리플렉션 없이 짠다.
리플렉션은 “이름을 미리 알 수 없는” 경우에만 쓴다.
대부분의 일상 코드에서는
리플렉션이 필요 없습니다.
28.8 프레임워크가 객체를 다루는 방식 이해하기
이제 큰 그림이 보입니다.
스프링 같은 프레임워크는
우리가 만든 클래스를 미리 알지 못합니다.
그런데도 우리 객체를 만들고 연결해 줍니다.
어떻게 가능할까요?
바로 리플렉션 덕분입니다.
@Service같은 애너테이션을 리플렉션으로 찾고- 그 클래스의 생성자를 리플렉션으로 확인하고
- 필요한 객체를 만들어 넣어 줍니다
이 과정을 이해하면
“프레임워크가 마법이 아니라
리플렉션이라는 기술 위에 서 있다“는 것을
알게 됩니다.
리플렉션을 직접 쓸 일은 적지만,
이 원리를 알면 프레임워크가 훨씬 덜 신비롭게 보입니다.
28장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 리플렉션은 실행 중에 자기 구조를 들여다보는 기술이라는 점
KClass로 클래스 정보에 접근하는 법- 프로퍼티와 함수, 애너테이션을 리플렉션으로 읽는 법
- 리플렉션은 비용이 있어 꼭 필요할 때만 쓴다는 점
- 프레임워크가 리플렉션 위에서 동작한다는 원리
이것으로 9부(자바 생태계)를 마칩니다.
다음 10부에서는
프로젝트를 구성하고 테스트하는 법을 배웁니다.
29장. Gradle과 Kotlin 프로젝트
지금까지는 작은 코드 조각을 실행해 봤습니다.
하지만 실제 프로젝트는 파일이 수십, 수백 개입니다.
이런 프로젝트를 어떻게 조립하고,
필요한 라이브러리를 어떻게 가져올까요?
이 일을 해 주는 것이 빌드 도구(build tool)입니다.
코틀린 백엔드에서는 주로 Gradle을 씁니다.
이 장은 문법을 외우는 장이 아닙니다.build.gradle.kts 파일을 열었을 때
“무슨 말인지 읽을 수 있게” 만드는 것이 목표입니다.
29.1 Gradle이 필요한 이유
프로그램 하나를 만들려면
여러 단계를 거쳐야 합니다.
- 필요한 라이브러리 내려받기
- 소스 코드 컴파일하기
- 테스트 실행하기
- 실행 가능한 결과물로 묶기
이걸 매번 손으로 하면 너무 번거롭습니다.
빌드 도구는 이 과정을 자동으로 처리합니다.
비유하자면 이렇습니다.
Gradle은 부품을 모아
자동으로 조립해 주는 공장입니다.
우리는 “설계도“만 적어 주면 됩니다.
그 설계도가 바로 build.gradle.kts입니다.
29.2 Gradle Wrapper
프로젝트를 받으면gradlew라는 파일이 들어 있습니다.
이것을 Gradle Wrapper라고 합니다.
Wrapper는
“이 프로젝트에 맞는 Gradle 버전“을
자동으로 내려받아 실행해 줍니다.
./gradlew build # 맥/리눅스
gradlew.bat build # 윈도우
덕분에 Gradle을 따로 설치하지 않아도,
누구나 같은 버전으로 빌드할 수 있습니다.
팀원마다 Gradle 버전이 달라 생기는 문제를
Wrapper가 막아 줍니다.
29.3 build.gradle.kts
build.gradle.kts는 프로젝트의 설계도입니다.
확장자 .kts는 “코틀린 스크립트“라는 뜻입니다.
즉, 이 설정 파일도 코틀린 문법으로 씁니다.
(이것을 Kotlin DSL이라고 부릅니다.)
전형적인 파일은 이렇게 생겼습니다.
plugins {
kotlin("jvm") version "2.0.0"
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
이제 각 블록을 하나씩 읽어 봅시다.
29.4 Plugin
plugins 블록은
“이 프로젝트에 어떤 기능을 더할지” 정합니다.
plugins {
kotlin("jvm") version "2.0.0"
}
kotlin("jvm")은
“코틀린을 JVM용으로 빌드하는 기능“을 켭니다.
플러그인은
“Gradle에 끼우는 확장 부품“이라고 보면 됩니다.
스프링을 쓸 때는 스프링 플러그인을 여기에 추가합니다.
29.5 Repository
repositories 블록은
“라이브러리를 어디서 가져올지” 정합니다.
repositories {
mavenCentral()
}
mavenCentral()은
전 세계 개발자가 쓰는 대형 라이브러리 저장소입니다.
Gradle은 여기서
필요한 라이브러리를 자동으로 내려받습니다.
29.6 Dependency
dependencies 블록은
“어떤 라이브러리가 필요한지” 적습니다.
dependencies {
implementation("com.google.code.gson:gson:2.11.0")
testImplementation(kotlin("test"))
}
의존성(dependency)이란
“내 코드가 기대어 쓰는 다른 라이브러리“입니다.
앞에 붙는 키워드가 용도를 정합니다.
| 키워드 | 의미 |
|---|---|
| implementation | 일반 코드에서 사용 |
| testImplementation | 테스트 코드에서만 사용 |
29.7 Configuration
방금 본 implementation, testImplementation을
설정(Configuration)이라고 부릅니다.
이는 “이 라이브러리를 어떤 범위에서 쓸 것인가“를
나누는 이름표입니다.
예를 들어 테스트에만 필요한 라이브러리를testImplementation으로 두면,
실제 배포 결과물에는 포함되지 않습니다.
덕분에 결과물이 불필요하게 무거워지지 않습니다.
29.8 Task
Gradle이 실제로 수행하는
하나하나의 작업을 태스크(Task)라고 합니다.
./gradlew build # 전체 빌드
./gradlew test # 테스트 실행
./gradlew clean # 이전 결과물 삭제
build, test, clean이 모두 태스크입니다.
플러그인을 추가하면
그에 맞는 태스크도 함께 늘어납니다.
29.9 Kotlin DSL 읽기
build.gradle.kts가 처음엔 낯설지만,
사실 우리가 배운 코틀린 문법 그대로입니다.
dependencies {
implementation("라이브러리 좌표")
}
이것은 사실dependencies라는 함수에
람다를 넘기는 코드입니다. (14장 참고)
그 람다 안에서implementation(...)이라는 함수를 부르는 것이고요.
그래서 코틀린을 알면
Gradle 설정도 “읽을 수 있는 코드“가 됩니다.
29.10 프로젝트 디렉터리 구조
마지막으로 표준 폴더 구조를 봅시다.
my-project
├─ build.gradle.kts 설계도
├─ settings.gradle.kts 프로젝트 이름 등
├─ gradlew Wrapper 실행 파일
└─ src
├─ main
│ └─ kotlin 실제 코드
└─ test
└─ kotlin 테스트 코드
핵심은 두 가지입니다.
- 실제 코드는
src/main/kotlin에 - 테스트 코드는
src/test/kotlin에
이 구조는 거의 모든 코틀린/자바 프로젝트에서
동일하게 쓰입니다.
29장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- Gradle이 빌드 과정을 자동화해 준다는 점
- Wrapper 덕분에 누구나 같은 버전으로 빌드한다는 점
build.gradle.kts의 plugins, repositories, dependencies 블록- 의존성과 Configuration으로 라이브러리 범위를 나누는 법
- 표준 디렉터리 구조(
src/main/kotlin,src/test/kotlin)
이제 프로젝트를 만들 수 있으니,
다음 장에서는 코드를 테스트하는 법을 배웁니다.
30장. Kotlin 코드 테스트하기
코드를 짜고 나면
“이게 정말 잘 동작할까?“가 궁금해집니다.
매번 프로그램을 실행해 손으로 확인하는 것은
느리고 실수도 잦습니다.
그래서 우리는 테스트 코드를 씁니다.
코드가 코드를 검사하게 만드는 것입니다.
이 장에서는 코틀린에서
테스트를 작성하는 기본기를 배웁니다.
30.1 테스트가 필요한 이유
테스트 코드가 있으면 좋은 점은 많습니다.
- 코드가 의도대로 동작하는지 자동으로 확인
- 나중에 코드를 고쳐도 망가지지 않았는지 검증
- “이 함수는 이렇게 동작한다“는 살아 있는 설명서
특히 마지막이 중요합니다.
좋은 테스트는
그 자체로 사용 설명서가 됩니다.
테스트를 읽으면
“이 코드를 어떻게 쓰는지“가 보입니다.
30.2 JUnit 5
코틀린 백엔드에서 가장 많이 쓰는
테스트 도구는 JUnit 5입니다.
29장에서 배운 대로build.gradle.kts에 의존성을 추가합니다.
dependencies {
testImplementation(kotlin("test"))
}
이제 src/test/kotlin 폴더에
테스트 코드를 작성하면 됩니다.
30.3 테스트 코드 작성하기
간단한 계산기를 테스트해 봅시다.
먼저 검사할 코드입니다.
class Calculator {
fun add(a: Int, b: Int): Int = a + b
}
이제 테스트입니다.
import kotlin.test.Test
import kotlin.test.assertEquals
class CalculatorTest {
@Test
fun add_두_수를_더한다() {
val calculator = Calculator()
val result = calculator.add(2, 3)
assertEquals(5, result)
}
}
@Test는 “이 함수는 테스트다“라는 표식입니다. (27장 애너테이션)assertEquals는 “두 값이 같은지 확인“합니다.
기대한 값과 실제 값이 다르면
테스트가 실패로 표시됩니다.
30.4 Given-When-Then
좋은 테스트에는 흐름이 있습니다.
많이 쓰는 방식이 Given-When-Then입니다.
- Given : 준비 (필요한 값과 객체를 만든다)
- When : 실행 (검사할 동작을 실행한다)
- Then : 검증 (결과가 기대와 같은지 확인한다)
@Test
fun 잔액에서_출금하면_잔액이_줄어든다() {
// Given
val account = Account(balance = 1000)
// When
account.withdraw(300)
// Then
assertEquals(700, account.balance)
}
이 세 단계로 나누면
테스트가 읽기 쉬워집니다.
30.5 Kotlin의 백틱 함수 이름
위 예제에서 함수 이름이 특이했습니다.
fun 잔액에서_출금하면_잔액이_줄어든다() { ... }
코틀린에서는 함수 이름을
백틱(`)으로 감싸면
공백이나 한글도 자유롭게 쓸 수 있습니다.
@Test
fun `잔액에서 출금하면 잔액이 줄어든다`() {
// ...
}
테스트 이름은 곧 설명이므로,
이렇게 문장으로 적으면
무엇을 검사하는지 한눈에 보입니다.
이 문법은 테스트 이름에 특히 잘 어울립니다.
일반 코드에서는 잘 쓰지 않습니다.
30.6 Assertion
검증에 쓰는 함수를
어서션(Assertion)이라고 합니다.
자주 쓰는 것들을 정리하면 이렇습니다.
| 함수 | 확인 내용 |
|---|---|
assertEquals(a, b) | 두 값이 같은가 |
assertTrue(x) | x가 참인가 |
assertFalse(x) | x가 거짓인가 |
assertNull(x) | x가 null인가 |
assertNotNull(x) | x가 null이 아닌가 |
assertTrue(user.isActive)
assertNotNull(user.email)
30.7 Test Fixture
여러 테스트가
같은 준비 과정을 반복할 때가 있습니다.
이 공통 준비를@BeforeEach로 묶을 수 있습니다.
class CalculatorTest {
private lateinit var calculator: Calculator
@BeforeEach
fun setUp() {
calculator = Calculator() // 각 테스트 전에 실행
}
@Test
fun `더하기`() {
assertEquals(5, calculator.add(2, 3))
}
}
@BeforeEach는
각 테스트가 실행되기 직전에 매번 호출됩니다.
덕분에 모든 테스트가
깨끗한 상태에서 시작합니다.
(lateinit은 5장에서 배웠습니다.)
30.8 예외 테스트
“이 상황에서는 예외가 나야 한다“도
테스트할 수 있습니다.
assertThrows를 씁니다.
@Test
fun `음수 나이는 예외가 발생한다`() {
assertThrows<IllegalArgumentException> {
checkAge(-1)
}
}
블록 안에서
지정한 예외가 발생하면 테스트 성공,
발생하지 않으면 실패입니다.
정상 동작뿐 아니라
“실패해야 하는 상황“도 검증하는 것이 중요합니다.
30.9 Parameterized Test
같은 로직을
여러 입력값으로 반복 검사하고 싶을 때가 있습니다.
이때 파라미터화 테스트(Parameterized Test)를 씁니다.
@ParameterizedTest
@ValueSource(ints = [2, 4, 6, 100])
fun `짝수인지 확인한다`(number: Int) {
assertTrue(number % 2 == 0)
}
[2, 4, 6, 100] 각각에 대해
테스트가 한 번씩 실행됩니다.
비슷한 테스트를 여러 번 복사하는 대신
하나로 깔끔하게 묶을 수 있습니다.
30장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 테스트가 왜 필요한지
- JUnit 5로 테스트를 작성하는 기본기
- Given-When-Then 흐름
- 백틱으로 읽기 좋은 테스트 이름 짓기
- 다양한 Assertion과 예외 테스트
@BeforeEach와 파라미터화 테스트
지금까지는 의존성이 없는 코드를 테스트했습니다.
다음 장에서는 다른 객체에 의존하는 코드를
어떻게 테스트하는지 배웁니다.
31장. 의존성이 있는 코드 테스트하기
30장에서 테스트의 기본기를 배웠습니다.
계산기처럼 혼자 동작하는 코드는 테스트하기 쉬웠습니다.
그런데 실제 백엔드 코드는
다른 객체에 기대어 동작합니다.
예를 들어 회원 가입 서비스는
데이터베이스(Repository)에 의존합니다.
이럴 때 진짜 데이터베이스를 쓰면
테스트가 느리고 불안정해집니다.
그래서 우리는 “가짜 대역“을 씁니다.
이 장에서 그 방법을 배웁니다.
31.1 Test Double
영화에서 위험한 장면은
배우 대신 스턴트 대역이 연기합니다.
테스트에서도 마찬가지입니다.
진짜 객체 대신 쓰는 “대역“을
테스트 더블(Test Double)이라고 합니다.
테스트 더블에는 여러 종류가 있습니다.
| 종류 | 한 줄 설명 |
|---|---|
| Dummy | 그냥 자리만 채우는 가짜 |
| Stub | 정해진 답만 돌려주는 가짜 |
| Fake | 간단하지만 실제로 동작하는 가짜 |
| Mock | 호출을 기록하고 검증하는 가짜 |
이제 하나씩 살펴봅니다.
먼저 예제에 쓸 인터페이스를 정합니다. (8장 참고)
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
31.2 Dummy
더미(Dummy)는
그냥 자리만 채우는 가짜입니다.
호출되지 않을 것을 알지만,
매개변수 자리를 채워야 할 때 씁니다.
class DummyUserRepository : UserRepository {
override fun findById(id: Long): User? = null
override fun save(user: User): User = user
}
내용은 거의 비어 있습니다.
“필요하지만 실제로 쓰이지는 않는” 역할입니다.
31.3 Stub
스텁(Stub)은
“정해진 답만 돌려주는” 가짜입니다.
특정 상황을 만들고 싶을 때 유용합니다.
class StubUserRepository : UserRepository {
override fun findById(id: Long): User? {
return User(id = 1, name = "홍길동") // 늘 같은 답
}
override fun save(user: User): User = user
}
“회원이 존재하는 상황“을
간단히 만들어 낼 수 있습니다.
31.4 Fake
페이크(Fake)는
간단하지만 실제로 동작하는 가짜입니다.
예를 들어 진짜 데이터베이스 대신
메모리(Map)에 저장하는 저장소를 만들 수 있습니다.
class FakeUserRepository : UserRepository {
private val storage = mutableMapOf<Long, User>()
override fun findById(id: Long): User? = storage[id]
override fun save(user: User): User {
storage[user.id] = user
return user
}
}
저장하면 진짜로 저장되고,
조회하면 저장한 값이 나옵니다.
다만 메모리에서만 동작하므로
빠르고 가볍습니다.
Fake는 백엔드 테스트에서 특히 자주 쓰입니다.
31.5 Mock
목(Mock)은
“어떤 호출이 일어났는지“를 기록하고
그것을 검증하는 데 쓰는 가짜입니다.
“save가 정말 호출됐는가?” 같은 것을
확인하고 싶을 때 유용합니다.
Mock은 보통 손으로 만들기보다
전문 라이브러리로 만듭니다.
코틀린에서는 MockK를 많이 씁니다.
31.6 Repository를 Fake로 교체하기
이제 실제 테스트를 해 봅시다.
검사할 서비스입니다. (Service는 36장에서 자세히 다룹니다)
class UserService(
private val userRepository: UserRepository
) {
fun register(name: String): User {
val user = User(id = 1, name = name)
return userRepository.save(user)
}
}
UserService는 UserRepository에 의존합니다.
테스트에서는 이 자리에 Fake를 넣습니다.
@Test
fun `회원을 등록하면 저장된다`() {
// Given
val fakeRepository = FakeUserRepository()
val userService = UserService(fakeRepository)
// When
val user = userService.register("홍길동")
// Then
assertEquals("홍길동", fakeRepository.findById(user.id)?.name)
}
진짜 데이터베이스 없이도
서비스 로직을 완전히 검증할 수 있습니다.
이것이 가능한 이유는UserService가 구현체가 아니라
인터페이스에 의존하기 때문입니다.
(8장에서 배운 원리입니다.)
31.7 MockK 맛보기
MockK는 코틀린용 목 라이브러리입니다.
dependencies {
testImplementation("io.mockk:mockk:1.13.11")
}
간단한 사용 예를 봅시다.
@Test
fun `조회 결과를 지정한다`() {
val repository = mockk<UserRepository>()
// findById(1)을 부르면 이 값을 돌려주도록 지정
every { repository.findById(1) } returns User(1, "홍길동")
val user = repository.findById(1)
assertEquals("홍길동", user?.name)
}
every { ... } returns ...로
“이 호출엔 이 답을 줘라“라고 지정합니다.
호출 여부를 검증할 수도 있습니다.
verify { repository.findById(1) }
MockK는 강력하지만,
Fake로 충분한 경우에는
Fake가 더 읽기 쉬울 때도 많습니다.
31.8 테스트하기 좋은 코드의 구조
여기서 중요한 깨달음이 있습니다.
테스트하기 어려운 코드는
대개 구조가 좋지 않은 코드입니다.
반대로,
테스트하기 쉬운 코드는 대개 구조가 좋습니다.
테스트하기 좋은 코드의 특징은 이렇습니다.
- 의존성을 생성자로 받는다 (36장 참고)
- 구현체가 아니라 인터페이스에 의존한다
- 하나의 객체가 하나의 책임만 가진다
“테스트하기 쉽게 짜라“는 말은
사실 “좋은 구조로 짜라“는 말과 같습니다.
이 감각은 11부(백엔드 구조)에서
본격적으로 다룹니다.
31장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 테스트 더블(Dummy, Stub, Fake, Mock)의 종류와 차이
- Fake Repository로 의존성을 교체하는 법
- 인터페이스 덕분에 교체가 가능하다는 점
- MockK로 목을 만드는 기본 사용법
- 테스트하기 좋은 코드가 곧 좋은 구조라는 점
이것으로 10부(프로젝트와 테스트)를 마칩니다.
다음 11부에서는 지금까지 배운 것을 모아
백엔드 애플리케이션의 구조를 만들어 봅니다.
32장. 백엔드 애플리케이션의 객체 나누기
지금까지 코틀린 문법을 두루 배웠습니다.
이제 그 문법을 “구조“로 엮을 차례입니다.
11부에서는 지금까지 배운 것을 모아
백엔드 프로그램의 뼈대를 만들어 봅니다.
중요한 점을 먼저 밝힙니다.
이 부에서는 웹 프레임워크(스프링)를 쓰지 않습니다.
순수 코틀린만으로 백엔드의 “구조“를 익힙니다.
프레임워크는 나중에 배웁니다.
그 전에, 프레임워크가 왜 그런 구조를 권하는지
직접 손으로 만들며 이해하는 것이 목표입니다.
32.1 회원과 주문 예제 설계
이 부에서 계속 쓸 예제는
“회원이 상품을 주문하는” 간단한 서비스입니다.
필요한 개념을 먼저 떠올려 봅시다.
- 회원(User)
- 상품(Product)
- 주문(Order)
그리고 이들을 다루는 동작이 필요합니다.
- 회원 등록
- 상품 등록과 조회
- 주문 생성과 조회
이 개념들을
서로 다른 “역할“의 객체로 나눌 것입니다.
32.2 Domain Object
가장 중심에 있는 것이 도메인 객체(Domain Object)입니다.
도메인 객체는
“우리 서비스의 핵심 개념“을 표현합니다.
회원, 상품, 주문이 여기에 해당합니다.
class User(
val id: Long,
val name: String,
val email: String
)
도메인 객체는 서비스의 심장입니다.
데이터베이스나 화면 같은
바깥 세계의 사정에 휘둘리지 않아야 합니다.
(도메인 설계는 34장에서 깊게 다룹니다.)
32.3 Request와 Response
바깥에서 들어오는 요청과
바깥으로 나가는 응답도 객체로 표현합니다.
- Request : 들어오는 데이터
- Response : 나가는 데이터
이런 객체를 통틀어
DTO(Data Transfer Object)라고 부릅니다.
data class CreateUserRequest(
val name: String,
val email: String
)
data class UserResponse(
val id: Long,
val name: String
)
왜 도메인 객체를 그대로 쓰지 않고
따로 DTO를 만들까요?
바깥에 보여 줄 정보와
내부에서 다루는 정보가 다르기 때문입니다.
(이 이유는 33장에서 자세히 다룹니다.)
32.4 Service
Service는
“실제 일을 처리하는” 객체입니다.
여러 도메인 객체와 저장소를 엮어
하나의 기능을 완성합니다.
class UserService(
private val userRepository: UserRepository
) {
fun register(request: CreateUserRequest): UserResponse {
val user = User(
id = 0,
name = request.name,
email = request.email
)
val saved = userRepository.save(user)
return UserResponse(saved.id, saved.name)
}
}
Service는 “흐름“을 담당합니다.
“요청을 받아 → 도메인을 만들고 → 저장하고 → 응답을 돌려준다“는
줄거리를 여기서 씁니다.
32.5 Repository
Repository는
“데이터를 저장하고 꺼내는” 역할입니다.
핵심은 인터페이스로 정의한다는 점입니다. (8장)
interface UserRepository {
fun save(user: User): User
fun findById(id: Long): User?
}
이렇게 해 두면
저장 방식(메모리, 데이터베이스 등)이 바뀌어도
Service 코드는 그대로 둘 수 있습니다.
(Repository 설계는 35장에서 다룹니다.)
32.6 객체의 역할과 책임
지금까지 나눈 객체들의 관계를
그림으로 정리하면 이렇습니다.
요청(Request)
│
▼
Service ← 흐름을 조율
│
├─▶ Domain Object ← 핵심 개념과 규칙
│
└─▶ Repository ← 저장/조회
│
▼
응답(Response)
각 객체는 하나의 책임만 집니다.
- DTO : 데이터를 실어 나른다
- Domain : 핵심 규칙을 담는다
- Service : 흐름을 조율한다
- Repository : 저장하고 꺼낸다
하나의 객체가 하나의 일만 하면,
코드가 이해하기 쉽고 고치기도 쉽습니다.
이렇게 역할을 나누는 것을
관심사의 분리(Separation of Concerns)라고 합니다.
32.7 패키지 구성하기
역할이 나뉘면
파일도 그 역할대로 폴더에 묶는 것이 좋습니다.
(패키지는 1장에서 배웠습니다.)
com.example.shop
├─ domain User, Product, Order
├─ dto
│ ├─ request CreateUserRequest ...
│ └─ response UserResponse ...
├─ repository UserRepository ...
└─ service UserService ...
이렇게 나누면
“회원 관련 코드가 어디 있지?“를 찾기 쉽습니다.
패키지 구조는 곧
프로젝트의 지도가 됩니다.
32장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 백엔드 객체를 역할별로 나누는 이유
- Domain, DTO, Service, Repository의 역할
- 요청에서 응답까지의 흐름
- 관심사의 분리라는 원칙
- 역할에 맞춘 패키지 구성
다음 장부터는 이 각 역할을
하나씩 깊이 있게 설계해 봅니다.
먼저 DTO입니다.
33장. DTO 설계하기
32장에서 요청과 응답을
DTO라는 객체로 표현한다고 했습니다.
DTO는 Data Transfer Object,
“데이터를 실어 나르는 객체“입니다.
이 장에서는 DTO를
어떻게 잘 설계하는지 구체적으로 다룹니다.
DTO는 단순해 보이지만,
잘 설계하면 서비스 전체가 깔끔해집니다.
33.1 요청 객체 만들기
요청 객체(Request DTO)는
바깥에서 들어오는 데이터를 담습니다.
data class CreateUserRequest(
val name: String,
val email: String,
val password: String
)
data class로 만드는 것이 자연스럽습니다. (9장)
값을 담는 것이 주 역할이기 때문입니다.
요청 객체는
“클라이언트가 우리에게 보내는 양식“이라고
생각하면 됩니다.
33.2 응답 객체 만들기
응답 객체(Response DTO)는
바깥으로 내보낼 데이터를 담습니다.
data class UserResponse(
val id: Long,
val name: String,
val email: String
)
여기서 중요한 점이 있습니다.CreateUserRequest에는 있던 password가UserResponse에는 없습니다.
비밀번호는 절대 응답으로 내보내면 안 되니까요.
요청에 담는 것과 응답에 담는 것은
서로 다르다.
이 차이 때문에 요청과 응답을
별도의 객체로 나눕니다.
33.3 Data Class 활용하기
DTO에 data class가 잘 맞는 이유는
9장에서 배운 기능들 덕분입니다.
equals/hashCode: 값 비교가 쉬움toString: 로그 찍기 좋음copy: 일부만 바꾼 새 객체 만들기 쉬움
val request = CreateUserRequest("홍길동", "hong@test.com", "1234")
println(request) // 값이 그대로 출력되어 디버깅에 편리
DTO는 “값 그 자체“가 중요하므로data class의 장점을 그대로 누립니다.
33.4 Nullable Field 결정하기
DTO를 만들 때
“이 필드가 null일 수 있는가“를
신중히 정해야 합니다. (5장 null 안전성)
data class UpdateUserRequest(
val name: String?, // 안 바꿀 수도 있으니 null 허용
val email: String?
)
“반드시 있어야 하는 값“은 non-null로,
“없을 수도 있는 값“만 nullable로 둡니다.
습관적으로 모든 필드를 nullable로 두지 마세요.
null 허용은 “정말 없을 수 있을 때“만.
타입만 봐도
어떤 값이 필수인지 드러나게 하는 것이 좋습니다.
33.5 Default Parameter 사용하기
기본값이 있는 필드는
기본 인자로 처리하면 편리합니다. (4장)
data class SearchRequest(
val keyword: String,
val page: Int = 1,
val size: Int = 20
)
page와 size를 넘기지 않으면
자동으로 1과 20이 쓰입니다.
SearchRequest(keyword = "노트북") // page=1, size=20
SearchRequest(keyword = "노트북", page = 2) // size=20
기본 인자 덕분에
불필요한 오버로딩 없이 유연해집니다.
33.6 Domain Object를 그대로 응답하지 않기
가장 흔한 실수 중 하나가
도메인 객체를 그대로 응답으로 내보내는 것입니다.
// 나쁜 예: 도메인 객체를 그대로 반환
fun getUser(id: Long): User {
return userRepository.findById(id)!!
}
이렇게 하면 문제가 생깁니다.
- 비밀번호 같은 민감한 정보가 노출될 수 있음
- 도메인이 바뀌면 응답 형태도 딸려서 바뀜
- 내부 구조가 바깥에 그대로 드러남
그래서 도메인은
반드시 응답 DTO로 변환해서 내보냅니다.
33.7 Domain과 DTO 변환하기
변환은 보통 이렇게 합니다.
fun toResponse(user: User): UserResponse {
return UserResponse(
id = user.id,
name = user.name,
email = user.email
)
}
도메인에서 필요한 값만 골라
응답 객체를 만듭니다.
반대로 요청을 도메인으로 바꾸는 변환도 있습니다.
fun CreateUserRequest.toDomain(): User {
return User(id = 0, name = name, email = email)
}
이렇게 요청 → 도메인 → 저장 → 응답으로
데이터가 흐릅니다.
33.8 Extension Function으로 변환 코드 정리하기
변환 코드가 여기저기 흩어지면 지저분해집니다.
확장 함수(17장)로 깔끔하게 모을 수 있습니다.
fun User.toResponse(): UserResponse =
UserResponse(id = id, name = name, email = email)
fun CreateUserRequest.toDomain(): User =
User(id = 0, name = name, email = email)
이제 변환이 아주 자연스럽게 읽힙니다.
val user = request.toDomain()
val saved = userRepository.save(user)
return saved.toResponse()
request.toDomain(), saved.toResponse()처럼
마치 원래 있던 기능처럼 부를 수 있습니다.
변환 로직을 확장 함수로 모아 두면
Service 코드가 훨씬 읽기 좋아집니다.
33장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 요청과 응답 DTO를 나누는 이유
data class가 DTO에 잘 맞는 이유- Nullable 필드와 기본 인자를 신중히 정하기
- 도메인 객체를 그대로 응답하지 않기
- 확장 함수로 변환 코드를 깔끔하게 모으기
다음 장에서는
서비스의 심장인 도메인 객체를 설계합니다.
34장. 도메인 객체 설계하기
도메인 객체는 서비스의 심장입니다.
회원, 상품, 주문 같은 핵심 개념을 담습니다.
DTO가 “데이터를 나르는 그릇“이라면,
도메인 객체는 “규칙을 지키는 주인공“입니다.
이 장에서는 도메인 객체를
어떻게 튼튼하게 설계하는지 배웁니다.
핵심 주제는 하나입니다.
잘못된 상태가 애초에 만들어지지 않게 하라.
34.1 데이터를 담는 객체와 행동을 가진 객체
객체에는 두 가지 성격이 있습니다.
- 데이터만 담는 객체 (예: DTO)
- 데이터 + 행동을 함께 가진 객체 (예: 도메인)
DTO는 값만 실어 나르면 됩니다.
하지만 도메인은 다릅니다.
도메인은 “자기 규칙“을 스스로 지켜야 합니다.
class Order(
val id: Long,
val items: List<OrderItem>,
var status: OrderStatus
) {
fun cancel() {
if (status == OrderStatus.COMPLETED) {
throw IllegalStateException("완료된 주문은 취소할 수 없습니다")
}
status = OrderStatus.CANCELED
}
}
cancel()이라는 행동이
“완료된 주문은 취소 불가“라는 규칙을 품고 있습니다.
이렇게 규칙을 객체 안에 두는 것이
좋은 도메인 설계의 시작입니다.
34.2 상태를 외부에서 변경하지 못하게 하기
도메인의 상태를
바깥에서 마음대로 바꾸면 규칙이 깨집니다.
// 나쁜 예: 바깥에서 아무렇게나 바꿀 수 있음
order.status = OrderStatus.CANCELED
이러면 cancel()의 규칙을 우회하게 됩니다.
그래서 상태 변경은
반드시 메서드를 통하게 만듭니다.
바깥에서 직접 못 바꾸게 막는 것입니다. (7장 접근 제어)
class Order(
val id: Long,
val items: List<OrderItem>,
status: OrderStatus
) {
var status: OrderStatus = status
private set // 바깥에서 쓰기 금지, 읽기만 허용
fun cancel() { ... }
}
private set 덕분에
상태는 오직 cancel() 같은 메서드로만 바뀝니다.
34.3 생성 시점에 유효한 객체 만들기
가장 좋은 방어는
“애초에 잘못된 객체를 못 만들게” 하는 것입니다.
생성 시점에 검증을 넣습니다.init 블록을 활용합니다. (7장)
class User(
val id: Long,
val name: String,
val email: String
) {
init {
require(name.isNotBlank()) { "이름은 비어 있을 수 없습니다" }
require(email.contains("@")) { "올바른 이메일이 아닙니다: $email" }
}
}
require는 조건이 거짓이면
예외를 던지는 함수입니다.
이제 이름이 비었거나
이메일 형식이 틀린 User는
아예 만들어지지 않습니다.
유효하지 않은 객체가 존재할 수 없게 만들면,
그 뒤 코드에서는 안심하고 쓸 수 있습니다.
34.4 Enum으로 상태 표현하기
주문 상태처럼
“정해진 몇 가지 값 중 하나“는
Enum으로 표현합니다. (10장)
enum class OrderStatus {
CREATED, // 생성됨
COMPLETED, // 완료됨
CANCELED // 취소됨
}
문자열로 상태를 다루면
오타나 잘못된 값이 들어올 수 있습니다.
// 나쁜 예: 문자열은 오타에 취약
var status: String = "COMPLTED" // 오타!
Enum을 쓰면
정해진 값만 쓸 수 있어 안전합니다.
34.5 Value Object 만들기
값 자체가 하나의 개념일 때
값 객체(Value Object)로 감싸면 좋습니다.
예를 들어 “돈“을 그냥 Int로 두면
음수 금액 같은 실수가 생길 수 있습니다.
@JvmInline
value class Money(val amount: Int) {
init {
require(amount >= 0) { "금액은 음수일 수 없습니다" }
}
}
value class는
값을 감싸면서도 성능 부담이 거의 없는 특별한 클래스입니다.
이제 금액은 항상 Money로 다뤄지고,
음수 금액은 존재할 수 없습니다.
값에 의미와 규칙을 부여하는 것,
이것이 값 객체의 힘입니다.
34.6 불변성을 활용한 모델링
2장에서 배운 불변성을
도메인에도 적극 활용합니다.
상태가 바뀌는 대신
“새 상태의 객체를 만든다“는 접근입니다. (9장 copy)
주문을 예로,
다음 흐름을 구현해 봅시다.
주문 생성
주문 완료
주문 취소
취소할 수 없는 주문 검증
data class Order(
val id: Long,
val items: List<OrderItem>,
val status: OrderStatus = OrderStatus.CREATED
) {
fun complete(): Order {
check(status == OrderStatus.CREATED) { "생성 상태에서만 완료할 수 있습니다" }
return copy(status = OrderStatus.COMPLETED)
}
fun cancel(): Order {
check(status != OrderStatus.COMPLETED) { "완료된 주문은 취소할 수 없습니다" }
return copy(status = OrderStatus.CANCELED)
}
}
complete()와 cancel()은
기존 객체를 바꾸지 않고copy로 새 객체를 돌려줍니다.
val order = Order(id = 1, items = items)
val completed = order.complete() // order는 그대로, 새 객체 반환
check는 상태 조건을 검사하는 함수입니다.require가 “입력값“을 검사한다면,check는 “현재 상태“를 검사합니다.
이렇게 불변 + 규칙으로 설계하면
주문이 잘못된 상태로 빠질 일이 없습니다.
34장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 도메인은 데이터에 더해 규칙(행동)을 가진다는 점
private set으로 상태를 보호하는 법init과require로 생성 시점에 검증하기- Enum과 값 객체로 안전하게 표현하기
- 불변성과
copy로 상태 전이를 모델링하기
다음 장에서는
도메인을 저장하고 꺼내는 Repository를 설계합니다.
35장. Repository 설계하기
도메인 객체를 만들었으니
이제 그것을 어딘가에 저장해야 합니다.
데이터를 저장하고 꺼내는 역할을
Repository가 맡습니다.
이 장에서는 Repository를
어떻게 설계하는지 배웁니다.
핵심은 8장에서 배운
“인터페이스로 경계를 만드는” 것입니다.
35.1 Repository가 필요한 이유
만약 Service가
저장 방식을 직접 알고 있다면 어떨까요?
// 나쁜 예: Service가 저장 방식에 직접 의존
class UserService {
fun register(user: User) {
val connection = database.connect() // 저장 방식이 코드에 박힘
// ...
}
}
이러면 저장 방식이 바뀔 때마다
Service를 통째로 고쳐야 합니다.
Repository는
“어떻게 저장하는가“를 숨겨 줍니다.
Service는 “저장한다“는 사실만 알면 됩니다.
Service는 “무엇을 할지“에 집중하고,
Repository는 “어떻게 저장할지“를 담당합니다.
35.2 Repository Interface
Repository는 인터페이스로 정의합니다.
interface UserRepository {
fun save(user: User): User
fun findById(id: Long): User?
fun findAll(): List<User>
}
우리 예제에는 세 종류가 필요합니다.
UserRepository
OrderRepository
ProductRepository
인터페이스에는
“무엇을 할 수 있는가“만 적습니다.
“어떻게 하는가“는 적지 않습니다.
조회 결과가 없을 수 있으므로findById의 반환 타입은 User?입니다. (5장 null)
35.3 Memory Repository 만들기
가장 간단한 구현은
메모리(Map)에 저장하는 것입니다.
데이터베이스 없이도
서비스를 동작시키고 테스트할 수 있습니다.
class MemoryUserRepository : UserRepository {
private val storage = mutableMapOf<Long, User>()
private var sequence = 0L
override fun save(user: User): User {
val id = if (user.id == 0L) ++sequence else user.id
val saved = user.copy(id = id)
storage[id] = saved
return saved
}
override fun findById(id: Long): User? = storage[id]
override fun findAll(): List<User> = storage.values.toList()
}
mutableMapOf로 저장소를 만들고, (12장)copy로 id를 채운 새 객체를 저장합니다. (9장)
여기서 배운 문법들이
자연스럽게 쓰이고 있습니다.
35.4 조회 결과와 Nullable
조회는 “없을 수도 있는” 작업입니다.
그래서 반환 타입을 nullable로 둡니다.
override fun findById(id: Long): User? = storage[id]
Service에서는
없는 경우를 어떻게 다룰지 결정합니다. (24장)
val user = userRepository.findById(id)
?: throw UserNotFoundException(id)
“없음“을 null로 정직하게 표현하고,
그 처리를 Service에 맡기는 것입니다.
이것이 5장에서 배운
null 안전성이 실무에 녹아드는 지점입니다.
35.5 Repository 구현체 교체하기
인터페이스로 나눈 덕분에
구현체를 자유롭게 바꿀 수 있습니다.
UserRepository (인터페이스)
↑
├─ MemoryUserRepository (개발/테스트용)
└─ DatabaseUserRepository (실제 운영용)
Service는 인터페이스만 바라보므로,
어떤 구현체를 넣어도 그대로 동작합니다.
// 테스트에서는 메모리 구현
val service = UserService(MemoryUserRepository())
// 운영에서는 데이터베이스 구현 (나중에)
// val service = UserService(DatabaseUserRepository())
31장에서 Fake로 교체했던 것과
정확히 같은 원리입니다.
35.6 데이터 저장 방식과 비즈니스 로직 분리하기
지금까지의 설계가 주는 선물은 이것입니다.
저장 방식(기술)과
비즈니스 로직(규칙)이 분리된다.
- 도메인/Service는 “규칙“에 집중
- Repository 구현체는 “저장 기술“에 집중
덕분에 이런 일이 가능합니다.
- 데이터베이스를 바꿔도 Service는 그대로
- 메모리 구현으로 빠르게 테스트
- 규칙을 고칠 때 저장 코드를 건드리지 않음
이 분리가 바로
스프링 같은 프레임워크가 권하는 구조입니다.
우리는 그것을 프레임워크 없이 직접 만든 셈입니다.
35장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- Repository가 저장 방식을 숨겨 주는 이유
- 인터페이스로 Repository를 정의하는 법
- 메모리 구현으로 간단히 저장소 만들기
- 조회 결과를 nullable로 정직하게 표현하기
- 구현체 교체와 관심사 분리의 효과
다음 장에서는
이 Repository를 사용하는 Service와
의존성 주입을 다룹니다.
36장. Service와 의존성
Service는 흐름을 조율하는 객체라고 했습니다.
그리고 Service는 Repository에 의존합니다.
여기서 “의존“이라는 말이 나옵니다.
이 장에서는 의존성을 어떻게 다루는지,
그리고 그 유명한 “의존성 주입“이 무엇인지 배웁니다.
11부의 마무리이자,
스프링을 이해하는 데 가장 중요한 개념입니다.
36.1 Service의 역할
Service는 하나의 기능을
처음부터 끝까지 완성합니다.
예를 들어 주문 생성은 이런 흐름입니다.
- 회원이 존재하는지 확인
- 상품이 존재하는지 확인
- 주문 객체를 만든다
- 주문을 저장한다
- 응답을 돌려준다
이 흐름을 Service가 조율합니다.
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository
) {
fun createOrder(request: CreateOrderRequest): OrderResponse {
val product = productRepository.findById(request.productId)
?: throw IllegalArgumentException("상품 없음")
val order = Order(id = 0, items = listOf(OrderItem(product.id, request.quantity)))
val saved = orderRepository.save(order)
return saved.toResponse()
}
}
Service는 여러 객체를 엮어
“줄거리“를 완성하는 감독 같은 존재입니다.
36.2 생성자를 통한 의존성 전달
OrderService는
Repository들이 있어야 동작합니다.
이 필요한 객체들을
생성자로 받는 것이 핵심입니다.
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository
)
Service가 필요한 것을
스스로 만들지 않고 “밖에서 받는다“는 점이 중요합니다.
// 나쁜 예: Service가 직접 만든다
class OrderService {
private val orderRepository = MemoryOrderRepository() // 특정 구현에 묶임
}
직접 만들면
그 특정 구현에 딱 붙어 버립니다.
그러면 테스트에서 교체할 수 없습니다.
36.3 구현체가 아닌 Interface에 의존하기
생성자로 받되,
받는 타입은 구현체가 아니라 인터페이스여야 합니다. (8장)
class OrderService(
private val orderRepository: OrderRepository // 인터페이스!
)
OrderRepository는 인터페이스이므로
어떤 구현체든 넣을 수 있습니다.
- 운영에서는 데이터베이스 구현
- 테스트에서는 메모리/Fake 구현
이것이 31장에서 테스트가
그토록 쉬웠던 이유입니다.
구현이 아닌 인터페이스에 의존하라.
이 한 문장이 좋은 구조의 핵심입니다.
36.4 Dependency Injection의 기본 개념
지금까지 한 일에 이름이 있습니다.
바로 의존성 주입(Dependency Injection, DI)입니다.
말은 거창하지만 뜻은 간단합니다.
필요한 객체를 밖에서 만들어 넣어 주는 것.
여기서는 프레임워크 없이
직접 객체를 조립합니다.
OrderService
├─ OrderRepository
└─ ProductRepository
조립 코드는 이렇게 생깁니다.
fun main() {
// 구현체를 만들고
val orderRepository = MemoryOrderRepository()
val productRepository = MemoryProductRepository()
// Service에 넣어 준다 (주입)
val orderService = OrderService(orderRepository, productRepository)
// 이제 사용
orderService.createOrder(request)
}
이렇게 객체를 만들어 연결하는 코드를
조립(assembly)이라고 부릅니다.
스프링은 바로 이 조립을
자동으로 해 주는 도구입니다.
지금 우리는 그것을 손으로 해 본 것입니다.
36.5 Service 테스트하기
의존성을 생성자로 받으면
테스트가 아주 쉬워집니다. (30·31장)
@Test
fun `상품이 없으면 주문 생성에 실패한다`() {
// Given: 빈 Fake 저장소
val orderService = OrderService(
FakeOrderRepository(),
FakeProductRepository() // 상품이 하나도 없음
)
// When & Then
assertThrows<IllegalArgumentException> {
orderService.createOrder(CreateOrderRequest(productId = 999, quantity = 1))
}
}
진짜 데이터베이스 없이
원하는 상황을 자유롭게 만들 수 있습니다.
36.6 Fake Repository 활용하기
성공 시나리오도 테스트해 봅시다.
@Test
fun `주문을 생성하면 저장된다`() {
// Given
val productRepository = FakeProductRepository()
productRepository.save(Product(id = 1, name = "노트북", price = 1000))
val orderRepository = FakeOrderRepository()
val orderService = OrderService(orderRepository, productRepository)
// When
val response = orderService.createOrder(
CreateOrderRequest(productId = 1, quantity = 2)
)
// Then
assertNotNull(orderRepository.findById(response.id))
}
Fake를 미리 채워 두고
원하는 상태에서 검증합니다.
이렇게 빠르고 안정적인 테스트가
좋은 구조에서 자연스럽게 나옵니다.
36.7 의존성이 늘어날 때 발생하는 문제
서비스가 커지면
의존성도 함께 늘어납니다.
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val userRepository: UserRepository,
private val paymentService: PaymentService,
private val notificationService: NotificationService
// ... 계속 늘어남
)
손으로 조립하는 코드도 점점 길어집니다.
val orderService = OrderService(
orderRepository,
productRepository,
userRepository,
paymentService,
notificationService
)
객체가 수십, 수백 개가 되면
이 조립을 손으로 관리하기 벅차집니다.
바로 이 문제를 해결하려고
스프링 같은 DI 프레임워크가 등장합니다.
프레임워크는 이 “조립“을 대신해 줍니다.
우리가 손으로 한 일을 자동화한 것뿐입니다.
그래서 지금 이 과정을 직접 해 본 것이
나중에 스프링을 배울 때 큰 도움이 됩니다.
36장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- Service가 흐름을 조율하는 역할이라는 점
- 의존성을 생성자로 받는 이유
- 구현체가 아닌 인터페이스에 의존하기
- 의존성 주입(DI)과 조립의 개념
- Fake로 Service를 쉽게 테스트하기
- 의존성이 늘면 프레임워크가 필요해지는 이유
이것으로 11부(백엔드 구조)를 마칩니다.
다음 12부에서는 지금까지 배운 모든 것을 모아
하나의 주문 시스템을 완성합니다.
37장. Kotlin으로 주문 시스템 만들기
드디어 실전입니다.
지금까지 배운 문법과 구조를 모두 모아
하나의 작은 주문 시스템을 완성해 봅니다.
웹 서버나 데이터베이스는 쓰지 않습니다.
순수 코틀린만으로 백엔드 핵심 로직을 완성합니다.
이 장은 새로운 문법을 배우는 장이 아닙니다.
배운 것을 “엮는” 장입니다.
코드를 눈으로만 읽지 말고
직접 따라 쳐 보길 권합니다.
37.1 프로젝트 요구사항
만들 기능은 다음과 같습니다.
회원 등록
상품 등록
상품 조회
주문 생성
주문 조회
주문 취소
작지만 백엔드의 핵심 흐름이
모두 담긴 요구사항입니다.
37.2 프로젝트 구조 만들기
32장에서 배운 대로 역할별로 나눕니다.
domain
├─ User
├─ Product
├─ Order
└─ OrderItem
repository
├─ UserRepository
├─ ProductRepository
└─ OrderRepository
service
├─ UserService
├─ ProductService
└─ OrderService
dto
├─ request
└─ response
이 구조를 머릿속에 그린 채
아래로 내려갑시다.
37.3 회원 기능 구현하기
먼저 도메인입니다. (34장)
data class User(
val id: Long,
val name: String,
val email: String
) {
init {
require(name.isNotBlank()) { "이름은 비어 있을 수 없습니다" }
require(email.contains("@")) { "올바른 이메일이 아닙니다: $email" }
}
}
Repository 인터페이스와 메모리 구현입니다. (35장)
interface UserRepository {
fun save(user: User): User
fun findById(id: Long): User?
}
class MemoryUserRepository : UserRepository {
private val storage = mutableMapOf<Long, User>()
private var sequence = 0L
override fun save(user: User): User {
val id = if (user.id == 0L) ++sequence else user.id
val saved = user.copy(id = id)
storage[id] = saved
return saved
}
override fun findById(id: Long): User? = storage[id]
}
DTO와 변환 함수입니다. (33장)
data class CreateUserRequest(val name: String, val email: String)
data class UserResponse(val id: Long, val name: String)
fun User.toResponse() = UserResponse(id = id, name = name)
Service입니다. (36장)
class UserService(
private val userRepository: UserRepository
) {
fun register(request: CreateUserRequest): UserResponse {
val user = User(id = 0, name = request.name, email = request.email)
return userRepository.save(user).toResponse()
}
}
37.4 상품 기능 구현하기
상품도 같은 패턴을 따릅니다.
data class Product(
val id: Long,
val name: String,
val price: Int
) {
init {
require(price >= 0) { "가격은 음수일 수 없습니다: $price" }
}
}
interface ProductRepository {
fun save(product: Product): Product
fun findById(id: Long): Product?
fun findAll(): List<Product>
}
Service에는 등록과 조회를 둡니다.
class ProductService(
private val productRepository: ProductRepository
) {
fun register(name: String, price: Int): Product =
productRepository.save(Product(id = 0, name = name, price = price))
fun findAll(): List<Product> = productRepository.findAll()
}
37.5 주문 기능 구현하기
주문은 여러 도메인을 엮으므로 조금 더 복잡합니다.
data class OrderItem(val productId: Long, val quantity: Int)
enum class OrderStatus { CREATED, CANCELED }
data class Order(
val id: Long,
val userId: Long,
val items: List<OrderItem>,
val totalPrice: Int,
val status: OrderStatus = OrderStatus.CREATED
) {
fun cancel(): Order {
check(status == OrderStatus.CREATED) { "이미 취소된 주문입니다" }
return copy(status = OrderStatus.CANCELED)
}
}
주문 Service는
회원과 상품이 실제로 있는지 확인하고,
금액을 계산해 주문을 만듭니다.
class OrderService(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository,
private val productRepository: ProductRepository
) {
fun create(userId: Long, items: List<OrderItem>): Order {
userRepository.findById(userId)
?: throw IllegalArgumentException("회원 없음: $userId")
val total = items.sumOf { item ->
val product = productRepository.findById(item.productId)
?: throw IllegalArgumentException("상품 없음: ${item.productId}")
product.price * item.quantity
}
val order = Order(id = 0, userId = userId, items = items, totalPrice = total)
return orderRepository.save(order)
}
fun cancel(orderId: Long): Order {
val order = orderRepository.findById(orderId)
?: throw IllegalArgumentException("주문 없음: $orderId")
return orderRepository.save(order.cancel())
}
}
37.6 Collection을 활용한 주문 데이터 처리
주문 여러 건을 다룰 때
13장에서 배운 컬렉션 함수가 빛을 발합니다.
val orders: List<Order> = orderRepository.findAll()
// 회원별 주문 그룹화
val byUser = orders.groupBy { it.userId }
// 취소되지 않은 주문의 총 매출
val revenue = orders
.filter { it.status == OrderStatus.CREATED }
.sumOf { it.totalPrice }
// 금액이 큰 순으로 정렬
val topOrders = orders.sortedByDescending { it.totalPrice }
반복문 대신groupBy, filter, sumOf로
의도가 한눈에 드러납니다.
37.7 오류와 예외 처리
실패 상황을 어떻게 다룰지도 설계합니다. (23·24장)
없는 회원이나 상품은
명확한 예외로 알립니다.
userRepository.findById(userId)
?: throw IllegalArgumentException("회원 없음: $userId")
메시지에 원인이 된 값($userId)을 담아
나중에 추적하기 쉽게 만듭니다.
37.8 DTO 변환
마지막으로 도메인을 응답으로 바꿉니다. (33장)
data class OrderResponse(
val id: Long,
val userId: Long,
val totalPrice: Int,
val status: String
)
fun Order.toResponse() = OrderResponse(
id = id,
userId = userId,
totalPrice = totalPrice,
status = status.name
)
도메인은 안에서만 쓰고,
바깥에는 응답 DTO만 내보냅니다.
37.9 테스트 작성
Fake와 DI 덕분에
테스트가 쉽습니다. (31·36장)
@Test
fun `주문을 생성하면 총액이 계산된다`() {
// Given
val users = MemoryUserRepository().apply { save(User(0, "홍길동", "a@b.com")) }
val products = MemoryProductRepository().apply { save(Product(0, "노트북", 1000)) }
val orders = MemoryOrderRepository()
val orderService = OrderService(orders, users, products)
// When
val order = orderService.create(
userId = 1,
items = listOf(OrderItem(productId = 1, quantity = 2))
)
// Then
assertEquals(2000, order.totalPrice)
}
apply로 저장소를 만들며 바로 채웠습니다. (15장 스코프 함수)
37.10 전체 코드 리팩터링
동작하는 코드를 만든 뒤에는
“더 코틀린답게” 다듬습니다. (다음 38장의 예고이기도 합니다)
- 불필요한
var를val로 바꾸기 (2장) - 반복문을 컬렉션 함수로 바꾸기 (13장)
- 변환 로직을 확장 함수로 모으기 (17·33장)
!!대신 엘비스 연산자로 실패를 명확히 (5장)
마지막으로 main에서 전체를 조립해
실제로 흐름을 실행해 봅니다.
fun main() {
val userRepository = MemoryUserRepository()
val productRepository = MemoryProductRepository()
val orderRepository = MemoryOrderRepository()
val userService = UserService(userRepository)
val productService = ProductService(productRepository)
val orderService = OrderService(orderRepository, userRepository, productRepository)
val user = userService.register(CreateUserRequest("홍길동", "hong@test.com"))
val product = productService.register("노트북", 1000)
val order = orderService.create(
userId = user.id,
items = listOf(OrderItem(product.id, 2))
)
println("주문 생성됨: ${order.toResponse()}")
val canceled = orderService.cancel(order.id)
println("주문 취소됨: ${canceled.toResponse()}")
}
웹 서버도 데이터베이스도 없지만,
백엔드의 핵심 로직이 완성되었습니다.
여기까지 스스로 만들었다면,
스프링을 배울 준비가 충분히 된 것입니다.
37장을 마치며
이 장에서 우리는 다음을 해냈습니다.
- 회원, 상품, 주문 기능을 순수 코틀린으로 구현
- 도메인·DTO·Repository·Service를 엮어 하나의 흐름 완성
- 컬렉션 함수로 데이터 가공
- Fake와 DI로 테스트 작성
- 조립(
main)으로 전체 실행
이제 마지막 13부에서는
이 코드를 더 코틀린답게 다듬는 법을 정리합니다.
38장. Java스럽게 작성한 Kotlin 개선하기
자바를 하다 코틀린으로 넘어오면
자기도 모르게 “자바처럼” 코드를 짜게 됩니다.
동작은 하지만
코틀린의 장점을 살리지 못한 코드입니다.
이 장에서는 흔한 “자바스러운 코틀린“을
“코틀린다운 코틀린“으로 바꾸는 법을 정리합니다.
지금까지 배운 내용을
“고치는 관점“에서 복습하는 장이기도 합니다.
38.1 Getter와 Setter를 그대로 옮기지 않기
자바 습관대로
게터/세터를 손으로 만드는 경우가 있습니다.
// 자바스러운 코틀린
class User {
private var name: String = ""
fun getName(): String = name
fun setName(value: String) { name = value }
}
코틀린은 프로퍼티가
게터/세터를 자동으로 만들어 줍니다. (7장)
// 코틀린다운 코드
class User(var name: String)
한 줄이면 충분합니다.
38.2 불필요한 var 줄이기
습관적으로 var를 쓰는 경우가 많습니다.
// 개선 전
var total = 0
for (item in items) {
total += item.price
}
대부분은 val로 바꿀 수 있습니다. (2·13장)
// 개선 후
val total = items.sumOf { it.price }
값이 한 번 정해지면 바뀌지 않으니val이 더 안전합니다.
일단
val, 꼭 필요할 때만var.
38.3 불필요한 !! 없애기
!!는 “null이면 터뜨려라“는 뜻입니다. (5장)
남발하면 위험합니다.
// 위험한 코드
val name = user!!.name!!
대부분 엘비스 연산자나 safe call로
더 안전하게 바꿀 수 있습니다.
// 개선 후
val name = user?.name ?: "이름 없음"
!!가 보이면
“정말 여기서 터져도 되는가“를 되물어야 합니다.
38.4 반복문을 Collection 함수로 바꾸기
for 반복문으로 목록을 가공하는 코드가 많습니다.
// 개선 전
val names = mutableListOf<String>()
for (user in users) {
if (user.age >= 20) {
names.add(user.name)
}
}
컬렉션 함수를 쓰면
의도가 한눈에 드러납니다. (13장)
// 개선 후
val names = users
.filter { it.age >= 20 }
.map { it.name }
“거른다 → 변환한다“는 흐름이
그대로 코드가 됩니다.
38.5 Utility Class를 Extension Function으로 바꾸기
자바에서는 StringUtils 같은
유틸리티 클래스를 만들곤 했습니다.
// 자바스러운 코틀린
object StringUtils {
fun isValidEmail(value: String): Boolean = value.contains("@")
}
StringUtils.isValidEmail(email)
코틀린에서는 확장 함수가 더 자연스럽습니다. (17장)
// 코틀린다운 코드
fun String.isValidEmail(): Boolean = contains("@")
email.isValidEmail()
값이 앞에 오니
문장처럼 읽힙니다.
38.6 과도한 Scope Function 줄이기
스코프 함수(15장)는 편하지만
너무 중첩하면 오히려 읽기 어렵습니다.
// 과한 예
user?.let { u ->
u.address?.let { a ->
a.city?.let { c ->
println(c)
}
}
}
safe call 체인으로 펴는 편이 낫습니다.
// 개선 후
val city = user?.address?.city
if (city != null) println(city)
코틀린다운 코드가
코틀린 문법을 많이 쓴 코드는 아닙니다.
38.7 불필요한 상속 제거하기
상속을 습관적으로 쓰는 경우가 있습니다.
하지만 코틀린 클래스는 기본이 final입니다. (8장)
상속보다
- 인터페이스로 역할을 나누거나 (8장)
- 위임(by)을 쓰는 편이 (19장) 더 유연할 때가 많습니다.
“이건 저것의 한 종류다“가 아니면
상속을 의심해 보세요.
38.8 Null과 Exception을 명확하게 표현하기
실패를 어정쩡하게 표현하지 말고
의도를 분명히 드러냅니다. (24장)
// 모호한 코드
fun find(id: Long): User {
return repository.findById(id)!! // 없으면 그냥 터짐
}
“없을 수 있음“인지 “없으면 오류“인지
명확히 구분합니다.
// 없을 수 있음 → nullable
fun findOrNull(id: Long): User? = repository.findById(id)
// 없으면 오류 → 명확한 예외
fun getById(id: Long): User =
repository.findById(id) ?: throw UserNotFoundException(id)
이름과 타입만 봐도
동작이 예상되게 만드는 것이 좋습니다.
38장을 마치며
이 장에서 우리는 다음을 정리했습니다.
- 손수 만든 게터/세터 대신 프로퍼티 쓰기
- 불필요한
var와!!줄이기 - 반복문을 컬렉션 함수로 바꾸기
- 유틸리티 클래스를 확장 함수로 바꾸기
- 과한 스코프 함수와 상속 줄이기
- null과 예외로 실패를 명확히 표현하기
다음 장은 이 책의 마지막입니다.
백엔드 개발자가 기억해야 할 핵심을 정리합니다.
39장. 백엔드 개발자가 기억해야 할 Kotlin
긴 여정의 마지막 장입니다.
지금까지 문법부터 구조,
그리고 실전 프로젝트까지 달려왔습니다.
이 장에서는 그 모든 것을
여덟 개의 문장으로 압축합니다.
앞으로 코드를 짜다 길을 잃을 때
이 여덟 가지를 떠올리면 됩니다.
39.1 불변성을 기본값으로 사용한다
값은 되도록 바뀌지 않게 만듭니다. (2장)
- 일단
val로 시작하고, 꼭 필요할 때만var - 도메인은
copy로 새 상태를 만든다 (9·34장)
바뀌지 않는 값은
추적할 걱정이 없어 버그가 줄어듭니다.
39.2 Null을 타입의 일부로 생각한다
코틀린에서 null은
“타입에 포함된 정보“입니다. (5장)
String과String?는 다른 타입이다!!는 최후의 수단으로만- 실패는
?.,?:로 명확히 다룬다
null을 두려워하지 말고
타입으로 다스립니다.
39.3 데이터와 상태를 명확하게 모델링한다
값과 상태를
정확한 도구로 표현합니다.
- 데이터 묶음은
data class(9장) - 정해진 값은
enum(10·34장) - 여러 결과는
sealed(10·24장)
타입이 곧 문서가 되게 만듭니다.
39.4 Interface로 경계를 만든다
구현이 아니라
인터페이스에 의존합니다. (8·35·36장)
- Repository는 인터페이스로 정의
- Service는 인터페이스를 생성자로 받는다
- 덕분에 구현을 자유롭게 교체하고 테스트한다
경계를 잘 그으면
바뀌는 것과 바뀌지 않는 것이 분리됩니다.
39.5 Collection 함수를 활용한다
반복문 대신
컬렉션 함수로 의도를 드러냅니다. (13장)
filter,map,groupBy,sumOf- “무엇을 하는지“가 코드에 그대로 보이게
백엔드는 데이터 가공의 연속입니다.
컬렉션 함수는 그 핵심 도구입니다.
39.6 Kotlin 문법을 과도하게 사용하지 않는다
코틀린다운 코드는
문법을 많이 쓴 코드가 아닙니다. (15·38장)
- 스코프 함수를 깊게 중첩하지 않기
- 화려함보다 읽기 쉬움을 택하기
좋은 코드의 기준은 언제나 “읽기 쉬움“입니다.
39.7 Java 생태계와의 연결을 이해한다
코틀린은 자바 위에서 삽니다. (9부)
- 자바 라이브러리를 그대로 쓸 수 있다
- 플랫폼 타입은 방어적으로 다룬다 (6·25장)
- 프레임워크는 리플렉션 위에서 동작한다 (28장)
이 연결을 이해하면
스프링을 배울 때 훨씬 수월합니다.
39.8 테스트하기 쉬운 객체를 만든다
테스트하기 쉬운 코드가
곧 좋은 구조입니다. (30·31·36장)
- 의존성은 생성자로 받는다
- 하나의 객체는 하나의 책임만
- Fake로 빠르고 안정적으로 검증한다
“테스트하기 쉽게“라는 기준 하나가
전체 설계를 좋은 방향으로 이끕니다.
마치며: 다음 여정으로
축하합니다.
이 책의 모든 장을 마쳤습니다.
우리는 웹 프레임워크 없이도
백엔드의 핵심 로직을 코틀린으로 완성했습니다.
- 코틀린 문법을 두루 익혔고
- 안전하게 데이터를 다루는 법을 배웠고
- 객체를 설계하고 테스트하는 법을 익혔고
- 순수 코틀린으로 주문 시스템을 만들었습니다
이제 다음 단계가 남았습니다.
바로 스프링입니다.
지금까지 손으로 만든
DI, 인터페이스, 계층 구조를
스프링은 자동으로 도와줍니다.
그 편리함의 밑바탕에
“왜 그렇게 하는가“가 있다는 것을
이제 여러분은 이미 알고 있습니다.
좋은 코틀린 코드가
좋은 백엔드 개발자의 출발점입니다.
여기까지 온 여러분의 다음 코드를 응원합니다.
부록 D. Java 개발자를 위한 Kotlin 비교표
자바를 아는 분이
“이건 코틀린으로 어떻게 쓰지?“를
빠르게 찾을 수 있게 정리했습니다.
Java POJO → Kotlin Data Class
자바에서는 게터/세터/equals/hashCode/toString을
길게 작성했습니다.
// Java
public class User {
private final String name;
private final int age;
// 생성자, getter, equals, hashCode, toString ...
}
코틀린은 한 줄입니다.
// Kotlin
data class User(val name: String, val age: Int)
(자세히: 9장)
static → Companion Object
// Java
public class Factory {
public static Factory create() { ... }
}
Factory.create();
// Kotlin
class Factory {
companion object {
fun create(): Factory = Factory()
}
}
Factory.create()
상수는 const val을 씁니다.
companion object {
const val MAX = 100
}
(자세히: 11장)
Optional → Nullable
// Java
Optional<User> findUser(Long id);
Optional<User> user = findUser(1L);
user.map(User::getName).orElse("없음");
// Kotlin
fun findUser(id: Long): User?
val user = findUser(1)
user?.name ?: "없음"
코틀린은 Optional 대신
타입에 ?를 붙여 null 가능성을 표현합니다.
(자세히: 5장, 6장)
Stream → Collection 함수
// Java
list.stream()
.filter(u -> u.getAge() >= 20)
.map(User::getName)
.collect(Collectors.toList());
// Kotlin
list.filter { it.age >= 20 }
.map { it.name }
.stream()이나 .collect()가 필요 없습니다.
컬렉션에서 바로 함수를 부릅니다.
(자세히: 13장)
익명 클래스 → Lambda / Object Expression
// Java
button.setOnClickListener(new OnClickListener() {
public void onClick() { ... }
});
// Kotlin: 람다 (SAM 변환)
button.setOnClickListener { ... }
// 여러 메서드가 필요하면 object 식
val listener = object : SomeListener {
override fun onStart() { ... }
override fun onEnd() { ... }
}
(자세히: 11장, 14장, 25장)
Checked Exception 차이
// Java: 반드시 처리하거나 선언해야 함
public void read() throws IOException { ... }
// Kotlin: 검사 예외가 없음
fun read() { ... } // throws 선언 불필요
코틀린에는 검사 예외가 없어try-catch가 강제되지 않습니다.
다만 실패 가능성은 스스로 챙겨야 합니다.
(자세히: 23장, 25장)
한눈에 보는 대응표
| 자바 | 코틀린 |
|---|---|
| POJO + 게터/세터 | data class |
static | companion object |
static final | const val |
Optional<T> | T? (nullable) |
| Stream API | Collection 함수 |
| 익명 클래스 | Lambda / object 식 |
| Checked Exception | 없음 (강제 처리 X) |
void | Unit (생략 가능) |
== (객체 비교) | === |
equals() (값 비교) | == |
이 표 하나면
자바 지식을 코틀린으로 빠르게 옮길 수 있습니다.
부록 C. Scope Function 선택표
스코프 함수 다섯 가지가 헷갈릴 때
이 표로 빠르게 골라 쓰세요.
(자세한 설명은 15장 참고)
한눈에 보는 선택표
| 함수 | 객체 접근 | 반환값 | 주요 용도 |
|---|---|---|---|
let | it | Lambda 결과 | 변환, Nullable |
run | this | Lambda 결과 | 객체 기반 계산 |
with | this | Lambda 결과 | 여러 멤버 접근 |
apply | this | 객체 | 객체 설정 |
also | it | 객체 | 부가 작업 |
두 가지 기준으로 고르기
스코프 함수는 두 질문으로 정리됩니다.
첫째, 객체를 무엇으로 부르는가?
it으로 부름 :let,alsothis로 부름 :run,with,apply
둘째, 무엇을 돌려주는가?
- 람다 결과를 돌려줌 :
let,run,with - 객체 자신을 돌려줌 :
apply,also
상황별 예시
let — nullable 처리와 변환
val length = name?.let { it.trim().length }
null이 아닐 때만 실행하고
결과를 돌려받고 싶을 때.
run — 객체 기반 계산
val area = rectangle.run { width * height }
객체의 멤버를 써서
어떤 값을 계산할 때.
with — 여러 멤버에 연속 접근
with(person) {
println(name)
println(age)
}
한 객체의 여러 멤버를
반복해서 다룰 때.
apply — 객체 설정 후 그 객체 반환
val user = User().apply {
name = "홍길동"
age = 20
}
객체를 만들면서 설정하고
그 객체를 그대로 받고 싶을 때.
also — 부가 작업(로그 등)
val result = compute().also {
println("계산 결과: $it")
}
흐름을 끊지 않고
로그나 검증 같은 곁다리 작업을 할 때.
주의: 과하게 쓰지 않기
스코프 함수를 깊게 중첩하면
오히려 읽기 어려워집니다. (38장)
// 나쁜 예: 과한 중첩
a?.let { it.b?.let { it.c?.let { println(it) } } }
// 좋은 예
val c = a?.b?.c
if (c != null) println(c)
편리하다고 남발하지 말고,
“읽기 쉬운가“를 기준으로 선택하세요.
부록 A. Kotlin 문법 빠른 참조
자주 쓰는 문법을 한곳에 모았습니다.
기억이 가물가물할 때 빠르게 찾아보세요.
val / var
val name = "홍길동" // 변경 불가 (권장 기본값)
var age = 20 // 변경 가능
val price: Int = 1000 // 타입 명시
val: 한 번 정하면 못 바꿈var: 나중에 바꿀 수 있음
(자세히: 2장)
if / when
// if는 표현식 (값을 돌려줌)
val max = if (a > b) a else b
// when
val grade = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
else -> "F"
}
// 타입 검사 when
when (value) {
is String -> println("문자열")
is Int -> println("정수")
else -> println("기타")
}
(자세히: 3장)
Nullable 연산자
val name: String? = null
name?.length // Safe Call: null이면 null
name ?: "기본값" // Elvis: null이면 기본값
name!! // Not-null 단언 (위험)
val len = name?.length ?: 0 // 조합
| 연산자 | 뜻 |
|---|---|
?. | null이 아닐 때만 실행 |
?: | null이면 오른쪽 값 사용 |
!! | null이면 예외 (최후의 수단) |
as? | 캐스팅 실패 시 null |
(자세히: 5장)
Collection 함수
list.filter { it > 0 } // 조건에 맞는 것만
list.map { it * 2 } // 변환
list.groupBy { it.category } // 그룹화
list.sortedBy { it.price } // 정렬
list.sumOf { it.price } // 합계
list.any { it.isActive } // 하나라도 참인가
list.all { it.isActive } // 모두 참인가
list.find { it.id == 1L } // 첫 번째 일치 (없으면 null)
(자세히: 13장)
Scope Function
val result = value.let { it.trim() } // 변환, nullable
val obj = Person().apply { name = "홍" } // 객체 설정
value.also { println(it) } // 부가 작업
with(person) { println(name) } // 여러 멤버 접근
val x = run { compute() } // 블록 실행 후 결과
(자세히: 15장, 부록 C)
클래스 관련 키워드
class User(val name: String) // 클래스 + 프로퍼티
data class Point(val x: Int, val y: Int) // 데이터 클래스
enum class Color { RED, GREEN, BLUE } // 열거형
sealed interface Result // 봉인된 타입
object Config // 싱글턴
interface Repository // 인터페이스
abstract class Shape // 추상 클래스
open class Base // 상속 허용 (기본은 final)
class Child : Base() // 상속
| 키워드 | 용도 |
|---|---|
class | 일반 클래스 |
data | 값 중심 클래스 |
enum | 정해진 값들 |
sealed | 제한된 타입 계층 |
object | 싱글턴 |
open | 상속 허용 |
(자세히: 7~11장)
부록 B. 자주 사용하는 Collection 함수
백엔드에서 특히 자주 쓰는
컬렉션 함수를 용도별로 모았습니다.
(자세한 설명은 13장 참고)
예제에 쓸 데이터를 먼저 가정합니다.
data class User(val id: Long, val name: String, val age: Int)
val users = listOf(
User(1, "홍길동", 20),
User(2, "김철수", 35),
User(3, "이영희", 28)
)
조회
users.first() // 첫 번째
users.firstOrNull() // 첫 번째 (없으면 null)
users.find { it.age > 30 } // 조건에 맞는 첫 번째
users.getOrNull(10) // 인덱스 접근 (없으면 null)
“없을 수 있는” 조회는
~OrNull을 써서 안전하게.
필터
users.filter { it.age >= 30 } // 조건에 맞는 것만
users.filterNot { it.age >= 30 } // 조건에 안 맞는 것만
users.filterNotNull() // null 제거
names.mapNotNull { it.toIntOrNull() } // 변환하며 null 제거
변환
users.map { it.name } // 각각 변환
users.map { it.age * 2 }
users.flatMap { it.orders } // 중첩 목록 펼치기
users.mapNotNull { it.emailOrNull() } // 변환 + null 제거
그룹
users.groupBy { it.age / 10 } // 나이대별 그룹
users.associateBy { it.id } // id를 키로 하는 Map
users.associate { it.id to it.name } // 키-값 쌍으로 Map
users.partition { it.age >= 30 } // 조건으로 두 그룹 분리
associateBy는 특히 자주 씁니다.
val userMap: Map<Long, User> = users.associateBy { it.id }
val user = userMap[1] // id로 빠르게 조회
정렬
users.sortedBy { it.age } // 오름차순
users.sortedByDescending { it.age } // 내림차순
users.sortedWith(compareBy({ it.age }, { it.name })) // 다중 기준
users.distinct() // 중복 제거
users.distinctBy { it.name } // 특정 기준 중복 제거
집계
users.count() // 개수
users.count { it.age >= 30 } // 조건 개수
users.sumOf { it.age } // 합계
users.maxByOrNull { it.age } // 최댓값 요소
users.minByOrNull { it.age } // 최솟값 요소
users.map { it.age }.average() // 평균
users.any { it.age >= 30 } // 하나라도 참인가
users.all { it.age >= 20 } // 모두 참인가
users.none { it.age < 0 } // 하나도 없는가
누적 계산이 필요하면 fold/reduce를 씁니다.
val total = users.fold(0) { acc, u -> acc + u.age }
자주 쓰는 조합
// 30세 이상 회원의 이름만
users.filter { it.age >= 30 }.map { it.name }
// 나이대별 인원 수
users.groupBy { it.age / 10 }.mapValues { it.value.size }
// 조건에 맞는 총합
users.filter { it.age >= 20 }.sumOf { it.age }
이 조합 패턴이
백엔드 데이터 가공의 대부분을 차지합니다.