다음에 올 것을 만들어가는 기술에 대한 심층 기사.

Rob Pike의 프로그래밍 규칙, 지금도 유효하다

Rob Pike가 1989년에 정리한 프로그래밍 규칙 다섯 가지. 쓰였을 때보다 지금 더 유효하며, 특히 우리가 자주 무시하는 규칙들이 그렇다.

낡은 인덱스 카드가 조명 하나 아래 어수선한 현대식 책상 옆에 붙어 있는 모습

1989년, 이후 Go, UTF-8, Plan 9를 함께 만든 Rob Pike는 프로그래밍 규칙 다섯 가지를 정리했습니다. 인덱스 카드 한 장에 다 적을 만큼 짧지만, 프로그래밍 세계는 37년 동안 이 규칙을 두고 논쟁해 오고 있습니다. 대부분의 개발자는 블로그나 컨퍼런스 발표에서 이 중 일부를 한 번쯤 봤을 겁니다. 하지만 실제로 몸에 익힌 사람은 드뭅니다. 아까운 일이죠. 제대로 지키기만 해도 낭비되는 노력을 상당히 줄일 수 있거든요.

이 규칙들은 겉보기에는 단순합니다. 문법이나 아키텍처 패턴에 관한 이야기가 아닙니다. 프로그래머가 어디에서 꾸준히 시간을 낭비하는지, 그리고 그만두려면 어떻게 해야 하는지에 관한 이야기입니다.

다섯 가지 규칙

하나씩 뜯어보기 전에, 먼저 간단히 적어 보겠습니다:

  1. 프로그램이 시간을 어디에 쓰는지는 미리 알 수 없습니다. 병목은 예상 밖의 곳에서 생기니, 병목이 거기라는 걸 증명하기 전에는 머리로 추측해서 속도 해킹을 넣지 마세요.
  2. 측정하세요. 측정하기 전에는 속도를 위해 튜닝하지 마세요. 측정한 뒤에도 코드의 한 부분이 나머지를 압도하지 않는 한 튜닝하지 마세요.
  3. 화려한 알고리즘은 n이 작을 때 오히려 느립니다. 그리고 n은 대개 작습니다. 화려한 알고리즘은 상수 항이 큽니다. n이 자주 커진다는 걸 알기 전까지는 멋 부리지 마세요.
  4. 화려한 알고리즘은 단순한 것보다 버그가 많고 구현하기도 훨씬 어렵습니다. 단순한 자료 구조와 마찬가지로 단순한 알고리즘을 쓰세요.
  5. 데이터가 지배합니다. 올바른 자료 구조를 고르고 구조를 잘 잡아 두면, 알고리즘은 거의 저절로 드러납니다. 프로그래밍의 중심은 알고리즘이 아니라 자료 구조입니다.

규칙 1과 2는 최적화에 관한 것입니다. 규칙 3과 4는 복잡도에 관한 것입니다. 규칙 5는 설계에 관한 것입니다. 전부 합치면 결국 겸손에 관한 철학입니다. 성능에 대한 우리의 직관은 틀리기 쉽고, 복잡도에는 우리가 과소평가하는 비용이 따르며, 영리한 코드보다 좋은 자료 구조가 더 중요하다는 것을 인정하라는 얘기죠.

규칙 1: 병목이 어디인지 모른다

개발자들이 가장 자신 있게 어기는 규칙입니다. ‘이 함수는 중첩 루프가 있어서 느린 게 분명해.’ ‘조회가 O(1)이니까 여기는 해시 맵을 써야지.’ ‘할당 비용이 크니까 배열을 미리 잡아 두자.’ 다 그럴듯하게 들립니다. 그런데 대개 틀립니다.

운영 시스템을 충분히 프로파일링해 보면, 직관적으로 짚은 병목이 실제 병목이 아닌 사례를 여럿 모아 둘 수 있습니다. 모두가 데이터베이스가 병목이라고 믿었지만, 프로파일링해 보니 요청 시간의 60%를 JSON 직렬화가 잡아먹고 있던 시스템이 있었습니다. ‘비싼’ 행렬 곱셈은 런타임의 5%밖에 안 되는데 CSV 파싱이 70%를 차지한 데이터 파이프라인도 있었습니다. 팀이 몇 달 동안 데이터베이스 쿼리를 최적화했지만, 실제 병목은 아웃바운드 HTTP 요청마다 일어나는 DNS 조회였던 웹 애플리케이션도 있었습니다.

