# 오버플로우·언더플로우의 원인과 대처

#### 오버플로우(Overflow)의 정의와 발생 원인

컴퓨터에서 부동소수점 실수를 표현할 때 일정한 자릿수를 가지는 유효숫자와 지수부가 사용된다. 일반적으로 IEEE 754 표준을 따르면 싱글 정밀도에서는 32비트, 더블 정밀도에서는 64비트로 실수가 표현된다. 표현 범위를 벗어날 정도로 값이 지나치게 커지면 오버플로우가 발생한다. 오버플로우가 발생하는 근본 원인은 부동소수점에서 표현할 수 있는 최댓값(대략 $10^{38}$ 정도의 범위, 더블 정밀도에서는 대략 $10^{308}$ 정도)을 초과한 값이 연산 결과로 나타나는 경우다.

디지털 시스템에서 오버플로우가 발생하면 일반적으로 $\infty$나 NaN(Not a Number)으로 처리되는 경우가 많다. 예를 들어 $x$가 오버플로우를 일으킬 정도로 큰 값이라면, IEEE 754 방식에서는 $x$가 $\infty$로 표현될 수 있다. 이러한 상황은 곱셈, 지수 연산, 거듭제곱 연산 등에서 흔히 나타난다. 예를 들어 $10^{308}$ 정도의 수를 두 번 곱하는 경우, 더블 정밀도에서 표현할 수 없는 범위를 초과하여 $\infty$가 되어버릴 수 있다.

정밀도 문제와 복합되어 오버플로우 현상은 알고리즘의 신뢰도를 떨어뜨린다. 예를 들어 큰 수를 여러 번 곱하거나 지수함수를 계산하는 과정에서 임시 변수나 중간 계산 결과가 시스템의 표현 한계를 초과하여 연산이 제대로 수행되지 않을 위험이 있다.

수학적으로 오버플로우를 직관적으로 이해하기 위해 다음 예시를 생각할 수 있다. 부동소수점 표현에서 실수는 일반적으로 아래 형태로 표현된다.

$$
x = (-1)^s \times 1.m\_1 m\_2 m\_3 \dots \times 2^e
$$

여기서 $1.m\_1 m\_2 m\_3 \dots$는 가수부(significand)이며 $e$는 지수부(exponent)다. 주어진 정밀도에서 $e$가 가질 수 있는 최댓값이 존재하고, $e$가 그 한계를 넘어서면 오버플로우가 발생한다. 이를 수식으로 나타내면, $e\_\max$가 지수부의 최댓값일 때

$$
|x| > (2^{1 + p} - 1)\times 2^{e\_\max - (p+1)}
$$

여기서 $p$는 가수부의 유효 비트 개수를 의미한다. 싱글 정밀도에서는 $p = 23$, 더블 정밀도에서는 $p = 52$에 해당한다. 따라서 $|x|$가 이 표현 한계를 넘어설 경우 내부적으로 $\infty$ 또는 NaN 등으로 처리됨을 알 수 있다.

#### 언더플로우(Underflow)의 정의와 발생 원인

언더플로우는 오버플로우와 반대되는 현상으로, 부동소수점에서 표현할 수 있는 최솟값 이하로 값이 작아지면 0에 수렴해버리는 상태를 뜻한다. 예를 들어 더블 정밀도에서 $10^{-308}$보다 훨씬 더 작은 수를 연산해야 하는 상황이 발생하면, 표현 가능한 정밀도를 벗어나므로 그 결과가 0에 가까운 어떤 수(subnormal number)나 실제 0으로 반올림되어 표현될 수 있다.

언더플로우의 경우, 오버플로우처럼 갑자기 $\infty$가 되거나 NaN을 반환하기보다는 매우 작은 값이 0으로 처리되거나 부정확하게 표현되는 문제를 일으킨다. 특히 차이가 매우 작은 두 수의 차를 구하거나 지수함수를 음수 방향으로 크게 취해 미소한 값을 계산하는 과정에서 쉽게 발생할 수 있다. 예를 들어 $e^{-1000}$과 같은 값은 $10^{-308}$ 정도보다 훨씬 더 작아 부동소수점의 표현 범위 아래로 떨어지게 된다.

언더플로우가 발생하면 결과가 0이 되어버리거나, 소위 서브노멀(subnormal) 영역에 들어가면서 정밀도가 급격히 떨어진다. 서브노멀 수는 가수부의 숨겨진 1(hidden bit)를 두지 않고 표현함으로써 가능한 한 더 작은 수를 표현하려 하지만, 이때는 효과적인 가수부 비트 수가 줄어들어 상대 오차가 크게 증가한다.

#### 대표적 예시: IEEE 754 부동소수점

IEEE 754 표준에서 싱글 정밀도는 1비트 부호(sign bit), 8비트 지수부, 23비트 가수부로 구성된다. 지수부의 최대값을 포함하여 실제 표현 범위는 $1.2\times 10^{-38}$에서 $3.4\times 10^{38}$ 정도이며, 이 범위를 넘어서는 연산은 언더플로우 혹은 오버플로우로 이어진다. 더블 정밀도에서는 1비트 부호, 11비트 지수부, 52비트 가수부를 사용하며, $2.2\times 10^{-308}$에서 $1.8\times 10^{308}$ 정도를 표현 가능하다.

IEEE 754에서 오버플로우가 발생하면 $\pm \infty$로 결과가 표현되며, 언더플로우가 발생하면 0 또는 서브노멀 영역의 값이 사용된다. 서브노멀 영역에서도 표현할 수 없을 정도로 작다면 결과는 0이 된다. 이러한 표현 방법은 프로그래머가 의도적으로 예외 처리를 하지 않아도 시스템에서 최소한의 처리를 수행하도록 설계한 것이다.

#### 대표적 대처 방안

