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가 있는 연산 결과를 다루고 있음을 보여줌.- 새로운 연산자를 도입했으니 우리 코드에 이 연산자를 추가할 명분을 증명할 차례임.
- 이 연산자가 이미 Swift에 존재하는 혹은 의미를 가지고 있는가? → X
- 기존에 널리 쓰이던 선례가 있고 모양이 직관적인가? → O / Haskell 이나 PureScript에서 기본으로 제공되는 연산자이며, 수많은 함수형 프로그래밍 커뮤니티에서 라이브러리로 채택하고 있음.
- 범용적인 연산자인가? → 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(©)
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)을 전혀 희생할 필요가 없었음.
- 물론 또 하나의 연산자를 추가하는 비용을 치렀으니, 다시 한번 자격을 체크할 차례임.
- 이 연산자가 Swift에 이미 존재하는가? → X / 따라서 기존 기능과 혼동될 여지가 전혀 없음.
- 참고할 만한 사례가 있는가? → O / Haskell, PureScript 등 함수형 커뮤니티가 탄탄한 언어들에서 이미 널리 채택되어 사용 중임. 양방향을 가리키는 이 기호 모양은 무언가를 하나로 ‘결합’한다는 의미를 아주 잘 전달함.
- 이것은 범용적인 연산자인가? → 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를 제어하는 훨씬 더 흥미로운 방법들을 탐험하게 될 것!
'내게 필요한 개발 공부' 카테고리의 다른 글
| 딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데? (0) | 2025.10.26 |
|---|---|
| 함수와 메서드는 다르다. (4) | 2025.08.01 |
| Task는 항상 부모 Context를 상속 받을까? (5) | 2025.07.22 |
| 멀티태스킹과 멀티프로세싱, 비슷한듯 다른 두 개념 (0) | 2025.03.17 |
| 안전한 놀이터 샌드박스 알아보기 (2) | 2025.02.26 |
