[번역] Point-Free #4 대수적으로 알아보는 타입 체계

본문 링크

Swift의 타입 체계와 대수의 연관점이 있을까요? 네, 아주 많이요! 이번 에피소드에선 둘 사이의 공통점을 알아보고 그 공통점을 이용해서 타입 안정성을 가지는 데이터 구조를 만들어보겠습니다.

시작하며

오늘은 두 세계 사이의 연결점에 대해 알아봅시다. 바로 Swift의 타입과 대수입니다. 우리 모두 두 세계에 익숙하지만, 이들이 아주 깊이 연관돼있고 아름답기까지 하다는 사실을 아시나요? 두 세계의 공통점을 사용하면 데이터 구조가 얼마 만큼의 복잡도를 가지는지 이해할 수 있으며, 일어날 수 없는 상태에 대해 컴파일 시간에 예방하는 더 나은 구조를 생성할 수 있습니다.

대수적으로 알아보는 구조체

먼저 간단한 예시인 구조체부터 시작하겠습니다.

struct Pair<A, B> {
  let first: A
  let second: B
}

이 구조체는 두 개의 제네릭을 받는 구조입니다. 그리고 두 개의 필드가 존재하는데 각 필드가 제네릭 데이터를 하나씩 받습니다. 이 구조체의 AB에 타입을 넣고 그에 맞는 경우의 수를 계산해보겠습니다.

Pair<Bool, Bool>(first: true, second: true)
Pair<Bool, Bool>(first: true, second: false)
Pair<Bool, Bool>(first: false, second: true)
Pair<Bool, Bool>(first: false, second: false)

Pair<Bool, Bool>는 정확히 4개의 값을 가질 수 있습니다. 다른 값을 넣는다면 Swift의 컴파일러가 허락하지 않을 것입니다. 또 다른 예제를 만들어봅시다. Bool과 짝을 이룰 세 개의 값을 가질 수 있는 열거형을 만들겠습니다.

enum Three {
  case one
  case two
  case three
}

BoolThree를 이용하면 가질 수 있는 값의 경우의 수가 어떻게 될까요?

Pair<Bool, Three>(first: true, second: .one)
Pair<Bool, Three>(first: true, second: .two)
Pair<Bool, Three>(first: true, second: .three)
Pair<Bool, Three>(first: false, second: .one)
Pair<Bool, Three>(first: false, second: .two)
Pair<Bool, Three>(first: false, second: .three)

6가지네요! 흥미롭습니다.

Swift에는 특이한 타입인 Void가 존재합니다. 이 타입이 특이한 이유는 두 가지나 있습니다. 하나는 Void의 타입과 값을 동일한 방식으로 표현할 수 있다는 점입니다.

_: Void = Void()
_: Void = ()
_: () = ()

두 번째는 Void는 단 하나의 값만 가진다는 점입니다! 그러한 이유로 ()에 아무 값을 넣지 않아도 문제가 없습니다. 이는 Void의 단 하나의 값을 표현하는 것이지만 ()로 무언가를 할 수는 없습니다. 반환 값이 없는 함수에서 반환값을 명시적으로 지정하지 않아도 비밀리에 Void를 반환하는 이유이기도 합니다.

func foo(_ x: Int) /* -> Void */ {
  // return ()
}

Swift 컴파일러가 숨겨진 부분을 채워줍니다.

그럼 이제 VoidBool과 연결해봅시다.

Pair<Bool, Void>(first: true, second: ())
Pair<Bool, Void>(first: false, second: ())

두 가지 값을 가지네요.

VoidVoid의 쌍은 어떨까요?

Pair<Void, Void>(first: (), second: ())

한 가지 값을 가집니다!

Swift에는 이상한 타입이 하나 더 있습니다. 바로 Never입니다. Never의 정의는 아주 간단합니다.

enum Never {}

이게 무슨 의미일까요? 열거형인데 케이스가 없습니다. 이런 타입을 보통 “uninhabited type”이라고 부릅니다. 아무런 값도 가지지 않는 타입이라는 뜻입니다. 이런 타입은 다음과 같이 작성할 수 있는 방법이 없습니다.

_: Never = ???

위의 물음표에 넣을 수 있는 값도 없는데다가 Swift 컴파일러도 허락하지 않습니다.

만약 NeverPair를 쌍으로 연결하면 어떻게 될까요?

