Human coders are still better than LLMs

Salvatore Sanfilippo

인간 코더는 여전히 LLM보다 낫다

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

인간이 아직 LLM보다 얼마나 더 뛰어난지를 보여주는 짧은 이야기다. 나는 반(反) AI주의자 같은 게 아니라는 걸 먼저 밝혀둔다. 나를 알거나 어딘가에서 나를 팔로우한다면 알 것이다. 나는 오늘도 그랬듯 LLM을 일상적으로 쓴다. 아이디어를 검증할 때, 코드 리뷰를 할 때, 내가 생각한 것보다 더 나은 접근법이 있는지 알아볼 때, 내 전문성의 경계에 있는 것들을 탐색할 때 등등(나는 거의 2년 전, 아직 쿨하지 않던 시절에 LLM으로 코딩하기에 대한 블로그 글을 쓴 적이 있다. 그때부터 LLM을 코딩에 써왔고 한 번도 멈춘 적이 없다. 업데이트 글을 써야겠지만 그건 오늘 주제가 아니다).

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

그래서 오늘은 Redis의 Vector Sets 작업을 하며 복잡한 버그를 고치고 있었다. 내가 Redis 작업을 잠시 멈춘 사이에 동료들이 RDB와 RESTORE 페이로드의 손상에 견디는 기능을 도입했는데, 데이터 체크섬을 통과하더라도 동작하는 기능이다. 이 기능은 기본값으로는 꺼져 있지만, 원하는 사람들에게는 한층 강화된 안전장치를 제공한다.

하지만… 코끼리만큼이나 큰 ‘하지만’이 있다. HNSW를 Redis RDB에 빠르게 저장하고 다시 불러오기 위해 나는 요소-벡터 쌍이 아니라 *그래프* 표현 자체를 직렬화했다. 그렇게 하지 않으면 데이터를 HNSW에 다시 삽입해야 하는데, 그러면 100배는 느려진다(!). 그래서 나는 각 노드가 다른 노드들과 맺고 있는 모든 링크를 정수로 저장해 두었다가 나중에 포인터로 복원한다. 꽤 괜찮은 트릭이고 아주 잘 동작한다. 그런데 여기에 표현(representation)의 무작위적 손상과, 내가 직접 변형한 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에게 말했다. A:B:X에 대해 고정된 어큐뮬레이터를 쓰는 건 어때? 해시 테이블 아예 없이. 링크(A:B:X, 그러니까 8+8+4바이트)를 볼 때마다 12바이트짜리 현재 어큐뮬레이터에 xor하는 거지. 같은 걸 두 번 저장하면 서로 상쇄되니까, 마지막에 레지스터가 0이 아니면 뭔가 이상하다는 걸 알 수 있잖아! 다만 나는 이 방식이 충돌 가능성을 가지고 있을 수 있다고 미리 말하고, 그걸 평가해 달라고 했다. 이 기능은 보통 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인지 확인한다(모든 링크가 상호적이면 0이다).

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

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

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

댓글