[목표]
Scope 5개를 상황만 보면 골라 쓸 수 있다 (let / apply / also 중심)
패턴 A·B·C를 직접 타이핑할 수 있다
data class, nullable, ?., ?:, takeIf를 결제 도메인 예제에 섞어 쓸 수 있다
https://play.kotlinlang.org/에서 작성하면됨
문제를 풀기 위한 가상 결제 DTO
data class PaymentRequest(
val orderId: String,
val amount: Long,
val idempotencyKey: String?, // 멱등키 — 없을 수 있음
val couponCode: String?
)
data class PaymentEntity(
var orderId: String = "",
var amount: Long = 0,
var status: String = "PENDING"
)
enum class PaymentStatus { PENDING, REQUESTED, COMPLETED, FAILED }
TEST 1 — Scope Function “선택” 근육 (20분)
미션
아래 요구에 함수 이름만 적어보기 (코드 X, 5분). 그다음 코드 작성 (15분)
| # | 요구 | 쓸 함수 |
| 1 | couponCode가 null이 아니면 대문자로 바꿔 쓰기 | |
| 2 | PaymentEntity() 만들고 orderId, amount 설정 후 그 객체 사용 | |
| 3 | 저장 직후 println 로그만 찍기 (객체는 그대로) | |
| 4 | request 여러 필드로 요약 문자열 만들기 | |
| 5 | request 기준으로 amount - fee 한 줄 계산 |
정답
1.couponCode null 아니면 대문자: let
2.PaymentEntity() 만들고 필드 설정: apply
3.저장 직후 로그만: also
4.여러 필드로 요약 문자열: with
5.amount - fee 계산 결과: run
추가: 타이핑 연습하기
// 1) let — null이면 스킵
request.couponCode?.trim()?.uppercase()?.let { code ->
println("쿠폰 적용: $code")
}
// 2) apply — 설정 후 객체
val entity = PaymentEntity().apply {
orderId = request.orderId
amount = request.amount
status = PaymentStatus.REQUESTED.name
}
// 3) also — 부가 작업만
entity.also { println("생성됨 orderId=${it.orderId}") }
📌 체크: it vs this를 입으로 말하기 — let/also는 it, apply/run은 this
TEST2
패턴 A (null-safe 검증 체인) (30분)
미션 1: 멱등키 검증 함수 만들기
fun validateIdempotencyKey(raw: String?): String {
// TODO: trim → 길이 10~64 (takeIf) → 실패 시 throw
// 힌트: ?. ?: throw IllegalArgumentException("...")
TODO()
}
미션 2: 검증 통과 후 “가짜 DB 조회”
fun findByIdempotencyKey(key: String): String? =
if (key.startsWith("dup")) "ALREADY_EXISTS" else null
fun handle(request: PaymentRequest) {
// TODO: validate 후 let으로 findByIdempotencyKey 호출
// 이미 있으면 println("중복 요청 차단")
}
정답
fun validateIdempotencyKey(raw: String?): String {
return raw
?.trim()
?.takeIf { it.length in 10..64 }
?: throw IllegalArgumentException("유효하지 않은 멱등키")
}
fun findByIdempotencyKey(key: String): String? =
if (key.startsWith("dup")) "ALREADY_EXISTS" else null
fun handle(request: PaymentRequest) {
val key = validateIdempotencyKey(request.idempotencyKey)
findByIdempotencyKey(key)?.let {
println("중복 요청 차단: $it")
return
}
println("신규 결제 진행 key=$key")
}
스스로 채점하기
- null 입력 시 예외가 난다
- " abcdefghij " → trim 후 길이 검사 통과
- let 없이 ?: throw만으로도 동작하는 버전을 한 번 더 써 봤다
- let 버전과 차이(실패 시 null vs 예외)를 한 줄로 설명할 수 있다
TEST 3
패턴 B (apply + also) (30분)
미션: 결제 엔티티 생성 파이프라인
fun createPayment(request: PaymentRequest): PaymentEntity {
// TODO:
// 1) PaymentEntity().apply { ... status = REQUESTED }
// 2) .also { 로그 — orderId, amount만 (카드번호 없음)
return TODO()
}
fun save(entity: PaymentEntity): PaymentEntity {
// 가짜 저장
return entity.copy(status = PaymentStatus.COMPLETED.name) // copy는 data class일 때; 지금은 var라 그냥 entity 반환해도 됨
}
실무 습관 연습: 로그는 apply 안에 넣지 말 것. save 이후 also로 한 번 더 로그 찍는 버전도 작성.
정답
fun createPayment(request: PaymentRequest): PaymentEntity {
return PaymentEntity().apply {
// 객체 초기화만 apply에서 수행
orderId = request.orderId
amount = request.amount
status = PaymentStatus.REQUESTED.name
}.also {
// 부가 작업(로그)은 also에서 수행
println("PAYMENT_INITIALIZED orderId=${it.orderId}, amount=${it.amount}")
}
}
fun save(entity: PaymentEntity): PaymentEntity {
// 가짜 저장 처리
entity.status = PaymentStatus.COMPLETED.name
return entity
}
fun createAndSave(request: PaymentRequest): PaymentEntity {
return save(createPayment(request)).also {
println("PAYMENT_SAVED orderId=${it.orderId}, status=${it.status}")
}
}
스스로 채점
- 실행 순서를 말로 설명: 생성 → apply → also → save → also
- also 블록 안에서 amount = 0 같은 설정 코드를 넣지 않았다
TEST 4
패턴 C (run vs 함수 승격) (35분)
미션 1: 짧은 run
data class PaymentCommand(val amount: Long, val fee: Long, val tax: Long)
fun netAmount(command: PaymentCommand): Long =
command.run { amount - fee - tax }
미션 2: 길면 승격
data class PaymentCommand2(
val amount: Long,
val fee: Long,
val tax: Long,
val memberGrade: String
)
// TODO: calculateSettlementAmount() 확장 함수로 옮기기
// VIP면 base의 5% 할인, finalAmount < 0 이면 require 실패
미션 3: 테스트 느낌으로 main에서 호출
fun main() {
val cmd = PaymentCommand2(10_000, 100, 0, "VIP")
println(cmd.calculateSettlementAmount())
}
정답
data class PaymentCommand(val amount: Long, val fee: Long, val tax: Long)
fun netAmount(command: PaymentCommand): Long =
command.run { amount - fee - tax }
data class PaymentCommand2(
val amount: Long,
val fee: Long,
val tax: Long,
val memberGrade: String
)
fun PaymentCommand2.calculateSettlementAmount(): Long {
val base = amount - fee - tax
val discount = if (memberGrade == "VIP") (base * 0.05).toLong() else 0L
val vat = ((base - discount) * 0.1).toLong()
val finalAmount = base - discount + vat
require(finalAmount >= 0) { "정산금액 음수" }
return finalAmount
}
fun main() {
val cmd = PaymentCommand2(10_000L, 100L, 0L, "VIP")
println(cmd.calculateSettlementAmount())
}
스스로 채점
- run 버전과 fun 버전 둘 다 직접 타이핑했다
- “5줄 넘으면 함수로 빼기” 기준을 나만의 말로
TEST 5
이전 문법 + Scope 합체 (40분)
한 파일에 미니 결제 서비스 하나만 완성합니다.
class PaymentService {
private val storage = mutableMapOf<String, PaymentEntity>()
fun pay(request: PaymentRequest): PaymentEntity {
// 1) 패턴 A: idempotencyKey 검증 (validateIdempotencyKey 재사용)
// 2) 패턴 A: storage에 이미 있으면 예외 or 기존 반환
// 3) 패턴 B: PaymentEntity().apply { }.also { 로그 }
// 4) storage 저장 후 .also { 저장 로그 }
// 5) 패턴 C: fee 3% run으로 짧게 계산하거나 fun으로
TODO()
}
}
* 넣어야 할 이전 문법 체크리스트
|
문법
|
어디에 쓸지 |
|
data class
|
Request, Entity
|
|
String?, ?., ?:
|
멱등키, 쿠폰
|
|
takeIf
|
키 길이, amount > 0
|
|
enum
|
status
|
|
when
|
status별 메시지 (선택)
|
|
require / check
|
금액·정산 검증
|
|
copy 또는 apply
|
엔티티 생성
|
main()에서 성공 케이스 1번, 멱등키 중복 1번, 잘못된 키 1번 호출하기
정답
data class PaymentRequest(
val orderId: String,
val amount: Long,
val idempotencyKey: String?,
val couponCode: String?
)
data class PaymentEntity(
var orderId: String = "",
var amount: Long = 0L,
var status: String = "PENDING",
var idempotencyKey: String = ""
)
enum class PaymentStatus { PENDING, REQUESTED, COMPLETED, FAILED }
class PaymentService {
private val storage = mutableMapOf<String, PaymentEntity>()
private fun validateIdempotencyKey(raw: String?): String {
return raw
?.trim()
?.takeIf { it.length in 10..64 }
?: throw IllegalArgumentException("유효하지 않은 멱등키")
}
fun pay(request: PaymentRequest): PaymentEntity {
require(request.amount > 0) { "결제 금액은 0보다 커야 합니다." }
val key = validateIdempotencyKey(request.idempotencyKey)
// 패턴 A: 중복 요청 차단
storage[key]?.let {
println("중복 요청 감지 key=$key, 기존 orderId=${it.orderId}")
return it
}
// 예시 수수료(3%) 계산 - 짧은 계산은 run
val fee = request.run { (amount * 0.03).toLong() }
val net = request.amount - fee
// 패턴 B: 생성/초기화 + 로그 분리
val entity = PaymentEntity().apply {
orderId = request.orderId
amount = net
status = PaymentStatus.REQUESTED.name
idempotencyKey = key
}.also {
println("PAYMENT_CREATED orderId=${it.orderId}, amount=${it.amount}")
}
storage[key] = entity
return entity.also {
println("PAYMENT_STORED key=${it.idempotencyKey}, status=${it.status}")
}
}
}
fun main() {
val service = PaymentService()
val ok = PaymentRequest("ORD-1", 10_000L, "abcdefghij", "SPRING")
println(service.pay(ok))
// 중복 요청
println(service.pay(ok))
// 잘못된 키
try {
service.pay(PaymentRequest("ORD-2", 5_000L, "short", null))
} catch (e: Exception) {
println("예외: ${e.message}")
}
}
TEST6
“읽기 전용” 리팩터링 (30분)
아래 나쁜 예를 고칩니다. (의도: Scope 남용·역할 혼합)
fun bad(request: PaymentRequest): PaymentEntity? {
return request.idempotencyKey?.let { key ->
PaymentEntity().apply {
orderId = request.orderId
amount = request.amount
println("log in apply $key")
if (key.length < 10) throw IllegalArgumentException("짧음")
status = "OK"
}.also {
it.amount = -1 // 버그 유도
}
}
}
할 일
- 검증은 패턴 A로 밖으로 빼기
- 로그는 also만
- amount = -1 같은 버그가 왜 위험한지 주석 한 줄
- 고친 good() 작성
정답
fun validateKey(raw: String?): String {
return raw
?.trim()
?.takeIf { it.length >= 10 }
?: throw IllegalArgumentException("유효하지 않은 멱등키")
}
fun good(request: PaymentRequest): PaymentEntity {
val key = validateKey(request.idempotencyKey)
val entity = PaymentEntity().apply {
// 설정만 수행
orderId = request.orderId
amount = request.amount
status = "OK"
}
return entity.also {
// 부가 작업만 수행
println("PAYMENT_READY key=$key, orderId=${it.orderId}")
// 금액/상태를 여기서 바꾸면 사이드이펙트로 버그가 생기기 쉬움
}
}
TEST 7
몸으로 체화 테스트 (25분, 닫고 쓰기)
노트·종이에 코드 없이만 적기.
1. Scope 5개 표 한 칸씩 (객체 참조 / 반환값 / 한 줄 용도)
2. 패턴 A·B·C 각 한 문장
3. let vs apply vs also 차이 한 문장
4. 코드 1개: PaymentRequest 받아서 pay()까지 머릿속으로 순서 적기
그다음 IDE에서 20분 제한으로 PaymentService 전체를 처음부터 다시 타이핑 (복붙 X).
채점 기준: 각 문항 핵심 키워드 2개 이상 포함 시 정답. 전부 맞출 필요 없고 80% 이상이면 체화 단계로 봐도 됩니다.
1. Scope Function 5개 표
| 함수 | 객체 참조 | 반환값 | 한 줄 용도 | |
|
let
|
it
|
람다 결과
|
null일 때만 실행, 값 변환·검증 후 다음 작업
|
|
|
run
|
this
|
람다 결과
|
객체 컨텍스트에서 짧은 계산 후 결과 반환
|
|
|
with
|
this
|
람다 결과
|
이미 있는 객체로 여러 필드를 묶어 처리 (with(obj) { })
|
|
|
apply
|
this
|
객체 자신
|
객체 생성·초기화(필드 설정) 후 그대로 사용
|
|
|
also
|
it
|
객체 자신
|
객체는 그대로, 로그·메트릭·부가 작업만
|
|
빠른 암기 (모범 한 줄)
- 값을 바꿔 반환 → let / run / with · 객체를 유지 → apply / also · null → ?.let
2. 패턴 A/B/C 한 문장
| 패턴 | 모범답안 |
|
A
|
?.trim() → ?.takeIf { } → ?: throw(또는 ?.let)로 null·조건 검증을 한 흐름으로 모아, 통과한 값만 비즈니스 로직에 넘긴다.
|
|
B
|
객체 필드 설정은 apply, **로그·추적은 also**로 나눠 역할을 분리한다.
|
|
C
|
계산이 짧으면 run, 5줄 이상·분기·금액 로직이면 fun으로 빼서 테스트·재사용한다.
|
3.let vs apply vs also
| 비교 | 모범답안 |
|
let
|
null이 아닐 때만 블록 실행. it으로 받아 변환하고 람다 결과를 돌려준다.
|
|
apply
|
객체를 this로 설정만 하고, 설정된 객체 그대로 돌려준다.
|
|
also
|
객체를 it으로 참조해 로그 등 부가 작업만 하고, 내용·반환 모두 같은 객체.
|
한 문장으로 묶기 (필수 암기)
- let = null-safe 변환, apply = 초기화, also = 부가 작업(로그)
4. pay() 처리 순서 (머릿속·서술형 모범답안)
아래 순서를 말하거나 글로 적을 수 있으면 정답입니다.
1. 입력 검증 — amount > 0 등 (require)
2. 멱등키 검증 — 패턴 A (trim → takeIf 길이 → 실패 시 예외)
3. 중복 요청 확인 — storage에 있으면 기존 건 반환 또는 차단 (?.let 또는 if)
4. 금액·수수료 계산 — 짧으면 run, 복잡하면 fun
5. 엔티티 생성 — PaymentEntity().apply { 필드 설정 }
6. 생성 로그 — .also { ... } (민감정보 X)
7. 저장 — storage[key] = entity
8. 저장 후 로그 — return entity.also { ... }
모범 서술 (3~5문장 버전)
- pay()는 먼저 금액과 멱등키를 검증하고, 이미 처리된 요청이면 중복을 막는다.
- 통과하면 수수료를 계산한 뒤 apply로 결제 엔티티를 만들고, also로 생성·저장 로그를 남긴다.
- storage에 저장한 뒤 최종 엔티티를 반환한다.
5. 추가 단답 정답
| # | 질문 | 정답 |
|
1
|
?.let { } 앞 단계에서 null이면?
|
블록 실행 안 함, 결과 null
|
|
2
|
apply 블록 안에서 this는?
|
방금 만든 그 객체
|
6. 20분 재타이핑 체크리스트 (자가 채점)
| 항목 | O / X |
|
data class Request / Entity 정의
|
|
|
validateIdempotencyKey에 ?. + takeIf + ?: throw
|
|
|
중복 요청 처리 (storage 조회)
|
|
|
apply와 also 분리
|
|
|
require(request.amount > 0)
|
|
|
main()에 성공 / 중복 / 예외 케이스 3가지
|
|
|
Scope 3단 이상 중첩 안 함
|
|
6개 중 5개 이상 O → Day 7 통과.
7. 흔한 오답 vs 정답 (틀렸을 때만 보기)
| 오답 | 정답 |
|
apply 안에 로그 + 검증 + 저장 다 넣음
|
검증은 밖, 로그는 also, 저장은 별도 줄
|
|
let으로 엔티티 필드 설정
|
필드 설정은 apply
|
|
also에서 amount 변경
|
apply에서만 상태 변경
|
|
?.let 실패 시 자동으로 예외
|
null 반환 — 예외는 ?: throw로 명시
|
|
모든 계산에 run 10줄
|
calculate...() 함수로 승격
|
8. Day 7 최종 한 줄 정리 (모범답안)
Scope Function은 짧게 쓰는 문법이 아니라, 검증(let·패턴 A), 초기화(apply), 로그(also), 계산(run→fun) 의 역할을 드러내는 도구다.
다음 단계 (선택)
|
결과
|
추천 |
|
80% 미만
|
Day 2·3만 다시 (패턴 A·B)
|
|
80% 이상
|
PaymentService를 Spring 없이 파일 하나로 유지하며, when·sealed class 추가 연습
|
|
완전 통과
|
본인 블로그 Scope Function 글에 Day 5 pay() 코드를 예제로 넣기
|
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] Coroutine 기초 과제 (0) | 2026.06.02 |
|---|---|
| [Kotlin] Sealed Class와 Result 패턴 복습과제 (0) | 2026.06.01 |
| [Kotlin] Coroutine 기초 (0) | 2026.05.20 |
| [Kotlin] Collection API & 함수형 프로그래밍 (0) | 2026.05.20 |
| [Kotlin] Scope Function 완전 정리 (0) | 2026.05.20 |