사람의 두뇌는 형편없는 프로파일러입니다. 데이터베이스 쿼리나 네트워크 호출처럼 개념상 비싸 보이는 연산은 과대평가하고, 문자열 연결이나 JSON 파싱, 메모리 할당처럼 싸 보이는 연산은 과소평가합니다. 최신 하드웨어는 상황을 더 나쁘게 만듭니다. CPU 캐시, 분기 예측, 비순차 실행 때문에 코드 복잡도와 실행 시간의 관계는 직관과 크게 어긋납니다.

규칙 2: 측정한 뒤에 최적화하라

규칙 1의 실천적인 귀결입니다. 직관에 기대어 최적화하지 마세요. 프로파일링하고, 실제 핫스팟을 찾고, 그 부분만 최적화하세요.

이 규칙의 뒷부분, 즉 ‘코드의 한 부분이 나머지를 압도하지 않는 한 하지 마라’는 똑같이 중요한데 자주 인용되지 않습니다. 프로파일러가 런타임이 20개 함수에 고르게 흩어져 있다고 보여 준다면, 각각 5%씩 차지하고 있다면, 최적화할 단일 병목이 없는 겁니다. 함수 하나를 2배 빠르게 만들어도 전체 런타임의 2.5%밖에 줄지 않습니다. 그 정도 이득은 추가되는 복잡도를 감수할 만한 가치가 거의 없습니다. 개별 함수를 최적화하는 대신 근본적으로 다른 접근법을 찾아야 합니다.

# Profile first. Always.
# Python
python -m cProfile -s cumulative my_script.py
# Node.js
node --prof app.js
node --prof-process isolate-*.log > profile.txt
# Go
go test -bench=. -cpuprofile=cpu.prof
go tool pprof cpu.prof
# Rust
cargo flamegraph
# General (Linux)
perf record ./my_program && perf report
# The flamegraph (brendangregg.com/flamegraphs) is the most
# informative visualization. It shows you exactly where time
# is spent, in a format that makes bottlenecks visually obvious.

규칙 3: n이 작을 때 화려한 알고리즘은 느리다

컴퓨터 과학 교육이 거꾸로 가르치는 규칙입니다. 우리는 O(n log n)이 O(n²)보다 낫다고 배웁니다. 점근적으로 보면 맞는 말입니다. 하지만 n = 20이라면, 잘 구현된 O(n²) 삽입 정렬이 O(n log n) 병합 정렬보다 빠릅니다. 상수 항, 캐시 동작, 오버헤드 때문입니다.

현실의 사례는 많습니다. 정렬된 50개 원소 배열에서는 이진 탐색보다 선형 탐색이 빠릅니다. 선형 탐색은 캐시 동작이 완벽하고 분기 예측 실패도 없기 때문입니다. 원소가 100개 안팎인 컬렉션에서는 균형 이진 트리보다 단순 연결 리스트가 빠릅니다. 트리를 따라 포인터를 쫓아가느라 캐시 지역성이 무너지기 때문입니다. 해시 맵은 조회가 평균 O(1)이지만, 상수 항이 커서 원소가 30~50개 이하인 컬렉션에서는 배열 선형 탐색이 더 빠릅니다.

표준 라이브러리는 이 사실을 알고 있습니다. Python의 sorted()는 Timsort를 쓰는데, 작은 부분 시퀀스에서는 삽입 정렬로 넘어갑니다. C++의 std::sort는 임계값(보통 16~32개 원소) 아래에서 삽입 정렬로 전환합니다. Rust의 sort_unstable은 퀵 정렬과 삽입 정렬을 섞어 씁니다. ‘화려한’ 알고리즘은 n이 실제로 커서 이길 수 있을 때만 쓰입니다.

