[번역] Point-Free #2 사이드 이펙트

본문 링크

사이드 이펙트! 사이드 이펙트가 없는 코딩은 상상할 수 없습니다! 오늘은 자주 만나는 몇 가지 사이드 이펙트를 살펴 본 후, 사이드 이펙트가 왜 코드를 이해하기 어렵고 테스트를 불가능하게 만드는지를 이해하고, 사이드 이펙트를 제대로 컨트롤하는 법까지 알아보도록 하겠습니다. 물론 함수 합성도 빠질 순 없겠죠?

시작하며

지난 에피소드에선 함수의 합성이 어떻게 작동하는지 이해하기 위해 함수의 입력과 출력 타입을 맞추는 것에 대해 강조했습니다. 하지만 프로그램을 작성하다보면 함수의 서명만 봐서는 알 수 없는 무언가가 많습니다. 이걸 우리는 “사이드 이펙트”라고 부릅니다.

사이드 이펙트는 코드의 복잡함을 담당하는 가장 큰 원천이며 테스트 하기도 매우 어려운데다가 사이드 이펙트가 있는 함수는 합성이 안 될 때도 있습니다. 지난 에피소드에서 아셨듯이 함수 합성을 하면 할수록 얻는 이득이 있는데 사이드 이펙트는 여기에 찬물을 끼얹습니다.

이번 에피소드에선 사이드 이펙트의 종류에 대해 알아보겠습니다. 그리고 사이드 이펙트가 왜 테스트하기 어려운지, 왜 합성되지 않는 지에 대한 이유를 찾아보고 그러한 문제들을 풀어보겠습니다.

사이드 이펙트라는 용어는 의미가 다양해서 헷갈릴 때가 있습니다. 그러니 정확한 정의를 위해 사이드 이펙트가 없는 함수를 먼저 만들어 보겠습니다.

func compute(_ x: Int) -> Int {
  return x * x + 1
}

이 함수를 호출하면 다음과 같은 결과가 나올 것입니다.

compute(2) // 5

사이드 이펙트가 없는 함수의 장점 중 하나는 바로 몇 번을 호출하든지 특정 입력에 대해 항상 같은 출력을 반환한다는 점입니다.

compute(2) // 5
compute(2) // 5
compute(2) // 5

이렇게 예측이 가능해지면 테스트를 작성하는 일이 너무 간단해집니다.

assertEqual(5, compute(2)) // ✅

입력과 출력을 예측할 수 있으니 틀린 테스트 케이스도 작성할 수 있습니다. 아래와 같이 입력이나 출력을 다르게 입력하면 항상 실패합니다.

assertEqual(4, compute(2)) // ❌
assertEqual(5, compute(3)) // ❌

이제 사이드 이펙트를 추가해보겠습니다.

func computeWithEffect(_ x: Int) -> Int {
  let computation = x * x + 1
  print("Computed \(computation)")
  return computation
}

함수 가운데에 print문을 넣었습니다.

computeWithEffect를 이전과 같은 입력으로 호출해보면 이전과 같은 출력이 나옵니다.

computeWithEffect(2) // 5

하지만 콘솔을 확인해보면 이전과는 다른 추가적인 출력을 확인하실 수 있습니다.

Computed 5

computeWithEffectcompute를 비교해보면 함수 서명은 같지만, 서명에서는 알 수 없는 또 다른 작업이 있는 것을 확인할 수 있습니다. 이 경우, print 함수는 세상에 손을 뻗어서 변화를 일으켜서 콘솔에 어떠한 값을 출력했습니다. 사이드 이펙트는 그들이 숨어있을 경우를 위해 함수의 바디를 항상 확인하게 만듭니다.

이 함수에 대한 테스트를 작성해보겠습니다.

assertEqual(5, computeWithEffect(2)) // ✅

통과하네요! 하지만 콘솔에 또 다른 내용이 추가됐습니다.

Computed 5
Computed 5

그런데 이 행동은 테스트할 수 있는 행동이 아닙니다. 지금 예시는 그냥 콘솔에 출력하는 거라 별거 아닌 것처럼 보이겠지만, 디스크에 저장하거나 API 리퀘스트를 만들거나 또는 애널리틱스 트래킹과 같은 내용으로 바꾼다면 생각이 달라지실 것입니다. 우리는 이 행동이 일어나는 것에 대해 더 신경쓰고 테스트도 가능하게 만들어야 합니다.

또한 사이드 이펙트는 합성이 가능한 것에 대한 직관을 부셔버리기도 합니다. 함수에 관한 내용을 다뤘던 이전 에피소드에서 우리는 배열에 대해 두 개의 함수가 map되는 것이 둘을 하나로 합성 시켜서 map하는 것과 동일하다는 것에 대해 얘기했었습니다.

[2, 10].map(compute).map(compute) // [26, 10202]
[2, 10].map(compute >>> compute)  // [26, 10202]

computeWithEffect의 경우는 어떨까요?