오버플로우와 언더플로우는 서로 반대 개념이지만, 유사한 맥락에서 처리될 수 있다. 예를 들어 중간 계산 단계에서 값이 지나치게 커지거나 작아지는 것을 방지하기 위해 로그 변환, 스케일링 등의 기법을 사용한다. 예를 들어 지수 연산에서 직접 $a^b$를 계산하는 대신 $\exp(b\ln a)$를 취할 때, $b\ln a$ 값을 고려하여 적절히 스케일링하면 오버플로우 가능성을 줄일 수 있다. 반대로 매우 작은 값을 다룰 때는 적절히 스케일링하거나, 알고리즘을 재구성하여 미소한 차이를 직접 계산하지 않도록 방안을 마련한다.

부동소수점 환경에서 큰 수나 작은 수를 다뤄야 할 때는 오버플로우와 언더플로우가 실제로 발생하는지, 결과가 0이나 $\infty$로 처리되어 예상치 못한 오류를 일으키지는 않는지 지속적으로 관찰해야 한다. 수치해석을 위해 개발된 라이브러리나 알고리즘의 경우 이러한 상황을 피하기 위해 조건을 사전에 점검하거나, 특별한 예외 처리를 구현하는 경우가 많다.

#### 수치 알고리즘에서 오버플로우·언더플로우를 피하기 위한 기법

수치 연산 과정에서 값이 기하급수적으로 커지거나 작아져 오버플로우와 언더플로우 위험이 있을 경우, 이를 완화하기 위해 여러 기법이 활용된다. 예컨대 지수 연산 $\exp(x)$을 직접 계산하기보다 로그 변환을 적용하면 연산 범위를 줄일 수 있다. 로그 변환을 사용하는 대표적 예로 소위 “로그-도메인 연산” 기법이 있다. 로그-도메인에서 덧셈과 곱셈은 각각 곱셈과 지수 연산으로 변환되지만, 전체 연산 흐름이 부동소수점의 폭넓은 표현 범위를 효과적으로 이용할 수 있게 설계된다.

분석적으로, 매우 큰 값 $A$와 매우 작은 값 $B$가 동시에 존재해 $A \times B$ 등을 계산해야 할 때 $A$가 이미 오버플로우 근처에 있다면 $B$가 아무리 작아도 결과가 무한대가 되어버릴 위험이 있다. 이 경우 $A$와 $B$를 각각 스케일링해 다음과 같은 형태로 변환하여 연산할 수 있다.

$$
\tilde{A} = A \times 2^{-k}, \quad \tilde{B} = B \times 2^{k}
$$

이처럼 적절한 스케일링 지수 $k$를 찾아 $\tilde{A}$와 $\tilde{B}$가 모두 안전한 범위 내에서 표현되도록 만들면, 실제 연산은 $\tilde{A}\times \tilde{B}$로 수행한 후 다시 $2^k$를 곱해 결과를 복원한다. 실제 구현 시에는 $10^k$ 혹은 $2^k$ 등 고정된 스케일링 기법이 알고리즘 전체에 적용될 수 있다.

고차원 벡터나 행렬을 다루는 수치해석 알고리즘에서는 이러한 스케일링 접근이 도처에서 쓰인다. 선형대수 라이브러리(예: BLAS, LAPACK 등)에는 벡터나 행렬의 노름(norm)에 따라 적절히 스케일링을 적용하는 루틴이 내장되어 있다. 역조건수가 매우 큰 선형시스템을 풀거나, 고차원 행렬의 고유값 문제를 푸는 상황 등에서는 자칫 중간 연산 결과가 표현 한계를 초과할 위험이 있으므로, 적절한 스케일링이 필수적이다.

#### Kahan Summation 알고리즘과 관련 기법

덧셈 연산에서도 언더플로우 혹은 매우 큰 수와 매우 작은 수의 합에서 발생하는 반올림 오류를 완화하기 위해 Kahan Summation 알고리즘이 자주 사용된다. 예를 들어 매우 큰 수 $x$와 매우 작은 수 $\delta$를 더하면, 부동소수점 표현에서 $\delta$가 무시되어 그대로 $x$가 나올 수 있다. 언더플로우 문제는 아니지만, 사실상 $\delta$를 잃어버리는 결과가 되어 상대 오차가 커진다. Kahan Summation은 보조 변수를 두어 누락된 부분을 추적·보정함으로써 덧셈 과정에서 발생하는 손실을 최소화한다.

구체적으로, Kahan Summation은 다음과 같은 아이디어로 이뤄진다. 만일 누적합을 $S$라 하고 새로 더해야 할 수를 $x$라 하자. 새로운 $\delta$를 고려하는 단계에서 반올림에 의해 누락되는 부분을 보조 변수 $c$에 저장해 두고, 이후에 이를 다시 보정해 $S$에 반영한다. 이러한 알고리즘은 매우 긴 벡터 합이나 확률 변수를 연속적으로 더하는 상황에서 큰 수와 작은 수가 섞여 있을 때 수치 정밀도를 크게 향상한다.

#### 정밀도 확장을 통한 대처

IEEE 754 부동소수점은 기본적으로 싱글, 더블, 심지어 쿼드러플(Quadruple, 128비트) 정밀도까지 규정하고 있으나, 실제 하드웨어에서 쿼드러플 정밀도를 직접 지원하는 사례는 드물다. 그러나 수치적 안정성이 매우 중요한 응용 분야(예: 기상 시뮬레이션, 고에너지 물리 시뮬레이션 등)에서는 필요한 경우 여러 소프트웨어 기법을 이용해 정밀도를 확장한다.

예를 들어 C++의 GMP 라이브러리나 MPFR 같은 다중 정밀도 연산 라이브러리를 사용하면, 자의적으로 유효 숫자 자릿수를 증가시킬 수 있다. Python은 내장된 float가 기본적으로 더블 정밀도를 따르지만, 필요하면 decimal이나 fractions 모듈, 심지어 외부의 mpmath 라이브러리를 통해 임의 정밀도 연산이 가능하다. 하지만 일반적으로 정밀도 확장은 성능 비용이 크게 증가하므로, 알고리즘에 따라 적절한 균형점을 찾아야 한다.

