IT·개발

MNIST × U-Net 노이즈 제거 실험기 — 28×28에 맞춘 경량 U-Net 직접 구현

5월 19, 2026 2 min read

MNIST × U-Net 노이즈 제거 실험기 — 28×28에 맞춘 경량 U-Net 직접 구현

도입: U-Net으로 MNIST 노이즈를 제거해봤습니다

U-Net이라고 하면 보통 의료 영상 세그멘테이션 모델로 먼저 떠오르시겠지만, 핵심 구조가 이미지-이미지 변환(image-to-image translation)이라는 점에 착안했습니다. 입력 이미지와 출력 이미지의 크기가 같아야 하는 구조라면, 노이즈가 섞인 이미지를 넣어 깨끗한 이미지를 내보내는 것도 자연스럽게 가능합니다.

MNIST를 고른 이유는 단순합니다. 28×28 그레이스케일 이미지라 연산이 가볍고, 빠르게 반복 실험할 수 있었습니다. 무겁고 복잡한 데이터셋 전에 구조가 제대로 작동하는지 확인하는 용도로 딱 맞는 선택이었습니다.

결론부터 말씀드리면, 이번 실험에서 핵심 설계 원칙은 두 가지였습니다. 모델 구조를 입력 크기에 맞게 2단계로 축소하는 것노이즈 위치를 랜덤하게 다양화하는 것입니다. 그 과정에서 얻은 교훈과 코드 전문을 함께 정리했습니다.


U-Net을 노이즈 제거에 쓰는 이유

U-Net의 구조는 크게 두 부분으로 나뉩니다. 입력 이미지의 특성을 압축하며 내려가는 인코더(다운샘플링 경로)와, 압축된 특성을 다시 원래 크기로 복원하는 디코더(업샘플링 경로)입니다.

여기서 핵심이 되는 것이 스킵 커넥션(Skip Connection)입니다. 인코더의 각 단계에서 추출한 특성 맵을 대응하는 디코더 단계에 직접 연결함으로써, 압축 과정에서 손실될 수 있는 세부 공간 정보를 디코더가 다시 활용할 수 있게 합니다.

결과적으로 U-Net은 입력과 같은 크기의 이미지를 출력합니다. 이 성질 덕분에 세그멘테이션뿐만 아니라, 이미지 복원·노이즈 제거와 같이 입력 형태를 유지하면서 화질을 개선해야 하는 작업에도 자연스럽게 적용할 수 있습니다.


데이터셋 재구성

MNIST는 원래 분류 문제용 데이터셋입니다. 이미지(x)와 레이블(y, 숫자 0~9)로 구성되어 있죠. 이것을 노이즈 제거용 이미지-이미지 변환 문제로 바꾸려면 데이터셋 구성 자체를 재정의해야 합니다.

아래 표는 변경 전후를 정리한 것입니다.

변경 전변경 후설명
x_train (입력 이미지)노이즈가 추가된 이미지노이즈 제거 학습을 위한 입력 데이터
y_train (레이블)원본 x_train 이미지노이즈 없는 깨끗한 이미지 (타겟)
x_test (테스트 이미지)노이즈가 추가된 테스트 이미지테스트용 입력 데이터
y_test (테스트 레이블)원본 x_test 이미지테스트용 타겟 데이터

즉, 원래 x_train의 깨끗한 이미지를 타겟(정답) 으로 두고, 이 이미지에 인위적으로 노이즈를 추가한 버전을 입력으로 사용하는 구조입니다. 이렇게 (노이즈 이미지, 원본 이미지) 쌍을 구성하면 모델이 노이즈 제거 방향으로 학습하게 됩니다.

[분석 — 2026년 재작성 시 추가] 이미지-이미지 변환 모델을 학습시키려면 이처럼 데이터셋 재구성이 선행되어야 합니다. 레이블(숫자)을 그대로 y_train으로 쓰는 분류 구조와는 출발점이 다릅니다. 처음 접하시는 분이 가장 헷갈리는 부분이기도 합니다.