[2, 10].map(computeWithEffect).map(computeWithEffect)
// [26, 10202]
[2, 10].map(computeWithEffect >>> computeWithEffect)
// [26, 10202]

결과값은 똑같지만 콘솔을 확인해보면 행동은 그렇지 않다는 것을 알 수 있습니다!

Computed 5
Computed 101
Computed 26
Computed 10202
--
Computed 5
Computed 26
Computed 101
Computed 10202

이렇게 되면 사이드 이펙트를 고려해야 하는 문제 때문에 map을 한 번만 쓸 수 있게 해주는 특성에서 나오는 이득을 누릴 수가 없습니다. 현업에서 두 번의 map을 하나로 바꾸는 것은 퍼포먼스 최적화에 대한 리팩토링과 같다고 할 수 있습니다. 그런데 만약 우리의 함수가 사이드 이펙트를 가지고 있다면 원하는 순서대로 작동하지 않을 것입니다. 사이드 이펙트가 있는 코드는 아무리 퍼포먼스가 오르는 방식으로 리팩토링해도 결국엔 제대로 작동하지 않는 코드가 된다는 말이죠!

숨겨진 출력값이 있는 경우

이 사이드 이펙트를 조종할 수 있는 가장 쉬운 방법을 알려드리겠습니다. 함수의 바디에 있는 이펙트를 실행하는 것보다 더 나은 것은 출력돼야 하는 내용을 나타내는 값을 같이 출력으로 반환하는 것입니다. 콘솔에 출력되던 것들이 많았으니 배열로 만들겠습니다.

func computeAndPrint(_ x: Int) -> (Int, [String]) {
  let computation = x * x + 1
  return (computation, ["Computed \(computation)"])
}

computeAndPrint(2) // (5, ["Computed 5"])

이제 우리는 계산 결과만 출력으로 받는 것이 아니라 콘솔에 출력할 로그도 같이 배열로 받게됐습니다.

그럼 테스트를 작성해볼까요?

assertEqual(
  (5, ["Computed 5"]),
  computeAndPrint(2)
)
// ✅

이제 우리의 테스트는 계산 결과에 대한 커버리지뿐만 아니라 하고자 하는 행동도 테스트할 수 있게 됐습니다! 그리고 사이드 이펙트가 잘 못된 경우에 대해서도 테스트가 가능하겠네요.

assertEqual(
  (5, ["Computed 3"]),
  computeAndPrint(2)
)
// ❌

지금은 아주 간단한 예시지만, 우리가 만날 사이드 이펙트는 API 리퀘스트나 애널리틱스 이벤트일 수도 있다는 사실을 항상 기억하셔야 합니다.

이런식으로 외부 세계를 변화시키는 사이드 이펙트는 그 기능을 숨기고 함수의 암묵적인 출력일 뿐입니다. 암묵적이라는 속성은 프로그래밍 세계에선 그렇게 좋은 속성은 아닙니다.

그럼 이제 자기 자신에게 물어보죠. “그래서 누가 이펙트를 작동시키는데?” 반환 타입으로 이펙트를 꺼내는 것으로 우리는 그 이펙트에 대한 책임을 함수를 호출한 쪽으로 넘겼습니다.

let (computation, logs) = computeAndPrint(2)
logs.forEach { print($0) }

하지만 우리는 호출하는 쪽에서도 사이드 이펙트를 가지지 않고 그들을 통과하기를 바랍니다. 그리고 호출하는 쪽에서도 사이드 이펙트를 넘겨받는 것이 썩 좋은 일은 아닐 것입니다! 복잡한 문제로 들릴 수도 있겠지만 다행히도 이를 해결할 방법이 존재합니다. 그러나 그 방법을 알기 전에 먼저 자세하게 알아야 할 내용이 있습니다.

지금까지 한 것을 보면 사이드 이펙틀 문제를 해결했다고 볼 수 있을 것입니다. 왜냐하면 함수의 출력에 사이드 이펙트에 대한 설명을 적으면 되니까요. 불행히도 이 방법은 우리에게 가장 중요한 기능을 사용할 수 없게 만듭니다. 바로 합성이죠.

compute 함수의 좋은 점은 자기 자신과 forward-compose가 된다는 것입니다.

compute >>> compute // (Int) -> Int

computeWithEffect 함수도 자기 자신과 forward-compose가 되니 어느정돈 괜찮다고 할 수 있습니다.

computeWithEffect >>> computeWithEffect // (Int) -> Int

합성이 가능하면 파이프도 가능할 것입니다.

2 |> compute >>> compute // 26
2 |> computeWithEffect >>> computeWithEffect // 26

당연히 computeWithEffect는 콘솔에 출력을 할 것입니다.

Computed 5
Computed 26

앞에서 만든 함수들이 합성이 가능한 반면에, computeAndPrint는 합성이 불가능합니다.

computeAndPrint >>> computeAndPrint
// Cannot convert value of type '(Int) -> (Int, [String])' to expected argument type '((Int, [String])) -> (Int, [String])'

