사이드 이펙트

2026. 1. 11. 18:50·내게 필요한 개발 공부

Point-Free의 첫 번째 에피스드인 Function 편 이후 이어지는 Side Effects 편 스크립트를 기반으로 내용을 정리해봤습니다. 이번 에피소드도 감탄의 연속,,, 어떤 사고 과정을 통해 이런 방향성을 자신있게 제시할 수 있는지, 이를 근거 있게 전달할 수 있는 흐름 등의 내용이 무척 인상깊었습니다.

 

Introduction

  • 지금까지 함수들이 어떻게 합성(compose)되는지 강조하며 에피소드를 진행했음.
  • 그러나 함수의 시그니처(선언부)만으로는 포착되지 않는 수많은 동작이 존재함 → Side Effects
  • Side Effects는 코드 복잡성의 주된 원인 중 하나임.
    • 테스트가 어려움.
    • 합성이 잘 되지 않음.
  • Side Effects의 이러한 복잡성은 함수 합성을 적극적으로 활용하는데 있어 걸림돌이 됨.
  • 이번 에피소드에서는 몇 가지 Side Effects의 유형을 다루고, 이것들이 왜 걸림돌이 되는지 보고, 문제를 깔끔히 해결해 볼 것임.
  • 먼저 Side Effects가 없는 함수를 살펴보겠음.
func compute(_ x: Int) -> Int {
  return x * x + 1
}
  • 위 함수를 호출하면 결과가 반환됨.
compute(2)  // 5
  • Side Effects가 없는 함수의 가장 좋은 속성은, 동일한 입력으로 몇 번을 호출하든 동일한 출력을 얻는다는 것임.
compute(2)  // 5
compute(2)  // 5
compute(2)  // 5
  • 이러한 예측 가능성 덕분에 테스트 작성 또한 매우 간단함.
assertEqual(5, compute(2))  // ✅
  • 만약 잘못된 예상값을 입력으로 넣으면 항상 실패할 것임.
assertEqual(4, compute(2)) // ❌
assertEqual(5, compute(3)) // ❌
  • 이제 함수에 Side Effect를 넣어보겠음.
func computeWithEffect(_ x: Int) -> Int {
  let computation = x * x + 1
  print("Computed \(computation)")
  return computation
}
  • 중간에 print 문을 넣었음.
  • 이전과 동일한 입력으로 위 함수를 호출하면 동일한 출력을 얻음.
computeWithEffect(2)  // 5
  • 하지만 콘솔을 살펴보면 여기에 추가 출력이 발생한 것을 확인할 수 있음.
Computed 5
  • 함수 시그니처를 비교하면 computeWithEffect 는 compute 함수와 정확히 동일하지만 시그니처만으로는 설명할 수 없는 작업이 수행되고 있음.
  • print 기능은 외부 어딘가로 나아가 어떤 ‘변화’를 일으키고 있고, 이 경우 콘솔에 출력하는 일임.
  • Side Effects는 함수 본문 내부에 숨어있다는 것을 알아야 한다는 것임.
  • 이제 이 함수에 대해 테스트를 작성해보겠음.
assertEqual(5, computeWithEffect(2))  // ✅
  • 이 테스트 코드는 통과하지만 또 새로운 줄이 콘솔에 출력됐을 것임.
Computed 5
Computed 5
  • 이 동작은 우리가 테스트할 수 없는 동작임.
  • 이 상황에서는 콘솔에 print 하는 것이 별 문제가 아닌 것처럼 보일 수 있음.
  • 하지만 디스크에 쓰기, API 요청, Analysis 트래킹과 같은 다른 Side Effects가 발생하게 되면 이야기가 달라짐.
  • Side Effects는 우리의 구성적 직관성(compositional intuitions)을 깨트릴 수 있음.
  • 이전 에피소드(함수)에서 두 개의 함수가 포함된 배열에 대한 mapping이 어떻게 기존 방식과 동일할 수 있을지 논의했었음.
