얼마 전 십 대 시절에 쓴 프로젝트를 모아둔 아카이브를 발견했다. 그중 일부는 내가 열다섯 살 때 만든 것까지 거슬러 올라간다.

아카이브에는 온갖 것이 들어 있었다. 파이썬으로 만든 C++ 편집기, 라즈베리 파이로 구동하는 무선 조종 자동차, 소프트웨어 렌더러, 암호 시스템, 인터프리터, 그리고 자칭 해커 시절의 수상한 실험도 꽤 많았다. 그때는 여가 시간이 아주 많았던 모양이다.

코드 대부분은 예상할 만한 수준이었다. 눈에 띈 건 어떻게든 기발한 방법을 찾아내려는 집착이었다. 특히 몇 줄 안에 기능을 넣을 수 있다면 더 그랬다. 나중에 코드를 읽어야 하는 사람에게는 가독성이 문제가 됐다. 그리고 여러 해가 지나 그 사람은 바로 나였다!

하지만 코드를 쓰는 동안 얼마나 푹 빠져 있었는지는 기억난다. 프로그래밍 언어의 기능을 하나 발견하면 쓸 곳을 찾았고, 그다음에는 어쩌면 쓰지 말았어야 할 곳까지 몇 군데 더 찾아냈다. 프로그래밍에 대한 이해 중 상당 부분은 이렇게 지나치게 몰두하는 과정에서 생겼다.

그 프로젝트들을 돌아보며, 열다섯 살 때 LLM이 있었다면 어떻게 접근했을지 궁금해졌다. 그런 우회로를 얼마나 많이 거쳤을까?

우선 그때 쓴 코드 몇 가지를 살펴보자.

파서 없이 INI 파일 파싱하기

프로젝트 중 하나는 Cartoon이라는 라즈베리 파이 기반 무선 조종 자동차였다. HTTP 서버, GPIO로 제어하는 모터, MJPEG 카메라 스트리밍 기능이 있었고, 안드로이드·데스크톱·웹 클라이언트 세 종류를 지원했다. 설정 파일도 사용했다.

파이썬에는 파일 전체를 처리할 수 있는 ConfigParser가 이미 있었지만, 그 방법은 별로 흥미롭지 않았던 모양이다. 그래서 직접 작성했다.

def parse_config(data):
 data = re.split('\[(.*?)\]\n', data)[1:]
 data = dict(map(list, zip(*[data[_::2] for _ in range(2)])))

 for sect, vals in data.items():
 vals = re.findall('\w+', vals)
 data[sect] = dict(zip(*[vals[i::2] for i in range(2)]))

 for sect, vals in data.items():
 for key, val in vals.items():
 if val.isdigit():
 data[sect][key] = int(val)

 return data

특히 이 줄이 마음에 든다.

dict(map(list, zip(*[data[_::2] for _ in range(2)])))

정말 대단한 작품(?)이다.

정규식은 입력을 섹션 이름과 내용이 번갈아 나오는 형태로 나눈다. 확장 슬라이싱으로 인덱스 0이나 1에서 시작해 두 칸씩 건너뛴 원소를 각각 가져온 뒤, zip으로 짝을 맞추고 리스트로 바꿔 dict에 넘긴다.

그리고 개별 키와 값의 쌍을 파싱할 때도 사실상 같은 일을 다시 한다. 정규식으로 추출한 단어에서 시작한다.

>>> vals = ['speed', '3', 'fps', '30']
>>> [vals[i::2] for i in range(2)]
[['speed', 'fps'], ['3', '30']]
>>> list(zip(*[vals[i::2] for i in range(2)]))
[('speed', '3'), ('fps', '30')]
>>> dict(zip(*[vals[i::2] for i in range(2)]))
{'speed': '3', 'fps': '30'}

seq[::2]와 seq[1::2]로 번갈아 나오는 항목을 분리할 수 있다는 걸 알아낸 뒤에는 파서 전체를 이 방법으로 짜고 싶어 했다. 슬라이싱에 대한 열정이 설정 파일에 대한 관심보다 컸던 모양이다.

