2~5주차 알고리즘 문제를 풀 때는 약간 지능 테스트를 하는 느낌이 강했음. 반면 C언어 주차는 문제 해결 아이디어 자체보다, 언어에 얼마나 익숙한지에 따라 체감 난이도가 크게 달라지는 주차였던 것 같음. 그래서 이번 주의 핵심은 새로운 개념을 많이 배우는 것보다, C언어 문법과 특징에 익숙해지는 데 있었음.
특히 포인터나 엄격한 데이터 타입 체크 같은 부분은 파이썬에서는 거의 신경 쓰지 않았던 요소들이라 처음에는 낯설었음. 하지만 이번 주 문제들을 풀면서 이런 C언어 특유의 방식에 조금씩 익숙해질 수 있었고, 언어 차이에서 오는 사고방식의 차이도 같이 느낄 수 있었음.
이번 mini SQL 프로젝트를 진행하면서 가장 크게 느낀 점은, 평소 당연하게 쓰던 SQL도 내부적으로는 꽤 체계적인 처리 과정을 거친다는 점이었음. 그냥 문자열 한 줄 넣는다고 바로 결과가 나오는 게 아니라, 먼저 문장을 잘게 나누고, 문법에 맞게 해석한 뒤, 그 의미에 따라 실제 데이터를 읽고 쓰는 과정이 필요했음. 이번 프로젝트는 규모는 작았지만, SQL 실행기의 흐름을 처음부터 끝까지 직접 만들어봤다는 점에서 의미가 컸음.
가장 인상 깊었던 부분은 SQL 문장을 처리하는 구조였음. INSERT, SELECT, WHERE 같은 익숙한 문법도 직접 구현하려고 하니, 먼저 토큰 단위로 나누고, 각 토큰이 어떤 의미를 가지는지 판별한 뒤, 이를 구조화된 형태로 바꾸는 과정이 필요했음. 이 과정을 통해 parser가 왜 필요한지, 그리고 프로그래밍 언어나 쿼리 언어를 처리할 때 왜 문자열을 그대로 다루지 않고 중간 표현 형태를 만드는지 자연스럽게 이해하게 됐음.
평소에는 눈에 보이지 않던 언어 처리기의 역할을 직접 체감한 순간이기도 했음. 사용자는 단순히 SQL 한 줄을 입력하지만, 내부에서는 그 문장을 해석 가능한 형태로 바꾸는 작업이 먼저 일어나야 한다는 점이 꽤 흥미로웠음.
이번 프로젝트에서는 파싱을 담당하는 부분, 실행을 담당하는 부분, 파일 입출력을 담당하는 부분을 나누어 구현했음. 처음에는 그냥 기능만 돌아가면 된다고 생각했는데, 구현이 조금만 길어져도 역할이 섞인 코드는 금방 복잡해졌음. 반대로 parser, executor, storage처럼 책임을 분리해두니 흐름이 훨씬 명확해졌고, 수정이나 확장도 쉬워졌음.
작은 프로젝트일수록 구조를 대충 잡기 쉬운데, 오히려 이런 프로젝트에서 설계 감각을 익히는 게 중요하다는 점을 느꼈음. 어떤 코드를 어디에 두어야 하는지 고민하는 과정 자체가 좋은 연습이 됐음.
데이터를 파일에 저장하는 과정도 생각보다 재미있었음. .schema 파일과 .data 파일을 나누어 테이블 구조와 실제 데이터를 관리했는데, 단순히 줄 단위로 값을 저장한다고 끝나는 게 아니었음. 구분자 처리, escape 규칙, 컬럼 순서 정렬 같은 세부 요소들을 함께 고려해야 했음.
특히 사용자가 입력한 컬럼 순서와 실제 스키마 순서가 다를 수 있다는 점, 그리고 WHERE 조건을 처리하기 위해 컬럼 인덱스를 찾고 row를 하나씩 비교해야 한다는 점을 구현하면서 데이터 저장과 조회가 꽤 논리적인 과정이라는 걸 배웠음. 평소에는 데이터베이스가 알아서 해주던 일들을 직접 구현해보니, 아주 기본적인 조회 기능 하나에도 여러 단계의 판단이 들어간다는 점이 인상적이었음.
이번 구현에서 특히 흥미로웠던 점은, 많은 함수가 “실제 결과값 자체”를 반환하지 않고 “성공했는지 실패했는지”만 bool로 반환한다는 점이었음. 대신 실제 결과 데이터는 포인터나 주소를 통해 직접 채워 넣는 방식이 사용됐음.
예를 들어 아래 코드는 parse_sql_script()가 SQLScript를 반환하는 것이 아니라, &script로 넘겨준 주소에 파싱 결과를 채우고, 함수 자체는 성공 여부만 돌려주는 구조임.
if (!parse_sql_script(source, &script, error, sizeof(error))) {
fprintf(stderr, "parse error: %s\n", error);
free(source);
return 1;
}
이 방식이 보이는 이유는 몇 가지가 있음.
이 프로젝트에서도 같은 흐름이 반복됐음.
parse_sql_script(source, &script, ...)script에 채움execute_statement(..., &result, ...)result에 채움append_insert_row(..., &result->affected_rows, ...)run_select_query(..., &result->query_result, ...)SELECT 결과 테이블을 주소로 전달받아 채움관련 코드도 이런 패턴을 잘 보여줌.
if (!execute_statement(&script.items[index], db_root, &result, error, sizeof(error))) {
fprintf(stderr, "execution error: %s\n", error);
free_execution_result(&result);
free_script(&script);
free(source);
return 1;
}
if (statement->type == STATEMENT_INSERT) {
result->kind = EXECUTION_INSERT;
return append_insert_row(db_root, &statement->as.insert, &result->affected_rows, error, error_size);
}
if (statement->type == STATEMENT_SELECT) {
result->kind = EXECUTION_SELECT;
return run_select_query(db_root, &statement->as.select, &result->query_result, error, error_size);
}
이 패턴을 보면서, C에서는 “무엇을 반환할지”보다 “어디에 결과를 써 넣을지”를 먼저 설계하는 경우가 많다는 점을 체감했음. 단순히 문법 차이 정도가 아니라, 함수 책임 분리와 에러 처리 구조까지 같이 연결된다는 점이 특히 인상적이었음.
이번 프로젝트는 C 언어로 구현했기 때문에 더 선명하게 보이는 부분이 많았음. 동적 메모리 할당과 해제, 문자열 복사, realloc을 이용한 배열 확장, 그리고 에러가 발생했을 때 이미 확보한 자원을 안전하게 정리하는 과정까지 직접 다루면서 메모리 관리의 중요성을 다시 느끼게 됐음.
기능이 정상적으로 동작하는 것만큼이나, 실패했을 때도 프로그램이 안전하게 종료되는 것이 중요하다는 점도 체감했음. 다른 언어에서는 비교적 덜 드러나는 부분들이 C에서는 훨씬 직접적으로 드러나기 때문에, 프로그램의 동작 원리를 더 깊이 이해하는 데 도움이 됐음.
테스트의 필요성도 분명하게 느꼈음. 파싱이 제대로 되는지, insert한 데이터가 select로 다시 정확히 조회되는지 확인하는 테스트를 작성하면서, “구현했다”와 “정상적으로 동작한다”는 완전히 다른 이야기라는 점을 배웠음.
특히 입력과 출력이 연결된 시스템에서는 작은 예외 하나가 전체 흐름을 깨뜨릴 수 있기 때문에, 반복적으로 검증할 수 있는 테스트의 역할이 중요하다는 걸 실감했음. 구현을 마친 뒤에도 안심할 수 있는 근거를 만드는 과정이 바로 테스트라는 생각이 들었음.
높이가 상대적으로 큼
→ 자식이 2개뿐이라 depth 증가
디스크 환경에서 비효율
→ 노드 하나당 데이터가 적음
→ 탐색 시 많은 노드를 거침
→ I/O 횟수 증가
캐시/로컬리티 불리
→ 노드가 포인터로 분산됨
재균형 비용 존재
→ 삽입/삭제 시 rotation 발생
범위 검색 비효율
→ inorder traversal 필요
→ 노드 간 이동 많음
높이가 매우 낮음
→ 한 노드에 많은 키 저장
디스크 I/O 최적화
→ 노드 = 디스크 블록
→ 한 번 읽을 때 많은 데이터 확보
캐시 효율 좋음
→ 연속된 데이터 저장
재균형 효율적
→ split / merge 방식
→ 빈도 낮고 구조적으로 안정적
범위 검색에 유리
→ 특히 B+Tree는 leaf 연결
→ 순차 접근 가능