[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
  • Side Effects를 고려하지 않고서는 더 이상 함수 합성을 활용하기 어려울 것임.
  • 이런 종류의 리팩토링은 실질적인 성능 최적화 수단임.
    • 배열을 2번 순회하는 대신 함수 합성으로 1번만 순회하게 하는 것.
  • 그러나 함수에 Side Effects가 있다면 실행 순서가 동일하게 유지되지 않음.
  • 결국 Side Effects가 있는 환경에서는 성능 최적화를 진행하는 것은 코드를 오동작하게 만들 위험이 있음.

Hidden Outputs

  • 이제 Side Effects를 제어할 수 있는 가장 간단한 방법을 알아볼 것임.
  • 함수 본문 안에서 Side Effects를 직접 수행하는 대신, 무엇을 출력해야하는지 설명하는 값을 추가로 반환하는 것임.
  • 함수는 여러 반환값을 가질 수 있으므로, 이를 표현하기 위해 [String] 을 모델로 사용해보겠음.
func computeAndPrint(_ x: Int) -> (Int, [String]) {
  let computation = x * x + 1
  return (computation, ["Computed \(computation)"])
}

computeAndPrint(2)  // (5, ["Computed 5"])
  • 이제 계산 결과 뿐 아니라 print하려는 로그가 담긴 배열도 반환받게 됨.
assertEqual(
 (5, ["Computed 5"]),
  computeAndPrint(2)
) // ✅
  • 이제 우리는 계산 결과 뿐 아니라 수행하려는 효과(Effect)에 대해서도 다룰 수 있게 됨!
  • 이제 Side Effects가 예상치 못한 포맷이라면 테스트가 실패함.
assertEqual(
 (5, ["Computed 5"]),
  computeAndPrint(3)
) // ❌
  • 이 함수에서 데이터는 매우 간단하지만 실제 프로덕트에서는 API 요청이나 이벤트 분석과 같은 것들을 설명하는 중요한 데이터일 수 있음을 기억해야 함.
  • 이 방식을 활용하면 우리가 의도한 대로 Side Effects들이 준비되고 있는지 검증하는 테스트 코드를 작성할 수 있게 될 것임.
  • 이런 관점에서 보면 외부 세계에 변화를 주는 Side Effects는 사실 함수의 감춰진(암묵적) 출력값이나 다름 없음.
  • 프로그래밍 세계에서 무언가 ‘암묵적’이라는 것은 대개 좋은 징조가 아님.
  • 이제 우리는 궁금해질 것임. → 그럼 Side Effects는 누가 실행하는가?
  • 우리는 Effect를 명시적 반환 타입으로 끌어올림으로써, Effect를 실행할 책임을 함수 내부가 아닌 함수를 호출하는 주체에게 넘긴 것임.
// 1. Effect를 명시적 반환 타입으로 받고
let (computation, logs) = computeAndPrint(2)

// 2. 함수를 호출하는 주체가 특정 Action을 취할 수 있게 함.
logs.forEach { print($0) }
  • 우리는 함수를 호출하는 주체 역시도 Side Effects를 직접 처리하고 싶지 않을 수도 있음.
  • 그럼 그 함수를 호출한 또 다른 상위로 Side Effects를 넘기고, 넘기고, 넘길 것임.
  • 이에 대한 해결책을 알아보기 전에, 이 문제가 정확히 어떤 것인지 구체적으로 이해할 필요가 있음.
  • Side Effects 문제를 드디어 해결한 것처럼 보일 수 있지만, 안타깝게도 이 과정에서 우리는 함수의 가장 중요한 특징 중 하나인 합성(Composition)을 망가뜨리고 말았음.
  • compute 함수는 자체적으로 forward-compose를 하기 때문에 적절함.
compute >>> compute  // (Int) -> Int
  • computeAndPrint 함수 또한 자체적으로 forward-compose 하기 때문에 실제로 꽤 괜찮아보임.
computeWithEffect >>> computeWithEffect  // (Int) -> Int
  • 둘 모두 값을 파이프(|>)로 연결해 결과를 얻을 수도 있을 것.
2 |> compute >>> compute  // 26
2 |> computeWithEffect >>> computeWithEffect  // 26
  • 물론 콘솔에 print가 찍히겠지만 말임.
Computed 5
Computed 26
  • 하지만 사실 computeAndPrint 함수는 합성되지 않음.
❌ Cannot convert value of type '(Int) -> (Int, [String])' to expected argument type '((Int, [String])) -> (Int, [String])'

computeAndPrint 함수의 출력은 (Int, [String])이라는 튜플 형태인 반면, 입력은 그저 Int일 뿐입니다.
  • 앞으로 우리는 이런 상황을 반복해서 마주치게 될 것.
  • Side Effects를 수행해야 하는 함수가 있을 때마다 그 Effect를 묘사하기 위해 반환 타입을 확장할 것이고, 그 때마다 함수 합성은 깨질 것.
  • 우리는 이런 형태의 함수들도 다시 잘 합성될 수 있도록 새로운 방식을 고민해야함.
  • 튜플을 반환하는 함수의 경우 이 문제를 우아하게 해결할 수 있음.
func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
_ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {
  …
}
  • 이 구조는 이전에 만들었던 >>> 함수와 시그니처가 매우 비슷함.
  • (A) -> B, (B) -> C, 그리고 (A) -> C라는 흐름은 같지만, 옆에 약간의 추가 정보(Side Effects)가 곁들여져 있을 뿐임.
  • 우리가 가진 함수들의 타입과 사용 가능한 값들이 무엇인지 하나씩 살펴보다 보면 이 함수를 어떻게 구현해야 할지 알 수 있음.
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가 있으니, f에 a를 전달한 뒤 그 결과값인 b와 발생한 logs(Side Effects)들을 추출.
  • 이제 함수 g에 들어갈 b가 생겼으니 g에 b를 넣으면 c와 logs(Side Effects)가 돌아올 것.
  • 이제 이를 f에서 받은 logs와 함께 반환하면 될 것임.
compose(computeAndPrint, computeAndPrint)
// (Int) -> (Int, [String])
  • 이제 우리는 computeAndPrint 함수를 두 번 호출하는 완전히 새로운 함수를 만들어냈음.
  • 여기에 데이터를 넣으면 최종 계산 결과 뿐 아니라 각각의 실행 단계마다 누적된 모든 log(Side Effects) 기록 까지 한 번에 확인할 수 있음.
2 |> compose(computeAndPrint, computeAndPrint)
// (26, ["Computed 5", "Computed 26"])

Introducing >=>

  • 합성 기능을 복구하고 문제를 완전히 해결한 것처럼 보이지만 2개 이상의 함수를 합성하면 꽤나 지저분해지기 시작함.
2 |> compose(
  compose(computeAndPrint, computeAndPrint),
  computeAndPrint
)
  • 더 나쁜 점은 동일한 합성 기능을 만드는데 2가지 다른 방법이 있다는 것.
2 |> compose(
  compose(computeAndPrint, computeAndPrint),
  computeAndPrint
)
2 |> compose(
  computeAndPrint,
  compose(computeAndPrint, computeAndPrint)
)
  • 괄호는 언제나 함수 합성의 적이었음.
  • 그렇다면 괄호의 천적은 무엇일까? → 바로 중위 연산자(infix operator)임.
precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
}
  • 이제 우리는 친숙해보이는 형태로 중위 연산자를 하나 정의해볼 수 있을 것임.