#### 다이나믹 범위 제어와 로그-도메인 구현 예시 (Python)

아래 예시는 Python에서 매우 큰 수와 매우 작은 수를 곱할 때 오버플로우나 언더플로우 없이 결과를 계산하기 위해 로그 변환 기법을 단순화하여 구현한 것이다.

```python
import math

def log_multiply(x, y):
    # x, y는 매우 큰(또는 작은) 양의 실수라 가정
    # 오버플로우나 언더플로우 방지 위해 로그 도메인에서 곱셈 수행
    return math.exp(math.log(x) + math.log(y))

if __name__ == "__main__":
    a = 1e200
    b = 1e100
    # 직접 a*b 연산할 경우 double 범위를 초과해 inf가 될 가능성 있음
    safe_product = log_multiply(a, b)
    print("로그 기법 사용:", safe_product)
```

이처럼 로그 도메인으로 변환하면, 직접 $a \times b$를 계산했을 때 보다 훨씬 안전하게 오버플로우를 방지할 수 있다. 다만 $x$ 또는 $y$가 0에 매우 가깝다면 $\log(0)$이 정의되지 않는 문제가 있으므로, 실제 구현에서는 에러처리나 스케일링을 추가로 고려해야 한다.

#### 기계 오차와 소수점 재배열 기법

수치 연산 중 오버플로우와 언더플로우는 기계 오차(machine epsilon)와도 밀접한 연관이 있다. 기계 오차란, 부동소수점 체계에서 1과 1보다 조금 큰 수가 구분될 수 있는 최소 간격이다. 이를 $\varepsilon\_\mathrm{mach}$라 할 때, 예를 들어 더블 정밀도에서는 대략 $2^{-52}\approx 2.22\times10^{-16}$ 정도가 된다. 만일 $\varepsilon\_\mathrm{mach}$ 수준으로 매우 작은 차이를 계산한다면, 실제로 그 차이는 기계 오차에 파묻혀 0이 되거나 정밀도가 급격히 떨어질 위험이 있다.

이러한 측면에서 오버플로우·언더플로우 현상을 줄이기 위해서는 소수점 재배열(Floating-Point Shifting) 같은 기법이 자주 활용된다. 예컨대 $\mathbf{x}$와 $\mathbf{y}$를 각각 벡터라 할 때, 내적(inner product) $\mathbf{x}\cdot\mathbf{y}$을 계산하는 과정에서 항들의 크기가 매우 다를 수 있다. 크기가 현저히 다른 항끼리는 합치기보다, 유사한 크기의 항들끼리 먼저 더하는 식으로 재배열하면 기계 오차 및 언더플로우에 의한 누락을 줄일 수 있다. 단순 예시로, 다음 두 경우를 비교할 수 있다.

$$
(1 + 10^{-10}) + 10^{10} \[라인피드]1010+1+10−10\[라인피드]10^{10} + 1 + 10^{-10}
$$

부동소수점 환경에서 연산 순서를 바꾸면 반올림 오차가 상이하게 누적된다. 큰 수 $10^{10}$에 먼저 상대적으로 큰 1을 더한 뒤, 마지막으로 $10^{-10}$을 더하면 $10^{-10}$이 완전히 무시될 가능성이 있다. 반면 비슷한 크기인 $1$과 $10^{-10}$을 먼저 더해 두면 그 합이 상대적으로 정밀하게 보존되고, 이후 큰 값 $10^{10}$과의 합에서 누락될 확률이 낮아질 수 있다. 물론 IEEE 754 표준이 정한 연산 순서 규칙과 최적화 정책 등에 따라 실제 동작이 달라질 수 있지만, 알고리즘 레벨에서 재배열은 여전히 중요한 기법이다.

#### 디바이스별 차이와 수치 안정성

CPU, GPU, FPGA 같은 하드웨어 플랫폼마다 부동소수점 연산 방식이 다르거나 동적 범위가 다를 수 있다. 더욱이 CPU와 GPU가 서로 다른 라운딩 모드(rounding mode)를 갖거나, 중간 결과를 내부에서 더 높은 정밀도로 유지하는 등 다양한 차이가 발생한다. 따라서 동일한 코드를 작성해도 플랫폼에 따라 오버플로우·언더플로우 시점이 달라질 수 있고, 그 결과 알고리즘이 의도한 안정성을 확보하기 어려울 때도 있다.

특히 GPU를 활용하는 대규모 병렬 연산에서는 같은 스레드 집합 내에서도 연산 순서가 유동적으로 바뀌고, 이것이 반올림 오차나 오버·언더플로우 발생에 큰 영향을 줄 수 있다. 수치 안정성이 엄격히 요구되는 애플리케이션(예: CFD, 대규모 과학 시뮬레이션 등)에서는, 병렬 연산의 순서를 고정하거나 사전에 충분한 스케일링을 적용하는 전략이 사용된다. 예를 들어 GPU 커널에서 큰 수를 다룰 때는 규격화(norm) 과정 등을 통해 모든 벡터가 유사한 크기가 되도록 처리한 다음 연산을 수행하여, 오버플로우·언더플로우 가능성을 줄일 수 있다.

#### 서브노멀 수와 성능 이슈

언더플로우가 발생하기 직전에 나타나는 서브노멀(subnormal) 영역은 매우 작은 수를 표현하기 위해 IEEE 754가 제공하는 특별한 방법이다. 가수부의 숨겨진 1(hidden bit)를 사용하지 않고, 사실상 유효 자릿수를 희생하여 가능한 한 0에 가까운 수를 표현한다. 이로 인해 대표적인 현상은 계산 성능 저하다. 일부 하드웨어는 서브노멀 수 처리를 전용 하드웨어 로직이 아닌 보조 루틴을 통해 수행하므로, 서브노멀 수가 자주 발생하면 연산 속도가 크게 떨어지게 된다.

