본문 바로가기

순수 함수와 불변성

같은 입력에 늘 같은 결과를 돌려주고 바깥을 바꾸지 않는 순수 함수와, 값을 바꾸는 대신 새 값을 만드는 불변 갱신을 예제로 익히고, readonly와 얕은 복사가 지키는 범위를 참조의 비교로 확인한 뒤, 순수한 계산과 바깥과의 입출력을 나누는 구조를 정리합니다.

함수형 프로그래밍(functional programming)은 프로그램을 값을 바꾸는 명령의 순서가 아니라, 값을 받아 새 값을 돌려주는 함수의 조합으로 보는 방식입니다. 그 바탕에는 두 가지 생각이 있습니다. 함수는 입력만으로 결과를 정하고 바깥을 바꾸지 않아야 한다는 순수 함수(pure function)와, 데이터를 바꾸지 않고 바뀐 새 데이터를 만든다는 불변성(immutability)입니다.

타입스크립트가 함수형 방식을 강제하지는 않지만, readonly 같은 타입으로 실수로 값을 바꾸는 코드를 막을 수 있습니다. 이 글은 순수 함수와 그렇지 않은 함수를 비교하고, 불변 갱신과 그 한계를 참조의 비교로 확인한 뒤, 순수한 계산과 입출력을 나누는 구조를 봅니다.

순수 함수

순수 함수는 두 조건을 만족하는 함수입니다. 같은 인수를 넣으면 언제나 같은 결과를 돌려주고, 바깥의 변수나 인수로 받은 객체를 바꾸거나 화면에 출력하는 것 같은 부수 효과(side effect)를 일으키지 않습니다. 수학의 함수 f(x) = x²이 x가 2이면 언제나 4이고, 계산한다고 x가 바뀌지 않는 것과 같습니다.

function doubleNumbers(numbers: readonly number[]): number[] {
  return numbers.map((num) => num * 2);
}

const original = [1, 2, 3];
const doubled = doubleNumbers(original);
console.log(original.join(","), doubled.join(","));
console.log(doubleNumbers([5, 6]).join(","), doubleNumbers([5, 6]).join(","));
1,2,3 2,4,6
10,12 10,12

map은 새 배열을 만들어 돌려주므로 original은 그대로이고, 같은 [5, 6]을 두 번 넣으면 두 번 모두 10,12가 나옵니다. 매개변수를 readonly number[]로 적어 두면, 본문에서 실수로 numbers.push(4) 같은 코드를 쓸 때 컴파일러가 진단해 줍니다.

다음 두 함수는 같은 인수를 넣어도 결과가 달라질 수 있어 순수 함수가 아닙니다.

let total = 0;
function addToTotal(amount: number): number {
  total += amount;
  return total;
}

let discountRate = 0.1;
function calculatePrice(price: number): number {
  return price * (1 - discountRate);
}

console.log(addToTotal(10), addToTotal(5));
console.log(calculatePrice(100));
discountRate = 0.2;
console.log(calculatePrice(100));
10 15
90
80

addToTotal은 바깥의 total을 바꾸는 부수 효과가 있어서, 같은 5를 넣어도 그전에 무엇이 더해졌는지에 따라 결과가 달라집니다. calculatePrice는 바깥을 바꾸지는 않지만 바깥의 discountRate를 읽으므로, 그 값이 바뀌면 같은 100에 대해 90과 80이 나옵니다. 이렇게 인수에 드러나지 않은 입력을 숨은 입력이라 부르며, 함수의 결과를 예측하려면 그 값도 함께 알아야 합니다.

숨은 입력은 인수로 드러내면 됩니다. 할인율도 인수로 받으면, 결과는 오로지 두 인수로 정해집니다.

function calculateWithRate(price: number, rate: number): number {
  return price * (1 - rate);
}

console.log(calculateWithRate(200, 0.15), calculateWithRate(200, 0.15));
170 170

