POOOLING FOREST
밑바닥부터 시작하는 딥러닝: NumPy 하나로 딥러닝 프레임워크를 만들어본 이야기 - NumPy 하나만으로 딥러닝 프레임워크를 직접 구현해보며 블랙박스 같았던 자동 미분과 텐서 연산의 원리를 파
AI

밑바닥부터 시작하는 딥러닝: NumPy 하나로 딥러닝 프레임워크를 만들어본 이야기

NumPy 하나만으로 딥러닝 프레임워크를 직접 구현해보며 블랙박스 같았던 자동 미분과 텐서 연산의 원리를 파헤친 기술 블로그 글입니다.

김영태

테크리드

안녕하세요. 풀링포레스트 테크리드 김영태입니다.

요즘 개발자들 사이에서 딥러닝은 마치 공기처럼 자연스러운 존재가 되었습니다. PyTorch나 TensorFlow 같은 훌륭한 도구들이 이미 너무 잘 되어 있어서, import torch 한 줄이면 누구나 그럴듯한 모델을 뚝딱 만들어낼 수 있는 세상이죠. 저 역시 실무에서는 당연히 이런 검증된 라이브러리를 사용합니다. 하지만 가끔 그런 생각이 듭니다. "내가 지금 쓰고 있는 이 backward() 함수가 도대체 내부에서 무슨 짓을 하고 있는 걸까?"

솔직히 고백하자면, 저도 처음 딥러닝을 접했을 때는 라이브러리 사용법 익히기에 급급했습니다. API 문서 뒤적거리면서 파라미터 값만 바꿔 끼우는 게 전부였죠. 그러다 보니 모델이 왜 학습이 안 되는지, 그라디언트가 왜 소실되는지 깊이 있게 이해하기 어려웠습니다. 마치 자동차 엔진이 어떻게 도는지도 모르고 운전대만 잡고 있는 기분이랄까요.

그래서 얼마 전, 아주 무모하면서도 재밌는 도전을 하나 시작했습니다. 바로 "빈 파일 하나와 NumPy만 가지고 나만의 딥러닝 라이브러리 만들어보기" 입니다. 거창한 목표는 아니었습니다. 그저 텐서(Tensor) 연산부터 자동 미분(Automatic Differentiation), 옵티마이저(Optimizer)까지 직접 손으로 구현해보며 블랙박스 내부를 들여다보고 싶었습니다.

오늘 공유할 내용은 최근에 감명 깊게 본 'Build a Deep Learning Library'라는 가이드에서 영감을 받아, 제가 직접 코드를 짜보며 느꼈던 생생한 삽질의 기록이자 배움의 과정입니다.

왜 사서 고생을 하나요?

"그냥 잘 만들어진 거 쓰면 되지, 왜 바퀴를 다시 발명해?"라고 물으실 수 있습니다. 맞습니다. 실무 프로젝트에서 직접 만든 프레임워크를 쓴다면 저는 도시락 싸 들고 다니며 말릴 겁니다. 하지만 학습의 관점에서는 이야기가 다릅니다.

제가 주니어 개발자들에게 항상 하는 말이 있습니다. "마법을 믿지 마라." 코드는 마법이 아닙니다. 논리와 수학의 집합체죠. loss.backward()가 호출될 때 일어나는 연쇄 법칙(Chain Rule)의 흐름을 직접 코드로 짜보면, 그동안 머릿속에 안개처럼 껴있던 개념들이 거짓말처럼 선명해집니다.

1단계: 텐서(Tensor), 모든 것의 시작

가장 먼저 할 일은 데이터를 담을 그릇, 텐서를 만드는 것입니다. 딥러닝 프레임워크의 가장 기본 단위죠. 처음엔 그냥 NumPy 배열을 래핑(Wrapping)하는 것만으로 충분하다고 생각했습니다.

import numpy as np

class Tensor:
    def __init__(self, data, requires_grad=False):
        self.data = np.array(data)
        self.requires_grad = requires_grad
        self.grad = None
        # ...

단순해 보이죠? 하지만 여기서부터 고민이 시작됩니다. 텐서끼리 더하고 곱할 때마다 연산 그래프(Computation Graph)를 어떻게 추적할 것인가? 이 텐서가 어떤 연산을 거쳐 만들어졌는지 기록해둬야 나중에 역전파를 할 수 있으니까요.

2단계: 자동 미분(Autograd)의 악몽

이 프로젝트의 꽃이자, 가장 머리 아픈 부분이 바로 자동 미분 엔진을 만드는 것입니다. 딥러닝 학습의 핵심은 결국 미분이니까요.

