[프로그래밍 이야기1] awk로부터 출발한 'in' 키워드 문법의 역사
프로그래밍 문법의 계보를 살피다 보면, 인간의 언어와 닮아 있어 직관적인 키워드를 마주하게 됩니다. 파이썬이나 SQL에서 흔히 볼 수 있는 in이라는 키워드가 그렇습니다. 이 짧은 단어는 단순히 리스트를 훑는 기능을 넘어, 언어 설계자가 데이터를 바라보는 관점을 담고 있습니다. 그 계보를 거슬러 올라가면 1977년 유닉스 환경의 텍스트 처리를 완전히 바꾸어 놓은 awk를 만나게 됩니다.
사실 연상 배열(Associative Array)의 기원을 찾자면 1967년의 스노볼4(SNOBOL4)까지 거슬러 올라가야 합니다. 필자 역시 이 글을 준비하며 처음 알게 된 이름인데, 대부분의 독자에게도 생소할 것입니다. 필자 또한 이런 언어가 있었나 싶을 정도로 낯설었습니다. 하지만 스노볼4는 학술적 영역에 머물렀고, 이를 실제 시스템 현장에서 C스러운 문법과 결합해 레코드 처리의 핵심 도구로 완성한 것은 awk였습니다.
awk는 엑셀 같은 스프레드시트가 대중화되기 이전, 구조화된 텍스트 데이터를 주무르던 사실상의 표준이었습니다. 특히 연상 배열(Associative Array)을 활용한 if (key in array) 문법은 존재 유무를 확인하는 멤버십 연산자의 기틀을 마련했습니다. 숫자가 아닌 문자열을 인덱스로 쓰는 이 혁신적인 방식은, 훗날 Perl의 해시(Hash)와 파이썬의 딕셔너리(Dictionary) 자료형이 탄생하는 결정적인 계기가 됩니다. 이를 가장 전문적으로 보여줄 수 있는 예제를 준비했습니다.
그것은 바로 삼성 스마트폰의 통화녹음 파일명 처리입니다.
삼성 통화녹음 파일명은 보통 "통화 녹음_이름_01012345678_260205.m4a" 와 같이 언더바(_)로 구분된 형식을 가집니다. 여기서 전화번호별로 통화 횟수를 집계하고 리스트를 뽑는 과정에 in의 두 가지 용법이 모두 쓰입니다.
# 삼성 통화녹음 파일명 리스트(list.txt)를 처리하는 awk 스크립트
# 파일명 예: 통화 녹음_홍길동_01012345678_260205.m4a
BEGIN { FS = "_" } # 필드 구분자를 '_'로 설정
{
tel = $3 # 세 번째 필드(전화번호) 추출 # awk는 count[tel]이 없어도 자동으로 생성하며 ++를 수행합니다.
count[tel]++ # 연상 배열에 번호별 횟수 누적
}
END {
# 1. 순회의 in: 배열 내의 모든 키(전화번호)를 훑음
for (t in count) {
# 2. 존재 확인의 in: 특정 조건 확인 시 활용
if (t in count) {
print "전화번호: " t " | 통화 횟수: " count[t]
}
}
}위 예제에서 보듯, awk는 데이터를 레코드 단위로 읽어 들이며 특정 필드를 추출하고, 이를 곧바로 연상 배열의 키로 사용합니다. 이 직관적인 레코드 지향적 처리 방식은 유닉스 쉘(sh, Bash)로 이어져 for var in list라는 반복문의 대중화를 이끌었습니다.
# Bash 예제: 특정 번호의 녹음 파일을 별도 폴더로 이동
# awk에서 뽑아낸 특정 리스트를 바탕으로 순회 작업을 수행할 수 있습니다.
TARGET_NUM="01012345678"
for file in *${TARGET_NUM}*; do
if [[ -f "$file" ]]; then
mv "$file" "./important_calls/"
fi
done
Bash 쉘에서 이 문법은 실무에서 마법 같은 편리함을 줍니다. 특정 이름을 가진 파일들을 폴더별로 분류하는 작업 등은 이 in 문법 하나로 요약됩니다. 정수 인덱스를 증가시키며 메모리 주소를 계산하던 기존 C 스타일의 고전적 방식과는 차원이 다른 생산성을 선사한 것입니다.
Bash가 리스트 순회에 집중했다면, Perl과 파이썬은 awk의 연상 배열 철학을 극한으로 끌어올렸습니다. 특히 파이썬은 awk가 정립한 in을 멤버십 연산자와 딕셔너리(Dictionary) 순회 문법 양쪽에서 완벽하게 통합하며 가장 읽기 쉬운 언어의 지위를 얻었습니다.
# Python 예제: 순회와 존재 확인의 통합
call_counts = {"01012345678": 5, "01098765432": 2}
# 1. 존재 확인 (Membership test)
if "01011112222" in call_counts:
print("기존 기록이 있습니다.")
# 2. 순회 (Iteration)
for number in call_counts:
print(f"번호: {number}, 횟수: {call_counts[number]}")
이제 시대를 이끌어갈 미래적 언어들인 Go와 Rust는 어떨까요? 이들은 awk의 직관성을 계승하면서도, 컴파일 언어다운 엄격함을 더했습니다.
Go는 in 대신 range라는 키워드를 사용하여 순회의 의도를 더 명확히 했습니다. 인덱스와 값을 동시에 반환함으로써 데이터 구조를 훑는 행위에 전문성을 더했습니다.
// Go 예제: range를 통한 명확한 순회
counts := map[string]int{"01012345678": 5}
for tel, count := range counts {
fmt.Printf("전화번호: %s, 횟수: %d\n", tel, count)
}
반면 Rust는 iterator 패턴을 통해 in을 사용합니다. Rust의 for in은 단순히 값을 읽는 것을 넘어, 데이터의 소유권(Ownership)까지 고려하는 가장 현대적인 형태입니다.
// Rust 예제: 안전하고 강력한 for-in 순회
use std::collections::HashMap;
let mut counts = HashMap::new();
counts.insert("01012345678", 5);
for (tel, count) in &counts {
println!("번호: {}, 횟수: {}", tel, count);
}
재미있는 지점은 C++입니다. C++은 끝내 in을 예약어로 도입하지 않고 :(콜론)으로 대체했습니다. 이는 기존 코드와의 충돌을 피하기 위한 고육지책이었으나, 결과적으로 문법적 직관성 면에서는 역사적 흐름에서 살짝 비껴난 타협안이 되었습니다.
우리가 오늘날 사용하는 in 혹은 그 변형된 문법들은 결국 50년 전 선배들이 연상 배열이라는 강력한 도구를 어떻게 하면 가장 효율적으로 주무를 수 있을지 고민했던 흔적입니다. 이후 모든 현대 언어들이 딕셔너리와 해시 테이블을 핵심 자료형으로 채택하고 그 인터페이스로 in을 선택한 것은, awk가 증명한 직관성이 얼마나 강력했는지를 보여주는 증거입니다.
[부록] 연상 배열의 심장: C 언어로 구현하는 해시 테이블과 체이닝
우리가 in 키워드로 데이터의 존재를 0.1초 만에 찾아내는 그 이면에는 해시 테이블(Hash Table)이라는 정교한 공학적 설계가 숨어 있습니다. awk나 Python 딕셔너리의 내부 엔진을 C 언어로 모사해보면, 우리가 누리는 '직관'이 얼마나 많은 '연결 리스트'의 희생 위에 세워졌는지 알 수 있습니다.
1. 자료구조의 정의: 노드와 테이블
우선 데이터를 저장할 단위인 노드(Node)와 이를 관리할 테이블을 정의해야 합니다. 충돌이 발생했을 때 Head 뒤로 데이터를 붙이기 위해 각 노드는 다음 노드를 가리키는 포인터를 가집니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TABLE_SIZE 10
// 연결 리스트를 위한 노드 구조체
typedef struct Node {
char key[20]; // 전화번호 (Key)
int value; // 통화 횟수 (Value)
struct Node* next; // 충돌 시 다음 노드를 가리키는 포인터
} Node;
// 해시 테이블 (10개의 헤드 포인터를 가진 배열)
Node* hashTable[TABLE_SIZE];
2. 해시 함수와 데이터 삽입 (Chaining)
문자열 키를 배열의 인덱스로 변환하는 함수입니다. 만약 인덱스가 겹치면(충돌), 해당 버킷의 Head 뒤로 새 노드를 삽입하여 연결 리스트를 형성합니다.
// 아주 간단한 해시 함수 예시
unsigned int hash(char* key) {
unsigned int v = 0;
while (*key) v += *key++;
return v % TABLE_SIZE;
}
// 데이터를 집어넣는 함수 (in 문법의 하부 구현)
void insert(char* key) {
unsigned int idx = hash(key);
Node* newNode = (Node*)malloc(sizeof(Node));
strcpy(newNode->key, key);
newNode->value = 1;
// Chaining: 기존 헤드 앞에 새 노드를 끼워 넣음 (Head 뒤로 추가)
newNode->next = hashTable[idx];
hashTable[idx] = newNode;
}
3. 존재 확인 함수 (C 버전의 'in' 연산자)
현대 언어의 if (key in array)는 내부적으로 아래와 같은 리스트 순회 과정을 거칩니다.
int contains(char* key) {
unsigned int idx = hash(key);
Node* cursor = hashTable[idx];
// 해당 버킷의 연결 리스트를 타고 내려가며 일치하는 키가 있는지 확인
while (cursor != NULL) {
if (strcmp(cursor->key, key) == 0) return 1; // 존재함
cursor = cursor->next;
}
return 0; // 존재하지 않음
}
요약: 추상화가 준 선물
C 언어로 작성된 위 코드를 보면, 단 하나의 전화번호 존재 여부를 확인하기 위해 해시값을 계산하고, 포인터를 따라 연결 리스트를 탐색하는 번거로운 과정이 필요함을 알 수 있습니다.
우리가 awk나 Python에서 사용하는 in 문법은 바로 이 복잡한 포인터 연산과 연결 리스트 탐색 작업을 설계자가 미리 구현하여 숨겨놓은 결과물입니다. 연상 배열이라는 추상적인 도구가 연결 리스트라는 구체적인 재료를 만나 현대 프로그래밍의 가장 강력한 인터페이스인 in을 탄생시킨 것입니다.
직관성과, 실제컴퓨터의 운행과 가까운 c코드를 함께 보면, 우리의 in 키워드가 얼마나 고마운 친구인가 알 수 있습니다.
댓글
댓글 쓰기