또한 서브노멀 영역에서 벗어날 만큼만 수를 약간 스케일링해도 정규화(denormalized → normalized) 과정이 재개되어 성능이 개선될 수 있다. 따라서 실전에서 서브노멀 수가 자주 등장한다면, 근본적으로 알고리즘을 재설계해 서브노멀 영역을 회피하거나, 스케일링을 통해 계산 범위를 정규 영역 안에서 수행하도록 유도하는 것이 바람직하다. 예컨대 $10^{-320}$ 정도의 값을 반복적으로 다뤄야 한다면, 일괄적으로 $10^{10}$ 정도를 곱해 $10^{-310}$ 수준으로 옮기고, 모든 연산이 끝난 후 다시 되돌리는 방식이다.

#### 동적 범위와 특수 함수

로그, 지수, 삼각함수, 쌍곡선함수 같은 특수 함수를 계산할 때 오버플로우·언더플로우가 빈번히 발생한다. 예를 들어 $\exp(x)$는 $x$가 매우 크면 $\infty$로 발산하고, $x$가 매우 작으면 0에 수렴한다. 이때 $\exp(x)$ 자체를 직접 계산하기보다는, $x$가 특정 임계값을 초과하거나 미달할 경우 별도의 분기 처리를 수행하는 경우가 있다. 예컨대 $x > x\_\mathrm{max}$이면 바로 $\exp(x)=\infty$로 처리하고, $x < x\_\mathrm{min}$이면 0으로 처리하는 식이다.

수치해석 라이브러리는 내부적으로 이러한 분기 처리를 많이 수행한다. 예를 들어 $\exp$ 함수를 호출하면, 라이브러리 수준에서 입력값 $x$가 너무 크거나 작을 때 미리 정해둔 상수와의 비교를 통해 빠른 예외 처리를 하도록 최적화되어 있는 경우가 많다. 만일 이를 직접 구현해야 한다면, $\exp$ 호출 전 $|x|$가 특정 범위를 벗어나지 않는지 점검하고, 범위를 넘어설 경우 적절히 $\exp(x)=0$ 또는 무한대로 처리하거나 로그 스케일을 유지하는 방식으로 연산을 진행하는 것이 안전하다.

#### 메모리로부터의 전환: 캐싱과 임시 정밀도

오버플로우·언더플로우 대처는 메모리 상의 부동소수점 형식뿐만 아니라 CPU 내부 레지스터에서의 연산 정밀도와도 관련 있다. 일부 아키텍처(예: x86 FPU)는 내부 레지스터에서 80비트 정밀도(extended precision)를 사용하고, 이를 메모리에 저장할 때만 64비트(더블 정밀도)로 라운딩해 저장한다. 이 과정에서 내부적으로는 아직 오버플로우가 발생하지 않았던 값이, 메모리에 쓰는 순간 무한대로 반올림되거나 반대로 0으로 소실될 수 있다.

일부 컴파일러나 환경 설정에서는 “임시 정밀도(precise vs. fast-math)” 같은 옵션을 제공하여, 내부 레지스터에 남겨둘 때 정밀도를 유지할지 혹은 빠른 라운딩을 적용해도 될지 결정하게 한다. 매우 민감한 수치 해석 코드를 작성할 때는 이러한 옵션 설정을 세심하게 해 주어야, 예기치 않은 시점에서 오버플로우·언더플로우가 유발되지 않는다. 예컨대 gcc나 clang에서 제공하는 `-ffloat-store` 또는 `-mfpmath=sse` 같은 옵션이 대표적으로 거론된다.

#### 라운딩 모드와 반올림 오차 분석

부동소수점 환경에서 발생하는 오버플로우·언더플로우는 특정 라운딩 모드(rounding mode)와 맞물려 더욱 복잡한 양상을 띨 수 있다. IEEE 754 표준에서는 보통 4가지 라운딩 모드가 정의되어 있다. 대표적으로 가장 많이 쓰이는 “Round to Nearest, ties to Even” 모드가 있으며, 그 외에도 “Round Toward Zero”, “Round Down(−∞ 쪽)”, “Round Up(+∞ 쪽)” 등을 설정할 수 있다. 일반적인 데스크톱 CPU나 GPU에서는 Round to Nearest가 기본적으로 적용되는 경우가 많으나, 때로는 성능이나 특정 알고리즘 특성에 맞춰 다른 라운딩 모드를 강제하기도 한다.

라운딩 모드가 달라지면, 소수점 이하 비트를 어떤 방식으로 반올림해 정규화(normalization)할지 결정이 달라진다. 이로 인해 큰 값과 작은 값을 합하거나, 미세한 값을 곱하는 상황에서 생기는 결과값의 최종 표현이 조금씩 달라질 수 있다. 따라서 오버플로우 직전의 큰 수나 언더플로우 직전의 매우 작은 수를 다룰 때, 특정 라운딩 모드에서만 극단적인 에러(예: 갑작스럽게 0 혹은 무한대로 반올림되는 사례)가 발생할 수도 있다.

수치해석 코드에서 라운딩 모드를 변경해가며 테스트를 수행하면, 알고리즘이 얼마나 안정적인지 혹은 특정 모드에서만 취약한지 확인할 수 있다. 극단적인 예로, Round Down 모드에서 $x$가 음수일 때 덧셈이나 곱셈을 해 보면, Round to Nearest 모드에서보다는 빠르게 언더플로우가 일어날 가능성이 있다. 반면 Round Up 모드에서는 오버플로우에 좀 더 민감하게 반응할 수 있다. 물론 실제로 라운딩 모드를 자주 바꾸는 것은 시스템 자원 소모가 크고, 병렬 환경에서 일관성을 유지하기도 어려우므로 신중히 고려해야 한다.

#### 재배열의 한계와 조건수(Condition Number)

