본문 바로가기

순수 함수와 불변성

같은 입력에 같은 결과를 내고 외부 상태를 바꾸지 않는 순수 함수를 작성하며 복사 기반 불변 갱신으로 예측 가능성을 높입니다.

함수형 프로그래밍(Functional Programming, FP)은 명령형(Imperative) 프로그래밍이나 객체지향(Object-Oriented) 프로그래밍과는 다른 방식으로 소프트웨어를 구축하는 패러다임입니다.

FP는 계산을 함수 평가(evaluation)로 보고, 상태를 변경하는 명령보다는 함수 적용(application)에 중점을 둡니다.

함수형 프로그래밍의 핵심 개념 중 두 가지가 순수 함수(Pure Functions)와 불변성(Immutability)입니다.

이 두 원칙은 코드의 예측 가능성을 높이고, 테스트를 쉽게 만들며, 병렬/동시성 환경에서도 안정성을 확보하는 데 큰 역할을 합니다.

타입스크립트가 FP를 강제하지는 않지만, 강력한 타입 시스템을 통해 순수 함수와 불변성 원칙을 지키는 코드를 더 안전하게 작성할 수 있습니다.


순수 함수

정의: 순수 함수는 다음 두 가지 조건을 만족하는 함수입니다.

동일한 입력에는 항상 동일한 출력 보장: 함수의 실행 결과는 오직 입력된 인자에만 의존하며, 외부 상태에 영향을 받지 않습니다.

부수 효과(Side Effects) 없음: 함수는 외부 세계의 어떤 것도 변경하지 않습니다.

즉, 함수 외부의 변수를 수정하거나, 콘솔에 출력하거나, 네트워크 요청을 보내거나, 파일에 쓰는 등의 작업을 하지 않습니다.

설명: 순수 함수는 수학에서의 함수 개념과 매우 유사합니다.

f(x) = x^2라는 함수는 x가 2일 때 항상 4를 반환하고, 함수를 호출한다고 해서 x의 값이 바뀌거나 다른 어떤 외부 상태에도 영향을 미치지 않습니다.

타입스크립트 적용 예시 순수 함수 (좋은 예)
// 배열의 각 숫자를 두 배로 만드는 함수
function doubleNumbers(numbers: number[]): number[] {
  // 입력 배열을 변경하지 않고 새로운 배열을 반환
  return numbers.map(num => num * 2);
}

const originalNumbers = [1, 2, 3];
const doubledNumbers = doubleNumbers(originalNumbers);

console.log("Original Numbers:", originalNumbers); // [1, 2, 3] - 원본 변경 없음
console.log("Doubled Numbers:", doubledNumbers);   // [2, 4, 6]

// 항상 동일한 입력 -> 동일한 출력 보장
console.log(doubleNumbers([5, 6])); // [10, 12]
console.log(doubleNumbers([5, 6])); // [10, 12]

doubleNumbers 함수는 numbers 배열을 직접 수정하지 않고, map 메서드를 통해 새로운 배열을 반환합니다.

따라서 외부 상태를 변경하지 않고, 항상 동일한 입력에 대해 동일한 출력을 보장합니다.

순수 함수 위반 (나쁜 예)
let total = 0; // 외부 상태

// 1. 외부 상태를 변경하는 함수 (부수 효과 발생)
function addToTotal(amount: number): number {
  total += amount; // 외부 변수 total을 변경
  return total;
}

console.log(addToTotal(10)); // 10
console.log(addToTotal(5));  // 15 (total 값이 이전에 변경되었으므로 출력값이 달라짐)
console.log("Final total:", total); // 15

// 2. 입력에 따라 출력이 달라지는 함수 (외부 상태에 의존)
let discountRate = 0.1; // 외부 상태

function calculatePrice(price: number): number {
  // 외부 변수 discountRate에 의존
  return price * (1 - discountRate);
}