MNIST에 맞춘 U-Net 구조

원래 U-Net(Ronneberger et al., 2015)은 의료 영상처럼 고해상도 이미지를 처리하기 위해 설계되었습니다. 입력 크기 572×572를 기준으로 인코더·디코더가 각각 5단계로 구성되어 있습니다.

원래 U-Net 다운샘플링 경로 (5단계, 2×2 MaxPool 적용 개략값):

572×572 → 284×284 → 142×142 → 71×71 → 35×35 → 28×28

출처: Ronneberger O, Fischer P, Brox T. “U-Net: Convolutional Networks for Biomedical Image Segmentation.” MICCAI 2015. (arXiv:1505.04597) 위 수치는 단계별 2배 축소 기준의 개략값입니다. 원논문은 valid padding convolution을 적용하므로 실제 feature map 크기는 논문 Figure 1에서 직접 확인하시기 바랍니다.

그런데 MNIST 이미지는 28×28입니다. 이 크기에 5단계 다운샘플링을 적용하면 중간 어딘가에서 이미지 크기가 너무 작아져 정보가 과도하게 압축되거나 크기가 맞지 않는 문제가 생깁니다.

MNIST용 조정 U-Net 경로 (2단계):

28×28 → 14×14 → 7×7 → 14×14 → 28×28

2단계 인코더로 28×28을 7×7까지 압축하고, 2단계 디코더로 다시 28×28로 복원하는 구조입니다. 채널 기준값은 depth = 14로 설정했습니다. 28의 절반인 14를 실험적 기준값으로 선택한 것이며, 8이나 16 같은 다른 값으로도 동일한 구조를 만들 수 있습니다.

필터 수(채널 수)는 단계별로 다음과 같이 구성했습니다.

  • 첫 번째 인코더 블록: 28×28×1 → 28×28×14 → pool → 14×14×14
  • 두 번째 인코더 블록: 14×14×14 → 14×14×28 → pool → 7×7×28
  • 병목(Bottleneck): 7×7×28 → 7×7×56
  • 첫 번째 디코더 블록: 7×7×56 → 14×14×28 (업샘플링 + 스킵 커넥션)
  • 두 번째 디코더 블록: 14×14×28 → 28×28×14 (업샘플링 + 스킵 커넥션)
  • 출력: 28×28×1 (sigmoid)

[분석 — 2026년 재작성 시 추가] 병목 단계에서 채널 수를 56으로 최대화한 뒤, 디코더에서 다시 줄여나가는 이 패턴이 U-Net의 핵심 설계 원칙입니다. 채널 수가 늘어날수록 모델이 표현할 수 있는 특성의 수가 많아지고, 병목이 “요약된 표현”을 담당하게 됩니다.


모델 코드 전문

라이브러리 버전 안내: 원본 실험 당시(2020년 5월) TensorFlow 2.1.0 환경에서 작성되었습니다. 재현 시 아래 명령으로 동일 버전을 설치하거나, 본인 환경과의 호환성을 먼저 확인해 두세요. “ pip install tensorflow==2.1.0 ` 현재(2026년 기준) TensorFlow 최신 버전에서는 API 변경이 있을 수 있으므로, pip show tensorflow`로 버전을 확인한 후 실행을 권장합니다.

아래는 앞서 설명한 2단계 U-Net 구조를 TensorFlow/Keras로 구현한 전체 코드입니다.

from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Conv2D, BatchNormalization, MaxPooling2D, Conv2DTranspose, concatenate