소수점 재배열로 반올림 오차나 언더플로우를 완화할 수는 있지만, 어떤 문제는 근본적으로 대단히 불안정할 수 있다. 고유값 문제나 다항 방정식 근의 문제, 또는 선형 시스템 해석에서의 조건수가 매우 큰 경우가 대표적이다. 조건수가 크다는 것은 입력값이 아주 미세하게 변해도 해가 크게 변할 수 있다는 의미이므로, 조금이라도 수치 오차가 발생하면 결과가 급격히 틀어질 수 있다.

예를 들어 $\mathbf{A}\mathbf{x}=\mathbf{b}$ 형태의 선형시스템에서 $\mathbf{A}$의 조건수(condition number)가 $10^{10}$ 이상이라면, 단순 가우스 소거법을 수행하는 동안 중간에 매우 큰 수와 매우 작은 수가 함께 나타날 수 있다. 적절한 피벗팅(Partial Pivoting)과 스케일링을 통해 일부 문제를 완화할 수 있으나, 근본적으로 그 시스템 자체가 수치적으로 불안정하다면 언더플로우·오버플로우가 발생하기 쉬운 상황이 된다.

조건수가 큰 문제에서는, 설령 언더플로우나 오버플로우가 발생하지 않는다 해도 반올림 오차가 누적되어 해의 품질이 크게 떨어지는 경우가 흔하다. 그러므로 이 경우는 재배열 기법이나 스케일링을 넘어서, 애초에 문제를 재정의하거나(예: 다항식 루틴에서 근을 안정적으로 구하기 위해 ‘편미분 방정식’으로 전환하거나, 근분할법 같은 기법을 쓰거나) 특별히 고안된 알고리즘(예: 정규방정식 대신 QR 분해를 활용한 방법 등)으로 접근할 필요가 있다.

#### 캐터스트로픽 캔슬레이션(Catastrophic Cancellation)

수치 계산을 하다 보면 두 값이 거의 같아, 그 차이가 기계 오차만큼의 작아진 상황이 발생할 수 있다. 이를 캔슬레이션(cancellation)이라고 부른다. 특히 큰 수 두 개가 거의 같은 값을 가질 때, 그 차이를 계산하면 소수점 이하 유효 숫자가 제대로 남지 않아 오차가 극단적으로 커지는 문제가 생긴다. 이를 가리켜 “캐터스트로픽 캔슬레이션”이라 한다. 언더플로우처럼 결과가 0으로 반올림되기까지는 아니더라도, 실제로는 0이 아닌 값이 거의 0으로 훼손될 수 있다는 점에서 비슷한 맥락이다.

예를 들어

$$
x = 1.000000001,\quad y = 1.000000000
$$

을 더블 정밀도로 계산했을 때 $x - y$는 대략 $10^{-9}$ 정도가 되어야 하지만, 반올림 오차에 의해 생각보다 많은 정보가 사라져 $10^{-9}$이 정확히 표현되지 않을 수 있다. 이것은 오버플로우·언더플로우와 구별되는 개념이긴 하지만, 궁극적으로는 부동소수점 표현의 한계로 인해 “무의미한 0”이 발생한다는 의미에서 유사한 위험 요소다.

Kahan Summation, pairwise summation, treelike summation 같은 알고리즘들은 이러한 캔슬레이션 문제를 완화하기 위해 고안되었다. 그러나 모든 상황에서 근본적 해결책이 되지는 않으며, 매우 큰 수와 매우 비슷한 수를 빼거나 더해야 한다면, 계산 구조를 변경하거나 로그 변환 등으로 다시 스케일링을 적용하는 방향을 모색해야 한다.

#### 동적 범위가 매우 큰 문제에서의 슈도코드 예시 (C++)

아래 예시는 C++에서 매우 큰 범위의 수를 다루기 위해 모든 변수 연산을 로그 변환한 뒤, 마지막에 다시 지수화하여 결과를 복원하는 과정을 슈도코드 형태로 보여준다.

```cpp
#include <cmath>
#include <iostream>
#include <vector>

// 벡터 내 모든 원소의 곱을 안전하게 계산한 뒤 로그 상태로 반환
double logProductOfVector(const std::vector<double>& v) {
    double logSum = 0.0;
    for (double x : v) {
        // x <= 0 이라면 로그 정의 불가하므로 에러 처리 필요
        if (x <= 0.0) {
            throw std::runtime_error("Non-positive value in vector");
        }
        logSum += std::log(x);
    }
    return logSum;
}

int main() {
    // 매우 큰 값들을 담고 있다고 가정
    std::vector<double> hugeNumbers = {1e100, 1e50, 5e200, 9e150};

    try {
        // 곱의 로그값을 구함
        double logVal = logProductOfVector(hugeNumbers);

        // 실제 곱으로 복원할 때, 오버플로우 위험이 있을 수 있음
        double result = std::exp(logVal);

        std::cout << "로그도메인 계산 결과(복원): " << result << std::endl;
    } catch(const std::exception& e) {
        std::cerr << "에러: " << e.what() << std::endl;
    }

    return 0;
}
```

벡터 내 모든 원소가 양의 실수라 가정하고, $x \leq 0$이면 예외 처리 후 중단한다. $\log(x)$ 연산은 $x>0$에서만 정의되므로, 비양수 값이 포함된 상황은 별도로 대처해야 한다. 모든 요소의 로그값을 더해두면, 실제로는 매우 큰 곱셈이지만 로그 합 형태로 간단히 표현되므로 오버플로우를 막을 수 있다. 마지막에 $\exp(\cdot)$로 복원할 때 여전히 오버플로우 위험이 존재하므로, $\exp(\cdot)$ 수행 전 임계값을 확인하거나, 결과가 무한대로 표현될 경우 별도의 예외 처리를 수행할 수 있다.

#### 대규모 연산(빅데이터, 머신러닝)에서의 오버플로우·언더플로우 고려