console.log(calculatePrice(100)); // 90
discountRate = 0.2; // 외부 상태 변경
console.log(calculatePrice(100)); // 80 (동일 입력이지만 출력값 달라짐)

// 3. 콘솔 출력 (부수 효과)
function logAndReturn(value: string): string {
  console.log(value); // 콘솔 출력은 부수 효과
  return value;
}

addToTotal은 외부 변수 total을 변경하므로 부수 효과가 있고, total 값에 따라 동일한 입력에서도 다른 출력을 반환할 수 있습니다.

calculatePrice는 discountRate라는 외부 변수에 의존하므로, 이 값이 바뀌면 동일한 입력 price에 대해서도 결과가 달라집니다.

logAndReturn은 콘솔에 값을 출력하는 부수 효과를 가집니다.

순수 함수의 이점
  • 예측 가능성: 동일한 입력에는 항상 동일한 출력이 보장되므로, 함수 호출의 결과를 쉽게 예측할 수 있습니다.
  • 테스트 용이성: 외부 상태나 순서에 의존하지 않으므로, 함수를 격리하여 독립적으로 테스트하기 매우 쉽습니다. 목(Mock) 객체나 복잡한 테스트 환경 설정이 필요 없습니다.
  • 디버깅 용이성: 버그가 발생했을 때, 순수 함수는 문제의 원인을 추적하기 쉽습니다. 입력만 확인하면 됩니다.
  • 병렬 처리 용이성: 순수 함수는 외부 상태를 변경하지 않으므로, 여러 스레드나 코어가 동시에 실행해도 경쟁 조건(Race Condition)이나 교착 상태(Deadlock) 같은 동시성 문제가 발생하지 않습니다.
  • 캐싱(Memoization) 가능: 입력에 대한 출력이 항상 같으므로, 동일한 입력이 주어질 때 이전에 계산된 결과를 재사용(캐싱)할 수 있어 성능 최적화에 유리합니다.

불변성

정의: 불변성이란 데이터가 한 번 생성되면 그 상태를 변경할 수 없음을 의미합니다.

대신, 데이터를 변경해야 할 때는 원본 데이터를 수정하는 대신, 변경된 부분을 반영한 새로운 데이터 사본을 만듭니다.

설명: 객체지향 프로그래밍에서는 객체의 상태를 직접 변경(Mutable)하는 것이 일반적입니다.

하지만 이러한 가변성은 예측하기 어려운 버그를 유발하고, 코드의 복잡성을 증가시킵니다.

특히 여러 함수나 스레드가 동일한 데이터를 공유하고 변경할 때 문제가 커집니다.

불변성은 이러한 문제들을 해결하는 핵심적인 방법입니다.

모든 데이터가 불변하면, 데이터가 어떤 함수에 의해 언제 변경되었는지 걱정할 필요가 없어집니다.

타입스크립트 적용 예시 가변성 (나쁜 예)
interface User {
  name: string;
  age: number;
  hobbies: string[];
}

const userProfile: User = {
  name: "Alice",
  age: 30,
  hobbies: ["reading", "hiking"]
};

// 1. 객체 속성 직접 변경
function celebrateBirthday(user: User): void {
  user.age += 1; // user 객체의 age 속성을 직접 변경
}

// 2. 배열 직접 변경
function addHobby(user: User, newHobby: string): void {
  user.hobbies.push(newHobby); // user.hobbies 배열을 직접 변경
}

console.log("Before:", userProfile); // { name: 'Alice', age: 30, hobbies: [ 'reading', 'hiking' ] }
celebrateBirthday(userProfile);
addHobby(userProfile, "cooking");
console.log("After:", userProfile); // { name: 'Alice', age: 31, hobbies: [ 'reading', 'hiking', 'cooking' ] }

// userProfile 객체 자체가 변경되었음을 알 수 있습니다.
// 만약 이 userProfile 객체가 여러 곳에서 참조되고 있었다면,
// 의도치 않은 side effect를 유발할 수 있습니다.

