Human coders are still better than LLMs

Salvatore Sanfilippo

인간 코더는 아직 LLM보다 낫다

인간이 아직 LLM보다 얼마나 더 뛰어난지를 보여주는 짧은 이야기예요. 먼저 말씀드리면 저는 반AI주의자가 아니에요. 저를 아시거나 어딘가에서 팔로우하고 계신 분들은 아실 거예요. 저는 오늘 그랬던 것처럼 LLM을 일상적으로 써요. 아이디어를 검증할 때, 코드 리뷰를 할 때, 제가 생각한 것보다 더 나은 접근이 있는지 알아볼 때, 제 전문 지식의 경계에 있는 것들을 탐색할 때 등등이죠 (저는 거의 2년 전에 LLM으로 코딩하기에 대한 블로그 글을 쓴 적이 있어요. 그때는 아직 그리 유행이 아니었는데, 저는 이미 LLM으로 코딩을 하고 있었고 지금까지도 계속 쓰고 있어요. 업데이트 글을 써야겠지만, 그건 오늘 이야기의 주제는 아니에요).

하지만 그래도 말하고 싶은 건, 지금 수준의 AI도 유용하고 훌륭하지만 인간 지능에는 아직 한참 뒤처져 있다는 거예요. 요즘은 균형 잡힌 대화를 나누는 것 자체가 불가능할 정도라서 이 점을 꼭 짚고 싶었어요.

그래서 오늘 저는 Redis의 Vector Sets를 작업하면서 복잡한 버그 하나를 고치고 있었어요. 제가 Redis 작업을 잠시 쉬는 동안 동료들이 RDB와 RESTORE 페이로드 손상에 대한 내성 기능을 추가했더라고요. 데이터의 체크섬이 통과했을 때조차 동작하는 기능이에요. 이 기능은 기본적으로 꺼져 있지만, 원하는 사람들에게는 한층 더 강화된 안전 장치를 제공해요.

하지만… 코끼리만 한 ‘하지만’이 있어요. HNSW를 Redis RDB에 저장하고 다시 불러오는 과정을 빠르게 만들기 위해 저는 요소-벡터 쌍이 아니라 *그래프* 표현 자체를 직렬화했어요. 그렇지 않으면 데이터를 HNSW에 다시 삽입해야 하는데, 그건 100배는 더 느리거든요(!). 그래서 저는 노드들이 다른 노드들과 맺고 있는 모든 링크를 정수로 저장해 두고, 나중에 포인터로 해석해요. 꽤 괜찮은 트릭이고 아주 잘 동작하죠. 그런데 여기에 표현의 무작위 손상과, 제가 직접 만든 HNSW 변형이 노드 간 상호 링크를 강제한다는 점이 섞이면 이런 일이 생길 수 있어요 (저는 여러 유용한 기능을 가진 HNSW 구현을 직접 만들었는데, 그중 많은 기능을 위해 상호 링크가 필요해요).

  1. 손상된 데이터를 불러오는데, 그 데이터는 A가 B를 가리킨다고 하지만 B는 더 이상 A를 가리키지 않아요 (노드 ID가 손상된 거죠).
  2. 노드 B를 삭제해요. 그런데 상호성이 깨진 상태라 A에서 B로 가는 링크를 지우지 않아요.
  3. 그러다 그래프를 순회하면서 B에 도달했을 때 A에 접근하게 되죠. use-after-free가 터지는 거예요 :-D :-) :-|

그래서 데이터를 불러온 뒤에는 모든 링크가 상호적인지 검사해야 해요. 가장 단순한 방식으로 하면 이건 O(N^2)이 돼요. 각 노드마다 모든 레벨을 훑고, 각 레벨마다 그 노드의 모든 이웃을 확인한 뒤, 그 이웃이 해당 레벨에서 다시 이 노드를 가리키는지 링크를 스캔해서 확인해야 하거든요. 영 좋지 않죠.

인간 vs LLM

우선 가장 단순한 방식으로 구현해 봤어요. 퍼저(fuzzer)가 더 이상 버그를 찾지 못하는지 보려고 했는데, 실제로 버그는 더 이상 나오지 않았어요. 그런데 2천만 개 벡터가 든 큰 벡터 셋의 로딩 시간이 45초에서 90초 정도로 늘어나 버렸어요. WTF. 그래서 Gemini 2.5 Pro 채팅을 열고 LLM에게 물었죠. 야, 여기서 뭘 할 수 있을까? 엄청 빠른 방법이 있을까?

Gemini가 찾아낸 최고의 해법은 이거였어요. 이웃 링크의 포인터들을 정렬해서 이진 탐색을 쓰라는 거죠. 뭐, 물론 그건 저도 알고 있었어요. 사실 16개나 32개짜리 포인터 배열에서 그게 더 빠를지 느릴지 잘 모르겠더라고요. 그래서 또 물었죠, 다른 방법은 없어? 답은 없었어요. 더 나은 해법은 없다는 거였죠.