이 코드가 INI 형식을 제대로 처리하느냐고? 물론 아니다. 하지만 내 설정 파일은 읽을 수 있었고, 그 과정에서 재미있는 파이썬 기능을 여럿 써볼 수 있었다.

카메라 이동 사건

비슷한 시기에 파이썬으로 소프트웨어 3D 렌더러를 만들었다. 이 프로젝트 덕분에 변환, 좌표계, 투영에 관심을 갖게 됐다. 그리고 아래 코드를 만들었는데, 무슨 일을 하려 했는지 기억해내려면 역으로 분석해야 했다.

카메라는 WASD와 Q/E 키로 장면 안을 이동할 수 있었다.

move_keys = {
 pygame.K_q: [(1, 1), ( 1, 0)],
 pygame.K_e: [(1, 1), (-1, 0)],
 pygame.K_d: [(0, 2), ( 1, 1)],
 pygame.K_a: [(0, 2), (-1, -1)],
 pygame.K_w: [(2, 0), ( 1, -1)],
 pygame.K_s: [(2, 0), (-1, 1)]
}

class Camera(Camera):
 def move(self, table):
 ca = cos(self.rot[1]) * self.speed
 sa = sin(self.rot[1]) * self.speed

 if table[0][0] == 2:
 ca, sa = sa, ca

 axis_d = {0: ca, 1: self.speed, 2: sa}
 for axis, m in zip(*table):
 self.pos[axis] += (axis_d[axis] * m)

한참 들여다본 끝에 아이디어가 떠올랐다. 카메라 위치는 [x, y, z]로 저장하므로 축 0은 x, 1은 y(위아래), 2는 z다. 지면을 따라 움직이면 x와 z가 바뀌고, 수직으로 움직이면 y가 바뀐다.

각 키의 항목은 어떤 좌표를 바꾸고, 그 좌표에 대해 계산한 이동량을 더할지 뺄지를 나타낸다. W의 [(2, 0), (1, -1)]은 “z와 x를 바꾸고 z에는 더하고 x에서는 뺀다”는 뜻이다. zip(*table)은 이를 (2, 1)과 (0, -1)로 바꿔 반복문에 넘긴다.

더하거나 뺄 이동량은 카메라가 바라보는 방향에 따라 달라진다. self.rot[1]은 수직축을 기준으로 한 회전이다. W의 경우 사인과 코사인의 자리를 바꾸기 때문에 반복문은 다음 코드와 같은 결과를 낸다.

self.pos[2] += cos(self.rot[1]) * self.speed # z
self.pos[0] -= sin(self.rot[1]) * self.speed # x

회전이 0이면 코사인은 1이고 사인은 0이다. 위치 [0, 0, 0]에서 속도를 1로 설정하고 W를 누르면 카메라는 [0, 0, 1]로 이동한다. 양의 방향으로 90도 돌리면 같은 키를 눌러도 원점에서 [-1, 0, 0]으로 이동한다. W는 카메라가 향하는 방향을 따른다. S는 같은 축을 사용하되 부호를 반대로 해 뒤로 이동한다.

카메라 이동을 위한 작은 명령 형식을 사실상 만든 셈이다. 코드가 크게 줄었는지는 모르겠지만, 이런 요령을 쓰는 일을 즐겼던 건 분명히 기억한다. 동작을 작은 자료 구조에 담고, 이를 해석하는 범용 함수를 만드는 데서 묘한 만족감을 느꼈다.

튜플 인덱싱 아래에는 흥미로운 문제가 숨어 있다. 카메라가 바라보는 방향에 따라 이동을 어떻게 표현할 것인가? 회전이 좌표에 어떤 영향을 주는지 알아내고, 서로 다른 방향을 어떻게 나타낼지 정해야 했다. 결과물은 읽기 꽤 어렵지만, 렌더러를 만들면서 그런 개념에 시간을 들일 이유가 생겼다.

더 명확하게 이동 코드를 썼어도 같은 내용을 배웠을 수 있다. 그래도 기발한 방법을 궁리하는 과정이 계속 몰입하게 만든 한 요소였다. 이 렌더러를 기다리는 사람은 없었다. 카메라를 움직이는 별난 방법을 궁리하는 데 얼마든지 시간을 쓸 수 있었다.

