본문 바로가기
CS/Functional Programming

[FP] 아무도 모르는 side effect는 괜찮을까?

by 그냥하는거지뭐~ 2025. 2. 26.
목차
1. 보이지 않는 Side Effect, 정말 괜찮을까?
2. Referential Transparency와 순수 함수
3. 숨겨진 Side Effect의 위험성
4. 성능 최적화를 위한 Side Effect
5. Side Effect를 안전하게 관리하는 방법
6. 결론
💡 Functional-Light JavaScript Chapter 5: Reducing Side Effects의 일부분을 참고했습니다. 

1. 보이지 않는 Side Effect, 정말 괜찮을까?

If a tree falls in the forest, but no one is around to hear it, does it still make a sound?

 

좀 철학적인 질문인데, 한 번 생각해볼만 한 질문인 것 같다. 그리고 이걸 프로그래밍의 관점에서도 생각해보자. 

만약에 프로그램 내에서 어떤 Side Effect가 발생했지만, 이걸 아무도 모른다면.. 과연 이 Side Effect는 존재한다고 봐야 할까? 

 

많은 사람들이 Side Effect를 줄이는 것이 좋은 코드의 조건이라고 말한다. 함수형 프로그래밍에서는 이걸 극단적으로 밀어붙여서 "순수 함수"를 지향한다. (순수 함수는 동일한 input에 대해 항상 동일한 output을 반환하며, 그 어떤 외부 상태도 변경하지 않는 함수이다.)

 

순수 함수로만 프로그램을 완성한다면 정말 좋겠지만, 현실적으로 Side effect를 허용하는게 불가피할 때가 많다. 예를 들어 fetch, cache 관리 등에서는 side effect가 어쩔 수 없다. 그럼에도 불구하고 우리가 Side Effect를 최소화하려는 이유는 바로 코드의 예측 가능성, 가독성 때문이다. 

 

보이지 않는 side effect가 위험한 이유는 코드의 실행 결과에 미묘한 영향을 미칠 수 있기 때문이다. 어떤 함수가 외부 상태를 몰래 변경하지만, 그 변경이 프로그램의 그 어떤 부분에서도 관찰되지 않는다면, 그 함수는 순수 함수라고 착각할 수 있다.

하지만 나중에 그 변경된 상태를 다른 곳에서 참조하게 되면?? 당연히 예상치 못한 버그가 발생할 가능성이 높아진다. 또, 유지보수할 때도 어려움을 유발하는데, 예를 들어 side effect가 있는 함수가 여러 번 호출됐을 때, 호출 순서에 따라 결과가 달라진다면? 테스트가 어려워지고, 함수의 역할을 직관적으로 이해하기 힘들 것이다. 

 

그럼 다시 돌아와서, "아무도 모르는 Side Effect는 정말 괜찮을까?"라는 질문을 다시 생각해보자. 

이 질문에 대해 좀더 명확하게 답변하기 위해, Functional-Light JavaScript 책에서 소개하는 Referential Transparency의 개념과 순수 함수가 주는 이점에 대해 살펴보자. 


2. Referential Transparency와 순수 함수

프로그래밍에서 함수의 순수성(Purity) 을 정의하는 방법은 여러 가지가 있다. 일반적으로 우리가 아는 순수 함수란,

  • 1) Side Effect가 없고,
  • 2) 동일한 입력에 대해 항상 동일한 출력을 반환하는 함수 

를 의미한다.

하지만 또 다른 중요한 정의가 있는데, 바로 Referential Transparency 이다.

 

Referential Transparency란?

: 함수 호출을 그 결과 값으로 대체해도 프로그램의 실행 결과가 변하지 않는 성질

즉, 어떤 함수 f(x) 가 있다면, f(x) 를 y 라는 변수에 저장하고 y 를 사용해도 프로그램의 실행 흐름과 결과가 동일해야 한다. 이를 코드로 살펴보자.

function calculateAverage(nums) {
    var sum = 0;
    for (let num of nums) {
        sum += num;
    }
    return sum / nums.length;
}

var numbers = [1,2,4,7,11,16,22];

var avg = calculateAverage(numbers);
console.log("The average is:", avg); // The average is: 9

위 코드는 calculateAverage(numbers) 를 실행한 결과를 avg 변수에 저장한 후 출력한다. 그런데 이 코드를 아래처럼 바꿔도 동일하게 동작할까?

var numbers = [1,2,4,7,11,16,22];

var avg = 9;  // calculateAverage(numbers) 대신 직접 결과를 넣음
console.log("The average is:", avg); // The average is: 9

 