왜냐하면 computeAndPrint의 결과는 (Int, [String])의 튜플인 반면, 입력은 Int이기 때문이죠.

이런 장면은 아마 계속 보게 되실 것입니다. 사이드 이펙트를 발생해야 하는 함수의 경우, 반환 타입이 이펙트를 설명하게 되고 결국 함수 합성이 불가능하게 됩니다. 바로 이 시점이 우리가 합성 함수의 종류를 늘리는 것에 대해 고민해야 할 시기입니다.

튜플을 반환하는 함수의 경우 합성 함수를 꽤 괜찮은 방식으로 수정해서 computeAndPrint 함수를 더 일반적으로 만들 수 있습니다. 이러한 종류의 함수를 합성할 때 쓰기 위한 함수를 정의해봅시다.

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  // …
}

함수의 모양이 어디서 많이 본 모양입니다… (A) -> B, (B) -> C, 그리고 (A) -> C가 있는 것으로 보아 >>> 함수의 서명과 비슷한 것 같네요. 거기에 약간의 엑스트라가 추가됐네요.

함수 구현은 정의할 때 써놓은 타입과 값을 그대로 사용하면 됩니다.

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}

함수를 반환하고 있으니 a를 바인딩하는 것으로 함수를 열어보겠습니다. 그리고 A를 받는 f도 존재하네요. fa를 입력으로 넣어서 출력을 blogs에 바인딩하겠습니다. bg의 입력에 찰떡이네요. gb를 입력으로 넣어서 cmoreLogs로 만들겠습니다. 이제 우리에겐 c가 생겼고 이를 로그와 함께 반환합니다. logs, moreLogs 둘 중 어떤 것을 반환하든지 마음대로지만 이 경우는 모든 로그가 필요하니 둘을 합쳐서 반환하겠습니다.

이제 합성을 시작해봅시다!

compose(computeAndPrint, computeAndPrint)
// (Int) -> (Int, [String])

이제 computeAndPrint 함수가 두 번 호출하는 새로운 합성 함수가 생겼습니다. 이 함수는 적절한 값만 넣어주면 계산 결과뿐만 아니라 모든 과정에 대한 로그를 모두 얻을 수 있습니다.

2 |> compose(computeAndPrint, computeAndPrint)
// (26, ["Computed 5", "Computed 26"])

>=>를 소개합니다

합성 문제를 이해하고 완벽하게 해결했다고 생각할 수 있지만, 진짜 문제는 3개 이상의 함수를 합성할 때부터 시작됩니다.

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)

더 나쁜 점은 같은 합성을 두 가지 방법으로 할 수 있다는 것입니다.

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)
2 |> compose(computeAndPrint, compose(computeAndPrint, computeAndPrint))

괄호는 언제나 합성의 적인 것 같습니다. 괄호의 적은 누구일까요? 바로 infix 연산자입니다.

우리는 우리가 세 개 이상의 합성을 언젠가 할 것이라는 것과 이 합성에 값을 파이프할 것이라는 사실을 알고 있습니다. 그 때를 위해서 파이프 연산자(|>)보다 우선 순위가 높은 그룹을 만들어 놓읍시다.

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
}

이제 새로운 infix 연산자를 정의하겠습니다. 그런데 어디서 본 것 같지 않나요?

infix operator >=>: EffectfulComposition

>>>와 비슷하게 생겼지만 가운데 있는 화살표가 튜브 모양(=)으로 바뀌었습니다. 이 연산자의 또 다른 별명은 “물고기(fish)” 연산자입니다.

이제 compose 함수의 이름을 바꿔서 사이드 이펙트가 가득 들어있는 함수를 괄호 걱정 없이 붙여봅시다!

func >=> <A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}

computeAndPrint >=> computeAndPrint >=> computeAndPrint // (Int) -> (Int, [String])

이제 아래와 같이 여러 줄에 걸쳐서 읽기 좋은 코드를 작성할 수 있습니다.

2
  |> computeAndPrint
  >=> computeAndPrint
  >=> computeAndPrint

이전에 소개드린 >>>과 같은 연산자를 이용해서 기존 함수를 합성하는 것도 함수 합성을 연산자의 세계로 가져오는 좋은 방법 중 하나입니다.

2
  |> computeAndPrint
  >=> (incr >>> computeAndPrint)
  >=> (square >>> computeAndPrint)

이제 합성을 이용해서 사이드 이펙트를 가진 함수의 결과를 받아서 사이드 이펙트가 없는 함수에 적용하는 방법도 알았습니다. 괄호에 관한 자그마한 문제도 있었지만 해결했죠! 함수 합성은 함성의 가장 강력한 모습 중 하나일 것입니다. 하지만 위의 예시처럼 여러 연산자가 섞일 때 괄호가 생기는 것을 보셨을 것입니다. 이건 바로 우리의 EffectfulComposition 우선 순위 그룹을 업데이트할 때라는 의미입니다.

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
  lowerThan: ForwardComposition
}

이렇게 바꾸면 합성하는 데 있어서 더 이상 괄호를 쓰지 않아도 됩니다.