def unet(input_size=(28,28,1)):
    inputs = Input(input_size)
    depth = 14

    # 인코더 부분(다운샘플링)
    # 첫 번째 블록: 28×28×1 → 28×28×14
    conv1 = Conv2D(depth*1, (3, 3), activation='relu', padding='same')(inputs)
    conv1 = BatchNormalization()(conv1)
    conv1 = Conv2D(depth*1, (3, 3), activation='relu', padding='same')(conv1)
    conv1 = BatchNormalization()(conv1)
    pool1 = MaxPooling2D(pool_size=(2, 2))(conv1)  # 28×28×14 → 14×14×14

    # 두 번째 블록: 14×14×14 → 14×14×28
    conv2 = Conv2D(depth*2, (3, 3), activation='relu', padding='same')(pool1)
    conv2 = BatchNormalization()(conv2)
    conv2 = Conv2D(depth*2, (3, 3), activation='relu', padding='same')(conv2)
    conv2 = BatchNormalization()(conv2)
    pool2 = MaxPooling2D(pool_size=(2, 2))(conv2)  # 14×14×28 → 7×7×28

    # 병목 부분: 7×7×28 → 7×7×56
    conv3 = Conv2D(depth*4, (3, 3), activation='relu', padding='same')(pool2)
    conv3 = BatchNormalization()(conv3)
    conv3 = Conv2D(depth*4, (3, 3), activation='relu', padding='same')(conv3)
    conv3 = BatchNormalization()(conv3)

    # 디코더 부분(업샘플링)
    # 첫 번째 업샘플링 블록: 7×7×56 → 14×14×28
    up1 = concatenate([Conv2DTranspose(depth*2, (2, 2), strides=(2, 2), padding='same')(conv3), conv2], axis=3)
    dconv1 = Conv2D(depth*2, (3, 3), activation='relu', padding='same')(up1)
    dconv1 = BatchNormalization()(dconv1)
    dconv1 = Conv2D(depth*2, (3, 3), activation='relu', padding='same')(dconv1)
    dconv1 = BatchNormalization()(dconv1)

    # 두 번째 업샘플링 블록: 14×14×28 → 28×28×14
    up2 = concatenate([Conv2DTranspose(depth*1, (2, 2), strides=(2, 2), padding='same')(dconv1), conv1], axis=3)
    dconv2 = Conv2D(depth*1, (3, 3), activation='relu', padding='same')(up2)
    dconv2 = BatchNormalization()(dconv2)
    dconv2 = Conv2D(depth*1, (3, 3), activation='relu', padding='same')(dconv2)
    dconv2 = BatchNormalization()(dconv2)

    # 출력 레이어: 28×28×14 → 28×28×1
    outputs = Conv2D(1, (1, 1), activation='sigmoid')(dconv2)

    return Model(inputs=[inputs], outputs=[outputs])

# 모델 컴파일
model = unet()
model.compile(loss="binary_crossentropy", optimizer="adam", metrics=['accuracy'])
model.summary()

핵심 설계 포인트 세 가지를 짚어봅니다. (이하 [분석] 구간은 2026년 재작성 시 저자가 추가한 코드 해설입니다.)

[분석 1] depth 변수와 업샘플링 메커니즘 depth = 14 한 줄이 전체 채널 수의 기준입니다. depth<em>1(14), depth</em>2(28), depth*4(56)으로 자동 계산되므로, 채널 수를 전체적으로 바꾸고 싶다면 이 값 하나만 수정하면 됩니다. 업샘플링은 Conv2DTranspose(..., strides=(2, 2))strides=(2, 2) 설정이 배율을 결정합니다. 덕분에 7×7이 14×14로, 14×14가 28×28로 2배씩 확장됩니다. MaxPooling2D의 역방향 연산이라고 이해하면 쉽습니다.

[분석 2] 스킵 커넥션 구현 concatenate([Conv2DTranspose(...)(conv3), conv2], axis=3) 이 줄이 스킵 커넥션의 실제 구현입니다. Conv2DTranspose가 7×7을 14×14로 업샘플링한 결과와, 인코더 두 번째 블록의 출력(conv2, 14×14×28)을 채널 축(axis=3)으로 이어 붙입니다. 덕분에 디코더가 인코더에서 추출한 공간 정보를 그대로 재활용할 수 있습니다.

