들어가며
지금까지 Part 1을 통해 아날로그 신호가 시간(샘플링)과 진폭(양자화) 측면에서 어떻게 디지털로 변환되는지 그 근간을 살펴보았습니다. 아날로그가 0과 1의 세계로 들어오는 관문을 통과했으니, 이제 그 디지털 데이터를 본격적으로 다루어 볼 차례입니다.
피아노 건반 앞에 앉아 도·미·솔 화음을 칩니다. 귀에는 하나의 소리로 들리지만, 그 소리 속에는 분명히 세 개의 음이 녹아 있습니다. 사람의 청각은 복잡하게 뒤섞인 신호에서 각각의 주파수 성분을 자연스럽게 분리해 냅니다. 푸리에 변환(Fourier Transform)은 바로 이 능력을 수학적으로 구현한 도구입니다.
이번 글에서는 신호 처리의 꽃이자, 시간 영역의 신호를 주파수 영역으로 분해하여 물리적 의미를 재해석하는 핵심 이론인 푸리에 변환(Fourier Transform)에 대해 본격적으로 살펴보겠습니다. 오늘은 그 첫 시간으로, 복잡한 수학 공식 이면에 숨겨진 푸리에 변환의 직관적인 의미와 함께 Python의 FFT(Fast Fourier Transform)를 활용해 섞여 있는 여러 신호를 주파수 성분별로 완벽하게 분리해 내는 과정을 실습해 보겠습니다.
1. 장 바티스트 조제프 푸리에의 위대한 발견
19세기 초, 프랑스의 수학자이자 물리학자인 푸리에(Fourier)는 열전도 현상을 연구하던 중 신호 처리를 통째로 뒤바꿀 엄청난 아이디어를 발표합니다.
"아무리 복잡하고 불규칙해 보이는 주기적인 파형이라도, 서로 다른 주파수를 가진 수많은 단순한 정현파(Sine, Cosine wave)들의 합으로 분해할 수 있다."
과일 스무디를 맛보고 그 안에 들어간 딸기, 바나나, 우유의 정확한 비율을 알아내는 것은 불가능에 가깝습니다. 하지만 푸리에 변환이라는 마법의 공식을 통과하면, 엉켜있던 '스무디(시간 영역의 신호)'가 각각의 '원재료(주파수 영역의 정현파 성분)'로 완벽하게 분리됩니다.
2. 시간 영역(Time Domain)과 주파수 영역(Frequency Domain)
- 시간 영역(Time Domain): 우리가 오실로스코프나 앞선 실습들에서 보았던 형태입니다. 가로축이 '시간(Time)', 세로축이 '진폭(Amplitude)'입니다. 신호가 언제 얼마나 큰 값을 가지는지는 직관적으로 보이지만, 이 신호가 어떤 특성의 파형들로 섞여 있는지는 알아내기 어렵습니다.
- 주파수 영역(Frequency Domain): 푸리에 변환을 거친 후의 형태입니다. 가로축이 '주파수(Frequency, Hz)', 세로축이 해당 주파수 성분의 '크기(Magnitude)'가 됩니다. 신호가 "어떤 주파수 성분을 얼마나 가지고 있는가?"라는 구성 성분을 명확하게 보여줍니다.
시간 영역(Time Domain)에서 신호는 "시간에 따라 진폭이 어떻게 변하는가"를 보여줍니다. 하지만 이 관점만으로는 다음과 같은 질문에 답하기 어렵습니다.
- 이 오디오 신호에서 고주파 노이즈만 제거하려면 어디를 건드려야 할까?
- 진동 센서 데이터에서 특정 주파수 성분이 갑자기 커졌다면, 기계 고장의 징후일까?
- 두 신호가 비슷해 보이는데, 주파수 구성은 실제로 얼마나 다를까?
주파수 영역(Frequency Domain)으로 전환하면 이 모든 질문에 명확하게 답할 수 있습니다. 푸리에 변환은 시간 영역 신호를 주파수 영역으로 옮겨주는 수학적 변환이며, 그 역방향도 완벽하게 정의되어 있어 정보 손실 없이 두 영역을 오갈 수 있습니다.
3. 푸리에 변환의 핵심 아이디어
푸리에 변환의 핵심 아이디어는 어떤 복잡한 신호도 서로 다른 주파수를 가진 정현파(사인파)의 합으로 분해할 수 있다는 것입니다. 이를 수식으로 표현하면 다음과 같습니다.
연속 푸리에 변환 (Continuous Fourier Transform, CFT):
여기서 $x(t)$는 시간 영역 신호, $X(f)$는 주파수 영역 표현, $e^{-j2\pi ft} = \cos(2\pi ft) - j \sin(2\pi ft)$는 복소 정현파입니다.
이 식의 물리적 의미는 다음과 같습니다. 신호 $x(t)$에 특정 주파수 $f$의 정현파를 곱한 뒤 전체 시간에 걸쳐 적분한다는 것은, 그 주파수 성분이 신호 안에 얼마나 포함되어 있는지를 측정하는 일입니다. 신호와 해당 주파수 성분이 잘 일치할수록 $X(f)$의 크기가 커집니다. 이것을 수학적으로 표현하면, 신호 $x(t)$를 특정 주파수 $f$의 정현파에 투영(Projection)시켜서 특정 주파수 $f$의 정현파 성분의 크기를 구하는 것입니다. 마치 벡터 공간에서 두 벡터의 내적을 구하듯, 함수 공간에서는 두 함수를 곱하여 적분을 수행하는 것이 함수 공간에서의 내적이 됩니다.
4. 이산 푸리에 변환(Discrete Fourier Transform, DFT)
컴퓨터는 연속 신호가 아니라 이산(discrete) 샘플만 다룰 수 있습니다. 따라서 실제 디지털 신호 처리에서는 이산 푸리에 변환(Discrete Fourier Transform, DFT)을 사용합니다.
DFT 공식:
주요 파라미터의 의미는 다음과 같습니다.
| 기호 | 의미 |
| $N$ | 신호 샘플 수 (DFT 점수) |
| $x[n]$ | $n$번째 시간 도메인 샘플 |
| $X[k]$ | $k$번째 주파수 빈(bin)의 복소 계수 |
| $k$ | 주파수 인덱스 (0 ~ N-1) |
$X[k]$는 복소수이므로 다음과 같이 분리하여 해석합니다.
- 진폭 스펙트럼(Amplitude Spectrum): $|X[k]|$ — 각 주파수 성분의 크기
- 위상 스펙트럼(Phase Spectrum): $\angle X[k]$ — 각 주파수 성분의 위상
주파수 분해능과 주파수 축:
DFT 결과에서 $k$번째 빈이 나타내는 실제 주파수는 다음과 같습니다.
$$f_k = \frac{k \cdot f_s}{N}$$
- $f_s$: 샘플링 주파수 (Hz)
- 주파수 분해능(Frequency Resolution): $\Delta f = f_s / N$
예를 들어 $f_s = 1000$ Hz, $N = 1000$이면 $\Delta f = 1$ Hz입니다. 분석할 신호가 길수록($N$이 클수록) 주파수 분해능이 높아집니다.
5. 고속 푸리에 변환(Fast Fourier Transform, FFT): DFT를 실용적으로 만든 혁신
데이터 길이가 $N$일 때 DFT의 연산량은 $O(N^2)$에 달합니다. 1초짜리 44.1 kHz 오디오를 처리하려면 무려 19억 번의 계산이 필요합니다. 이 엄청난 계산 장벽을 획기적으로 낮춘 알고리즘이 바로 FFT(Fast Fourier Transform)입니다. FFT는 연산량을 $O(N \log N)$으로 줄여주어, 현대 디지털 신호 처리와 통신의 실시간 구현을 가능케 한 역사적인 알고리즘입니다.
FFT는 1965년 Cooley와 Tukey가 발표한 알고리즘으로, DFT 계산의 대칭성과 주기성을 이용해 시간 복잡도를 $O(N \log N)$으로 줄입니다.
| N | DFT (O(N2)) 연산 횟수 | FFT (O(NlogN)) 연산 횟수 | 속도 향상 |
| 1,024 | 1,048,576 | 10,240 | 약 102배 |
| 1,048,576 (1M) | 약 $10^{12}$ | 약 $2 \times 10^7$ | 약 50,000배 |
FFT는 $N$이 2의 거듭제곱일 때 최적 성능을 발휘합니다. 아래 코드는 $N$의 크기에 따라 FFT와 DFT의 실행 속도 차이를 실험하는 코드입니다.
import time
import numpy as np
def dft_naive(x):
"""직접 구현한 DFT (교육용, O(N^2))"""
N = len(x)
X = np.zeros(N, dtype=complex)
n = np.arange(N)
for k in range(N):
X[k] = np.sum(x * np.exp(-1j * 2 * np.pi * k * n / N))
return X
# 크기별 성능 비교
sizes = [64, 128, 256, 512, 1024]
for n_size in sizes:
x_test = np.random.randn(n_size)
start = time.perf_counter()
dft_naive(x_test)
dft_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(100):
np.fft.fft(x_test)
fft_time = (time.perf_counter() - start) / 100
speedup = dft_time / fft_time
print(f"N={n_size:4d}: DFT={dft_time*1000:.2f}ms, "
f"FFT={fft_time*1000:.4f}ms, 속도 향상={speedup:.0f}x")
N= 64: DFT=0.84ms, FFT=0.0237ms, 속도 향상=36x
N= 128: DFT=0.82ms, FFT=0.0042ms, 속도 향상=198x
N= 256: DFT=2.14ms, FFT=0.0049ms, 속도 향상=433x
N= 512: DFT=6.05ms, FFT=0.0056ms, 속도 향상=1073x
N=1024: DFT=21.22ms, FFT=0.0088ms, 속도 향상=2410x
N이 커질수록 FFT와 직접 구현 DFT 간의 속도 차이가 기하급수적으로 벌어지는 것을 직접 확인할 수 있습니다.
6. Python 실습: 복합 신호 분석
복합 신호 생성 및 FFT 분석
아래 코드는 세 개의 정현파를 합성한 신호에 FFT를 적용하여 주파수 성분을 추출합니다.
import numpy as np
import matplotlib.pyplot as plt
# 한글 폰트 설정 (macOS: AppleGothic, Windows: Malgun Gothic)
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False
# 기본 파라미터 설정
fs = 1000 # 샘플링 주파수 (Hz)
T = 1.0 # 신호 길이 (초)
N = int(fs * T) # 총 샘플 수 = 1000
t = np.linspace(0, T, N, endpoint=False) # 시간 축
# 복합 신호 생성: 50Hz + 120Hz + 200Hz 정현파
f1, f2, f3 = 50, 120, 200
A1, A2, A3 = 1.0, 0.5, 0.3
signal = (A1 * np.sin(2 * np.pi * f1 * t) +
A2 * np.sin(2 * np.pi * f2 * t) +
A3 * np.sin(2 * np.pi * f3 * t))
# FFT 계산
X = np.fft.fft(signal) # 복소 FFT 결과 (N개)
freqs = np.fft.fftfreq(N, d=1/fs) # 주파수 축 (Hz)
# 단측 스펙트럼 (0 ~ fs/2, 즉 양측 스펙트럼의 절반만 사용) 추출 및 진폭 스케일링
half = N // 2
freqs_pos = freqs[:half]
amplitude = (2 / N) * np.abs(X[:half])
# 시각화
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
axes[0].plot(t[:200], signal[:200], color='steelblue', linewidth=1.5)
axes[0].set_title('시간 영역 신호 (앞 200 샘플)', fontsize=13)
axes[0].set_xlabel('시간 (s)')
axes[0].set_ylabel('진폭')
axes[0].grid(True, alpha=0.4)
axes[1].plot(freqs_pos, amplitude, color='darkorange', linewidth=1.5)
axes[1].set_title('주파수 영역 (FFT 진폭 스펙트럼)', fontsize=13)
axes[1].set_xlabel('주파수 (Hz)')
axes[1].set_ylabel('진폭')
axes[1].set_xlim(0, 300)
axes[1].grid(True, alpha=0.4)
plt.tight_layout()
plt.savefig('fft_amplitude_spectrum.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"주파수 분해능: {fs/N:.1f} Hz") # <예상출력> 주파수 분해능: 1.0 Hz
print(f"최대 분석 주파수 (Nyquist): {fs/2} Hz") # <예상출력> 최대 분석 주파수 (Nyquist): 500.0 Hz

시간 영역에서는 복잡하게 뒤섞인 파형이 보이지만, FFT 스펙트럼에서는 50Hz, 120Hz, 200Hz 위치에 정확히 세 개의 피크가 나타나며 각 피크의 높이가 설정한 진폭(1.0, 0.5, 0.3)에 대응합니다.
FFT 결과의 물리적 해석 — 진폭 스케일링
FFT 출력값을 그대로 사용하면 진폭이 $N$에 비례하여 나타납니다. 올바른 진폭을 복원하려면 다음과 같이 스케일링이 필요합니다.
# 단측 스펙트럼의 올바른 진폭 스케일링
# - DC(k=0)와 Nyquist(k=N/2) 성분: 1/N
# - 나머지 성분: 2/N (켤레 대칭으로 인해 양측에 에너지가 분산되므로)
amplitude_scaled = np.abs(X[:half]) / N
amplitude_scaled[1:] *= 2 # DC 제외 모든 성분에 2를 곱함
# 피크 위치 확인
peak_indices = np.where(amplitude_scaled > 0.1)[0]
for idx in peak_indices:
print(f"주파수: {freqs_pos[idx]:.1f} Hz, 진폭: {amplitude_scaled[idx]:.3f}")
아래 출력은 위의 예제 신호로부터 출력된 주파수 성분별 진폭입니다. FFT가 원래 신호의 주파수 성분과 진폭을 완벽하게 복원하였음을 확인할 수 있습니다.
주파수: 50.0 Hz, 진폭: 1.000
주파수: 120.0 Hz, 진폭: 0.500
주파수: 200.0 Hz, 진폭: 0.300
역 FFT(IFFT)를 이용한 신호 재구성
FFT의 역변환인 IFFT를 통해 주파수 영역에서 다시 시간 영역으로 돌아올 수 있습니다. 이 특성은 주파수 선택적 필터링의 핵심 원리입니다.
# 특정 주파수만 남기는 주파수 도메인 필터링 예시
X_filtered = X.copy()
# 180Hz 이상 성분 제거 (단순 저역 통과 필터)
cutoff_bin = int(180 * N / fs)
X_filtered[cutoff_bin : N - cutoff_bin] = 0
# IFFT로 시간 영역 신호 복원
signal_filtered = np.real(np.fft.ifft(X_filtered))
# 결과 비교 시각화
fig, axes = plt.subplots(3, 1, figsize=(12, 10))
axes[0].plot(t[:300], signal[:300], color='steelblue', lw=1.5)
axes[0].set_title('원본 신호 (50 + 120 + 200 Hz)')
axes[0].grid(True, alpha=0.4)
X_f_plot = (2 / N) * np.abs(X_filtered[:half])
axes[1].plot(freqs_pos, amplitude, color='lightgray', lw=1.5, label='원본 스펙트럼')
axes[1].plot(freqs_pos, X_f_plot, color='darkorange', lw=2.0, label='필터 후 스펙트럼')
axes[1].set_title('주파수 스펙트럼 비교')
axes[1].set_xlim(0, 300)
axes[1].legend()
axes[1].grid(True, alpha=0.4)
axes[2].plot(t[:300], signal_filtered[:300], color='mediumseagreen', lw=1.5)
axes[2].set_title('필터링 후 신호 (50 + 120 Hz만 복원)')
axes[2].grid(True, alpha=0.4)
plt.tight_layout()
plt.savefig('fft_filtering.png', dpi=150, bbox_inches='tight')
plt.show()

FFT 도메인에서 필터링을 수행하고 IFFT로 복원하는 것이 주파수 도메인 필터링의 핵심 원리입니다. 실행 결과를 살펴보면 200Hz 성분이 제거되어 원본보다 다소 부드러운 신호가 출력됩니다. 다만 위의 코드는 이 원리를 설명하기 위한 예시이며, 실제 필터 설계에서는 이후 연재 글(윈도우 함수, FIR/IIR 필터)에서 다룰 방법을 사용하는 것이 바람직합니다.
노이즈가 포함된 실제 신호 분석
실제 환경에서는 신호에 항상 노이즈가 혼입됩니다. FFT가 노이즈 속에서도 원하는 주파수 성분을 어떻게 탐지하는지 살펴보겠습니다.
np.random.seed(42)
# 신호 + 노이즈 생성
clean_signal = np.sin(2 * np.pi * 60 * t) + 0.5 * np.sin(2 * np.pi * 150 * t)
noise = 1.5 * np.random.randn(N)
noisy_signal = clean_signal + noise
# SNR 계산
signal_power = np.mean(clean_signal**2)
noise_power = np.mean(noise**2)
snr_db = 10 * np.log10(signal_power / noise_power)
print(f"입력 SNR: {snr_db:.1f} dB") # <예상출력> 입력 SNR: -5.4 dB
# FFT 분석
X_noisy = np.fft.fft(noisy_signal)
amp_noisy = (2 / N) * np.abs(X_noisy[:half])
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
axes[0].plot(t[:300], noisy_signal[:300], color='steelblue', lw=1.0, alpha=0.8)
axes[0].set_title(f'노이즈 포함 신호 (SNR = {snr_db:.1f} dB)')
axes[0].set_xlabel('시간 (s)')
axes[0].set_ylabel('진폭')
axes[0].grid(True, alpha=0.4)
axes[1].plot(freqs_pos, amp_noisy, color='steelblue', lw=1.0, alpha=0.7, label='FFT 스펙트럼')
axes[1].axvline(x=60, color='red', lw=2, ls='--', label='60 Hz (신호)')
axes[1].axvline(x=150, color='orange', lw=2, ls='--', label='150 Hz (신호)')
axes[1].set_title('노이즈 신호의 FFT 스펙트럼')
axes[1].set_xlabel('주파수 (Hz)')
axes[1].set_ylabel('진폭')
axes[1].set_xlim(0, 300)
axes[1].legend()
axes[1].grid(True, alpha=0.4)
plt.tight_layout()
plt.savefig('fft_noisy.png', dpi=150, bbox_inches='tight')
plt.show()

노이즈가 심해 시간 영역에서는 신호 성분이 거의 보이지 않더라도, FFT 스펙트럼에서는 60Hz와 150Hz 피크가 노이즈 바닥(noise floor) 위로 뚜렷하게 나타납니다. 이것이 주파수 영역 분석의 강점입니다.
7. DFT/FFT의 주요 특성 정리
실무에서 꼭 알아야 할 FFT의 핵심 특성을 정리합니다.
- 선형성(Linearity): 푸리에 변환은 다음 두 가지 성질을 동시에 만족합니다.
- 동차성(Homogeneity): 신호에 상수를 곱한 후 변환한 결과는, 먼저 변환한 후 상수를 곱한 결과와 같습니다.
- 중첩성(Additivity): 두 신호의 합에 대한 변환은, 각 신호를 개별적으로 변환한 결과의 합과 같습니다.
- 켤레 대칭(Conjugate Symmetry): 실수 신호의 FFT는 켤레 대칭을 가지므로, $N/2$ 이상의 주파수 성분은 $N/2$ 이하와 중복됩니다. 이것이 $0 \sim f_s/2$ 구간, 즉 단측 스펙트럼(one-sided spectrum)만 분석해도 충분한 이유입니다.
- 시프트 특성(Shift Property): 시간 이동은 주파수 영역에서 위상 변화로만 나타나며, 진폭 스펙트럼은 변하지 않습니다.
$$\mathcal{F}\{x[n-n_0]\} = X[k] \cdot e^{-j\frac{2\pi}{N}kn_0}$$
- 파시발 정리(Parseval's Theorem): 신호의 총 에너지는 시간 영역과 주파수 영역에서 보존됩니다. 이 특성은 다음 편에서 다룰 PSD 분석의 기초가 됩니다.
$$\sum_{n=0}^{N-1} |x[n]|^2 = \frac{1}{N}\sum_{k=0}^{N-1} |X[k]|^2$$
8. 실무 적용 사례
푸리에 변환은 교과서에만 존재하는 박제된 공식이 아닙니다. 지금 여러분이 이 글을 읽고 있는 스마트폰과 PC의 근간을 이루는 핵심 기술입니다.
- 오디오 압축 (MP3, AAC): 인간의 귀는 큰 소리 주변의 작은 소리를 잘 듣지 못합니다(Masking Effect). 오디오 데이터를 FFT로 주파수 영역으로 넘긴 뒤, 사람이 잘 듣지 못하는 주파수 대역에는 적은 비트를 할당하거나 삭제하는 방식으로 용량을 극적으로 줄입니다.
- 영상 압축 (JPEG, 비디오 코덱): 이미지는 시간 대신 2차원 공간에 대한 신호입니다. 이를 주파수로 변환(DCT: 이산 코사인 변환 사용)하면, 고주파 성분(디테일, 머리카락 등)은 사람 눈에 덜 민감하다는 점을 이용해 데이터양을 압축할 수 있습니다.
- 통신 (LTE, 5G, Wi-Fi): 공기 중으로 수많은 데이터가 동시에 날아다니지만 서로 간섭하지 않는 이유는, FFT를 이용해 데이터를 서로 다른 주파수 대역(직교 주파수 분할 다중화, OFDM)에 실어 보내기 때문입니다.
[Insight]
오늘은 다른 연재 글에 비해 푸리에 변환을 길게 다루었습니다. 이는 푸리에 변환이 그만큼 중요하다는 방증이 되기도 합니다. 푸리에 변환을 처음 접할 때 많은 분들이 "복소 지수함수 $e^{-j2\pi ft}$가 왜 나오는가?"에서 멈추곤 합니다. 이 질문에 직관적으로 답하자면, 복소수는 크기와 위상을 동시에 표현하는 가장 간결한 수단이기 때문입니다. $e^{j\theta} = \cos\theta + j\sin\theta$ (오일러 공식)를 사용하면 사인파와 코사인파를 한 식으로 통합할 수 있고, 회전(rotation)이라는 기하학적 의미도 자연스럽게 얻습니다.
또 한 가지 중요한 관점은 FFT가 단순한 분석 도구가 아니라 연산 인프라라는 점입니다. 두 신호의 컨볼루션은 시간 영역에서 $O(N^2)$이지만, FFT로 변환한 뒤 주파수 영역에서 곱하고 역변환하면 $O(N \log N)$으로 줄어듭니다. 이 "FFT 컨볼루션" 기법은 디지털 필터 구현은 물론, CNN(합성곱 신경망)의 효율적 구현에도 활용됩니다.
푸리에 변환을 깊게 이해하면, 신호 처리, 영상 처리, 컴퓨터 비전 등의 영역에서 주파수를 기준으로 하는 새로운 시각을 갖게 될 것입니다.
다음 글 예고
오늘은 푸리에 변환의 개념과 FFT를 통한 주파수 스펙트럼 분석의 기초를 다져보았습니다. 하지만 우리가 FFT 결과를 시각화할 때 사용한 배열에는 '진폭(Magnitude)' 정보 외에 또 다른 중요한 정보가 숨겨져 있습니다. 다음 시간에는 [10. FFT 심화: 진폭/위상 스펙트럼 및 PSD(Power Spectral Density) 해석] 편을 통해 복소수 데이터가 품고 있는 '위상(Phase)'의 의미와 노이즈 분석의 핵심인 전력 스펙트럼 밀도(PSD)에 대해 더 깊이 파헤쳐 보겠습니다.
📌 이전 연재 글 보기
- [06] 신호의 합성 및 SNR: 신호 대 잡음비 개념과 실무적 의미
- [07] 샘플링 정리(Nyquist): 앨리어싱(Aliasing) 현상 시뮬레이션
- [08] 양자화(Quantization): 비트 깊이(Bit-depth)에 따른 오차 분석