infix operator >=>: EffectfulComposition
  • 이 연산자는 >>> 와 매우 닮았지만, 가운데 화살표를 튜브 모양의 =으로 바꿨음.
  • 이 연산자의 이름은 피쉬(Fish)로 불리기도 함.
  • 이제 이전에 만든 compose 함수의 이름을 이 연산자로 바꿀 수 있음.
  • 이제 괄호를 어떻게 배치해야 할지 고민해야 하는 번거로움 없이도 Side Effects가 있는 함수들을 매끄럽게 이어붙일 수 있을 것.
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)
  • 이제 우리는 합성을 통해 Side Effects가 있는 함수의 결과값을 → Side Effects가 없는 순수 함수에 전달하며 자유롭게 활용할 수 있게 되었음.
  • 비록 새로운 괄호 문제가 생기긴 했지만 이 또한 해결이 가능함.
  • 괄호가 나타나는 위치와 그 사이의 입력 / 출력 타입을 살펴보면 순수 함수의 합성이 언제나 더 높은 우선순위를 가져야 한다는 결론에 도달하게 됨.
  • 따라서 앞에서 만든 EffectfulComposition 우선순위 그룹을 업데이트 할 필요가 있음.
precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
  lowerThan: ForwardComposition
}
  • 이제 우리는 더 이상 괄호가 필요하지 않음!