결과적으로 두 코드의 실행 흐름과 출력이 동일하다. 즉, calculateAverage(numbers) 가 referential transparency를 만족한다는 것을 의미한다.

 

Referential transparency를 가지는 함수는 우리가 읽을 때 "한 번만 계산하고 기억할 수 있는 값" 으로 인식할 수 있다. 즉, 우리가 calculateAverage(numbers) 를 처음 읽었을 때, 그것이 9라는 결과를 반환한다는 것을 알았다면, 이후에는 그 함수의 동작을 다시 분석할 필요가 없다.

이러한 특징 덕분에 referential transparency를 가진 함수는 코드를 읽고 이해하는 데 드는 인지 부담(Cognitive Load)을 줄여준다. 함수의 동작을 한 번만 생각하면 되므로, 코드가 훨씬 더 예측 가능하고 유지보수하기 쉬워진다.

 


3. 숨겨진 Side Effect의 위험성

3.1. 보이지 않는 Side Effect

보이지 않는 Side Effect의 예시를 보자. 

function calculateAverage(nums) {
    sum = 0; // sum 변수가 함수 외부에 있음
    for (let num of nums) {
        sum += num;
    }
    return sum / nums.length;
}

var sum; // 외부에서 선언된 sum 변수
var numbers = [1,2,4,7,11,16,22];

var avg = calculateAverage(numbers);
console.log("The average is:", avg); // The average is: 9

 

이 코드를 실행할 때마다 sum이 0으로 초기화되고, 결국 calculateAverage(numbers) 가 항상 9를 반환하므로, 마치 순수 함수처럼 보인다.

하지만, 여기에는 숨겨진 Side Effect가 존재한다.

  • sum 변수가 함수 외부에서 선언되어 있다.
  • calculateAverage(..) 함수 내부에서 sum 값을 변경하고 있다.
  • 하지만 프로그램의 실행 흐름에서는 sum 값이 다른 부분에서 사용되지 않기 때문에 이 Side Effect가 눈에 띄지 않는다.

즉, Side Effect가 존재하지만 관찰되지 않는 것이다.

 

3.2. 이 함수는 Referential Transparenc를 만족할까?

❓ calculateAverage(numbers) 의 결과가 항상 동일한가?
👉 YES. 동일한 입력에 대해 동일한 값을 반환한다.

 

calculateAverage(numbers) 를 결과 값인 9 로 대체했을 때, 프로그램의 동작이 동일한가?
👉 YES. 이 코드에서 sum 변수는 프로그램의 다른 부분에 영향을 미치지 않으므로, 실행 결과는 같다.

 

이렇게 보면 referential transparency를 만족하는 것처럼 보인다. 하지만 이 함수는 과연 순수 함수라고 할 수 있을까?

👉 NO.

이 함수는 Side Effect를 포함하고 있으며, 이 Side Effect가 현재 코드에서는 우연히 관찰되지 않을 뿐이다.

 

3.3. 보이지 않는 Side Effect가 위험한 이유

이런 숨겨진 Side Effect는 코드가 작을 때는 문제가 되지 않지만, 코드가 커지면 다음과 같은 문제를 일으킬 수 있다.

  • 유지보수 중 Side Effect가 갑자기 드러날 수 있다.
  • 함수가 더 이상 독립적으로 동작하지 않는다.
  •  코드의 예측 가능성이 떨어진다.

처음에는 sum 변수가 프로그램의 다른 부분에서 사용되지 않아서 문제가 없어 보이지만, 만약 이후에 sum 변수를 다른 곳에서도 사용하게 된다면?

function calculateAverage(nums) {
    sum = 0; // 전역 변수 sum을 재할당
    for (let num of nums) {
        sum += num;
    }
    return sum / nums.length;
}

function calculateSum(nums) {
    for (let num of nums) {
        sum += num; // ❌ 전역 변수 sum을 사용 (초기화 안 됨)
    }
    return sum;
}

var sum; // 전역 변수 sum 선언
var numbers = [1,2,4,7,11,16,22];

var avg = calculateAverage(numbers);
console.log("The average is:", avg); // The average is: 9 ✅ 예상대로 동작

var total = calculateSum(numbers);
console.log("The total sum is:", total); // ❌ 예기치 않은 값

💥 버그 발생!

  • calculateAverage() 가 실행된 후 sum 값이 초기화되지 않은 상태로 유지되었기 때문에, calculateSum() 이 실행될 때 이전 값이 누적되는 문제가 발생했다.
  • 이는 sum 변수가 함수 호출마다 0으로 초기화되지 않기 때문에 발생한 문제다.
  • 즉, 원래는 보이지 않던 Side Effect가 이제는 프로그램의 실행 흐름에 영향을 주기 시작했다.