빅데이터나 머신러닝 분야에서도 오버플로우·언더플로우 문제는 매우 빈번하게 발생한다. 특히 신경망 훈련 과정에서 가중치와 기울기(gradient)를 반복적으로 업데이트해야 하는 역전파(backpropagation) 알고리즘이 대표적이다. 예컨대 활성화 함수로 Softmax를 사용하면, 지수 연산 과정에서 큰 입력값이 $\exp$ 연산을 통해 무한대가 되거나 매우 작은 입력값이 0으로 소멸되기 쉽다. 이를 방지하기 위해 흔히 적용하는 기법이 “Log-Sum-Exp” 변환이다.

딥러닝 라이브러리 구현체를 살펴보면, Softmax 함수를 계산할 때 직접 $\exp(z\_i)/\sum\_j \exp(z\_j)$를 구하지 않고, 보정 항 $\max\_i z\_i$를 빼는 형태로 안정성을 높인다. 예를 들어 $\mathbf{z}=(z\_1,z\_2,\dots,z\_n)$에 대해

$$
\text{Softmax}(z\_i) = \frac{\exp(z\_i - \alpha)}{\sum\_{j=1}^n \exp(z\_j - \alpha)}
$$

여기서 $\alpha = \max\_i z\_i$처럼 가장 큰 항을 기준으로 삼음으로써, $\exp(z\_i - \alpha)$ 값이 너무 커지거나 작아지는 상황을 완화한다. 머신러닝 라이브러리들은 이러한 수치적 안정화 기법을 광범위하게 활용하며, 실제 계산 구현에서 오버플로우·언더플로우가 일어나지 않도록 자동으로 처리한다.

오버플로우·언더플로우가 기울기 갱신 과정에서 발생하면, 가중치가 NaN으로 변하거나 0으로 고착되는 문제가 생길 수 있다. 이를 방지하기 위해 학습 루프 내에서 정기적으로 손실 함수나 기울기 값이 이상치(예: $\infty$ 혹은 NaN)가 아닌지 모니터링하고, 문제가 확인되면 학습률(learning rate)이나 레귤러라이제이션(regularization) 등 하이퍼파라미터를 조정한다. 예컨대 $L\_2$ 정규화 항이 작은 값을 과도하게 생성하거나, 드롭아웃 등 확률적 기법이 특정 뉴런에 비정상적으로 큰 활성화를 유도하는 경우를 살펴야 한다.

빅데이터 분석에서도 대규모 행렬 연산, 분산처리 환경에서의 반복 알고리즘(예: PageRank 계산, 확률적 그래프 모델 추론 등)에서 매우 큰 값과 매우 작은 값의 충돌이 생길 수 있다. 이를 위해 분산 처리 프레임워크(예: Spark, Hadoop 등)에 구현된 수치 모듈에서도 로그 스케일 기반 처리 혹은 스케일링 기법을 자주 사용한다. 데이터 크기가 기하급수적으로 커지거나, 부분 데이터들이 병합되는 과정에서 한쪽이 지나치게 큰 값을 갖거나 거의 0에 가까운 값을 갖는 일이 적지 않기 때문이다.

#### 한계값 근처에서의 예외 처리

IEEE 754 표준에서 부동소수점 예외(floating-point exceptions)는 오버플로우, 언더플로우, 0으로 나눔, 무한대-무한대 연산, NaN 간 연산 등이 발생할 때 시스템 내부 플래그를 세팅하거나 시그널을 발생시킨다. 일반적으로는 컴파일러나 런타임에서 이 예외를 자동으로 무시하고 $\pm\infty$나 NaN을 반환하지만, 좀 더 엄격한 분석이 필요할 때는 예외를 감지하고 즉시 오류나 경고 메시지를 발생시켜 문제 지점을 추적할 수 있다.

현대 프로그래밍 언어와 환경은 이러한 예외 처리 방식을 다양하게 제공한다. 예를 들어 C나 C++에서는 `<fenv.h>` 같은 헤더를 통해 부동소수점 플래그를 직접 조작하고, 예외 발생 시점을 확인 가능하다. Python이나 MATLAB 등 고수준 언어는 대부분 런타임에서 NaN이나 $\pm\infty$ 발생 여부를 감지하는 함수나 메서드를 제공한다. 이를 이용해 예외 상황을 빠르게 확인하면, 모델이 수치적 불안정에 빠지기 전에 조기 대응할 수 있다.

#### 구현 시 주의사항: 언어별 컴파일러 최적화와 스레드 안전성

병렬 프로그래밍 또는 최적화 설정이 높은 컴파일러 환경에서, 수학적으로 동등한 표현이라도 실제로는 컴파일러가 특정 연산 순서를 바꾸거나, 정수나 분수 상수를 고정해서 스케일링 작업을 암묵적으로 수행할 수 있다. 예컨대 `-ffast-math` 옵션을 켜면, IEEE 754 표준 호환성을 완전히 따르지 않아 약간의 수치 오차나 예외 처리를 희생하고 성능을 높이기도 한다. 이 과정에서 오버플로우·언더플로우가 발생해도 예외 처리를 건너뛸 수 있으므로, 민감한 계산이라면 주의해야 한다.

스레드 안전성(thread safety) 문제도 중요하다. 많은 언어에서 부동소수점 연산은 로컬 스레드 컨텍스트에 의존하거나, 스레드마다 레지스터 셋이 다를 수 있다. 만약 오버플로우 예외를 트리거하고 싶은데 스레드별 동작이 달라 한 스레드에서는 예외가 제대로 감지되고, 다른 스레드에서는 그냥 넘어가는 사례가 발생할 수 있다. 이를 피하려면 스레드별로 통일된 라운딩 모드와 예외 마스크(exception mask)를 설정해 동작을 일관되게 가져가도록 만들어야 한다.

#### 예제: 매우 큰 지수 입력값 처리 (Octave)

아래 예시 코드는 Octave에서 $\exp(x)$를 직접 호출하기에 앞서 $x$가 특정 범위를 초과하지 않도록 사전에 조정하는 방식을 보여준다.