처음 구현할 때 저는 덧셈과 곱셈의 역전파 로직을 헷갈려서 한참을 헤맸습니다. 순전파(Forward)는 입력값들을 계산해서 결과를 내놓으면 끝이지만, 역전파(Backward)는 출력 쪽에서부터 흘러들어온 그라디언트(grad)를 입력 쪽으로 적절히 배분해줘야 합니다.

예를 들어 행렬 곱셈(matmul)의 역전파를 구현할 때, 전치(Transpose)를 해야 하는지 말아야 하는지, 차원(Dimension) 축소는 어떻게 처리해야 하는지 멘붕이 왔습니다. NumPy의 브로드캐스팅(Broadcasting) 기능 때문에 차원이 안 맞아 에러가 터질 때는 정말 막막하더군요.

결국 종이와 펜을 꺼내 들고 수식을 하나하나 써 내려가며 디버깅했습니다. 그제야 y = w * x + b라는 단순한 수식이 코드 레벨에서 어떻게 미분되어 파라미터를 업데이트하는지 '뼈저리게' 이해하게 되었습니다. 이 과정을 겪고 나니 PyTorch의 autograd가 얼마나 우아하게 설계되었는지 경외감마저 들더군요.

3단계: 레고 블록 쌓기 (nn.Module & Optimizer)

자동 미분 엔진이 돌아가기 시작하면, 이제 진짜 재미있는 파트입니다. Linear, ReLU, Sigmoid 같은 레이어들을 마치 레고 블록처럼 만드는 것이죠.

class Linear(Module):
    def __init__(self, in_features, out_features):
        self.w = Tensor(np.random.randn(in_features, out_features))
        self.b = Tensor(np.zeros(out_features))

    def forward(self, x):
        return x @ self.w + self.b

이런 식으로 모듈을 하나씩 늘려가다 보면, 어느새 OptimizerLoss Function까지 구현하게 됩니다. SGD(Stochastic Gradient Descent)를 직접 구현해보니 "파라미터를 그라디언트 방향으로 조금 이동시킨다"는 개념이 코드 한 줄로 명쾌하게 다가왔습니다.

4단계: 진짜 학습이 되네? (MNIST와 CNN)

마지막 관문은 이렇게 만든 누더기(?) 라이브러리로 실제 데이터를 학습시켜보는 것입니다. 딥러닝의 'Hello World'인 MNIST 데이터셋을 가져와서 학습을 돌렸습니다.

솔직히 처음엔 기대 안 했습니다. 어디선가 버그가 터지겠지 싶었죠. 그런데 터미널에 찍히는 Loss 값이 줄어드는 걸 보는 순간, 등줄기에 전율이 흘렀습니다.

Epoch 1: Loss 2.30 -> Accuracy 10%
Epoch 5: Loss 0.45 -> Accuracy 88%

단순한 완전 연결 계층(Fully Connected Layer)을 넘어, 합성곱 신경망(CNN)과 ResNet 구조까지 구현해 보았습니다. 물론 속도는 PyTorch에 비할 바가 못 됩니다. GPU 가속도 없고 최적화도 덜 되었으니까요. 하지만 "내가 만든 코드로 숫자를 인식하고 있다"는 성취감은 이루 말할 수 없었습니다.

마치며: 바퀴를 재발명하며 배운 것들

이 프로젝트를 통해 얻은 가장 큰 수확은 '자신감'입니다. 이제 더 이상 복잡한 딥러닝 모델 아키텍처나 새로운 논문의 수식을 봐도 겁먹지 않게 되었습니다. 결국 다 텐서 연산이고, 미분이고, 행렬 곱이니까요.

여러분도 거창한 프레임워크가 아니더라도 좋습니다. 주말에 시간 내서 NumPy만으로 간단한 신경망 하나를 밑바닥부터 짜보시는 건 어떨까요? AI가 코드를 다 짜주는 시대라지만, 그 코드가 돌아가는 원리를 아는 사람과 모르는 사람의 차이는 분명 존재합니다.

풀링포레스트 팀에서도 우리는 도구를 단순히 '쓰는' 사람을 넘어, 도구의 원리를 '이해하고 응용하는' 엔지니어가 되기 위해 끊임없이 공부하고 있습니다. 여러분의 삽질을 진심으로 응원합니다. 코드가 뿜어내는 에러 메시지를 두려워하지 마세요. 그게 다 뼈가 되고 살이 되는 경험입니다.

감사합니다.

지금 읽으신 내용, 귀사에 적용해보고 싶으신가요?

상황과 목표를 알려주시면 가능한 옵션과 현실적인 도입 경로를 제안해드립니다.