2
  |> computeAndPrint
  >=> incr
  >>> computeAndPrint
  >=> square
  >>> computeAndPrint

드디어 코드의 각 줄이 각자의 의미를 가지게 되었습니다. >>>로 시작하는 줄은 사이드 이펙트가 없는 함수의 결과를 다루는 줄이고, 그와는 반대로 >=>로 시작하는 줄은 사이드 이펙트가 있는 계산 결과를 다루는 줄이라고 할 수 있습니다.

새로운 연산자를 소개했으니 우리 코드에서 공식적으로 인정할지에 대한 재판을 하겠습니다.

  1. 이 연산자가 Swift와 겹치는 부분이 있나요? 아니요, 기존 의미를 덮어 쓸 기회조차 없었습니다.

  2. 이 연산자를 다른 선배 언어에서도 사용하고 있으며 어느정도 인정받은 상태인가요? 네! 피쉬 연산자는 Haskell과 PureScript를 포함해서 함수형 라이브러리를 채택한 프로그래밍 언어 커뮤니티에서는 다들 사용하는 개념입니다. 특히 모양은 >>>와 비교해봤을 때 무언가가 흘러간다는 것을 인지할 수 있다는 점에서 아주 좋다고 볼 수 있습니다.

  3. 이 연산자가 혹시 너무 범용적이거나 너무 도메인 특화되어 있지는 않나요? 지금 당장은 튜플 관련 작업을 하기 위한 목적밖에 없지만 이러한 모양은 프로그래밍 언어 역사에서 꾸준히 발견되었기 때문에 사용처가 많을 것입니다. 예를 들면 Swift의 타입인 옵셔널을 적용할 수도 있습니다.

func >=> <A, B, C>(
  _ f: @escaping (A) -> B?,
  _ g: @escaping (B) -> C?
  ) -> ((A) -> C?) {

  return { a in
    fatalError()
  }
}

튜플을 옵셔널로 바꿨더니 이제 옵셔널을 반환하는 함수를 합성하는 데에 집중하는 연산자가 되었습니다. 그러면 다음과 같이 실패 가능성이 있는 이니셜라이저 둘을 합성할 수 있습니다.

String.init(utf8String:) >=> URL.init(string:)
// (UnsafePointer<Int8>) -> URL?

새로운 실패가 가능한 이니셜라이저를 하나 공짜로 얻었네요!

배열을 반환하는 함수를 합성하기 위해선 옵셔널을 배열로 바꾸기만 하면 됩니다.

func >=> <A, B, C>(
  _ f: @escaping (A) -> [B],
  _ g: @escaping (B) -> [C]
  ) -> ((A) -> [C]) {

  return { a in
    fatalError()
  }
}

만약 Promise 또는 Future 타입을 사용하고 있다면 promise를 반환하는 함수를 합성할 수 있는 연산자도 만들 수 있습니다.

func >=> <A, B, C>(
  _ f: @escaping (A) -> Promise<B>,
  _ g: @escaping (B) -> Promise<C>
  ) -> ((A) -> Promise<C>) {

  return { a in
    fatalError()
  }
}

시리즈가 계속 될수록 비슷한 모양을 계속 보게 되실 것입니다. 강력한 타입 시스템을 가진 몇몇 언어에선 연산자를 한 번 정의하면 모든 타입에 대해서 즉시 구현이 된다고 합니다. Swift엔 이러한 기능이 존재하지 않기 때문에 새로운 타입을 만나면 그에 맞춰서 구현을 해야 합니다. 그래도 우리는 이 연산자의 모양에 대해선 직관적으로 알고 있기 때문에 어떠한 타입이 나와도 구현은 가능합니다. >=> 연산자를 알고 사이드 이펙트를 체이닝할 때 사용하면 무서울 것이 없습니다.

숨겨진 입력값이 있는 경우

지금까지는 사이드 이펙트를 숨겨진 출력값으로 만들고 이를 함수에 명시하는 것으로 컨트롤 하는 방법에 대해 알아보았습니다. 물론 합성에 대한 우리의 마음도 지키면서 말이죠. 이제 조금 더 까다로운 사이드 이펙트에 대해 알아보겠습니다.

다음은 사용자에게 인사를 건네는 간단한 함수입니다.