Pair<Bool, Never>(first: true, second: ???)

앞에서 말한대로 ??? 자리에 넣을 수 있는 값이 없습니다!

또한 Never는 컴파일러의 특별한 관리를 받습니다. 만약 어떤 함수가 Never를 반환하면 아무것도 반환하지 않는 함수라고 이해합니다. 예를 들어, fatalError 함수는 Never를 반환합니다.

앞의 예제를 바탕으로 AB로 이루어진 쌍인 Pair<A, B>의 경우의 수를 계산해볼까요?

// Pair<Bool, Bool>  = 4
// Pair<Bool, Three> = 6
// Pair<Bool, Void>  = 2
// Pair<Void, Void>  = 1
// Pair<Bool, Never> = 0

패턴이 보이네요. 이건 곱셈입니다!

// Pair<Bool, Bool>  = 4 = 2 * 2
// Pair<Bool, Three> = 6 = 2 * 3
// Pair<Bool, Void>  = 2 = 2 * 1
// Pair<Void, Void>  = 1 = 1 * 1
// Pair<Bool, Never> = 0 = 2 * 0

Pair는 값의 수를 곱한 수만큼의 값을 가질 수 있습니다. 앞에서 본 것 처럼 Pair<A, B>가 가질 수 있는 값의 경우의 수는 A가 가질 수 있는 값의 수와 B가 가질 수 있는 값의 수를 곱한 것과 동일합니다.

이러한 현상을 대수적으로 풀어낸 또 다른 해석이 존재합니다. 바로 논리곱(logical conjunction)입니다. Pair 타입은 두 타입의 “and” 값을 모아놓은 것입니다. 즉, Pair<A, B>는 반드시 A의 어떤 값과 B의 어떤 값을 가집니다.

그리고 이것은 Pair에서만 일어나는 일이 아닙니다. 다른 예시도 보시죠.

enum Theme {
  case light
  case dark
}

enum State {
  case highlighted
  case normal
  case selected
}

struct Component {
  let enabled: Bool
  let state: State
  let theme: Theme
}

Component를 대수적으로 풀면 어떻게 될까요?

// Bool * Theme * State = 2 * 3 * 2 = 12

Component가 가질 수 있는 값의 경우의 수는 12네요!

이제 타입의 이름은 신경쓰지 말고 어떤 데이터가 저장되어 있는지에 집중해봅시다. 지금까지 Pair<A, B>라고 쓰던 것이 A * B로 간결해졌습니다. 이런 모습이 이상해보일 수 있지만, 직관을 키우는데는 이만한 것이 없습니다.

// Pair<A, B>        = A * B
// Pair<Bool, Bool>  = Bool * Bool
// Pair<Bool, Three> = Bool * Three
// Pair<Bool, Void>  = Bool * Void
// Pair<Bool, Never> = Bool * Never

저희는 A * BAB의 곱(product)이라고 부릅니다. Pair<A, B>를 풀어서 AB가 가질 수 있는 값의 수를 곱하는 직관에 대해 조금 더 추상적으로 생각해봅시다. 타입이 가질 수 있는 값이 한정적일 때는 이런 식의 방식이 참인 반면, 다음과 같은 상황에선 도움이 되지 않습니다.

// Pair<Bool, String> = Bool * String

위 예시가 가질 수 있는 값의 경우의 수는 계산하지 않겠습니다. 왜냐하면 String이 가질 수 있는 값의 수는 무한대이기 때문입니다.

다음과 같이 무한 개의 값을 가질 수 있는 타입끼리 곱하는 경우도 있을 것입니다.

// String * [Int]
// [String] * [[Int]]

이제는 Void, Never 그리고 Bool의 이름을 없애고 각 타입이 가질 수 있는 값의 경우의 수를 표현해보겠습니다.

// Never = 0
// Void = 1
// Bool = 2

이제 우리는 특정 타입에 대해서만 고민하지 않고 추상적이고 대수적인 개체에 대해 생각할 수 있게 되었습니다!

대수적으로 알아보는 열거형

지금까지 Swift의 구조체가 타입의 곱셈에 대응한다는 것을 알아보았습니다. 그렇다면 곱셈의 쌍대(dual)인 덧셈에 해당하는 것은 무엇일까요? 이를 Swift의 타입 체계로 본다면 어떤 것일까요?