[분석 3] 출력 레이어와 손실 함수 출력 레이어 Conv2D(1, (1, 1), activation='sigmoid')(1, 1) 커널은 공간 크기를 바꾸지 않고 채널 수(14 → 1)만 조정합니다. sigmoid 활성화로 픽셀값이 0~1 범위로 출력됩니다. loss="binary_crossentropy"는 픽셀값을 0~1로 정규화한 상태에서 각 픽셀을 독립적인 이진 분류 문제처럼 다루는 방식입니다. 이미지 복원에 MSE(Mean Squared Error)를 쓰는 경우도 많지만, 이 실험에서는 binary_crossentropy + sigmoid 조합을 선택했습니다.


첫 번째 실험: 고정 위치 노이즈

첫 번째 실험에서는 모든 학습 이미지의 동일한 위치에 동일한 형태의 노이즈를 추가했습니다. 특정 행 전체에 흰색 픽셀 값(1.0)을 덮어씌우는 방식으로 가로줄 형태의 고정 위치 노이즈를 생성했습니다. 의도는 간단했습니다. 고정된 노이즈 패턴을 모델이 학습하고 제거하는지를 먼저 확인해보는 것이었습니다.

아래 그림에서 위 행이 노이즈가 추가된 이미지, 아래 행이 원본입니다.

가로줄 형태의 고정 위치 노이즈가 추가된 MNIST 손글씨 이미지(위)와 원본 깨끗한 이미지(아래) 비교

실험 결과

고정 위치 노이즈로 학습한 U-Net 모델의 훈련 및 테스트 예측 결과 — X_train, Y_train, Prediction 비교

학습 후 결과를 보니 예상치 못한 패턴이 나타났습니다. 동일 위치의 노이즈는 어느 정도 제거했지만, 노이즈 위치가 달라지면 제거 성능이 크게 떨어졌습니다. 수치 기록은 별도로 남기지 않았지만, 같은 위치의 노이즈는 잘 지워지는 반면 위치가 달라지면 흔적이 그대로 남아 있는 결과를 육안으로 확인할 수 있었습니다.

원인은 비교적 명확했습니다. 모델이 “이 위치에 이런 패턴이 있으면 지워라”는 방식으로만 학습한 것입니다. 노이즈가 항상 같은 자리에 있으니, 모델 입장에서는 위치를 외우는 것이 최적 전략이 되어버렸습니다. 일반적인 노이즈 제거 능력을 키우는 것과는 전혀 다른 방향으로 학습이 진행된 셈이었습니다.

[분석 — 2026년 재작성 시 추가] 이 결과는 학습 데이터의 다양성이 모델 일반화에 얼마나 중요한지를 잘 보여줍니다. 학습 데이터가 모두 같은 패턴을 공유하면, 모델은 그 패턴을 외워버리는 방향으로 수렴합니다. 이런 현상을 “데이터 편향(data bias)”이라고 부릅니다.


두 번째 실험: 랜덤 위치 노이즈

첫 번째 실험의 교훈을 바탕으로 방향을 바꿨습니다. 두 번째 실험에서는 노이즈 위치를 이미지 전체에 랜덤하게 분산시켰습니다. 노이즈 패치의 크기는 첫 번째 실험보다 줄이되, 각 이미지마다 패치가 등장하는 위치를 무작위로 다르게 배치했습니다.

아래 그림에서 위 행이 랜덤 노이즈가 추가된 이미지, 아래 행이 원본입니다.

이미지 전체에 산발적으로 분산된 소규모 랜덤 노이즈가 추가된 MNIST 이미지(위)와 원본 깨끗한 이미지(아래) 비교

실험 결과

랜덤 위치 노이즈로 학습한 U-Net 모델의 훈련 및 테스트 예측 결과 — X_train, Y_train, Prediction 비교