func greetWithEffect(_ name: String) -> String {
  let seconds = Int(Date().timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greetWithEffect("Blob")
// "Hello Blob! It's 14 seconds past the minute."

이 코드를 다시 실행하면 우리가 얻는 결과 같이 다를 것입니다. 이는 compute 함수에서 가졌던 예측 가능성과는 정반대입니다.

이 함수를 테스트하면 거의 항상 실패할 것입니다.

assertEqual(
  "Hello Blob! It's 32 seconds past the minute.",
  greetWithEffect("Blob")
)
// ❌

이것은 특히 더 나쁜 사이드 이펙트라고 할 수 있는데요, 왜냐하면 이전의 경우는 적어도 테스트는 성공한 반면에 이번엔 출력값이 계속해서 변하기 때문에 테스트 조차도 작성이 불가능합니다.

이전의 사이드 이펙트는 입력을 받지만 출력값은 없는 함수인 print였습니다. 이번엔 입력은 받지 않지만 출력값이 있는 Date가 있습니다.

이 사이드 이펙트에도 이전과 비슷한 방식이 통하는지 먼저 확인해봅시다. 이전에 한 방식은 compute의 반환 값에 print의 이펙트를 명시했었습니다. 그러면 Date의 이펙트를 함수의 인자에 명시할 수 있는지 보겠습니다.

func greet(at date: Date, name: String) -> String {
  let seconds = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greet(at: Date(), name: "Blob")

이 함수는 이전과 동일하게 작동하면서 딱 하나 다른 점을 가지고 있습니다. 이제는 날짜를 조종할 수 있어서 항상 통과하는 테스트를 작성할 수 있게 되었다는 점이 다릅니다.

assertEqual(
  "Hello Blob! It's 39 seconds past the minute.",
  greet(at: Date(timeIntervalSince1970: 39), name: "Blob")
)
// ✅

우리는 약간의 보일러 플레이트 코드를 감수하고 테스트가 가능하게 만들었습니다. 하지만 테스트가 아닐 때는 적을 필요가 없는 부분인데 함수의 호출부에선 항상 날짜를 넣어주게 되었습니다. 이는 아래와 같이 인자에 디폴트 값을 넣어서 필요할 경우에만 넣어주는 방식으로 의존성 주입을 사용할 수 있습니다.

func greet(at date: Date = Date(), name: String) -> String {
  let s = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(s) seconds past the minute."
}

greet(name: "Blob")

위 코드는 읽기엔 좀 더 좋아졌겠지만 더 큰 문제가 있습니다. 바로 합성이 불가능해졌다는 것이죠.

처음 greetWithEffect 함수의 모양은 (String) -> String 으로 아주 깔끔했습니다. 이는 다른 String을 반환하거나 String을 입력으로 받는 함수랑 합성할 수 있기 때문입니다.

String을 대문자로 바꾸는 간단한 함수를 예로 들어보겠습니다.

func uppercased(_ string: String) -> String {
  return string.uppercased()
}

이 함수는 greetWithEffect의 앞이든 뒤든 깔끔하게 합성됩니다.

uppercased >>> greetWithEffect
greetWithEffect >>> uppercased

각 합성에 이름을 pipe해보면 서로 다른 결과가 나옵니다.

"Blob" |> uppercased >>> greetWithEffect
// "Hello BLOB! It's 56 seconds past the minute."
"Blob" |> greetWithEffect >>> uppercased
// "HELLO BLOB! IT'S 56 SECONDS PAST THE MINUTE."

그러나 우리의 greet 함수는 제대로된 합성을 할 수가 없습니다.

"Blob" |> uppercased >>> greet
"Blob" |> greet >>> uppercased
// Cannot convert value of type '(Date, String) -> String' to expected argument type '(_) -> _'

greet 함수는 두 인자를 받기 때문에 uppercased 함수의 결과를 합성할 수 있는 방법이 없습니다. Date 입력을 무시하면 (String) -> String 모양의 합성을 할 수 있습니다. 여기서 약간의 트릭을 쓰면 함수의 명세에서 Date를 꺼낼 수 있습니다. 그렇게 Date를 입력으로 받지만 (String) -> String 모양을 가진 greet 함수로 다시 한 번 작성해보면 다음과 같을 것입니다.

func greet(at date: Date) -> (String) -> String {
  return { name in
    let s = Int(date.timeIntervalSince1970) % 60
    return "Hello \(name)! It's \(s) seconds past the minute."
  }
}

이제 greet 함수에 date를 넣으면서 (String) -> String 모양이 만들어졌습니다.

greet(at: Date()) // (String) -> String

이 함수는 합성이 되겠네요!

uppercased >>> greet(at: Date()) // (String) -> String
greet(at: Date()) >>> uppercased // (String) -> String

값도 넣을 수 있습니다!

"Blob" |> uppercased >>> greet(at: Date())
// "Hello BLOB! It's 37 seconds past the minute."
"Blob" |> greet(at: Date()) >>> uppercased
// "HELLO BLOB! IT'S 37 SECONDS PAST THE MINUTE."

드디어 합성을 가능하게 만들고 테스트도 가능하게 만들었습니다.

assertEqual(
  "Hello Blob! It's 37 seconds past the minute.",
  "Blob" |> greet(at: Date(timeIntervalSince1970: 37))
)
// ✅

이번엔 테스트가 불가능한 이펙트를 만났을 때 어떻게 컨트롤하고 함수의 입력으로 맥락을 옮기는 방식에 대해 알아보았습니다. 앞에서 출력을 명시하는 것과 쌍이라고 할 수 있습니다. 모든 사이드 이펙트는 이와 같은 방식으로 선언될 수 있습니다.

값의 변화 (Mutation)

이번엔 특별한 종류의 사이드 이펙트인 Mutation에 대해 알아보고 분석해보겠습니다. 다들 한 번 쯤은 mutation을 다뤄보고 뒤따라오는 코드 복잡도에 고통받은 기억이 있으실 것입니다. 다행히도 Swift는 타입 레벨에서 mutation을 컨트롤할 수 있는 기능을 제공하고 있으며 문서에 어디서 어떻게 발생할 수 있는지 잘 기록돼있습니다.

다음은 mutation이 복잡해지는 경우의 예시입니다. 아래 코드는 저희가 실제로 겪었던 경험을 바탕으로 만들었는데, 쓰면 쓸수록 못생겨졌으며 mutation을 컨트롤할 수 있도록 코드를 다시 작성하기 전까진 꾸준히 고통만 받았던 기억이 있습니다.

let formatter = NumberFormatter()

func decimalStyle(_ format: NumberFormatter) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func currencyStyle(_ format: NumberFormatter) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func wholeStyle(_ format: NumberFormatter) {
  format.maximumFractionDigits = 0
}

다양한 종류의 숫자 포맷을 맞추기 위해 Foundation를 포함해서 몇 가지 함수로 NumberFormatter를 만들었습니다. 이 스타일 함수를 사용하기 위해서 저희는 formatter에 직접 값을 넣었습니다.

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,235"

currencyStyle(formatter)
formatter.string(for: 1234.6) // "$1,234"

문제는 위와 같이 사용 후에 다른 곳에서 재사용할 때 발생했습니다.

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,234"

출력값이 "1,235"에서 "1,234"로 바뀌었습니다. 바로 Mutation 때문이죠. currencyStyle 함수의 변화가 formatter의 다른 용도에 섞여서 결국 버그를 만들어냈고 코드가 커질수록 이런 문제는 찾기 어려워집니다.

이게 바로 mutation이 까다로운 이유입니다. Mutation에겐 각 줄이 무엇을 하는지 찍어보기 전까진 알 수 없다는 단점이 존재합니다. 그래서 Mutation은 우리가 앞서 봤던 두 가지의 사이드 이펙트를 합친 것이라 할 수 있는데, 왜냐하면 값의 변경이 가능한 데이터가 함수를 지나가는 것은 숨겨진 입력이면서 숨겨진 출력이라고 할 수 있기 때문입니다.

이러한 종류의 mutation을 겪게 되는 이유는 바로 NumberFormatter가 “참조” 타입이기 때문입니다. Swift에선 클래스는 참조 타입입니다. 참조 타입의 인스턴스는 단일 객체이며 변화됐을 때 이 객체를 참조하는 모든 곳의 결과를 바꿔버립니다. 이 객체를 참조하는 코드 베이스를 하나 하나 찾는 것은 쉽지 않은 일이기 때문에 Mutation이 관련되는 순간 혼돈의 도가니가 돼버립니다. 만약 위에서 예시로 들었던 코드를 기반으로 새로운 기능이 formatter에 추가된다면 새로운 코드 베이스에도 버그들이 퍼질 것입니다.

Swift에는 참조 타입과는 다른 “값” 타입이 존재합니다. 값 타입이 바로 Swift가 Mutation을 조종하는 방식에 대한 답변이라고 할 수 있습니다. 값을 할당하면 주어진 스코프에서 사용할 수 있는 새로운 복사본을 얻습니다. 모든 Mutation은 그 지역에만 영향을 끼칠 것이고 바깥 세상에는 영향을 끼치지 않을 것입니다.

그럼 위 예시를 값 타입을 사용하도록 바꿔볼까요?

기존에 NumberFormatter를 통해서 하던 설정을 대신 해주는 구조체를 만들겠습니다.

struct NumberFormatterConfig {
  var numberStyle: NumberFormatter.Style = .none
  var roundingMode: NumberFormatter.RoundingMode = .up
  var maximumFractionDigits: Int = 0

  var formatter: NumberFormatter {
    let result = NumberFormatter()
    result.numberStyle = self.numberStyle
    result.roundingMode = self.roundingMode
    result.maximumFractionDigits = self.maximumFractionDigits
    return result
  }
}

이젠 computed property인 formatter가 기본값으로 존재하며 이를 통해 새로운 “가장 정직한” NumberFormatter를 만들 수 있게 되었습니다. 바로 NumberFormatterConfig를 통해 스타일링하는 함수를 만드는 방법도 알아보죠.

func decimalStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

func currencyStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .currency
  format.roundingMode = .down
  return format
}

func wholeStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.maximumFractionDigits = 0
  return format
}

각 스타일링 함수는 NumberFormatterConfig를 받으며 var 키워드를 통해 값을 복사합니다. 그리고 이 로컬 값을 변경해서 작업 후 반환합니다.

결과가 바뀌었는지 확인하러 가시죠.

let config = NumberFormatterConfig()

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

currencyStyle(config)
  .formatter
  .string(for: 1234.6)
// "$1,234"

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

스타일링 함수에 config를 보내서 새로운 복사본으로 작업을 했더니 버그가 사라졌네요!

Swift의 값 타입으로만 값을 복사해서 사용할 수 있는 것은 아닙니다. 참조 타입도 비슷한 기능을 제공하긴 합니다. 클래스의 NSCopying 구현체인 copy 메소드를 이용해서 명시적으로 복사본을 사용하면 됩니다.

func decimalStyle(_ format: NumberFormatter) -> NumberFormatter {
  let format = format.copy() as! NumberFormatter
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

불행히도 이 코드는 컴파일러가 기존 formatter를 바꾸지 않는다고 보장해주지 않습니다. 하지만 호출하는 쪽에선 복사본을 예상하고 그에 맞춰서 부담없이 mutation을 할 것입니다. 바로 여기가 코드 복잡도가 높아지는 부분입니다.

참조 타입은 자동으로 복사본을 만들지 않아서 더 나은 퍼포먼스를 내는 장점을 가지고 있기 때문입니다.

그렇다면 복사하지 않고 값이 변하는 것은 그대로 두되, 값이 변하는 것을 명시적으로 확인할 수 있는 방법은 없을까요? inout 키워드가 바로 Swift에서 값의 변화를 의미적으로 나타낼 때 사용하는 기능입니다.

inout을 통해 스타일링 함수에서 config를 변경하도록 해보겠습니다.

func inoutDecimalStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func inoutCurrencyStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func inoutWholeStyle(_ format: inout NumberFormatterConfig) {
  format.maximumFractionDigits = 0
}

위 코드는 처음 짰던 NumberFormatter를 바꾸는 코드랑 매우 비슷하게 생겼습니다. 이 방법을 사용하면 값을 복사하거나 반환하는 것에 대해 고민하지 않아도 됩니다. 이 방식을 이용해서 스타일링 함수를 사용하는 코드를 작성해보겠습니다.

let config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)

다음과 같은 컴파일러 에러가 발생합니다.

Cannot pass immutable value as inout argument: 'config' is a 'let' constant

Swift에선 이런 에러 정돈 잡아주니 바로 수정해봅시다.

var config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)