Swift에는 정확히 이런 기능을 지원하는데요, 바로 열거형입니다! 일단 아주 일반적인 열거형을 정의해보겠습니다.

enum Either<A, B> {
  case left(A)
  case right(B)
}

구조체에서 봤던 것과 동일하게 Bool로 예제를 구성해보겠습니다.

Either<Bool, Bool>.left(true)
Either<Bool, Bool>.left(false)
Either<Bool, Bool>.right(true)
Either<Bool, Bool>.right(false)

구조체와 동일하게 결과는 네 개네요. Three의 경우는 어떨까요?

Either<Bool, Three>.left(true)
Either<Bool, Three>.left(false)
Either<Bool, Three>.right(.one)
Either<Bool, Three>.right(.two)
Either<Bool, Three>.right(.three)

이번엔 다섯 개네요. 흥미롭습니다! 그럼 Void는 어떨까요?

Either<Bool, Void>.left(true)
Either<Bool, Void>.left(false)
Either<Bool, Void>.right(Void())

세 개입니다!

Never는요?

Either<Bool, Never>.left(true)
Either<Bool, Never>.left(false)
Either<Bool, Never>.right(???)

마지막 결과는 특히나 흥미롭습니다. 구조체에서 Never가 들어간 쌍(Pair)으로 예시를 들 때는 Pair<A, Never>와 같은 모양을 가져서 아무런 쌍도 얻을 수 없었습니다. 하지만 Either의 경우엔 하나의 경우만 불가능한 경우이고 다른 값은 Bool을 받는 가능한 경우입니다.

그렇다면 여기에 숨어있는 대수적인 비밀은 무엇일까요? Either<A, B>에서 AB의 값 사이의 관계를 알아봅시다!

Either<Bool, Bool>  = 4 = 2 + 2
Either<Bool, Three> = 5 = 2 + 3
Either<Bool, Void>  = 3 = 2 + 1
Either<Bool, Never> = 2 = 2 + 0

위의 예시에서 알 수 있듯이, Either<A, B>A의 값의 수와 B의 값의 수를 더한 결과입니다. 이 결과를 통해 Either는 타입의 덧셈에 직접적으로 대응한다는 것을 알 수 있습니다. 그리고 이게 바로 열거형이 “합 타입(sum types)”이라고 불리는 이유이기도 합니다. 또한, Pair에 했던 것처럼 Either를 논리적인 측면에서 해석할 수도 있습니다. Either 타입은 두 타입 중 하나를 값으로 가집니다. 즉, Either<A, B>A 타입이 나타낼 수 있는 값의 수와 B 타입이 나타낼 수 있는 값의 수를 합한 수 만큼의 값을 가질 수 있다는 것을 의미합니다.

Swift 문법에 맞지는 않지만 더욱 직관적으로 이해하기 위해 기존의 코드를 좀 더 풀어서 써보겠습니다.

Either<Bool, Bool>  = Bool + Bool  = 2 + 2 = 4
Either<Bool, Three> = Bool + Three = 2 + 3 = 5
Either<Bool, Void>  = Bool + Void  = 2 + 1 = 3
Either<Bool, Never> = Bool + Never = 2 + 0 = 2

Void라는 단어를 조심하세요!

몇몇 언어(Haskell, PureScript, Idris 등)에서 값이 없는 타입을 표현하기 위해 Void(Swift에선 Never입니다)를 사용합니다. 그리고 이는 다른 언어로 갔을 때 혼란을 만들기도 합니다. 생각해보면 “Void”라는 단어의 의미는 아무것도 없는 공허를 뜻하기 때문에 아무것도 없는 타입을 표현하기 위한 이름으로 적절한 것 같기도 합니다.

Swift의 Void처럼 단 하나의 값을 가지고 있는 타입을 표현하는 더 나은 이름은 Unit이 있습니다. Unit은 다음과 같이 정의할 수 있습니다.

struct Unit {}
let unit = Unit()

Unit이 좋은 점은 드디어 타입의 이름(Unit)과 타입의 값(unit)을 분리할 수 있게 되었다는 점입니다. 또 다른 좋은 점은 Unit에 대한 실제 구조체 타입이 생겨서 익스텐션을 추가할 수 있게 되었다는 점입니다.

extension Unit: Equatable {
  static func == (lhs: Unit, rhs: Unit) -> Bool {
    return true
  }
}

