배열 인덱스는 왜 0부터 시작하나요 — 메모리 주소 계산을 그대로 빌려 쓴 결정
배열의 첫 번째 요소를 꺼낼 때 우리는
A[0]이라고 적습니다. 평소엔 그냥 그런가보다 하고 넘어갔는데, Max 께서 "그거 왜 0부터 시작하는지 생각해 본 적 있어?" 라고 물어보는 바람에 갑자기 궁금증이 생겨서 한번 파고들어 봤습니다. 이번 글은 그 질문을 메모리 주소 계산 공식 한 줄로 다시 정리해 본 기록입니다. 결론부터 적어두면, 0-based 인덱스는 메모리 주소 계산을 그대로 인덱스로 빌려 쓴 결과 였습니다.
1. 다시 한번 멈춰보기 — 왜 0부터일까
Max 께서 그 질문을 던지기 전까지, 저는 for (int i = 0; i < n; i++) 를 너무 당연하게 적고 있었습니다. 막상 "왜?" 라는 말을 듣고 나니 머릿속에 이런 의문이 떠올랐습니다.
"첫 번째를 꺼낸다면
A[1]이 자연스럽지 않나? 왜A[0]이지?"
평소에는 "원래 그렇게 정해진 거다" 정도로 넘어갔던 자리였는데, 메모리 구조를 한번 들여다 보고 나니 언어 설계자 입장에서는 0이 가장 합리적인 선택이었다 는 게 보이기 시작했습니다.
2. 메모리는 연속된 박스입니다
배열은 메모리에 연속된 칸으로 자리잡습니다. 정수 하나가 4바이트라고 치면, 시작 주소가 1000 일 때 모양은 이렇습니다.
주소 1000: 첫 번째 요소
주소 1004: 두 번째 요소
주소 1008: 세 번째 요소
주소 1012: 네 번째 요소요소를 찾을 때 CPU 는 사람이 외우는 "몇 번째" 가 아니라, 아래 공식 한 줄로 주소를 계산합니다.
address = base address + index × element size여기서 인덱스를 0부터 시작한다고 약속해 두면 공식이 깔끔하게 떨어집니다.
A[0] = 1000 + 0 × 4 = 1000 ← 첫 번째 요소
A[1] = 1000 + 1 × 4 = 1004 ← 두 번째 요소
A[2] = 1000 + 2 × 4 = 1008 ← 세 번째 요소이 공식에서 인덱스의 의미는 "몇 번째냐" 가 아니라 "시작 주소에서 몇 칸 떨어져 있느냐" 입니다. 첫 번째 요소는 시작 주소에서 0 칸 떨어져 있으므로, 자연스럽게 인덱스가 0 이 됩니다.
3. 만약 1-based 였다면 — 보정 연산이 영원히 따라붙습니다
첫 번째 요소를 A[1] 로 부르기로 약속했다고 가정해 봅시다. 그러면 CPU 는 주소를 계산할 때 매번 이렇게 -1 을 해야 합니다.
address = base + (index − 1) × element size언어 설계자 입장에서 이 한 줄이 가져오는 부담은 생각보다 큽니다.
- 배열 접근 한 번마다 빼기 연산이 한 번 추가 됩니다.
- C 가 자랑하던 "배열 = 포인터 + offset" 이라는 등식이 깨집니다.
- 포인터 산술(pointer arithmetic)이 어색해집니다.
- 컴파일러와 최적화기가 매번 이 보정을 고려해야 합니다.
이걸 사람이 보기 좋자고(A[1] 이 첫 번째라는 게 직관적이니까) 도입하기에는, 얻는 이득에 비해 영원히 따라붙는 비용 이 더 컸습니다.
[0-based] [1-based]
base + i × size base + (i − 1) × size
└── CPU 한 줄로 끝 └── -1 보정이 영원히 붙음
배열 = 포인터 + offset 배열 = 포인터 + offset − 1
(왠지 어색)4. 그래서 0-based 가 표준이 됐습니다
물론 1-based 를 채택한 언어도 있습니다 (Lua, R, MATLAB 같은 것들). 도메인이 수학·통계에 가까운 언어들은 "사람이 세는 방식" 을 더 우선시한 셈입니다. 그런데 C 계열의 시스템 프로그래밍 언어가 0-based 를 깔고 가면서, 그 위에서 자라난 거의 모든 주류 언어(C++, Java, JavaScript, Python, ...)가 같은 선택을 그대로 물려받았습니다.
한 줄로 정리하면 이렇게 될 것 같습니다.
인덱스는 "몇 번째" 가 아니라 "시작 주소에서 몇 칸 떨어져 있느냐" 였다. 그래서 첫 번째 요소는 0 칸 떨어져 있고, 자연스럽게
A[0]이 된다.
5. 주니어로서 이번에 정리한 것
① "원래 그렇다" 는 답은 보통 어딘가에 이유가 숨어 있습니다
처음에는 "0부터 시작하는 게 그냥 규칙" 이라고만 알고 있었습니다. 그런데 한 꺼풀만 들춰보니 메모리 주소 계산이라는 분명한 이유가 있었습니다. 개발하면서 "관습이라 그렇다" 라고 답하게 되는 것들 — null 과 undefined 의 차이, == 와 === 의 차이, HTTP 상태 코드의 분류 — 이런 것들도 한 꺼풀씩 들여다 보면 비슷한 이유가 숨어 있을 것 같습니다.
② 인덱스의 의미를 "오프셋" 으로 다시 읽어보면 헷갈리지 않습니다
배열을 다루다 보면 off-by-one 에러를 자주 만납니다. i < n 인지 i <= n 인지, arr.length - 1 인지 arr.length 인지 헷갈리는 순간 말입니다. 이때 인덱스를 "몇 번째" 가 아니라 "시작 주소에서 몇 칸 떨어졌나" 로 다시 읽어보면 머릿속이 정리되는 느낌이 있습니다. 길이가 n 인 배열의 마지막 요소는 시작점에서 n − 1 칸 떨어져 있고, 그래서 인덱스가 n − 1 입니다.
③ 언어 설계 결정은 보통 "오버헤드를 어디에 둘 것인가" 의 트레이드오프 였습니다
-1 보정 한 번이 별것 아닌 것 같지만, 배열 접근이 일어나는 모든 자리에 그 한 번이 영원히 따라붙는다는 게 핵심이었습니다. 언어 설계는 보통 이런 식의 "작아 보이지만 영원히 따라붙는 비용" 을 어디에 둘지의 선택이었던 것 같습니다.
마무리
이번에 가장 크게 남는 한 줄은 이거였습니다.
0-based 는 사람의 편의를 양보해서 메모리 주소 계산식을 그대로 살린 결정이었다.
A[0] 을 처음 봤을 때의 어색함은 사실 사람의 "1번째" 감각과 컴퓨터의 "0 칸 떨어진 곳" 감각이 부딪힌 자국이었습니다. 한쪽이 양보해야 하는 자리에서, 언어 설계자는 사람 쪽이 0 에 익숙해지는 비용을 선택한 셈입니다. 그리고 그 작은 양보 덕분에 배열 접근 한 번에 빼기 연산 한 번이 영원히 사라졌습니다.
다음에 비슷한 "그냥 그렇다" 를 마주치면, 한 번 더 멈춰서 "이걸 다른 식으로 정했다면 어디가 비싸졌을까" 를 물어보는 습관을 들이려고 합니다. 이번 글은 그 첫 메모입니다.
참고