```octave
function val = safe_exp(x)
  % 안전한 exp 계산
  % 임계값을 설정: double 정밀도에서 오버플로우가 나기 직전 근사
  max_val = 709; % 대략 ln(1.7976931348623157e+308)
  if x > max_val
    val = inf;
  elseif x < -max_val
    val = 0;
  else
    val = exp(x);
  end
end

% 테스트
x_vals = [710, 1, -711];
for i=1:length(x_vals)
  disp(["x = ", num2str(x_vals(i)), ", safe_exp(x) = ", num2str(safe_exp(x_vals(i)))]);
end
```

이 코드는 $\exp(x)$가 사실상 $10^{308}$ 이상으로 커지는 상황을 감안해 $x$가 $709$를 넘어서면 결과를 $\infty$로, 반대로 $-709$ 이하이면 0으로 처리한다. 실제로는 더 정밀한 경계값을 사용할 수도 있고, NaN 대신 예외 처리를 하거나 로그 스케일을 유지하도록 구현할 수도 있다. 이 접근법은 “안전한 계산”을 우선하는 방안이며, 오버플로우 예외를 야기하는 대신 미리 분기 처리로 결과를 결정해준다.

#### 고정소수점(Fixed-Point) 연산과의 비교

컴퓨터 연산에는 부동소수점(Floating-Point)만 있는 것이 아니라 고정소수점(Fixed-Point) 방식도 존재한다. 고정소수점 환경에서는 소수점 위치가 고정되어 있으며, 정해진 비트 수만큼 정수부와 소수부를 나누어 표현한다. 예를 들어 정수부에 16비트, 소수부에 16비트를 두어 32비트 안에서 수를 나타낼 수도 있다. 이때는 부동소수점처럼 지수부가 따로 존재하지 않으므로, 표현 가능한 수의 범위가 훨씬 좁다. 따라서 오버플로우와 언더플로우가 더 쉽게 발생할 수 있다. 예를 들어 매우 큰 값이 고정소수점 환경에 입력되면 단순히 최대 양수(overflow)로 “wrap-around”되거나, 또는 하드웨어가 예외를 발생시키기도 한다. 반대로 매우 작은 양수는 0으로 처리될 가능성이 크다.

고정소수점 연산을 사용하는 가장 큰 이유 중 하나는 부동소수점보다 단순한 하드웨어 구현과 낮은 전력 소모다. DSP(Digital Signal Processor)나 임베디드 기기, 모바일 환경에서 정밀도보다는 실시간 처리나 전력 효율이 중요한 경우 고정소수점 방식을 일부러 채택하기도 한다. 그러나 고정소수점 방식을 쓰면 동적 범위가 제한되어, 오버플로우·언더플로우 문제를 더 자주 고려해야 한다. 일부 알고리즘은 정밀도 손실 없이 동작하기 어렵기도 하며, 그 결과 시스템 오작동으로 이어질 수 있다.

#### 하프 정밀도(Half Precision)와 BF16

머신러닝 분야에서는 연산량을 줄이고 메모리 대역폭을 절약하기 위해 더 낮은 정밀도를 사용하는 방법이 널리 연구된다. 대표적으로 16비트 부동소수점인 FP16(Half Precision)이 있으며, 구글 TPU 등에서 활용하는 BF16(Brain Floating Point)도 있다. 이들은 지수부 길이를 부분적으로 유지해 일정 수준의 동적 범위를 확보하면서 가수부를 축소하여, 연산 속도와 메모리 전송 효율을 높인 형식이다. 예를 들어 FP16은 1비트 부호, 5비트 지수, 10비트 가수부로 구성되고, BF16은 8비트 지수, 7비트 가수부로 구성되어 결과적으로 더 많은 연산을 동시에 수행 가능하게 만든다.

하지만 FP16이나 BF16은 가수부가 매우 짧아 상대 오차가 쉽게 커진다. 특히 지수부가 충분히 크지 않으면 오버플로우 및 언더플로우가 더 자주 발생한다. 이를 방지하기 위해 혼합 정밀도(Mixed Precision) 기법이 도입되었다. 예컨대 신경망 훈련 중 가중치와 기울기를 계산할 때는 내부적으로 FP16을 쓰지만, 합산이나 축적(accumulation) 과정에서는 더블 정밀도(혹은 적어도 FP32)로 임시 보관하여 오차 누적이나 언더플로우를 줄일 수 있다. NVIDIA GPU에서 지원하는 텐서 코어(Tensor Core)도 이런 식의 혼합 정밀도 연산으로 높은 연산량을 처리하되, 결과의 수치 품질을 어느 정도 보장한다.

#### 소프트웨어 최적화와 수치적 풍선효과(Blow-Up)

수치 코드에서 한두 번의 곱셈, 덧셈으로는 오버플로우·언더플로우가 일어나지 않더라도, 반복 계산이 장기적으로 누적되면 “풍선효과(blow-up)”가 발생할 수 있다. 예를 들어 차분 방정식을 시간에 따라 반복적으로 적분하거나, 뉴턴-랩슨(Newton-Raphson) 방법으로 근을 찾는 과정을 계속 진행할 때, 중간 결과가 기하급수적으로 커지거나 작아질 수 있다. 단순히 로그 변환 한 번으로 해결되지 않으며, 매 스텝마다 동적 범위를 관리해야 할 수도 있다.

수학적으로, 어떤 반복관계가

$$
x\_{n+1} = f(x\_n)
$$

형태로 주어져 있고, $f(\cdot)$가 폭발적 성장을 유도하거나(예: 지수적) 극히 작은 값으로 수렴하게 만든다면, 부동소수점 환경에서는 오버플로우 또는 언더플로우로 결과가 0이나 무한대가 되어버릴 위험이 높다. 이 문제를 방지하려면, 각 스텝에서 $|x\_n|$을 검사하고 필요하다면 스케일링을 적용하거나, 로그 스케일에서 해석적으로 전환하여 $f(\cdot)$ 자체를 로그 도메인에서 재정의해야 한다.