순수 함수는 결과를 예측하기 쉽고, 바깥의 상태를 준비할 필요 없이 인수만 넣어 테스트할 수 있습니다. 또 같은 인수에 같은 결과가 나오므로 한 번 계산한 결과를 저장해 두었다가 다시 쓰는 메모이제이션(memoization)도 안전하게 할 수 있는데, 이때 캐시 키(cache key)에는 결과에 영향을 주는 모든 인수가 들어가야 합니다. 다만 number 타입만으로는 할인율이 0에서 1 사이라는 조건까지 검사하지 않으므로, 바깥에서 들어온 값은 부르기 전에 확인합니다.

불변 갱신

객체를 직접 고치면, 같은 객체를 가리키던 다른 코드도 모르는 사이에 바뀐 값을 보게 됩니다. 불변 갱신은 원래 객체를 그대로 두고, 바뀐 부분을 반영한 새 객체를 만들어 돌려주는 방식입니다.

interface User {
  readonly name: string;
  readonly age: number;
  readonly hobbies: readonly string[];
}

function nextBirthday(user: User): User {
  return { ...user, age: user.age + 1 };
}

function addHobby(user: User, hobby: string): User {
  return { ...user, hobbies: [...user.hobbies, hobby] };
}

const alice: User = { name: "Alice", age: 30, hobbies: ["reading", "hiking"] };
const older = nextBirthday(alice);
const withCooking = addHobby(older, "cooking");

console.log(alice.age, alice.hobbies.join(","));
console.log(older.age, withCooking.hobbies.join(","));
30 reading,hiking
31 reading,hiking,cooking

nextBirthday는 전개 구문으로 alice의 속성을 새 객체에 옮기고 age만 바꾸며, addHobby는 기존 취미 뒤에 새 취미를 더한 새 배열을 만듭니다. 그래서 두 함수를 거친 뒤에도 alice는 30세에 두 개의 취미를 그대로 가집니다. alice.age = 31;은 readonly 때문에 진단되고, alice.hobbies.push("painting")은 readonly string[]에 push가 없어 진단됩니다.

readonly와 얕은 복사가 지키는 범위

readonly는 그 타입으로 접근할 때의 대입을 검사에서 막을 뿐, 실행 중의 객체를 얼리지는 않습니다. 그리고 전개 구문의 복사는 한 단계만 하는 얕은 복사(shallow copy)여서, 바꾸지 않은 속성은 새 객체와 원래 객체가 같은 값을 함께 가리킵니다.

console.log(older === alice, older.hobbies === alice.hobbies);
console.log(withCooking.hobbies === older.hobbies);
false true
false

older는 새 객체이지만, hobbies는 바꾸지 않았으므로 alice의 배열을 그대로 함께 씁니다. addHobby는 새 배열을 만들었으므로 withCooking의 hobbies는 older의 것과 다릅니다. 새 참조를 만들었다는 것과 모든 안쪽 값까지 복사했다는 것은 다릅니다. 함께 쓰는 배열을 어딘가에서 직접 바꾸면 그 변화가 양쪽 객체에 모두 보이므로, 불변 갱신은 아무도 그 배열을 직접 바꾸지 않을 때만 안전합니다.

이렇게 바뀐 부분만 새로 만들고 나머지를 함께 쓰는 방식은 복사의 비용을 줄여 줍니다. 또 객체가 바뀌었는지를 내용이 아니라 참조가 같은지만으로 빠르게 판단할 수 있어서, React가 다시 그릴지를 정하거나 계산 결과를 저장해 둘 때 이 성질을 씁니다. 상태가 바뀔 때마다 새 객체가 남으므로, 이전 상태들을 그대로 보관해 두고 거슬러 올라가며 디버깅하기도 쉽습니다.

더 깊이: 실행 중에 얼리기와 깊게 복사하기의 한계