2
  |> computeAndPrint
  >=> incr
  >>> computeAndPrint
  >=> square
  >>> computeAndPrint
  • 이제 코드의 모든 줄은 그 의미를 명확히 해주는 연산자와 함께함.
  • >>> 로 시작하는 줄은 Side Effects가 없는 순수 함수의 결과물을 다루고 있음을 알리고,
  • >=> 로 시작하는 줄은 Side Effects가 있는 연산 결과를 다루고 있음을 보여줌.
  • 새로운 연산자를 도입했으니 우리 코드에 이 연산자를 추가할 명분을 증명할 차례임.
  1. 이 연산자가 이미 Swift에 존재하는 혹은 의미를 가지고 있는가? → X
  2. 기존에 널리 쓰이던 선례가 있고 모양이 직관적인가? → O / Haskell 이나 PureScript에서 기본으로 제공되는 연산자이며, 수많은 함수형 프로그래밍 커뮤니티에서 라이브러리로 채택하고 있음.
  3. 범용적인 연산자인가? → O / 현재는 튜플을 처리하도록 정의되어 있지만 이 연산자가 묘사하는 구조는 프로그래밍 전반에서 나타날 것.
func >=> <A, B, C>(
  _ f: @escaping (A) -> B?,
  _ g: @escaping (B) -> C?
) -> ((A) -> C?) {

  return { a in
    fatalError()  // an exercise for the viewer
  }
}
  • 이제 튜플 대신 옵셔널을 사용해보겠음.
  • 위 형태는 옵셔널을 반환하는 함수들을 합성할 때 유용한 연산자를 하나 얻을 수 있게 됨.
  • 이 연산자를 활용하면 실패할 가능성이 있는 initializer 들을 매끄럽게 하나로 연결할 수 있음.
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()  // an exercise for the viewer
  }
}
  • 만약 Promise나 Future 타입을 사용하고 있다면 이 연산자를 활용해 프로미스를 반환하는 함수들도 아주 쉽게 합성할 수 있음.
func >=> <A, B, C>(
  _ f: @escaping (A) -> Promise<B>,
  _ g: @escaping (B) -> Promise<C>
) -> ((A) -> Promise<C>) {

  return { a in
    fatalError()  // an exercise for the viewer
  }
}
  • 매우 강력한 타입 시스템을 가진 몇몇 언언에서는 이 연산자를 단 한번만 정의하면 모든 구현체를 즉시 얻을 수 있기도 함. → 아직 Swift에는 그런 기능이 없어서 새로운 타입이 생길 때 마다 직접 정의해 주어야 함.
  • 하지만 덕분에 우리는 이러한 구조에 대한 직관을 기를 수 있고, 수많은 타입에 걸쳐 공유할 수 있게 되었음.
  • 이제 >=> 연산자를 볼 때마다 우리는 이것이 어떤 종류의 Side Effects 속으로 로직을 연쇄적으로 이어주고 있다는 것을 바로 알 수 있을 것임.

Hidden Inputs

  • 지금까지 우리는 암묵적 출력을 만들어내는 Side Effects를 살펴보고, 그 출력을 함수의 명시적 출력으로 바꿈으로써 합성 가능성을 유지한 채 Side Effects를 제어하는 방법을 알아봤음.
  • 그런데 이것보다 조금 더 까다로운 또 다른 종류의 Side Effects가 있음.
  • 사용자를 위한 인사말을 생성하는 아주 간단한 함수를 예시로 들어보겠음.
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")
) // ❌
  • 이것은 특히나 골치아픈 Side Effects임.
  • 이전 사례(암묵적 출력)에서는 적어도 출력값에 대해 테스트라도 할 수 있었지만, 이번에는 출력값이 계속 변하기 때문에 테스트 코드를 작성하는 것조차 불가능함.
  • print는 입력은 받지만 반환값이 없는 함수였음. 반면 이번 사례인 Date는 입력은 없지만 반환값이 있는 함수임.
  • 이번에도 비슷한 해결책을 적용해 볼 것임.
  • 앞서 print의 Effect를 compute 함수의 반환값으로 명시했던 것처럼, D**ate의 Effect를 함수의 인자로 명시해보는 것임.**
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")
  • 이 함수는 이전과 동일하게 동작하지만 한 가지 결정적인 차이점이 있음.
  • 이제 우리가 Date를 완벽하게 통제할 수 있게 되었고, 덕분에 언제 실행하더라도 항상 통과하는 테스트 코드를 작성할 수 있게됨.