더 넓게 보면 교훈은 n을 알라는 것입니다. 단순한 O(n²) 알고리즘과 복잡한 O(n log n) 알고리즘 사이에서 고민된다면, 실제로 n이 얼마나 클지 스스로 물어보세요. 수백 이하라면 단순한 알고리즘이 거의 확실히 충분하고, 작성하고 디버깅하고 유지보수하기도 더 쉽습니다.

규칙 4: 똑똑한 것보다 단순한 것이 낫다

규칙 4는 규칙 3을 성능 바깥으로 확장한 것입니다. 화려한 알고리즘은 작은 n에서 느릴 뿐 아니라 버그도 많습니다. 레드-블랙 트리는 정렬된 배열보다 엣지 케이스가 많습니다. 락 프리 동시성 자료 구조는 뮤텍스로 보호된 것보다 미묘한 실패 모드가 많습니다. 커스텀 메모리 할당기는 시스템 할당기보다 메모리를 망가뜨릴 방법이 많습니다.

팀이 O(1) 연산을 갖춘 커스텀 LRU 캐시를 구현하고 디버깅하느라 몇 주를 쓰는 경우를 여러 번 봤습니다. 캐시 항목이 많아야 수백 개인 워크로드라면, 선형 축출을 쓰는 단순한 고정 크기 배열로 반나절이면 짜서 한 번에 맞게 동작하고 충분히 빨랐을 텐데요.

복잡도의 비용은 처음 구현할 때만 드는 게 아닙니다. 그 코드를 이해하고, 수정하고, 디버깅해야 하는 앞으로의 모든 개발자에게 계속 부담이 됩니다. 팀 모두가 이해하는 단순한 알고리즘이, 원작자만 유지보수할 수 있는 영리한 알고리즘보다 가치가 큽니다. 그리고 여섯 달 뒤의 원작자는 사실상 다른 사람이고, 그 사람도 이 코드가 어떻게 돌아가는지 잊어버렸을 겁니다.

디버깅은 처음 코드를 짜는 것보다 두 배 어렵다. 따라서 코드를 최대한 영리하게 짰다면, 정의상 그 코드를 디버깅할 만큼 똑똑하지 않은 것이다. — Brian Kernighan

규칙 5: 데이터가 지배한다

이것이 Pike의 가장 중요한 규칙이자, 알고리즘과 디자인 패턴에만 집중하는 개발자들이 가장 많이 놓치는 규칙입니다. 주장은 이렇습니다. 자료 구조가 올바르면 알고리즘은 자연스럽게 따라옵니다. 자료 구조가 잘못되었다면 아무리 알고리즘을 영리하게 짜도 구해 주지 못합니다.

Fred Brooks도 비슷한 말을 했습니다. ‘플로차트는 보여 주고 테이블은 감추라고 하면 나는 계속 헷갈릴 것이다. 테이블을 보여 주면 플로차트는 보통 필요 없다.’ Linus Torvalds도 이렇게 맞장구쳤습니다. ‘나쁜 프로그래머는 코드를 걱정하고, 좋은 프로그래머는 자료 구조와 그 관계를 걱정한다.’

이 원칙은 실무에서 끊임없이 드러납니다. 사용자 권한을 권한 문자열의 평면 리스트로 저장하는 코드베이스는, 코드 곳곳에 복잡하고 오류가 나기 쉬운 검사 로직을 쌓아 가게 됩니다. 데이터를 역할 계층으로 재구성하면 검사 로직은 사소해집니다. 이벤트를 JSON 블롭으로 저장하는 시스템은 모든 소비자에서 복잡한 파싱과 검증이 필요합니다. 이벤트를 명시적 스키마를 가진 타입 레코드로 구조화하면 소비자 코드가 극적으로 단순해집니다.