같은 클래스에는 이런 코드도 있었다.

self.speed += {
 keys[pygame.K_PLUS]: radians(1),
 keys[pygame.K_MINUS]: -radians(1)
}.get(1, 0)

불리언을 키로 쓰는 딕셔너리로 속도를 올릴지 내릴지 정했다. 두 키를 동시에 누르면 둘 다 True이므로 항목이 충돌하고 하나가 다른 하나를 덮어쓴다.

메서드가 갑자기 생겨나게 하기

Pygame을 그대로 쓰는 것만으로는 흥미롭지 않았던 모양인지, 작은 Pygame 장면 관리 프레임워크도 만들었다.

그리기 함수에 디스플레이 표면을 반복해서 넘기는 점이 마음에 들지 않았다. 보통은 필요한 함수를 감싸는 래퍼를 몇 개 만들면 되지만, 파이썬이 메서드를 직접 만들어내게 할 수 있다고 생각했다.

def init_draw_functions(self):
 draw_template = lambda func: lambda *a, **k: func(self.display, *a, **k)

 for name in filter(lambda f: '__' not in f, dir(pygame.draw)):
 setattr(
 self,
 f'draw_{name}',
 draw_template(getattr(pygame.draw, name))
 )

이 코드는 pygame.draw를 살펴보고, 각 함수의 첫 번째 인수에 self.display를 자동으로 넣는 래퍼를 만든 다음 장면 관리자 인스턴스에 직접 붙인다.

그래서 메서드를 명시적으로 정의하지 않고도 self.draw_circle(...)이나 self.draw_line(...)을 호출할 수 있었다. 실행 중에 코드를 다루는 작은 메타프로그래밍이었지만, 이 방법을 얼마나 기발한지 고민한 시간보다 절약한 시간은 적었을 것이다.

그래도 여전히 꽤 마음에 든다. 객체가 실행 중에 자기 인터페이스를 늘려가는 데는 멋진 구석이 있다. 지금 다시 읽으니 나중에 이 메서드를 찾아내거나, 다른 사람에게 어디서 생겼는지 설명해야 할 일도 떠오른다. 열다섯 살 때는 메서드가 나타나게 만드는 것만으로도 충분히 신났다.

같은 파일에는 이런 코드도 있었다.

def main_loop(self): # fix your timestep! lmao

중요한 문제를 이미 알고 있었던 모양이다!

다른 곳에서는 비트 연산에 대한 열정으로 시뮬레이션의 프레임률 제한을 켜고 끄는 지름길을 만들었다.

self.fps ^= 60 * (event.key == pygame.K_f)

이 방법은 x ^ x = 0과 0 ^ x = x라는 성질을 이용한다. 어떤 값을 상수와 반복해서 XOR 연산하면, 시작값이 0이나 그 상수 중 하나인 경우 두 값 사이를 오간다.

비교식은 True 또는 False를 내놓으므로, 곱셈을 거치면 이 XOR 연산에는 60이나 0이 들어간다.

돌아보면 이런 선택이 왜 매력적이었는지 알겠고, 다시 생각해볼 이유도 보인다. 작은 추상화와 언어 기능의 뜻밖의 활용을 여전히 좋아한다. 한때 무척 뿌듯해했던 코드를 다시 봤다가 어떻게 작동하는지 전혀 모르겠던 경험도 있다.

그때 부족했던 건 판단력이었다. 무언가 할 수 있다는 사실을 발견하면 어디에나 써볼 만하다고 느끼기 쉽다. 그 방법이 실제로 도움이 되는 때를 알아보는 감각은 더 오래 걸려야 생긴다.

답을 프롬프트 하나로 얻을 수 있을 때

이 프로젝트들을 하면서 정해진 교육 과정을 밟지는 않았다. 흥미로운 것을 발견하면 이해하려고 터무니없이 많은 시간을 들였고, 그러다 의도한 것과 어렴풋이 비슷한 일을 하는 작은 프로그램을 만들곤 했다. 때로는 전혀 다른 언어 기능이 다음 프로젝트의 주제가 되기도 했다.