숨겨진 Side Effect가 있는 함수는 누군가가 함수를 읽을 때, 해당 함수가 외부 상태를 변경하는지 인지하기 어렵다.

function calculateAverage(nums) {
    sum = 0; 
    for (let num of nums) {
        sum += num;
    }
    return sum / nums.length;
}

다시 위의 예시를 보면, 이 함수를 읽는 사람은 sum이 외부 변수인지, 내부 변수인지 한눈에 알기 어렵다.
반면, 아래처럼 명확하게 작성하면 코드가 훨씬 직관적이다.

function calculateAverage(nums) {
    let sum = 0; // sum 변수를 함수 내부에서만 사용
    for (let num of nums) {
        sum += num;
    }
    return sum / nums.length;
}

이렇게 하면 함수 내부에서만 sum 변수를 사용하므로 Side Effect 없이 순수 함수로 동작하게 된다.

 

3.4. 결론: 숨겨진 Side Effect를 방치하지 말자!

위에서 보았듯이, Side Effect가 보이지 않는다고 해서 안전한 것은 아니다.

  • 유지보수가 어렵고,
  • 예측이 불가능하며,
  • 시간이 지나면 버그가 발생할 가능성이 높아진다.

Referential Transparency를 만족하는 순수 함수는,

  • 같은 입력 → 같은 출력
  • 코드가 읽기 쉽고,
  • 실행 흐름을 쉽게 예측할 수 있다.

4. 성능 최적화를 위한 Side Effect

그러나 현실적인 프로그래밍에서는 성능을 최적화하기 위해 일부러 Side Effect를 도입하는 경우가 많다. 

대표적으로, 불필요한 연산을 최소화하고 성능을 향상시키기 위해 캐싱메모이제이션을 하는 경우가 많다. 이런 기법들을 사용하면서도 side effect를 안전하게 관리할 수 있을까? 

 

4.1. 캐싱

var cache = [];  // 캐시를 저장하는 전역 변수

function specialNumber(n) {
    // 이미 계산한 값이 있다면, 캐시에서 가져오기
    if (cache[n] !== undefined) {
        return cache[n];
    }

    var x = 1, y = 1;
    for (let i = 1; i <= n; i++) {
        x += i % 2;
        y += i % 3;
    }

    cache[n] = (x * y) / (n + 1);  // 계산한 값을 캐시에 저장

    return cache[n];
}

console.log(specialNumber(6));   // 4
console.log(specialNumber(42));  // 22
console.log(specialNumber(6));   // 4 (캐시에서 가져옴, 연산 수행 X)

 

이 함수는 specialNumber(n)을 처음 호출할 때는 계산을 수행하지만, 두 번째 호출부터는 캐시된 값을 반환하므로 연산 비용을 줄일 수 있다.

하지만 여기에는 숨겨진 Side Effect가 존재한다.

  • cache라는 전역 변수를 사용하여 상태를 변경하고 있다.
  • specialNumber(..) 함수가 외부 상태를 변경하면서도, 이를 내부적으로 감추고 있기 때문에 완전히 예측 가능한 순수 함수라고 보기는 어렵다.
  • 이 함수는 referential transparency를 만족하지만, 캐시를 공유하는 방식이 문제가 될 수도 있다.

이 경우를 생각해보자. 

var cache = [];  // 전역 변수

function resetCache() {
    cache = [];  // 캐시를 초기화
}

console.log(specialNumber(10));  // 6
resetCache(); 
console.log(specialNumber(10));  // 6 (다시 연산을 수행함)

캐시를 초기화하는 resetCache() 함수를 호출한 후, specialNumber(10)을 다시 호출하면 캐시가 지워져서 다시 연산을 수행하게 된다.

이럴 경우, 캐싱이 제대로 동작하는지 예측하기 어렵고, 외부에서 캐시 상태를 변경할 가능성이 열려있다. 

 

그럼 어떻게 해야할까 

 

4.2. 안전한 캐싱

전역 변수를 직접 수정하는 방식 대신, 함수 내부에서만 캐시를 유지하도록 변경하면 Side Effect의 위험을 줄일 수 있다. 이를 위해 클로저를 활용한 Memoization 패턴을 사용할 수 있다.

var specialNumber = (function memoization() {
    var cache = {}; // 캐시를 함수 내부에서 관리

    return function specialNumber(n) {
        if (cache[n] !== undefined) {
            return cache[n];  // 캐시에서 가져오기
        }

        var x = 1, y = 1;
        for (let i = 1; i <= n; i++) {
            x += i % 2;
            y += i % 3;
        }

        cache[n] = (x * y) / (n + 1);
        return cache[n];
    };
})();