#### 다항식 및 고차방정식 연산에서의 주의

다항식 $p(x)=a\_0+a\_1 x + a\_2 x^2 + \dots + a\_n x^n$를 직접 계산할 때, $x$가 아주 크거나 작다면 중간 항들의 크기 차이가 극단적으로 날 수 있다. 예를 들어 $x^n$이 이미 부동소수점 표현범위를 초과해 무한대로 가버리면, 나머지 항은 더 이상 계산에 의미가 없다. 반면 $a\_0$가 상대적으로 매우 작은데, $a\_n x^n$이 이미 어마어마한 크기가 되어 버리면 $a\_0$는 사실상 더해지지 않는다. 이 과정에서 오버플로우와 언더플로우, 그리고 반올림에 의한 큰 오차가 동시에 나타날 수 있다.

이를 해결하기 위해 호너(Horner) 알고리즘이 널리 쓰인다. 호너 알고리즘은 다항식을 계수와 변수의 곱셈·덧셈 순서를 재구성해, 계산 과정을 최소화하면서도 반올림 오차를 줄이는 효과가 있다. 즉,

$$
p(x) = a\_0 + x(a\_1 + x(a\_2 + \dots + x(a\_{n-1} + a\_n x)\dots))
$$

형태로 진행하기 때문에, 큰 $x$나 작은 $x$에 대해서도 연산 순서를 더 체계적으로 관리해 중간 항이 과도하게 커지거나 작아지는 문제를 다소 완화한다. 그러나 $x$가 극단적으로 클 경우라면 여전히 오버플로우 위험이 있고, 그 외에 추가적인 스케일링 기법이 필요할 수 있다.

#### 다른 예외상황: 0으로 나눔과 무한대 연산

오버플로우·언더플로우와 관련해, 0으로 나누거나 무한대에 무한대를 더하거나 빼는 등 특수한 연산도 중간 계산을 무의미하게 만들 수 있다. 예를 들어

$$
\frac{1}{x}
$$

연산에서 $x$가 극히 작은 수라면 언더플로우를 방지하기 위해 $x$를 0으로 반올림해 버린 뒤 0으로 나누기를 수행할 가능성이 있다. 반대로 $x$가 극도로 커서 $\infty$로 표현될 때, 무한대끼리의 덧셈·뺄셈은 부정형(Infinite - Infinite) 상태로 NaN을 생성할 수 있다.

실제 코드 구현에서 $1/x$를 계산하기 전 $x$가 언더플로우 수준인지, 혹은 $x$가 0에 해당하는지를 검사하여 에러 처리를 넣을 필요가 있다. 매우 작은 값을 $x\_\mathrm{min}$으로 클리핑(clipping)하는 것도 한 방법이다. 예컨대

$$
x \leftarrow \max(x, x\_\mathrm{min})
$$

등으로 설정한 뒤 1/x 연산을 수행하면, 언더플로우로 인한 갑작스러운 0 분모를 피할 수 있다. 무한대 연산 또한 비정상적인 결과를 낳지 않도록 사전에 조건을 걸어 예외 처리를 할 수 있다.

#### 추가 연구 방향: 멀티 정밀도 해석과 자동 미분(AD)

복잡한 물리 시뮬레이션이나 대규모 최적화 과정에서는, 프로그램 전체의 수치적 안정성을 자동으로 점검해 주는 툴이 있으면 좋다. 최근에는 멀티 정밀도 시뮬레이션이라는 개념이 부각되어, 정밀도가 다른 환경에서 코드를 실행해 보고 차이를 분석함으로써 안정성을 평가하기도 한다. 예를 들어 IEEE 754 더블 정밀도와 쿼드러플(128비트) 정밀도를 각각 사용해 시뮬레이션한 결과를 비교하여, 어느 정도 편차가 발생하는지 살펴볼 수 있다. 만약 편차가 극단적으로 크다면, 중간 단계에서 오버플로우·언더플로우나 반올림이 큰 영향을 미치고 있음을 시사한다.

자동 미분(Automatic Differentiation, AD)을 사용할 경우에도, 역전파 과정에서 오버플로우·언더플로우가 일어나면 기울기가 NaN으로 변하거나 0으로 고착될 수 있다. 이를 방지하려면 내부 AD 그래프의 각 노드에서 로그 변환이나 스케일링을 적용해, 기울기 흐름을 안정적으로 유지하도록 구현해야 한다. 일부 AD 프레임워크는 “안전 모드”를 제공하여, 각 단계의 값이 유효한 범위를 벗어나면 경고를 발생시키거나 임의로 클리핑해 준다.

\--- 참고: 수학적 해석과 수치적 해석의 균형

수치해석 알고리즘을 설계하고 구현할 때, 근본적으로 “이 알고리즘이 다루는 입력 범위가 부동소수점 표현 한계를 침범할 수 있는지”를 먼저 고민해야 한다. 만일 너무 큰 범위나 너무 작은 범위가 필연적으로 요구된다면, 로그 도메인 사용이나 배율 인자 조절, 멀티 정밀도 라이브러리 등의 대안을 적극 검토해야 한다. 또한, 문제 자체가 매우 악조건(ill-conditioned)이거나 근본적으로 큰 스케일 차이를 포함한다면, 이 문제를 근사나 변환을 통해 재정의할 수 있는지 탐색해야 한다.

현대적인 수치 라이브러리는 오버플로우와 언더플로우를 자동으로 피할 수 있는 방안이 상당 부분 구현되어 있지만, 알고리즘 사용자(프로그래머나 연구자)가 문제의 특성을 알지 못하면 적절히 활용하기 어렵다. 따라서 알고리즘을 선택하거나 파라미터를 설정하기 전, 본인이 다루는 문제의 크기 스케일과 잠재적인 불안정성을 반드시 점검하는 것이 좋다.