assertEqual(
  "Hello Blob! It's 39 seconds past the minute.",
  greetWithEffect(at: Date(timeIntervalSince1970: 39), name: "Blob")
) // ✅
  • 약간의 보일러플레이트를 감수하고 테스트 가능성을 되찾아 왔음.
  • 하지만 이제 함수를 호출할 때마다 Date를 명시적으로 넘겨줘야 하는데, 테스트 상황이 아닐 때는 이게 불필요하게 느껴질 수도 있음.
  • 그래서 우리는 구현 세부 사항을 감추고, 호출부의 코드를 깔끔하게 유지하기 위해 기본 인자를 지정해 현재 날짜라는 의존성을 함수에 주입하고 싶은 유혹을 느낄지도 모름.
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 이라는 깔끔한 모양 덕분에 문자열을 반환하거나 문자열을 입력으로 받는 다른 함수들과 자유롭게 결합할 수 있었음.
func uppercased(_ string: String) -> String {
  return string.uppercased()
}
  • 위 함수는 greetWithEffect 함수와 멋지게 합성될 수 있음.
uppercased >>> greetWithEffect
greetWithEffect >>> uppercased
  • 또한 파이프로 연결해 각 합성에 대해 서로 다른 동작을 얻을 수도 있음.
"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 '(_) -> _'
  • 함수가 2개의 입력을 받게 되면서 다른 함수의 출력값을 이 함수로 직접 연결할 방법이 사라졌음.
  • 하지만 Date의 입력을 잠시 제쳐두고 보면, 여전히 그 내부에는 (String) → String 이라는 모양이 숨어있음을 알 수 있음.
  • 사실 시그니처에서 Date를 분리해낼 수 있는 묘수가 있는데, greet 함수가 Date를 먼저 입력받되, 그 결과로 실제 인사말 로직을 처리하는 새로운 (String) → String 함수를 반환하도록 구조를 바꾸는 것.
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."
  }
}
  • 이제 Date와 함께 greeting 함수를 호출해 완전히 새로운 (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))
) // ✅
  • 드디어 우리는 테스트가 불가능했던 그 context를 함수의 입력값으로 옮김으로써 Side Effects를 제어하는데 성공했음.
  • 이것은 우리가 앞서 보았던 Side Effects 사례와 정확히 대칭을 이루는 방식임.
  • 이전 Side Effects는 외부 세계에 영향을 주어 변화를 일으키는 ‘암묵적 출력’
  • 이번 Side Effects는 외부 세계의 상태에 의존하는 ‘암묵적 입력’
  • 모든 Side Effects는 결국 이 2가지 형태 중 하나로 나타나게 됨.

Mutation

  • 이제 상태 변경(Mutation)이라는 아주 특수한 형태의 Side Effects를 자세히 분석해보겠음.
  • 우리 모두 코드를 작성하며 상태 변경을 다뤄왔지만, 이는 종종 코드를 매우 복잡하게 만듦.
  • 다행히 Swift는 상태 변경이 언제, 어디서 발생하는지 제어하고 명확히 기록할 수 있도록 돕는 타입 수준의 기능들을 제공함.
  • 상태 변경이 얼마나 지저분해질 수 있는지 보여주는 예시를 가져왔음.
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에 특정 스타일을 입혀주는 여러 함수가 있음.
  • 이 스타일링 함수들을 사용하려면 우리가 만든 Formatter 인스턴스에 직접 적용하기만 하면 됨.
decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6)  // "1,235"

currencyStyle(formatter)
formatter.string(for: 1234.6)  // "$1,234"
  • 그런데 여기서 아까 사용했던 첫 번째 Formatter 설정들을 다시 적용하려고 하면 문제가 생김. → 이전 함수들이 남긴 설정값이 객체에 그대로 남아 있기 때문
decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6)  // "1,234"
  • 출력값이 “1,235” 에서 “1,234” 로 바뀌어 버렸음. → 바로 상태 변경 때문.
  • currencyStyle 함수가 저지른 변경 사항이 Formatter의 다른 용도에까지 스며들게 된 것.
  • 이런 버그는 규모가 큰 프로젝트에서 정말 추적하기 힘든 골칫덩이가 됨.
  • 이것이 바로 상태 변경이 까다로운 이유.
  • 이전의 모든 코드가 무슨 짓을 했는지 일일히 추적해 보지 않고서는 지금 내 눈앞의 코드 한 줄이 정확히 어떤 동작을 할지 알 방법이 없기 때문.
  • 사실 상태 변경은 우리가 앞서 만났던 2가지 Side Effects가 합쳐진 형태임.
  • 함수 사이에 주고 받는 ‘변경 가능한 데이터’는 그 자체로 암묵적 입력이자, 동시에 암묵적 출력이 되는 것!
  • 우리가 이런 문제를 겪는 이유는 NumberFormatter가 참조(Reference) 타입이기 때문.
  • Swift에서 class는 참조 타입임.
  • 참조 타입의 인스턴스는 단 하나의 객체이고, 이 객체의 상태가 바뀌면 참조를 가진 코드 베이스의 모든 곳이 영향을 받게 됨.
  • 어떤 코드들이 동일한 참조를 공유하고 있는지 추적할 쉬운 방법이 없기에, 상태 변경이 개입되는 순간 혼란이 가중되는 것.
  • 만약 이 예제 코드가 실제 앱에 쓰였고 새로운 기능을 개발하면서 이 Formatter에 의존했다면 완전히 엉뚱한 곳에서 미묘한 버그들이 기어 나오기 시작했을 것.
  • 이제 이 코드를 값(Value) 타입을 사용하는 방식으로 리팩토링 해볼 것.
  • 먼저 NumberFormatter에서 수행하던 설정 작업들을 감싸줄 수 있는 struct를 만드는 것부터 시작해보겠음.
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
  }
}
  • 이 struct는 적절한 기본값을 가지고 있으며, 새롭고 ‘정직한’(honest) NumberFormatter를 뽑아낼 수 있는 계산 속성인 formatter를 제공함.
  • 이제 기존의 스타일링 함수들을 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 키워드를 사용해 이를 복사함.
  • 그리고 지역 복사본(local copy)의 값을 변경한 뒤 호출자에게 다시 반환하는 방식임.
  • 이 방식을 실제로 사용하는 모습은 이전과 조금 달라진 것.
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를 스타일링 함수에 전달할 때마다 새로운 복사본을 받게 되어 아까 버그를 고칠 수 있게 될 것임.
  • 사실 참조 타입에서도 이와 비슷하게 처리할 방법이 있긴함. → NSCopying 프로토콜을 구현한 class들에서 copy 메서드를 호출해 복사본을 만들고 이를 명시적으로 반환해주는 것.
func decimalStyle(_ format: NumberFormatter) -> NumberFormatter {
  let format = format.copy() as! NumberFormatter
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}
  • 하지만 안타깝게도 참조 타입을 복사하는 위 방식은, 원본 Formatter를 변경하지 않았다는 것을 컴파일러가 보장해 주지 않음.
  • 또한 호출자가 복사본을 받았다고 생각하고 마음껏 상태를 변경하기 시작하면 복잡성은 더 커질 수도 있을 것임.
  • 하지만 참조 타입은 매번 복사할 필요가 없기 때문에 분명 성능적 이점이 있음.
  • 다행히도 Swift는 값 타입을 사용하면서도 성능을 챙길 수 있게 방법을 만들어뒀음 → inout 키워드임.
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로 바꾸라고 제안해주는 것.
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"
  • 다시 버그가 나타났음. → 그러나 이번에는 상황이 좀 다름! → 지금 우리 코드에는 상태 변경에 대한 수많은 문법적 장치들이 깔려 있기 때문.
  • 덕분에 이런 종류의 버그를 추적하기 훨씬 쉬워졌을 것.
  • 상태 변경이 일어나는 위치와 그 영향 범위를 제어할 수 있도록 Swift가 타입 수준의 기능을 제공한다는 점은 정말 훌륭함.
  • 하지만 이 기능을 제대로 활용하려면 여전히 해결해야 하는 문제가 있음. → 이전에 우리가 사용했던 매번 새로운 복사본을 반환하던 스타일링 함수들은 아주 깔끔한 형태를 가지고 있었음.