위 코드를 통해 우리는 unit을 비교가 가능한(Equatable) 값만 입력으로 받는 함수에 넣을 수 있게 되었습니다. Swift의 Void는 할 수 없는 일이라 더욱 끝내주네요. Void에 익스텐션을 추가하려고 하면 다음과 같은 에러를 만나게 됩니다.

Non-nominal type 'Void' cannot be extended

왜냐하면 Void는 빈 튜플로 정의되었기 때문입니다.

typealias Void = ()

Swift의 튜플은 non-nominal 타입입니다. 다시 말하자면, 이름으로는 접근할 수 없고 구조로만 접근할 수 있다는 의미입니다. 이것은 Swift의 정말 아픈 손가락 중 하나라고 생각됩니다. 꼭 빠른 시일내에 보수되기를 바랍니다.

빈 구조체 vs 빈 열거형

이제부터는 아주 이상한 일을 해볼 것입니다. 일단 UnitNever의 정의를 꼼꼼히 살펴보겠습니다.

struct Unit {}
enum Never {}

아무런 경우가 없는 열거형이라는 점과 아무런 필드가 없는 구조체라는 점에서 비슷한 점이 존재하네요. 그러면 아무런 경우가 없는 열거형은 값이 없는 반면, 아무런 필드가 없는 구조체는 값이 하나 존재할까요? 위 코드만 봐서는 Unit에도 아무런 값이 존재하지 않는다고 생각하는 것이 충분히 가능해보입니다.

이러한 이유를 직관적으로 이해할 수 있으신가요?

이 질문에는 Swift의 타입과 대수 사이의 대응을 사용하면 더욱 쉽게 답변을 낼 수 있습니다. 직관적으로, “빈 열거형과 빈 구조체에는 어떤 값이 존재할까?”라는 질문은 “정수로 이루어진 빈 배열의 합과 곱은 무엇일까?”라는 질문과 동일하다고 생각할 수 있습니다.

정수로 이루어진 배열이 있다고 해보겠습니다. 다음의 함수에 들어가야 할 내용은 무엇일까요?

func sum(_ xs: [Int]) -> Int {
  fatalError()
}

func product(_ xs: [Int]) -> Int {
  fatalError()
}

let xs = [1, 2, 3]
sum(xs)
product(xs)

우리가 할 일은 배열의 모든 원소를 돌면서 합하거나 곱하는 것이겠죠?

func sum(_ xs: [Int]) -> Int {
  var result: Int
  for x in xs {
    result += x
  }
  return result
}

func product(_ xs: [Int]) -> Int {
  var result: Int
  for x in xs {
    result *= x
  }
  return result
}

위 코드는 result의 기본값이 지정되지 않아서 당장은 컴파일되지 않습니다. 그러면 result의 기본값은 무엇이 되어야 할까요? 이 질문에 답변하기 위해선 sumproduct가 어떤 속성을 가져야하는지 이해해야 합니다. 그러면 저절로 result가 어떤 값으로 시작해야하는지 정해질 것입니다. 몇 개의 배열을 받았을 때 sumproduct가 가져야 할 가장 기본적인 속성은 다음과 같을 것입니다.

sum([1, 2]) + sum([3]) == sum([1, 2] + [3])
product([1, 2]) * product([3]) == product([1, 2] + [3])

그러면 여기에 빈 배열을 넣어볼까요?

sum([1, 2]) + sum([]) == sum([1, 2] + [])
product([1, 2]) * product([]) == product([1, 2] + [])

위 등식을 만족시키려면 sum([])은 0이 되어야 하고, product([])1이 되어야 합니다. 다른 선택지는 존재하지 않습니다. 그러니 합의 기본값은 0이고 곱의 기본값은 1이라고 할 수 있습니다.

sum([1, 2]) + 0 == sum([1, 2] + [])
product([1, 2]) * 1 == product([1, 2] + [])

sum([]) == 0
product([]) == 1

이제 이 개념을 타입 세계로 옮겨볼까요? 기본값을 타입으로 옮겨보면 “빈 합 타입”은 값이 없고(불가능하고) “빈 곱 타입”은 하나의 값을 가집니다! 드디어 대수를 이용해서 진퇴양난의 상황을 해결할 수 있게 되었네요! 정말 끝내줍니다!

대수적 속성