충분하지 않았나 봅니다. 다른 컴파일러 에러가 발생했네요.

Passing value of type 'NumberFormatterConfig' to an inout parameter requires explicit '&'

Swift는 값이 변하는 함수를 호출할 때 이에 동의하면 그에 맞는 기호를 붙여야 합니다.

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,235"

이제 에러가 나지 않네요. 이전에 했던 것처럼 스타일링 함수를 호출해보겠습니다.

inoutCurrencyStyle(&config)
config.formatter.string(from: 1234.6) // "$1,234"

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,234"

에러는 처음과 다르지 않지만 우리에겐 “mutation”이 어디서 일어나는지 문법적으로 알 수 있어서 버그 추적이 수월해졌습니다.

Swift가 제공하는 mutation 문제에 대해 타입 레벨의 기능을 제공해서 mutation이 일어나는 곳을 조종할 수 있고 어디까지 mutation할 수 있는지 가능하게 해주는 솔루션은 정말 훌륭합니다. 그렇지만 이 기능을 사용해도 문제가 사라지는 것은 아닙니다.

앞에서 만든 새로운 복사본을 반환하는 스타일링 함수는 꽤 익숙한 모양을 하고 있습니다.

(NumberFormatterConfig) -> NumberFormatterConfig

입력과 출력의 타입이 같기 때문에 다른 NumberFormatterConfig를 입력 혹은 출력으로 받는 함수와 합성할 수 있겠네요!