(NumberFormatterConfig) -> NumberFormatterConfig
  • 위 함수들은 입력과 출력의 타입이 서로 같음.
  • 즉, 이들끼리 자유롭게 합쳐질 수 있을 뿐 아니라 NumberFormatterConfig를 반환하거나 입력으로 받는 그 어떤 함수들과도 완벽하게 결합될 수 있다는 뜻!
decimalStyle >>> currencyStyle
// (NumberFormatterConfig) -> NumberFormatterConfig
  • 이제 우리는 작은 조각들을 합쳐서 만든 완전히 새로운 스타일링 함수를 갖게 되었음.
  • 반면 아까 살펴본 inout 함수들은 이런 모양을 갖추고 있지 않음.
  • 입력과 출력의 타입이 맞지 않아서 일반적인 함수들과는 합성이 되지 않음.
  • 하지만 두 방식 모두 담고 있는 로직은 동일하기 때문에 분명 연결할 방법이 있을 것.
  • 입력과 출력 타입이 같은 일반 함수를 inout 함수로 변환해주는 toInout 이라는 함수를 정의해 볼 것.
func toInout<A>(
  _ f: @escaping (A) -> A
) -> ((inout A) -> Void) {

  return { a in
    a = f(a)
  }
}
  • 또한 역변환을 수행하는 fromInout 함수를 정의할 수 있음.
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 형태의 함수 사이에는 아주 자연스러운 대응 관계가 존재한다는 점임.
  • (A) → A 함수들은 매우 우아하게 합성되는 특징이 있는데, 이런 대응 관계를 잘 활용한다면 (inout A) → Void 함수들 역시 그 강력한 합성 능력을 공유할 수 있을 것임.

Introducing <>

  • 비록 (A) → A 형태의 함수들이 >>> 연산자로 합성되는 것을 확인했지만, 이 연산자를 그대로 재사용하지는 않을 것.
  • >>>는 허용하는 범위가 너무 넓기 때문.
  • 우리는 그보다 훨씬 더 제약이 명확한, 즉 ‘단일 타입’(Single Type) 간의 합성에 집중하려고 함.
  • 자 그럼 새로운 연산자를 정의해보겠음.
precedencegroup SingleTypeComposition {
  associativity: left
  higherThan: ForwardApplication
}
infix operator <>: SingleTypeComposition
  • 이 연산자에 붙여진 재밌는 이름은 바로 ‘다이아몬드’ 연산자임.
  • 이 연산자를 (A) → A 함수들에 대해 정의하는 방법은 꽤나 간단함.
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
  • 값을 파이프해 함수 합성을 하면 어떤 일이 일어날까요?
config |> decimalStyle <> currencyStyle
config |> inoutDecimalStyle <> inoutCurrencyStyle
  • 우리의 버전에서는 에러가 발생할 것입니다.
Cannot convert value of type '(inout Int) -> ()' to expected argument type '(_) -> _'
  • 이건 우리가 만든 |> 연산자가 아직 inout 세계에서는 동작하지 않기 때문.
  • 하지만 우리는 함수 오버로딩을 통해 이를 추가로 정의할 수 있음.
func |> <A>(a: inout A, f: (inout A) -> Void) -> Void {
  f(&a)
}
  • 이젠 우리는 드디어 자유롭게 값을 파이프할 수 있게 되었음!