여기까지 Swift의 타입과 대수 사이의 대응을 이해하기 위해 몇 가지 개념을 익혔습니다. 그러면 이제 이 지식을 활용해서 타입 구성에 대한 조금 더 높은 단계의 직관을 얻어볼 준비가 되었습니다.

쉬운 것부터 시작하겠습니다. Void1에 대응한다는 사실은 기억하실 것입니다. 그리고 대수 세계에선 1로 곱하는 것은 아무일도 일으키지 않는다는 것을 알고 계실 것입니다. 그렇다면 이것을 타입에 적용하면 어떤 일이 일어날까요?

// Void = 1
// A * Void = A = Void * A

위 내용은 구조체의 필드에 Void 값을 사용하면 본질적으로는 타입이 변하지 않는 순 효과를 일으킨다는 의미입니다.

반대로 0에 대응하는 Never를 곱에 사용하면 0이라는 결과가 나옵니다. 타입 세계에서 보면 다음과 같습니다.

// Never = 0
// A * Never = Never = Never * A

그러니 구조체의 필드에 Never를 넣는 것은 구조체에 Never 타입 하나만 넣는 것과 같은 결과를 냅니다. 완전히 소멸시켜버리는 것이죠.

그러나 0을 더하는 것은 결과값을 바뀌지 않게 해주며, 다음과 같이 표현할 수 있습니다.

// A + Never = A = Never + A

다음과 같은 타입 표현이 있다고 해보겠습니다.

// 1 + A = Void + A

Either로 표현하면 다음과 같은 모양일 것입니다.

// Either<Void, A> {
//   case left(())
//   case right(A)
// }

이 타입은 오른쪽엔 A의 모든 값을 가지고 왼쪽엔 단 하나의 특별한 값인 left(Void())를 가지게 될 것입니다. Swift에서 이런 모양을 가진 타입이 무엇이 있을까요? 바로 Optional입니다!

enum Optional<A> {
  case none
  case some(A)
}

none의 경우는 left에 대응하고, some의 경우는 right에 대응합니다. 그러니 다음과 같이 표현할 수 있겠죠.

// 1 + A = Void + A = A?

이제 아래와 같은 표현식을 만나게 되었다고 해보겠습니다.

// Either<Pair<A, B>, Pair<A, C>>

지금까지 했던 방식으로 표현해보면 다음과 같겠네요.

// A * B + A * C

기본적인 대수를 사용해서 간단한 표현식으로 바꿀 수 있을 것 같습니다.

A * (B + C)

이를 다시 원래대로 표현하면 열거형을 가진 Pair로 표현할 수 있겠네요.

Pair<A, Either<B, C>>

이렇게 대수적인 직관은 데이터 구조를 간단하게 만들 수 있는 능력을 길러줍니다.

이전 예제의 PairEither를 뒤집어 봅시다.

Pair<Either<A, B>, Either<A, C>>

수학 세계의 말로 표현하면 다음과 같을 것입니다.

// (A + B) * (A + C)

위 식은 인수분해할 것이 없기 때문에 더 이상 간단하게 만들 수 없습니다.

물론 확장할 수는 있습니다.

// A * A + A * C + B * A + B * C

이 결과를 타입 세계의 언어로 표현하면 네 개의 Pair를 경우로 가지는 열거형이 될 것입니다. 만약 이게 여러분이 원하던 결과라면 대수의 힘을 한 번 더 느끼셨겠군요!

그래서 요점이 무엇인가요?

지금까지는 직관에 도움을 주기 위해 Swift에서 작동하지 않는 많은 의사코드를 작성해왔습니다. 도움이 좀 되셨나요?

이제는 실제 상황인 URLSession의 메소드를 살펴보겠습니다.

// URLSession.shared
//   .dataTask(with: url, completionHandler: (data: Data?, response: URLResponse?, error: Error?) -> Void)

이 컴플리션 핸들러는 모두 옵셔널인 세 개의 값을 반환합니다. Swift의 튜플은 곱이니 세 개의 필드의 곱 타입이라고 생각할 수 있습니다. 그럼 대수적으로 적어볼까요?

// (Data + 1) * (URLResponse + 1) * (Error + 1)

이해하기 어려우니 완전히 풀어보겠습니다.

// (Data + 1) * (URLResponse + 1) * (Error + 1)
//   = Data * URLResponse * Error
//     + Data * URLResponse
//     + URLResponse * Error
//     + Data * Error
//     + Data
//     + URLResponse
//     + Error
//     + 1