실행 중에도 값을 바꾸지 못하게 하려면 Object.freeze를 쓰지만, 이 함수도 얕게 동작합니다. MDN의 설명에 따르면 Object.freeze는 그 객체 자신의 속성에만 적용되므로, 얼린 객체 안의 배열이나 객체는 그대로 바꿀 수 있습니다. 얼린 객체의 속성에 값을 넣으면 엄격 모드에서는 TypeError가 나고, 엄격 모드가 아니면 아무 오류 없이 무시됩니다. 타입스크립트의 출력은 엄격 모드이므로 이런 대입은 실행 중에 오류로 드러나며, 타입에서도 Object.freeze의 결과는 Readonly 타입이 되어 대입이 진단됩니다.

반대로 안쪽 값까지 모두 새로 만들고 싶다면 structuredClone으로 깊은 복사를 할 수 있지만, 이 함수가 쓰는 구조화된 복제 알고리즘(structured clone algorithm)은 모든 값을 복사하지 못합니다. MDN에 따르면 함수는 복사할 수 없어 DataCloneError가 나고, 속성 서술자와 getter, setter는 옮겨지지 않으며, 프로토타입 체인도 따라가지 않으므로 클래스의 인스턴스는 메서드가 없는 일반 객체로 복사됩니다. 클래스의 # 비공개 요소도 복사되지 않습니다. 그래서 이 글처럼 데이터를 담은 일반 객체와 배열에는 쓸 만하지만, 메서드를 가진 객체를 깊게 복사하는 도구로 쓰면 결과가 원래 객체와 같은 동작을 하지 않습니다.

순수한 계산과 바깥과의 입출력

프로그램은 결국 화면에 출력하고, 파일을 쓰고, 네트워크로 요청을 보내야 하므로 부수 효과를 아예 없앨 수는 없습니다. 대신 부수 효과를 프로그램의 가장자리로 밀어내고, 그 안쪽은 순수한 계산으로 채우는 구조를 만듭니다.

명시 입력과 이전 상태가 순수 코어에 합류하고 새 상태와 효과 명령으로 나뉜다. 효과 명령은 외부 adapter가 실행한다.

PURE CORE / EFFECT BOUNDARY

순수 계산의 결과와 효과 실행을 다른 경계에 둔다

순수 함수는 입력만으로 결과를 계산하며 입력과 외부 상태를 변경하지 않습니다. 아래는 교재의 배열·User 변환을 확장한 구조이며, 효과 명령·I/O adapter는 원문 구현에 포함되지 않습니다.

순수 계산의 결과와 효과 실행을 다른 경계에 둔다명시 입력과 이전 상태가 순수 코어에 합류하고 새 상태와 효과 명령으로 나뉜다. 효과 명령은 외부 adapter가 실행한다.명시 입력시간·전역값도 명시이전 상태변경하지 않을 입력순수 코어결정적 계산새 상태변경 경로 새 참조효과 명령무엇을 할지 값으로외부 adapter시간·저장·통신·로그
순수 코어의 두 입력과 두 결과명시 입력과 이전 상태가 각각 순수 코어에 들어온다. 코어에서 새 상태와 효과 명령으로 따로 나뉘며 효과 명령만 외부 adapter로 이어진다. 세로 배치된 두 입력이나 두 결과를 서로 잇는 간선은 없다. 원문 배열·User 변환을 확장한 설계이며 I/O 구현이 아니다.명시 입력시간·전역값도 명시이전 상태변경하지 않을 입력순수 코어결정적 계산새 상태변경 경로 새 참조효과 명령무엇을 할지 값으로외부 adapter시간·저장·통신·로그

PURE FUNCTION

계산 자체에는 효과가 없다

doubleNumbers는 새 배열을 계산합니다. total 변경, discountRate 숨은 의존, console 출력은 교재의 순수성 위반 예입니다.

STRUCTURAL SHARING

복사한 경로와 공유한 참조

birthday 갱신은 새 User를 만들지만 hobbies 배열을 공유합니다. hobby 추가는 새 배열도 만듭니다. 공유 참조를 다른 가변 경로에서 바꾸지 않아야 합니다.

STATIC RESTRICTION

readonly는 타입 검사 제한

해당 타입을 통한 대입을 막습니다. 런타임 동결·깊은 복사·다른 참조를 통한 변경 방지를 자동으로 제공하지 않습니다.