config |> inoutDecimalStyle <> inoutCurrencyStyle
  • 정말 멋진 결과임! Swift의 훌륭한 기능들을 활용하면서도 함수형 프로그래밍의 핵심인 합성 가능성(Composability)을 전혀 희생할 필요가 없었음.
  • 물론 또 하나의 연산자를 추가하는 비용을 치렀으니, 다시 한번 자격을 체크할 차례임.
  1. 이 연산자가 Swift에 이미 존재하는가? → X / 따라서 기존 기능과 혼동될 여지가 전혀 없음.
  2. 참고할 만한 사례가 있는가? → O / Haskell, PureScript 등 함수형 커뮤니티가 탄탄한 언어들에서 이미 널리 채택되어 사용 중임. 양방향을 가리키는 이 기호 모양은 무언가를 하나로 ‘결합’한다는 의미를 아주 잘 전달함.
  3. 이것은 범용적인 연산자인가? → O / 지금까지는 (A) → A 와 (inout A) → Void 함수를 위해서만 정의했지만 사실 <>는 같은 타입의 두 요소를 하나로 합치는 모든 상황에서 훨씬 범용적으로 사용됨. → 이것은 계산의 가장 근본적인 단위라고도 할 수 있음.

What’s the point?

  • 이제 스스로에게 물어볼 시간임. → 이 모든 것의 목적이 무엇인가?
  • 우리는 코드에 복잡성을 더하고 테스트를 어렵게 만드는 수많은 Side Effects를 마주했음.
  • 이를 해결하기 위해 입력과 출력이라는 타입 시스템 안에 Side Effects를 명시하는 약간의 사전 작업을 감수했음.
  • 하지만 그 과정에서 함수 합성이 깨졌고, 이를 복구하기 위한 Side Effects 전용 합성 연산자들을 도입해야 했음.
  • 과연 그럴 가치가 있었는가? → Point-Free의 대답은 당연히 그렇다!! 임.
  • 격리된 상태에서는 테스트도 불가능하고 추론하기도 힘들었던 Side Effects 덩어리의 코드들을 명시적으로 드러나게 끌어올렸기 때문.
  • 이제 우리는 이전 줄의 코드가 무슨 일을 했는지 일일히 파악하지 않고도 단 한 줄의 코드만으로 그 동작을 완벽히 이해하고 테스트할 수 있음.
  • 이 모든 것은 ‘함수 합성’이라는 강력한 무기를 잃지 않고 해낸 것. → 이것은 정말 강력한 힘임!
  • 비록 초기에 약간의 수고가 더 들었지만 이는 복잡하게 얽힌 상태 변경을 디버깅하거나,
  • 코드 여기저기에 뿌려진 Side Effects 때문에 발생하는 버그를 잡는데 들어갈 엄청난 시간을 절약해 줄 것.
  • 또한 테스트를 위해 억지로 코드를 끼워 맞추는 고통에서도 해방될 것.
  • Side Effects는 정말 방대한 주제이며, 우리는 이제 겨우 그 겉면을 봤을 뿐임.
  • 앞으로 우리는 Side Effects를 제어하는 훨씬 더 흥미로운 방법들을 탐험하게 될 것!
저작자표시 (새창열림)

'내게 필요한 개발 공부' 카테고리의 다른 글

onChange는 사실 조금 이상하다  (0) 2026.01.29
딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데?  (0) 2025.10.26
함수와 메서드는 다르다.  (4) 2025.08.01
Task는 항상 부모 Context를 상속 받을까?  (5) 2025.07.22
멀티태스킹과 멀티프로세싱, 비슷한듯 다른 두 개념  (0) 2025.03.17
'내게 필요한 개발 공부' 카테고리의 다른 글
  • onChange는 사실 조금 이상하다
  • 딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데?
  • 함수와 메서드는 다르다.
  • Task는 항상 부모 Context를 상속 받을까?
thinkyside
thinkyside
스스로에게 솔직해지고 싶은 공간
  • thinkyside
    또 만드는 한톨
    thinkyside
  • 전체
    오늘
    어제
    • 모아보기 (74)
      • 솔직해보려는 회고 (2)
      • 꾸준히 글쓰기 (10)
      • 생각을 담은 독서 (8)
      • 내게 필요한 개발 공부 (27)
      • 실무 내용 내껄로 만들.. (4)
      • 트러블슈팅 (5)
      • 프로젝트 일지 (9)
      • 개발 서적 (3)
      • 취준 (3)
      • 대외활동 (2)
      • UXUI (1)
  • hELLO· Designed By정상우.v4.10.3
thinkyside
사이드 이펙트
상단으로

티스토리툴바