celebrateBirthday와 addHobby 함수는 userProfile 객체의 내부 상태를 직접 변경합니다.

이는 원본 객체를 사용하는 다른 부분에 예기치 않은 영향을 미칠 수 있습니다.

불변성 (좋은 예)
interface User {
  readonly name: string; // readonly 키워드로 속성 불변성 강조
  readonly age: number;
  readonly hobbies: readonly string[]; // readonly 배열 타입
}

const userProfile: User = {
  name: "Alice",
  age: 30,
  hobbies: ["reading", "hiking"]
};

// 1. 객체 속성 변경 시 새로운 객체 반환
function getNextBirthdayUser(user: User): User {
  // 스프레드 연산자 (...)를 사용하여 새로운 객체 생성 및 속성 변경
  return { ...user, age: user.age + 1 };
}

// 2. 배열 변경 시 새로운 배열 반환
function getUserWithNewHobby(user: User, newHobby: string): User {
  // 스프레드 연산자와 slice/concat 등을 사용하여 새로운 배열 생성
  return {
    ...user,
    hobbies: [...user.hobbies, newHobby] // 새로운 배열 생성
  };
}

console.log("Original:", userProfile);
const userAfterBirthday = getNextBirthdayUser(userProfile);
const userAfterAddingHobby = getUserWithNewHobby(userAfterBirthday, "cooking");

console.log("Original after operations:", userProfile);         // 원본은 변경되지 않음
// { name: 'Alice', age: 30, hobbies: [ 'reading', 'hiking' ] }

console.log("After Birthday:", userAfterBirthday);             // 새로운 객체 생성
// { name: 'Alice', age: 31, hobbies: [ 'reading', 'hiking' ] }

console.log("After Adding Hobby:", userAfterAddingHobby);       // 또 다른 새로운 객체 생성
// { name: 'Alice', age: 31, hobbies: [ 'reading', 'hiking', 'cooking' ] }

// TypeScript의 'readonly' 키워드는 컴파일 타임에 불변성을 강제하여 실수를 방지합니다.
// userProfile.age = 31; // Error: 읽기 전용 속성이므로 'age'에 할당할 수 없습니다.
// userProfile.hobbies.push("painting"); // Error: 'readonly string[]' 형식에 'push' 속성이 없습니다.

getNextBirthdayUser와 getUserWithNewHobby 함수는 원본 user 객체를 수정하지 않고, 스프레드 문법(...)을 사용하여 변경된 속성을 가진 새로운 객체를 반환합니다.

readonly는 해당 타입으로 접근할 때의 대입을 컴파일 단계에서 제한합니다. 런타임 동결이나 중첩 객체의 깊은 불변성을 자동으로 보장하지 않으며, 위 스프레드도 얕은 복사입니다. 변경하지 않은 hobbies 참조는 공유되므로 다른 가변 참조로 수정하지 않아야 합니다.

이 예제에서는 값과 참조를 따로 대조하면 복사 범위가 보입니다. userAfterBirthday === userProfile은 false지만 두 객체의 hobbies 비교는 true입니다. 생일 갱신은 객체 한 단계만 새로 만들었기 때문입니다. 취미 추가는 hobbies 배열도 새로 만들므로 userAfterAddingHobby.hobbies === userAfterBirthday.hobbies는 false입니다. 새 참조를 만들었다는 사실과 모든 중첩 값을 깊게 복사했다는 사실은 다릅니다.

숨은 입력을 드러내기

할인율에 의존한 원래 calculatePrice와 달리 아래 함수는 가격과 할인율을 모두 인자로 받습니다. 이 실습에서는 가격은 음수가 아닌 유한 수, 할인율은 0 이상 1 이하의 유한 수로 가정하며, 금액 반올림 정책은 별도입니다.

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

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