console.log(specialNumber(6));   // 4
console.log(specialNumber(42));  // 22
console.log(specialNumber(6));   // 4 (캐시에서 가져옴)

 

✅ cache 변수를 함수 내부에서만 관리하므로, 외부에서 직접 수정할 수 없다.
✅ 전역 변수를 수정하는 Side Effect를 제거하고, 안전한 방식으로 캐싱을 유지할 수 있다.
✅ 동일한 입력에 대해 항상 같은 출력을 반환하는 특성을 유지하면서도, 성능 최적화를 적용할 수 있다.

 

최적화를 적용할 때도 Side Effect를 최소화하고, 함수 내부에서만 상태를 관리하는 방식이 바람직하다.


5. Side Effect를 안전하게 관리하는 방법

Side Effect를 완전히 없애지 않으면서도 안전하게 관리하는 방법이 뭐가 있을까? 

함수형 프로그래밍에서는 어떤 기법이 있고, 현실적인 개발 환경에서 어떻게 Side Effect를 다루는게 좋을지 살펴보자. 

 

5.1. Side Effect를 명확하게 드러내자

순수 함수와 side effect를 분리하기

// 순수 함수: 데이터 처리만 수행 (Side Effect 없음)
function calculateTax(price) {
    return price * 0.1;
}

// Side Effect를 담당하는 함수
function printTax(price) {
    const tax = calculateTax(price);
    console.log(`Tax for ${price} is ${tax}`);
}

printTax(100);  // Tax for 100 is 10
  • calculateTax(price) 는 완전히 순수한 함수이므로 독립적으로 테스트가 가능하다.
  • printTax(price) 는 Side Effect가 있는 함수지만, 역할이 분리되어 있어서 읽기 쉽다.
  • 코드의 예측 가능성이 높아지고, 디버깅이 쉬워진다.

5.2. Side Effect를 함수의 리턴값으로 다루기

FP에서는 Side Effect를 함수의 리턴값으로 관리하는 기법을 활용한다. 데이터를 반환하는 방식으로 side effect를 추상화해보자. 

 

아래처럼 Side Effect를 직접 실행하지 않고, 필요한 데이터를 반환한다. 

// Side Effect를 직접 수행하는 대신, "할 일"을 반환하는 함수
function createLogMessage(price) {
    return `Tax for ${price} is ${calculateTax(price)}`;
}

// 실제 Side Effect를 실행하는 곳에서만 log 출력
console.log(createLogMessage(100));  // Tax for 100 is 10
  • createLogMessage(price) 는 순수 함수이므로 Side Effect 없이 독립적으로 테스트할 수 있다.
  • Side Effect(즉, console.log)는 최종 실행할 때만 명시적으로 수행된다.

5.3. IO monad

Side Effect가 발생할 연산을 즉시 실행하는 것이 아니라, 실행할 계획을 담아두는 방법이다.
즉, Side Effect를 직접 실행하지 않고, 나중에 실행할 수 있도록 캡슐화하는 방식이다.

 

테스트 코드에서 console.log 를 직접 실행하면 출력값을 검사하기 어렵지만,
IO Monad를 사용하면 Side Effect가 나중에 실행되도록 제어할 수 있다.

const logEffect = new IO(() => console.log("Test message"));

// 테스트에서는 실행하지 않음
expect(typeof logEffect.effect).toBe("function");

// 필요할 때만 실행
logEffect.run();

이렇게 하면, 테스트 코드에서 console.log 가 실행되지 않으면서도 Side Effect를 확인할 수 있다. 

 

5.4. React의 useEffect

React에서는 useEffect를 사용하여 Side Effect를 특정 시점에서 실행하도록 제한한다. 

 

 

즉, 결론은 Side effect를 무조건 없애는 것이 아니라, 이를 예측 가능하고, 쉽게 관리할 수 있는 방식으로 다루는 것이 중요하다.


6. 결론: "보이지 않는 Side Effect를 만들지 않는 것이 더 중요하다"

If a tree falls in the forest, but no one is around to hear it, does it still make a sound?

 

이제 정리가 좀 된 것 같다. 단순히 Side Effect가 보이지 않는다고 해결된 것이 아니고, Side effect 자체가 발생할 수 없는 구조를 만드는 것이 베스트이다. 하지만 어쩔 수 없는 경우, Side Effect를 안전하게 다루는 여러 방법을 생각해보고, 예측 가능하고 쉽게 관리할 수 있는 방식으로 다루도록 노력하자.