decimalStyle >>> currencyStyle
// (NumberFormatterConfig) -> NumberFormatterConfig

작은 스타일링 함수를 합성한 새로운 스타일링 함수가 생겼습니다.

반면에 inout 키워드를 사용하는 함수는 이런 모양을 가질 수 없습니다. 그들은 입력과 출력의 타입이 같지 않으며 대부분의 함수와 합성하기에 알맞지 않습니다. 그렇지만 이 함수들도 내부 로직은 동일할 것입니다. 그렇다면 분명 inout 세상과 함수 세상을 이어주는 다리를 만들 수 있지 않을까요?

아래에서 새로 정의한 toInout이라는 함수는 입력과 출력이 같은 함수를 inout 함수로 만들어줍니다.

func toInout<A>(
  _ f: @escaping (A) -> A
  ) -> ((inout A) -> Void) {

  return { a in
    a = f(a)
  }
}

위와 쌍으로 fromInout라는 함수를 만들어서 inout 함수를 합성 가능한 함수로 만들 수도 있겠네요.

func fromInout<A>(
  _ f: @escaping (inout A) -> Void
  ) -> ((A) -> A) {

  return { a in
    var copy = a
    f(&copy)
    return copy
  }
}

여기서 확인할 수 있는 것은 (A) -> A 함수와 (inout A) -> Void 함수 사이의 자연적인 대응(natural correspondence)이 생긴다는 점입니다. (A) -> A의 모양을 가진 함수는 합성에 아주 용이하며 이 대응을 이용하면 (inout A) -> Void의 모양을 가진 함수들에게도 합성의 이점을 줄 수 있을 것입니다.