그래서 제가 말했어요. 봐봐, A가 레벨 X에서 B를 가리키는 걸 볼 때마다 해시 테이블에 A:B:X로 저장하는 건 어때? (단 A와 B는 항상 A>B가 되도록 정렬하고, 링크는 방향에 관계없이 동일하게 취급하는 거지) 그리고 같은 링크를 다시 보면 지우는 거야. 이번에는 링크의 ID를 포인터로 해석할 때 이미 하고 있는 것처럼 전체를 한 번만 스캔하면 되고, 마지막에 해시 테이블이 비어 있지 않으면 상호적이지 않은 링크가 있다는 걸 알 수 있잖아?

Gemini는 괜찮은 아이디어라고 했지만, 키를 만들 때 snprintf()가 필요하고 해싱 시간도 들고 어쩌고 하더라고요. 그래도 원래 방식(포인터 정렬까지 포함해서)보다는 낫다고 했어요. 저는 snprintf()가 필요 없다고 알려줬죠. 그냥 고정 크기 키에 포인터를 memcpy()하면 된다고요. Gemini도 그게 가능하다는 걸 인정했고, 그때 문득 뭔가 떠올랐어요…

야, Gemini에게 이렇게 말했어요. A:B:X에 대해 고정된 누산기를 쓰는 건 어때? 해시 테이블 아예 없이. 링크(A:B:X, 그러니까 8+8+4바이트)를 볼 때마다 12바이트짜리 현재 누산기에 xor하는 거야. 같은 걸 두 번 저장하면 서로 상쇄되니까, 마지막에 레지스터가 0이 아니면 뭔가 이상하다는 걸 알 수 있잖아! 다만 저는 Gemini에게 이 방식이 충돌 가능성이 있을 수 있으니 평가해 달라고 미리 말해뒀어요. 이 기능이 Redis에서는 보통 꺼져 있긴 하지만, 사용자가 이런 추가 검사를 켤 때는 공격자가 고의로 조작한 나쁜 페이로드에 대해서도 어느 정도 보호를 기대하는 경우가 많거든요.

Gemini는 이 아이디어에 꽤 감탄했지만, 그래도 포인터라는 게… 알잖아요, 구조가 비슷하고 몇 비트만 달라지니까, 만약 엉뚱한 링크 L1, L2, L3 세 개가 있다면 L1과 L2의 xor 결과가 L3의 비트와 같아져서 false negative(레지스터가 0이 되는 경우)가 발생할 수 있다고 하더라고요. 저도 할당자(allocator)가 매우 예측 가능하고 외부에서 추측하기 쉽다는 점을 언급했어요.

이걸 개선할 방법이 있는지 Gemini에게 물어봤지만, 별로 좋은 아이디어는 못 내놓더라고요. 그러다 문득 생각했죠. 잠깐, 이걸 충분히 좋은 해시 함수로 해시하면 되잖아? 빠르고, murmur-128 같은 거 말이야 (이 작업에는 암호학적 특성이 필요 없으니까). 그래서 Gemini에게 다음 스키마를 제안했어요.

  1. 링크 A:B:X를 가져오되, /dev/urandom에서 얻은 시드를 모든 키 앞에 붙여서 실제로는 S:A:B:X로 만들어요.
  2. murmur-128(S:A:B:X)의 출력을 128비트 레지스터에 그냥 xor해요.
  3. 마지막에 레지스터가 0인지 확인해요 (모든 링크가 상호적이라는 뜻이에요).

Gemini에게 이 방식에 대한 분석을 부탁했더니, 마침내 만족하며 이렇게 말하더라고요. 이렇게 하면 우연히 xor 결과가 0이 되는 고립된 링크들을 찾기도 훨씬 어려워지고, 외부 공격자가 이걸 유의미하게 악용하는 것도 훨씬 어려워진다고요. “S”를 알 수 없고 포인터까지 제어해야 하는 등 모든 걸 한꺼번에 맞추기가 정말 어렵다는 거죠. 또 이 기능은 어차피 켜야만 동작하는 최선형(best effort) 추가 보호 기능이고, 평소에는 꺼져 있으며 실용적이려면 성능 패널티가 너무 크면 안 되니까요.

어쨌든 하고 싶은 말은 이거예요. 방금 분석을 마치고 이렇게 블로그 글을 쓰려고 잠시 멈췄는데, 이 방식을 실제로 쓸지는 아직 모르겠어요 (아마 쓰게 될 것 같긴 해요). 하지만 인간의 창의성은 여전히 한 수 위예요. 우리는 정말 틀 밖에서 생각할 수 있고, 다른 것들보다 더 잘 동작할 수 있는 이상하고 불완전해 보이는 해법을 떠올릴 수 있죠. 이건 LLM에게는 극도로 어려운 일이에요. 그래도 제 아이디어들을 검증하는 데는 Gemini가 정말 유용했고, 어쩌면 “똑똑한 오리”와 대화할 상대가 있었기 때문에 이런 식으로 문제를 생각하기 시작했을지도 모르겠어요.

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.