그런 과정을 거치며 코드에 관한 결정을 내리고, 결과를 확인하고, 다시 생각하는 연습을 했다. 나는 이를 숙련도를 키우는 과정의 일부로 본다. 구현을 따져보고 바꿀 수 있으며, 선택한 방법이 어디서 일을 어렵게 만드는지 알아보는 능력이다.

그래서 내가 LLM을 어떻게 활용했을지 궁금하다.

카메라 예시를 보자. WASD 이동을 요청해 읽기 쉬운 구현을 받은 다음 렌더러 작업을 계속했을 수도 있다. 설명을 읽고 이해했다고 느꼈을 수도 있다. 카메라가 제대로 움직이는 순간 만족하고 멈췄을지도 모른다.

하지만 내가 직접 이동 방식을 알아낼 수 있었을까? 코드가 이상하게 작동할 때 무엇을 바꿔야 하는지 알았을까? 코드가 이미 작동하는 상황에서는 답하기 더 어려운 질문이다. 내가 만든 버전은 장면을 돌아다니기 전에 축과 부호, 회전에 관해 직접 결정하게 했다.

물론 2017년에 튜토리얼에서 카메라 코드를 복사할 수도 있었다. 라이브러리를 써서 렌더러의 상당 부분을 직접 만들지 않을 수도 있었다. 문제의 일부를 건너뛰는 방법은 늘 있었다. 다만 LLM은 그 선택을 유난히 편리하게 만든다. 내 코드에 꼭 맞는 결과를 요청하고, 다음에 마주치는 어려움도 계속 맡길 수 있다.

나도 이런 도구를 사용하고, 편리함은 유용하다. 무언가를 끝내고 싶을 때는 대체로 반갑다. 하지만 열다섯 살의 나도 작동하는 렌더러를 원했다. 그러면서도 렌더러를 만드는 과정의 온갖 우회로를 즐겼다. 매번 프롬프트를 보내기 전에 내가 즐겁게 알아낼 일을 건너뛰는 건 아닌지 고민했을 것 같지는 않다.

내가 놓쳤을까 걱정하는 건 배움이 종종 부수적으로 일어났다는 점이다. 매번 교육용 연습을 의식적으로 선택한 건 아니었다. 그저 뭔가가 작동하기를 바랐다. 설명을 들으면 따라갈 수 있는 구현만으로 충분하다고 느꼈다가, 비슷한 문제를 혼자 풀어야 할 때 비로소 그렇지 않다는 걸 알았을 수도 있다.

반대로 LLM을 활용해 훨씬 더 멀리 나아갔을 수도 있다. 카메라 수학이 왜 작동하는지 물어보고, 다른 표현 방법을 시험하거나, 그렇지 않았다면 프로젝트를 중단시켰을 개념을 이해하도록 도움을 받을 수 있었다. 배우는 동안 질문에 답해줄 상대가 있다는 건 엄청난 장점이었을 것이다.

어떤 어려움은 한 가지 생각을 충분히 오래 붙잡고 이해하게 만들었다. 도움을 받을 수 있으면 어떤 어려움에 시간을 쓸지 더 많이 선택할 수 있다. 동시에 프로젝트에서 무언가를 직접 알아내는 일 자체가 내가 원한 것인지 알아차려야 한다.

어려움 자체가 목적이었다

이 프로젝트들은 특별히 쓸모 있을 필요가 없었다. 나는 여가 시간에 좋아하는 일이라서 프로그래밍을 했고, 그 과정에서 꽤 많이 배웠다. 오후 시간을 마음껏 쏟아부어 우스꽝스러운 것을 만들고, 마침내 작동했을 때 터무니없이 뿌듯해할 수 있었던 게 기쁘다.

2017년에 LLM이 있었다면 두 배로 많이 배웠을지도 모른다. 아니면 평범하고 멀쩡한 INI 파서만 만들고, 오늘 쓸 이야기는 없었을지도 모른다. :)