여기엔 일어날 수 없는 상태에 대한 표현도 존재합니다. URLResponseError와 동시에 존재할 수 없기 때문에 URLResponse * Error라는 경우는 생길 수 없는 경우입니다. 같은 이유로 Data * Error도 불가능한 경우입니다. 모든 값이 nil인 경우 Void라는 뜻이니 1은 얻을 수 있는 값입니다. 그리고 절대 일어날 수 없지만 모든 값이 잘 왔을 때는 Data * URLResponse * Error 모양의 값을 얻을 수 있을 것입니다.

바로잡기 독자 중 한 분인 Ole Begemann에 의하면, URLResponseError가 동시에 nil이 아닌 경우는 존재할 수 있다고 합니다. 그 분이 작성하신 블로그 포스트가 있으니 자세한 내용은 여기서 읽어보시면 좋을 것 같습니다. 그리고 이 부분을 바로잡는 내용은 이 시리즈의 다음 에피소드인 대수적으로 풀어보는 타입 체계: 지수편에서 얘기할 예정입니다.

이 인터페이스를 그대로 사용하면 결과값이 필요한 경우엔 if let을 사용하고, 반대로 결과값이 필요없다고 생각하는 경우엔 fatalError를 두고 절대 호출되지 않기를 희망하는 수밖에 없습니다.

이번 에피소드에서 얻은 새로운 직관을 사용해서 우리에게 필요한 것을 표현해봅시다.

// Data * URLResponse + Error

타입을 사용하면 어떤 모양일까요?

Either<Pair<Data, URLResponse>, Error>

실제로 Swift 커뮤니티에서는 이미 이러한 종류의 타입인 Result를 채택했습니다.

// Result<(Data, URLResponse), Error>

그리고 이 경우엔 Pair 대신 DataURLResponse로 이루어진 간단한 튜플을 사용하면 됩니다.

컴플리션 콜백에서 적절한 타입을 사용한다면 컴파일 시간에 허용됐지만 실제로 일어날 수 없는 상태를 제거할 수 있게 되고, 콜백의 로직도 아주 간단해질 것입니다.

Result 타입에 대해 조금 더 알아보겠습니다. 사용하고 있는 API가 Result 타입을 반환하지만 이 연산은 절대 실패하지 않는다면 어떨까요? 그런 경우 Result의 에러 타입을 Never로 만들 수 있을 것입니다!

// Result<A, Never>

이제 우리는 타입만 보고도 에러가 발생할 수 없는 API라는 것을 알게되었습니다.

그렇다면 도중에 취소가 가능한 비동기 API를 다룰 때는 어떨까요? 취소에 대한 경우를 Result에 어떻게 포함할 수 있을까요?

// Result<A, Error>?

옵셔널로 만들어주면 됩니다!

대수적 직관은 코드의 복잡성을 해결하고 필요한 타입을 적절하게 사용하는 능력을 더 명확하게 만들어줍니다. 지금까지 보여드린 구조체나 열거형에 대한 예시는 우리가 매일 보는 그것들에 비하면 아주 간단한 편입니다.

튜플은 무서워하지 않는데 Either는 무서우시다구요? 그 말은 곱셈은 무섭지 않은데 덧셈은 무섭다라는 말과 같고, “or”는 무서운데 “and”는 무섭지 않다와 같습니다. 개발할 때 곱셈(*)과 “and”(&&)만 사용하는 사람은 아무도 없습니다. 덧셈(+)과 “or”(||)도 사용해보세요. 그러면 합 타입, 더 나아가서 Either가 편해질 것입니다!

오늘 알아본 내용은 대수적으로 타입을 알아보는 여행의 시작점일 뿐입니다. 오늘 알아본 덧셈과 곱셈을 제외하고도 타입 체계가 대수적인 개념을 어떻게 표현하는지 알아볼 내용이 아주 많습니다. 예를 들면 지수가 있겠네요! 타입의 제곱을 표현하는 방법엔 무엇이 있을까요? 더 말씀드리고 싶지만, 아쉽게도 오늘은 여기까지 입니다.

그럼 다음 에피소드에서 뵙겠습니다!


본문에 연습문제와 참고 자료가 있습니다. 여기에서 확인해주세요!