출력 호출은 콘솔 효과를 냅니다. 계산 함수 자체는 로그를 남기지 않고 인자로만 결과를 결정합니다. 타입의 number만으로 위 값 범위를 검사하지는 않으므로 외부 입력은 호출 전에 검증해야 합니다. TypeScript의 readonly 설명도 실행 중 동결과 정적 대입 제한을 구분합니다.

불변성의 이점
  • 예측 가능성: 데이터가 변경되지 않으므로, 어떤 함수가 데이터를 변경할지 추적할 필요가 없어 코드의 흐름을 이해하기 쉽습니다.
  • 병렬 처리 및 동시성: 데이터가 변경되지 않으므로, 여러 스레드나 함수가 동시에 데이터를 읽어도 안전하며, 경쟁 조건이 발생하지 않습니다.
  • 버그 감소: 의도치 않은 상태 변경으로 인한 버그를 원천적으로 방지합니다.
  • 테스트 용이성: 함수가 불변 데이터를 받아서 불변 데이터를 반환한다면, 테스트 케이스를 작성하기가 훨씬 쉬워집니다.
  • 시간 여행 디버깅: 이전 상태의 데이터를 쉽게 유지하고 접근할 수 있으므로, 애플리케이션의 상태 변화를 추적하고 디버깅하는 데 유용합니다 (예: Redux DevTools).
  • 성능 최적화: 특정 조건에서 객체 참조의 동일성 검사만으로 변경 여부를 빠르게 판단할 수 있어, 렌더링 최적화(React의 PureComponent/memo)나 캐싱에 활용될 수 있습니다.

순수 함수와 불변성의 관계

순수 함수는 부수 효과가 없어야 하므로, 함수 내에서 외부 상태를 변경하지 않아야 합니다.

이 외부 상태에는 함수 인자로 전달된 객체나 배열도 포함됩니다.

만약 함수 인자로 가변(Mutable) 객체가 들어왔는데, 함수가 그 객체를 직접 수정한다면 이는 부수 효과가 되어 순수 함수가 아닙니다.

따라서 순수 함수를 작성하기 위해서는 불변성 원칙을 철저히 지켜야 합니다.

함수가 인자로 받은 데이터를 변경해야 할 때는 항상 새로운 사본을 만들어서 반환해야 합니다.

아래 그림은 예제의 순수 계산을 애플리케이션 구조로 확장한 설계입니다. 예제 함수는 새 값을 반환하며, 그림의 효과 명령과 I/O 어댑터까지 구현한 것은 아닙니다.

명시 입력과 이전 상태가 순수 코어에 합류하고 새 상태와 효과 명령으로 나뉜다. 효과 명령은 외부 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까지 구현하지 않습니다.

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

복사 경로를 스스로 추적하기

원래 불변 User 예제에서 생일 갱신을 두 번 적용한 뒤 취미 "drawing"을 한 번 추가한다고 가정합니다. 시작 객체의 값은 원래 Alice·30·reading/hiking 그대로이며 다른 변경은 없습니다. 원본과 최종 객체의 나이·취미를 적고, 첫째 생일 객체와 둘째 생일 객체의 hobbies 참조가 같은지 설명해 보세요. 또한 calculateWithRate(200, 0.25)를 실행하면 170을 캐시해 그대로 써도 될까요?

값·참조·명시 입력의 확인

원본은 나이 30과 취미 reading/hiking을 유지합니다. 생일마다 age만 새 객체에 1씩 더하므로 최종 나이는 32이고, 최종 취미 배열은 reading/hiking/drawing입니다. 두 생일 객체는 각각 새 객체이지만 hobbies 배열은 같은 원본 배열을 공유합니다. 마지막 취미 갱신에서만 새 배열을 만듭니다.

할인율 0.25의 결과는 150입니다. 이전 170은 가격 200뿐 아니라 할인율 0.15라는 입력까지 같을 때 재사용할 수 있는 결과입니다. 필요한 입력을 캐시 키에서 누락하면 순수한 계산 함수라도 잘못된 값을 돌려줍니다.

이 페이지 목차

이 페이지 목차