icon
12장 : 함수형 프로그래밍

순수 함수와 불변성


함수형 프로그래밍(Functional Programming, FP)은 명령형(Imperative) 프로그래밍이나 객체지향(Object-Oriented) 프로그래밍과는 다른 방식으로 소프트웨어를 구축하는 패러다임입니다. FP는 계산을 함수 평가(evaluation)로 보고, 상태를 변경하는 명령보다는 함수 적용(application)에 중점을 둡니다.

함수형 프로그래밍의 핵심 개념 중 두 가지가 바로 **순수 함수(Pure Functions) **와 불변성(Immutability) 입니다. 이 두 가지는 코드의 예측 가능성을 높이고, 테스트를 용이하게 하며, 병렬 및 동시성 프로그래밍 환경에서 안정성을 확보하는 데 결정적인 역할을 합니다. 타입스크립트는 FP를 직접적으로 강제하지는 않지만, 강력한 타입 시스템을 통해 순수 함수와 불변성 원칙을 따르는 코드를 작성하는 데 큰 도움을 줍니다.


순수 함수

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

  1. 동일한 입력에는 항상 동일한 출력 보장: 함수의 실행 결과는 오직 입력된 인자에만 의존하며, 외부 상태에 영향을 받지 않습니다.
  2. 부수 효과(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의 값에 따라 동일한 입력에 대해 다른 출력을 반환할 수 있습니다. calculatePricediscountRate라는 외부 변수에 의존하므로, 이 변수가 변경되면 동일한 입력 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를 유발할 수 있습니다.

celebrateBirthdayaddHobby 함수는 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' 속성이 없습니다.

getNextBirthdayUsergetUserWithNewHobby 함수는 원본 user 객체를 수정하지 않고, 스프레드 문법(...)을 사용하여 변경된 속성을 가진 새로운 객체를 반환합니다. readonly 키워드를 사용하면 타입스크립트 컴파일러가 불변성 위반을 감지하여 오류를 발생시킵니다.

불변성의 이점

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

순수 함수와 불변성의 관계


순수 함수는 부수 효과가 없어야 하므로, 함수 내에서 외부 상태를 변경하지 않아야 합니다. 이 외부 상태에는 함수 인자로 전달된 객체나 배열도 포함됩니다. 만약 함수 인자로 가변(Mutable) 객체가 들어왔는데, 함수가 그 객체를 직접 수정한다면 이는 부수 효과가 되어 순수 함수가 아닙니다.

따라서 순수 함수를 작성하기 위해서는 불변성 원칙을 철저히 지켜야 합니다. 함수가 인자로 받은 데이터를 변경해야 할 때는 항상 새로운 사본을 만들어서 반환해야 합니다.

요약

  • 순수 함수: 동일 입력 -> 동일 출력, 부수 효과 없음.
  • 불변성: 데이터 생성 후 변경 불가, 변경 필요 시 새 사본 생성.

이 두 가지 원칙은 함수형 프로그래밍의 가장 기본적인 기둥이며, 이들을 적용함으로써 더 예측 가능하고, 테스트하기 쉬우며, 안정적인 코드를 작성할 수 있습니다. 타입스크립트는 readonly 키워드와 엄격한 타입 검사를 통해 이러한 원칙을 강제하고 장려하는 데 훌륭한 도구가 됩니다.