EXTENDED DESIGN

효과 명령은 확장 설계

코어가 실행할 일을 값으로 기술하고 shell이 실행하는 패턴입니다. 원문의 순수 예제는 값만 반환하며 이 adapter까지 구현하지 않습니다.

계산의 순수성과 애플리케이션의 효과 실행을 구분하고, 실제 공유 데이터의 변경 경로도 확인합니다.

위 그림처럼 순수한 중심은 인수로 받은 입력과 이전 상태만으로 새 상태를 계산하고, 바깥에서 해야 할 일은 직접 하지 않고 "이 메시지를 저장하라" 같은 명령의 값으로 돌려줍니다. 그 명령을 실제로 실행하는 일은 바깥의 입출력 코드가 맡습니다. 이 글의 예제는 새 값을 돌려주는 계산까지만 보여 주며, 명령을 실행하는 입출력 코드까지 구현한 것은 아닙니다. readonly의 동작은 공식 Handbook의 readonly 속성에서 확인할 수 있습니다.

정리

  • 순수 함수는 같은 인수에 언제나 같은 결과를 돌려주고, 바깥의 상태나 인수로 받은 객체를 바꾸는 부수 효과가 없습니다.
  • 바깥의 변수를 읽는 숨은 입력은 결과를 예측하기 어렵게 하므로, 인수로 드러내 결과가 인수로만 정해지게 합니다.
  • 불변 갱신은 원래 값을 그대로 두고 바뀐 부분을 반영한 새 값을 만들며, readonly는 검사에서 실수의 대입을 막습니다.
  • 전개 구문의 복사는 얕아서 바꾸지 않은 속성은 함께 쓰이므로, 새 참조와 깊은 복사를 구분합니다.
  • 부수 효과는 프로그램의 가장자리에 모으고, 안쪽은 새 상태와 할 일을 값으로 돌려주는 순수한 계산으로 채웁니다.

연습 문제

1. 두 번의 생일과 새 취미

alice에 nextBirthday를 두 번 적용하고 addHobby(..., "drawing")을 한 번 적용했습니다. alice와 최종 객체의 나이와 취미는 각각 무엇인가요? 첫 번째와 두 번째 생일 객체의 hobbies는 같은 배열인가요?

해설 보기

alice는 그대로 30세, reading,hiking이고, 최종 객체는 32세, reading,hiking,drawing입니다. 두 생일 객체는 서로 다른 새 객체이지만, hobbies는 바꾸지 않았으므로 둘 다 alice의 배열을 함께 가리킵니다. 새 배열은 마지막 addHobby에서만 만들어집니다.

2. 저장해 둔 결과를 다시 쓰기

calculateWithRate(200, 0.15)의 결과 170을 저장해 두었다가, calculateWithRate(200, 0.25)를 부를 때 그대로 써도 되나요?

해설 보기

쓰면 안 됩니다. 할인율 0.25의 결과는 150이며, 170은 가격 200과 할인율 0.15가 모두 같을 때만 다시 쓸 수 있는 결과입니다. 캐시 키에서 할인율을 빠뜨리면, 함수 자체는 순수하더라도 잘못된 값을 돌려주게 됩니다.

3. 함께 쓰는 배열 바꾸기

어떤 코드가 (alice.hobbies as string[]).push("running");으로 alice의 취미 배열을 직접 바꾸었습니다. 그 뒤 older.hobbies.join(",")은 무엇을 출력하나요?

해설 보기

reading,hiking,running을 출력합니다. 단언으로 readonly를 우회해 실제 배열에 값을 더했는데, older는 얕은 복사로 그 배열을 함께 가리키고 있기 때문입니다. readonly는 검사에서만 대입을 막으므로, 단언으로 우회하면 불변성이 깨지고 그 영향은 같은 배열을 공유하는 모든 객체에 퍼집니다.

다음 글 고차 함수와 커링에서는 함수를 인수로 받거나 돌려주는 고차 함수와, 인수를 하나씩 나누어 받는 커링을 타입과 함께 다룹니다.