연산자 <>를 소개합니다

(A) -> A 함수에 >>>를 사용할 수 있지만, 너무 많은 자유도를 부여하기 때문에 사용하지 않을 것입니다. 저희가 찾는 것은 더 제한적이며 하나의 타입만 사용하는 합성입니다. 그러니 새로운 연산자를 정의해봅시다. 우선 순위 그룹부터 만들어야겠네요.

precedencegroup SingleTypeComposition {
  associativity: left
  higherThan: ForwardApplication
}

이제 연산자를 정의합시다.

infix operator <>: SingleTypeComposition

이 연산자의 별명은 “다이아몬드” 연산자입니다.

내용은 다음과 같습니다.

func <> <A>(
  f: @escaping (A) -> A,
  g: @escaping (A) -> A)
  -> ((A) -> A) {

  return f >>> g
}

이 연산자의 특징은 이 합성을 하는 동안은 타입을 하나로 제한한다는 점입니다!

inout 함수를 위한 <> 연산자도 정의해보겠습니다.

func <> <A>(
  f: @escaping (inout A) -> Void,
  g: @escaping (inout A) -> Void)
  -> ((inout A) -> Void) {

  return { a in
    f(&a)
    g(&a)
  }
}

기존의 합성도 잘 작동하는지 한 번 확인해줍시다.

decimalStyle <> currencyStyle

그리고 inout 스타일링 함수도 잘 합성되네요.

inoutDecimalStyle <> inoutCurrencyStyle

이 합성에 값을 pipe하면 무슨 일이 일어날까요?

config |> decimalStyle <> currencyStyle
config |> inoutDecimalStyle <> inoutCurrencyStyle

inout 버전에선 에러가 발생합니다.

Cannot convert value of type '(inout Int) -> ()' to expected argument type '(_) -> _'

이 에러는 |> 연산자가 inout 세상에 완벽히 적응하지 않아서 발생했지만 다음과 같은 방식으로 바로 적응시킬 수 있습니다.

func |> <A>(a: inout A, f: (inout A) -> Void) -> Void {
  f(&a)
}

이제 자유롭게 inout 파이프라인에 값을 넣을 수 있게되었습니다.

config |> inoutDecimalStyle <> inoutCurrencyStyle

훌륭하네요! 우리는 Swift의 멋진 기능을 사용하기 위해 합성 가능성을 포기하지 않아도 된다는 것을 배웠습니다!

새로운 연산자가 생겼으니 어김없이 찾아오는 체크박스 타임이 왔습니다.

  1. Swift에 이미 존재하는 내용인가요? 아니요, 그러니 혼란스러울 것이 없습니다.

  2. 이미 하고 있는 언어가 있나요? 이미 Haskell, PureScript를 포함해 강력한 함수형 커뮤니티를 가진 언어는 이미 채택한 연산자입니다.

  3. 이 연산자는 전역 연산자인가요 아니면 도메인 특화된 문제만 해결하나요? (A) -> A 모양을 가진 함수와 (inout A) -> Void 모양을 가진 함수를 위한 연산자만 정의했지만 개념적으로 <> 연산자는 같은 타입을 가진 두 함수를 합성할 수 있는 일반적인 함수입니다. 앞으로 만들어갈 시리즈에서 계속 보게 될 것입니다.

그래서 요점이 뭔가요?

오늘 배운 내용을 정리할 시간입니다.

“그래서 요점이 뭔가요?”

오늘 우리는 코드에서 복잡도를 올리고 테스트 가능성을 낮추는 사이드 이펙트에 대해 알아보았습니다. 그리고 정의할 때 입력 또는 출력 부분에 사이드 이펙트를 명시하는 방식을 써봤지만 이 방법은 함수를 합성하기 어렵게 만든다는 단점이 있었습니다. 이 단점을 보완하기 위해 사이드 이펙트 합성에 특화된 새로운 연산자를 하나 소개드렸습니다.

이제 우리에겐 사이드 이펙트가 많아서 테스트가 불가능하거나 어디서 무슨 일이 일어나고 있는지 추측하기 어려운 코드를 사이드 이펙트가 명시되어 있고 테스트 할 수 있으며 다른 코드에 대한 의존성 없이 독립적으로 이해할 수 있는 코드로 만들 수 있는 능력이 생겼습니다! (심지어 합성도 가능하게 유지하면서요!) 끝내줍니다!

하지만 사이드 이펙트는 정말 거대한 주제입니다. 오늘은 그저 수박 겉핥기 정도 였다는 사실을 알아주세요. 앞으로도 사이드 이펙트를 다루는 흥미로운 방식에 대해 알아볼 예정이니 기대해주세요.

이번 에피소드는 여기까지입니다. 다음 글에서 뵙겠습니다!