# Bad data structure → complex algorithm
class OrderSystem:
def __init__(self):
self.orders = []  # flat list of all orders
def get_user_orders(self, user_id):
return [o for o in self.orders if o.user_id == user_id]  # O(n)
def get_pending_orders(self):
return [o for o in self.orders if o.status == 'pending']  # O(n)
def get_user_pending_orders(self, user_id):
return [o for o in self.orders
if o.user_id == user_id and o.status == 'pending']  # O(n)
# Better data structure → algorithms become obvious
class OrderSystem:
def __init__(self):
self.orders_by_user = {}      # user_id → [orders]
self.orders_by_status = {}    # status → [orders]
def get_user_orders(self, user_id):
return self.orders_by_user.get(user_id, [])  # O(1)
def get_pending_orders(self):
return self.orders_by_status.get('pending', [])  # O(1)
# The right data structure makes the code self-evident.
# You don't need to think about algorithms at all.

Go는 이 규칙들을 어떻게 구현했나

Pike의 규칙을 보면 Go의 설계 철학이 씨앗처럼 들어 있는 게 보입니다. Go는 Pike가 이 규칙을 쓴 지 20년 뒤에 함께 만든 언어인데, 체계적으로 영리함보다 단순함을 택한 언어입니다.

  • 제네릭 없음(처음에는) — 단순한 자료 구조를 강제합니다. (제네릭은 Go 1.18에서 추가되었지만, 충분히 단순한 설계를 찾기까지 수년간 버틴 끝에 들어왔습니다.)
  • 연산자 오버로딩 없음 — 코드는 보이는 그대로의 의미를 가집니다.
  • 암묵적 타입 변환 없음 — 영리함보다 명시성을 택합니다.
  • 예외 없음 — 오류는 발생한 곳에서 처리합니다.
  • 최소한의 표준 라이브러리 알고리즘 — 화려한 자료 구조 대신 슬라이스와 맵을 씁니다.
  • 내장 프로파일링(pprof) — 추측하지 말고 측정하세요.

Go는 표현력이 풍부한 언어를 선호하는 개발자들에게 ‘지루하다’는 비판을 자주 받습니다. 바로 그게 핵심입니다. Pike의 규칙은 지루한 코드를 만드는 레시피입니다. 단순하고, 측정 가능하고, 영리한 알고리즘 대신 좋은 자료 구조 위에 세워진 코드요. Go는 그 레시피를 언어로 만들었을 때 생기는 결과물입니다.

규칙이 적용되지 않는 경우

만능 규칙은 없고, Pike의 규칙에도 정당한 예외가 있습니다. 성능이 핵심인 시스템(게임 엔진, 데이터베이스 내부, 컴파일러)은 n이 실제로 크기 때문에 화려한 알고리즘이 필요할 때가 있습니다. 초당 수백만 번 실행되는 인프라 코드는 애플리케이션 코드에서는 정당화되지 않는 최적화를 정당화합니다. 그리고 때로는 ‘단순한’ 알고리즘이 O(n³)이라서, 작은 n에서도 정말로 받아들일 수 없을 때가 있습니다.

이 규칙들은 법칙이 아니라 경험칙입니다. 가치는 흔한 편향을 바로잡는 데 있습니다. 개발자는 최적화를 너무 일찍 하고, 지나치게 복잡한 알고리즘을 쓰고, 코드에 대해서는 너무 많이 고민하면서 자료 구조에 대해서는 너무 적게 고민하는 경향이 있습니다. Pike의 규칙은 그 경향을 밀어냅니다. 드물게 반대 편향이 적용되는 상황, 즉 오히려 복잡도가 더 필요한 상황에 놓였다면 그때는 마음껏 화려하게 가세요. 다만 측정부터 하고요.

Pike가 이 규칙을 정리한 지 37년이 지났지만, 여전히 이 규칙들은 발표된 프로그래밍 조언 가운데 최고 수준입니다. 놀라워서가 아닙니다. 경험 많은 개발자라면 읽으면서 대부분 ‘당연하지’라고 생각할 겁니다. 가치는 그 규칙이 일관되게 적용할 만큼 분명하게 적혀 있다는 데 있습니다. 다음에 레드-블랙 트리, 커스텀 할당기, 아직 프로파일링하지 않은 ‘최적화’에 손이 가려 할 때 기억하세요. 먼저 측정하고, 단순하게 유지하고, 자료 구조를 제대로 잡으라고요.