본문 바로가기

Language/Kotlin

[복습] 1~3강까지 미션

[목표]
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() 코드를 예제로 넣기