결과가 눈에 띄게 달라졌습니다. 첫 번째 실험과 달리 테스트 이미지에서도 노이즈 제거가 일관되게 이루어졌고, 다양한 위치의 노이즈를 훨씬 효과적으로 제거하는 모습을 확인했습니다. 수치를 별도로 기록하지는 않았지만, 예측 이미지와 원본 이미지의 시각적 차이가 첫 번째 실험보다 크게 줄어든 것을 육안으로 확인할 수 있었습니다.

노이즈 위치가 매 이미지마다 달라지니, 모델은 더 이상 특정 위치를 외우는 전략을 쓸 수 없게 됐습니다. 대신 “노이즈가 섞인 픽셀 패턴을 어떻게 복원할 것인가”라는 본질적인 문제를 학습하게 된 것입니다.

[분석 — 2026년 재작성 시 추가] 같은 U-Net 구조, 같은 하이퍼파라미터인데 노이즈 위치만 바꿨을 뿐인데도 결과가 이렇게 달라졌다는 점이 인상적이었습니다. 데이터 구성이 모델 성능을 좌우하는 결정적 요인이라는 것을 체감한 실험이었습니다.


최종 학습 설정

두 번째 실험을 기준으로 최종 학습 설정은 다음과 같습니다.

설정 항목
에포크 (Epochs)10
배치 크기 (Batch Size)3
학습 데이터 수1,000개 이미지
학습 : 검증 비율7 : 3
노이즈 유형랜덤 위치 노이즈

결론 및 교훈

이번 실험을 통해 정리한 네 가지 교훈입니다. 앞서 언급한 두 가지 핵심 설계 원칙(구조 축소, 랜덤 노이즈)에서 파생된 것들입니다.

1. 데이터 준비의 중요성 U-Net 같은 이미지-이미지 변환 모델을 학습시키려면 데이터셋을 문제에 맞게 재구성하는 작업이 반드시 선행되어야 합니다. MNIST의 (이미지, 레이블) 구조를 (노이즈 이미지, 원본 이미지) 쌍으로 바꿔야 한다는 점을 처음부터 설계해야 합니다.

2. 모델 구조 조정 원래 U-Net의 5단계 다운샘플링은 28×28 이미지에는 과도합니다. 입력 크기에 맞게 구조를 단순화하는 것이 오히려 효과적인 학습으로 이어졌습니다.

3. 데이터 다양성 고정 위치 노이즈로 학습한 모델은 특정 패턴만 외워 일반화에 실패했습니다. 랜덤 위치 노이즈로 학습한 모델은 다양한 패턴을 학습해 일반화 성능이 향상됐습니다. 학습 데이터의 다양성이 모델의 일반화 성능을 결정하는 핵심 요소였습니다.

4. 하이퍼파라미터 규모 에포크 10, 배치 크기 3, 데이터 1,000장이라는 작은 설정으로도 의미 있는 결과를 얻었습니다. 항상 큰 모델과 많은 에포크가 정답이 아니라는 점을 확인한 실험이었습니다.

U-Net은 세그멘테이션뿐 아니라 이미지 복원, 노이즈 제거와 같은 작업에서도 유연하게 활용될 수 있습니다. 이 실험을 통해 간단한 MNIST 데이터셋으로도 그 가능성을 확인할 수 있었습니다.

다음 실험 방향으로는 Gaussian 노이즈나 Salt & Pepper 노이즈처럼 유형이 다른 노이즈로 확장하거나, 전체 60,000장의 MNIST 이미지로 더 큰 데이터에서의 일반화 성능 변화를 관찰해보는 것도 흥미로울 것 같습니다.

전체 코드와 더 자세한 구현 내용은 GitHub 저장소(hiwony7933/Unet)에서 확인하실 수 있습니다.


이 글은 2020년 5월 7일 작성된 실험기를 SEO 보완 재작성한 것입니다.