티스토리 뷰

에세이

서버실의 유령

북항 2023. 4. 25. 15:00

서론: 서버실의 유령

소프트웨어 시스템의 광활한 우주에는 명백한 버그와는 다른 종류의 존재들이 떠다닌다. 이들은 시스템을 무너뜨리는 소행성 충돌(System Crash)을 일으키지도, 시끄러운 경고 로그(Warning Log)라는 비명을 지르지도 않는다. 그저 항성 간의 어두운 빈 공간처럼, 조용히 시스템의 에너지를 빨아들이고 효율이라는 행성의 궤도를 미세하게 뒤트는 그림자 같은 존재들이다. 나는 이런 문제들을 '유령'이라 부르곤 한다. 거대한 신규 프로젝트라는 우주 탐사를 마치고, 안정적인 시스템 운영과 신입 교육이라는 비교적 평온한 행성 기지에서의 일상에 접어들었을 때, 우리 시스템의 보이지 않는 공간에도 그런 유령 하나가 출몰하기 시작했다.

 

매일 아침, 어김없이 특정 시간에 시작되는 배치(Batch) 프로세스가 있었다. 이 작업의 임무는 명확했다. 하루 평균 1,000여 건에 달하는 고객 계약의 연장 데이터를 처리하는 것. 하지만 그 수행 방식은 불가사의에 가까웠다. 작업은 실패하지 않았고, 데이터가 누락되는 일도 없었다. 다만, 상식적으로 수 분 내에 끝났어야 할 이 작업이 설명할 수 없는 이유로 매번 40분에서 45분이라는 긴 시간 동안 계속되었다. 마치 매일 아침 어김없이 서버실에 나타나 시스템의 시간을 45분간 훔쳐 간 뒤, 아무 일도 없었다는 듯 홀연히 사라지는 디지털 유령과도 같았다.

 

이 현상은 누구도 공식적으로 문제 삼지 않았다. 시스템은 정해진 시간 안에 작업을 마쳤고, 비즈니스는 문제없이 흘러갔다. 경영진의 보고서에는 '정상'이라는 두 글자만 찍힐 뿐이었다. 하지만 엔지니어의 세계에서 '돌아가는 것'과 '올바른 것'은 완전히 다른 차원의 이야기다. 이 45분이라는 시간은, 마치 잘 닦인 고급 자동차의 엔진에서 들려오는 미세하지만 규칙적인 소음처럼, 내 엔지니어로서의 감각을 끊임없이 자극했다. 그 소음은 당장 차를 멈춰 세울 정도는 아니었지만, 분명 어딘가 잘못되었다는 불길한 신호이자, 풀리지 않는 찝찝함으로 마음 한구석에 남아 있었다.

서버실에 유령이 산다.

 

이 이야기는 단순히 하나의 느린 배치를 수정한 기술적 성공담이 아니다. 복잡한 문제라는 이름의 괴물을 마주했을 때, 우리는 종종 가장 최신의 번쩍이는 무기, 즉 '은 탄환(Silver Bullet)'을 먼저 찾으려 하는 경향이 있다. 하지만 이 경험은 때로는 가장 강력한 해법이 화려한 신기술이나 복잡한 프레임워크가 아닌, 문제의 본질을 꿰뚫는 단순하고 근본적인 원리의 재발견에 있음을 보여준다. 마치 미궁을 탈출하는 열쇠가 복잡한 지도가 아니라, '항상 오른손을 벽에 대고 걷는다'는 단순한 규칙이었던 것처럼 말이다.

 

이 글은 처음에는 현대적인 병렬 처리라는 강력한 기관총으로 유령을 제압하려 했던 1차 전투와, 그 후에도 사라지지 않는 미심쩍음이라는 유령의 잔상을 파고들어, 데이터베이스의 가장 기본적인 원리라는 날카로운 검으로 유령을 완전히 퇴치하게 된 2차 탐사의 여정을 담고 있다. 이 두 번의 전투는 '쉬운(easy)' 해결책과 '단순한(simple)' 해결책 사이의 깊은 철학적 차이를 탐험하는 과정이기도 했다.

제1장: 속도 저하의 해부학

괴물의 본질: 레거시 시스템의 기이한 유산

모든 유령이 그렇듯, 우리 시스템의 유령 또한 과거의 역사, 즉 레거시 시스템이라는 오래된 저택의 지하실에서 태어났다. 문제의 배치 프로세스가 동작하는 이 시스템은, 마치 고대의 유물을 발굴하듯 조심스럽게 들여다봐야 하는 일종의 디지털 고고학 현장이었다. 그곳에서 발견된 계약 정보 처리 방식은, 현재 시점에서는 기이하다고밖에 표현할 수 없었다.

 

통상적인 시스템이라면 계약을 연장할 때, 기존 계약 정보라는 문서에 새로운 종료일을 적고 도장을 찍는, 즉 UPDATE 방식을 택했을 것이다. 하지만 이 시스템은 그 문서를 수정하는 대신, 완전히 새로운 계약 문서를 생성하고 이전 문서는 '역사' 폴더에 보관하는 방식을 채택했다. 그리고 바로 이 지점에서, 단순한 기이함은 거대한 복잡성의 연쇄 반응으로 폭발하기 시작한다. 새로운 계약서가 발행되면, 이전 계약서에 붙어 있던 수많은 참조 딱지들—무려 20개가 넘는 테이블에 흩어져 있던 기존 계약번호(Primary Key)—를 일일이 떼어내어 새로운 계약서에 다시 붙여야만 했다. 즉, 단 하나의 계약 연장이 데이터베이스 전반에 걸쳐 수십 개의 UPDATE 구문을 동시다발적으로 일으키는, 거대한 업데이트 폭풍을 만들어낸 것이었다.

 

과거의 어떤 비즈니스적 요구사항이나 기술적 제약이 이런 독특한 설계를 낳았는지는 이제 아무도 알 수 없었다. 여러 개발자의 손을 거치며 진화와 퇴화를 거듭해 온 시스템의 역사 속에서, 그 결정의 맥락은 이미 시간의 안갯속으로 사라진 뒤였다. 어쩌면 당시에는 데이터를 덮어쓰지 않고 모든 변경 이력을 남기는 것이 최선이라 믿었던, 신중하지만 결과적으로는 비극이 된 선택이었을지도 모른다.

 

분명한 것은 그 결과였다. 이 설계는 명백한 기술 부채(Technical Debt)의 한 형태였고, 우리가 매일 아침 45분씩 지불하던 처리 시간은, 바로 그 부채에 대해 시스템이 꼬박꼬박 물고 있는 값비싼 '이자'였던 셈이다. 이 구조적 문제는 간단한 비즈니스 로직 하나가 시스템 전체에 엄청난 파급 효과를 일으키는, 마치 나비의 날갯짓이 지구 반대편에서 태풍을 일으키는 것과 같은 상황을 연출하고 있었다.

최초의 가설: 길 잃은 데이터베이스

이 비정상적인 느림의 원인에 대한 나의 첫 번째 가설은 지극히 합리적이고 교과서적인 것이었다. 바로 '인덱스의 부재'였다. 데이터베이스 세계에서 인덱스(Index)는, 방대한 도서관에서 원하는 책을 찾기 위한 필수 도구인 '도서목록 카드'와 같다. 특정 책(데이터)을 찾기 위해 수십만 권의 장서를 일일이 확인하는 대신, 잘 정리된 목록 카드에서 책의 위치(데이터의 물리적 주소)를 단번에 찾아내는 방식이다.

 

나는 20개가 넘는 테이블을 업데이트하는 과정에서, 데이터베이스가 계약번호라는 단서를 가지고도 매번 도서관 전체를 헤매고 있을 것이라 추정했다. 즉, 데이터베이스가 테이블의 모든 행을 처음부터 끝까지 샅샅이 뒤지는 풀 테이블 스캔(Full Table Scan)을 수행하고 있다는 가설이었다. 이는 마치 특정 계약 정보를 찾기 위해, 수억 건의 데이터가 빼곡히 들어찬 수십 권의 백과사전을 매번 첫 장부터 마지막 장까지 훑어보는 것과 같은 무모한 작업이었다. 이 가설이 맞다면, 매일 아침 데이터베이스는 수십억, 수백억 건의 데이터를 불필요하게 읽어 들이는 막대한 노동을 하고 있는 셈이고, 이것이 바로 성능 저하의 핵심일 것이라 짐작했다.

기술 심층 탐구: 풀 테이블 스캔의 보이지 않는 노동

풀 테이블 스캔(Full Table Scan, FTS)은 데이터베이스가 특정 데이터를 찾기 위해 사용하는 가장 원시적이면서도 정직한, 그리고 때로는 처절한 방식이다. 테이블의 첫 번째 데이터 블록부터 마지막 블록까지, 마치 컨베이어 벨트 위의 상자들을 하나씩 모두 열어보는 것처럼 순차적으로 검사한다. 이 작업의 복잡도는 O(n)으로, 테이블의 행 수(n)에 정비례하여 처리 시간이 끝없이 늘어난다.

 

성능 저하의 주범은 단연 입출력(I/O)이다. CPU가 눈 깜짝할 사이에 수 많은 연산을 처리하는 동안, 하드디스크는 겨우겨우 얼마 안되는 데이터를 읽어올 뿐이다. 이 둘 사이의 속도 차이는 슈퍼카와 달팽이의 경주에 비유할 수 있다. FTS는 이 느린 달팽이에게 운동장 전체를 기어가게 만드는 것과 같아서, 방대한 양의 디스크 I/O를 유발하며 시스템에 큰 부담을 준다.

 

물론 데이터베이스의 두뇌라 할 수 있는 쿼리 옵티마이저(Query Optimizer)가 어리석어서 FTS를 선택하는 것은 아니다. 옵티마이저는 통계 정보를 기반으로 여러 실행 계획의 비용을 치밀하게 계산하고 가장 효율적인 방법을 선택하는, 냉철한 회계사와 같다. 다음과 같은 경우에는 FTS가 인덱스를 사용하는 것보다 합리적인 선택이 될 수 있다.

  1. 조회 대상 데이터가 너무 많을 경우: 테이블의 상당 부분(일반적으로 5~10% 이상)을 읽어야 할 때가 그렇다. 이 경우, 인덱스를 통해 데이터의 위치를 일일이 찾아다니는 수많은 무작위 I/O(Random I/O)를 발생시키는 것보다, 차라리 처음부터 끝까지 한 번에 쭉 읽는 순차적 I/O(Sequential I/O)가 더 빠를 수 있다. 
  2. 사용 가능한 인덱스가 없는 경우: WHERE 절의 조건에 맞는 '도서목록 카드'가 아예 존재하지 않을 때, 옵티마이저에게는 FTS 외에 다른 선택지가 없다.

우리의 경우는 명백히 후자에 가까워 보였다. 매번 단 하나의 계약번호에 해당하는 데이터를 찾기 위해 테이블 전체를 읽는 것은, 도서관에서 책 한 권을 빌리기 위해 모든 서가를 뒤지는 것과 같은 비극적인 비효율이었다. 이 가설이 맞다면, 해결책은 이론적으로는 간단했다. 20여 개 테이블의 계약번호 컬럼에 인덱스라는 '도서목록 카드'를 만들어주는 것이었다. 이 카드는 데이터베이스의 가장 근본적인 자료구조인 B-Tree 형태로 구현되어, 데이터가 아무리 많아져도 O(log n)이라는 경이로운 속도로 원하는 데이터를 찾아갈 수 있게 해주는 마법 같은 존재다. 이 마법만 부릴 수 있다면, 유령은 쉽게 잡힐 것 같았다.

제2장: 병렬 처리와 파다완의 길

새로운 희망: 파다완에게 주어진 첫 번째 임무

모든 오래된 성에 유령이 출몰하듯, 모든 복잡한 시스템에는 후임자들을 위한 기묘한 미스터리가 숨어 있기 마련이다. 그리고 이 45분짜리 유령은 이제 막 팀에 합류하여 뜨거운 열정과 재능으로 빛나던 신입 개발자에게 더할 나위 없이 좋은 첫 번째 임무처럼 보였다. 나는 이 문제를 그에게 온전히 맡겨보기로 했다. 이는 단순히 일손을 더는 차원의 결정이 아니었다. 명확한 문제 상황을 제시하고, 그가 스스로 원인을 분석하고 해결책을 찾아 나가는 이상적인 '문제 해결의 여정'을 경험하게 해주고픈 선배로서의 욕심이었다.

 

훌륭한 멘토링은 정답을 알려주는 것이 아니라, 멘티가 스스로 답을 향한 지도를 그려나가는 과정을 묵묵히 지켜봐 주는 것이라 믿는다. 제다이 마스터가 파다완에게 광선검 만드는 법을 직접 가르치는 대신, 필요한 부품이 숨겨진 동굴의 위치만을 알려주듯이 말이다. 나는 그가 스스로 가설을 세우고, 해결책을 설계하며, 그 과정에서 부딪히는 기술적, 조직적 난관을 헤쳐나가는 경험 그 자체를 통해 한 뼘 더 성장하기를 바랐다. 그리고 그는 기대 이상으로 빠르게 문제의 핵심에 접근했다. 시스템의 동작을 며칠간 면밀히 분석한 끝에, 나 역시 어렴풋이 짐작했던 '인덱스 부재'가 성능 저하의 핵심 원인일 것이라는 결론에 독자적으로 도달했다. 그의 눈빛은 자신의 분석에 대한 확신으로 빛나고 있었다.

현자의 지혜: 예상치 못한 장애물과 보이지 않는 비용

자신만의 분석으로 얻은 결론에 자신감을 얻은 그는, 마치 전설의 검을 뽑아 들 듯 당당하게 데이터베이스 관리자(DBA)에게 계약번호 컬럼에 대한 인덱스 생성을 요청했다. 그러나 돌아온 답변은 이 이야기의 첫 번째 극적인 반전이었다. "그 요청은 승인할 수 없습니다. 사실, 과거에 바로 그 인덱스가 있었지만 저희가 직접 제거했습니다."

 

이 거절은 단순한 반대가 아니었다. 그 이면에는 시스템이라는 거대한 함선의 모든 항해 기록과 설계도를 꿰고 있는, 노련한 기관장의 깊은 통찰이 담겨 있었다. DBA는 과거 해당 컬럼에 인덱스가 존재했으나, 이 시스템의 독특한 '수정' 방식, 즉 UPDATE가 아닌 INSERT와 연쇄 UPDATE의 조합이 일으키는 과도한 쓰기 작업 때문에 오히려 시스템 전반의 성능을 저해하여 의도적으로 제거했던 이력을 차분히 설명해주었다. 이는 개발자와 DBA 사이의 오랜 문화적 간극을 보여주는 동시에, 협업의 중요성을 일깨우는 순간이었다.

 

개발자는 특정 쿼리의 '읽기(Read)' 성능이라는 망원경으로 눈앞의 행성을 관측하는 경향이 있지만, DBA는 그 행성의 움직임이 전체 항성계에 미치는 중력까지 계산하는 천문학자의 시각을 가져야 한다. 인덱스가 걸린 컬럼의 값이 변경될 때마다, 데이터베이스는 단순히 테이블의 데이터만 수정하는 것이 아니다. B-Tree 구조로 정교하게 짜인 인덱스 데이터 또한 정렬 순서에 맞게 재구성해야 하는, 보이지 않는 막대한 비용을 치른다. 이 과정에서 다음과 같은 값비싼 작업들이 수면 아래에서 벌어진다.

  • 페이지 분할(Page Splits)이라는 연쇄 충돌: 데이터가 저장되는 페이지(블록)에 더는 새로운 데이터를 삽입할 공간이 없을 때, 데이터베이스는 페이지를 두 개로 나누고 기존 데이터의 일부를 새 페이지로 옮긴다. 이는 마치 만원 버스에 승객 한 명이 더 타려고 할 때, 버스를 두 대로 쪼개고 승객 절반을 다른 버스로 옮긴 뒤, 노선표까지 새로 고쳐 쓰는 것과 같은 대혼란이다. 이 작업은 상당한 I/O와 트랜잭션 로그를 발생시키며, 데이터의 물리적 순서를 흐트러뜨려 인덱스 단편화(fragmentation)라는 후유증을 남긴다.
  • 잠금 경합(Lock Contention)이라는 교통 체증: UPDATE 작업 시 데이터베이스는 데이터 페이지뿐만 아니라 관련된 인덱스 페이지에도 잠금(Lock)을 건다. 인덱스가 많을수록 잠가야 할 대상이 늘어나고 잠금 유지 시간도 길어져, 다른 트랜잭션들이 하염없이 신호를 기다리는 병목 현상이 발생할 가능성이 커진다.

DBA는 20개가 넘는 테이블에서 매일 수천 건의 계약번호가 변경되는 이 작업의 특성을 정확히 이해하고 있었다. 여기에 모두 인덱스를 걸 경우, 배치 작업의 읽기 성능은 개선될지 몰라도, 시스템 전반의 쓰기 성능 저하와 잠금 문제로 인해 더 큰 재앙, 즉 '쓰기 증폭(Write Amplification)' 현상을 초래할 수 있다는 현명한 판단을 내렸던 것이다. 그의 설명은, 빠른 항해를 위해 무작정 돛을 더 다는 것이 아니라, 배의 균형과 바람의 방향까지 고려해야 한다는 베테랑 항해사의 지혜와도 같았다.

파다완의 전환: 현대적인 무기를 향한 열망

'인덱스'라는 왕도를 사용할 수 없게 된 신입 개발자는 잠시 좌절했지만, 이내 새로운 길을 모색했다. 그의 선택은 현대 소프트웨어 공학의 가장 강력하고 매력적인 무기 중 하나인 병렬 처리(Parallel Processing)였다. 그의 논리는 명쾌하고 현대적이었다. "한 명의 사서가 백과사전 전체를 읽는 것이 느리다면, 여러 명의 속독가를 동시에 고용해 각기 다른 권을 읽게 하면 되지 않을까요?"

"한 명의 사서가 백과사전 전체를 읽는 것이 느리다면, 여러 명의 속독가를 동시에 고용해 각기 다른 권을 읽게 하면 되지 않을까요?"

 

그는 당시 프로젝트의 기술 스택인 Java 7 환경에서 사용할 수 있는 가장 진보된 동시성 처리 도구인 Fork/Join Framework를 해결책으로 제시했다. 솔직히 나는 이 선택에 대해 약간의 회의감이 들었다. 이 작업의 본질은 CPU 연산(CPU-Bound)이 아니라 데이터베이스 I/O 대기 시간(I/O-Bound)이었기 때문이다. 그의 해법은 마치 교통 체증으로 꽉 막힌 도로에서 더 빠른 차로 갈아타려는 시도처럼 보였다. 차의 성능이 아무리 좋아도, 도로 자체가 뚫리지 않으면 무용지물이기 때문이다.

 

하지만 나는 멘토로서 그의 선택을 존중하고 함께 나아가기로 결정했다. 이는 드라이퍼스 모델(Dreyfus Model of Skill Acquisition)에서 '고급 초심자(Advanced Beginner)' 단계에 있는 학습자가 규칙을 넘어 상황에 맞게 지식을 적용해보는 중요한 과정이기 때문이었다. 그가 직접 부딪혀보고 그 기술의 장점과 한계를 체감하는 것이, 내가 백 마디 이론을 설명해주는 것보다 훨씬 값진 경험이 되리라 믿었다.

기술 심층 탐구: 우아한 춤과 어색한 무대 - Fork/Join 프레임워크의 명과 암

Fork/Join 프레임워크는 '분할 정복(divide and conquer)' 알고리즘을 병렬 처리에 우아하게 적용한 구현체이다. 그 작동 원리는 '작업 훔치기(Work-Stealing)'라는 혁신적인 알고리즘에 기반한다. 각 스레드가 자신의 작업 큐(Queue)를 가지고 처리하되, 할 일이 없어진 스레드는 다른 바쁜 스레드의 큐에서 일을 '훔쳐와' 처리함으로써 CPU 코어를 한순간도 쉬지 않고 최대한 활용하는 방식이다. 이는 마치 효율적인 레스토랑 주방에서, 자기 파트의 일이 끝난 요리사가 잠시도 쉬지 않고 가장 바쁜 동료의 일을 돕는 모습과도 같다.

 

하지만 이 우아한 춤에는 치명적인 약점이 있다. 바로 I/O 집중적(I/O-Bound) 작업이라는 어색한 무대 위에서는 그 진가를 발휘하기 어렵다는 점이다. Fork/Join 프레임워크는 이미지 렌더링이나 복잡한 수학 연산처럼 CPU가 쉴 틈 없이 일하는 CPU 집중적(CPU-Bound) 작업에 최적화되어 있다. 그러나 우리의 작업은 대부분의 시간을 데이터베이스가 UPDATE 쿼리를 처리하고 응답하기를 '기다리는' 데 사용한다. join() 메서드는 하위 작업이 끝날 때까지 현재 스레드를 차단(block)시키는데, I/O 대기 상황에서 이는 귀중한 스레드 자원의 낭비이며, 작업 훔치기 알고리즘의 효율을 크게 떨어뜨린다. CPU는 놀고 있는데 스레드만 하염없이 기다리는, 비효율의 극치가 발생하는 것이다.

 

이런 I/O 집중적 작업에는 Fork/Join 프레임워크보다 오히려 ExecutorService와 고정된 크기의 스레드 풀(Fixed Thread Pool)을 사용하는 것이 더 전통적이고 적합한 접근 방식일 수 있다. 이는 작업 훔치기와 같은 복잡한 메커니즘 없이, 정해진 수의 스레드가 꾸준히 I/O 작업을 요청하고 응답을 기다리는 단순하고 예측 가능한 모델이다. 스레드가 I/O 대기로 인해 차단되더라도, 이는 예상된 동작이며 스레드 풀의 크기를 적절히 조절함으로써 전체 시스템의 처리량을 제어할 수 있다.

가시적인 승리, 그리고 남겨진 그림자

이론적 한계에도 불구하고, 결과는 성공적이었다. 우리는 마치 오케스트라의 지휘자가 되어 각 악기(스레드)의 소리가 최적의 화음을 내도록 조율하는 것처럼, 수많은 테스트를 진행했다. 스레드 개수와 데이터베이스 커넥션 풀 크기라는 두 변수를 미세하게 조정하며 최적의 조합을 찾아 나갔고, 마침내 스레드 10개와 커넥션 풀 10개라는 '스위트 스폿(Sweet Spot)'을 발견했다. 그 결과, 45분에 달하던 처리 시간은 10분대로 극적으로 단축되었다.

 

이 성과는 여러 면에서 의미가 있었다. 비즈니스적으로는 매일 30분 이상의 시스템 자원을 절약했고, 팀 차원에서는 신입 개발자가 성공적으로 첫 임무를 완수하여 정규직으로 전환되는 기쁨을 안겨주었다. 그의 얼굴에 번진 성취감과 자신감은 그 어떤 성능 지표보다 값진 결과였다. 이는 실용적인 문제 해결이 개인의 성장과 조직의 성공에 어떻게 기여할 수 있는지를 보여주는 훌륭한 사례였다. 유령은 완전히 사라지지 않았지만, 우리는 그 활동 시간을 크게 줄여놓는 데 성공했다. 첫 번째 전투는 분명 우리의 승리였다.

제3장: 꺼지지 않는 불씨

유령은 아직 그곳에 있었다

45분에서 10분으로. 78%의 성능 개선. 누가 봐도 성공적인 프로젝트였고, 팀은 환호했다. 신입 개발자는 자신의 역량을 증명하며 팀의 정식 일원이 되었고, 그의 얼굴에 번진 성취감과 자신감은 그 어떤 성능 지표보다 값진 결과였다. 비즈니스적으로는 매일 30분 이상의 시스템 자원을 절약했으니, 우리는 유령을 성공적으로 제압한 듯 보였다. 모든 것이 제자리를 찾은 것 같았다.

 

하지만 내 마음속에는 잘 닦인 자동차 계기판에 들어온 '엔진 체크' 경고등처럼, 작지만 선명한 찝찝함이 꺼지지 않고 있었다. "10분도 여전히 너무 길다." 이 목소리는 축하 파티의 소음 속에서도 명확하게 들려왔다.

 

1,000여 건의 데이터를 처리하는 데 10분, 즉 600초가 걸린다는 것은 한 건의 계약을 처리하는 데 평균 0.6초가 소요된다는 의미였다. 현대 컴퓨팅의 관점에서 0.6초는 영겁과도 같은 시간이다. 빛이 지구를 일곱 바퀴 반이나 돌고, 평범한 CPU가 수십억 번의 연산을 처리할 수 있는 시간 동안, 우리의 데이터베이스는 고작 데이터 한 묶음을 처리하기 위해 끙끙대고 있었던 것이다. 이 속도는 마치 최신 스포츠카를 타고 시속 10킬로미터로 달리는 것과 같은 부조리함이었다. 차는 분명 앞으로 나아가고 있지만, 그 방식은 명백히 잘못되어 있었다.

 

우리의 첫 번째 해결책은 문제의 근원을 제거한 것이 아니었다. 그것은 마치 고금리 카드빚에 시달리다가, 이자 납부일에 맞춰 최소 결제 금액만 겨우 입금한 것과 같았다. 당장의 독촉 전화(시스템 경고)는 멈췄지만, 무시무시한 원금(근본적인 비효율)은 고스란히 남아 매일 조용히 자원을 갉아먹는 '이자'를 발생시키고 있었다. 유령은 쫓겨난 것이 아니라, 그저 활동 시간을 줄여 더욱 교묘하게 시스템의 그림자 속에 숨어들었을 뿐이었다.

철학의 교차점: '쉬움(Easy)'과 '단순함(Simple)'의 깊은 계곡

이러한 지적 불만족의 근원을 파고들다 보면, 소프트웨어 공학의 가장 심오한 철학적 질문과 마주하게 된다. 나는 클로저(Clojure) 언어의 창시자인 리치 히키(Rich Hickey)가 그의 명강의 "Simple Made Easy"에서 설파한 '쉬움(easy)'과 '단순함(simple)'의 구분에 깊이 공감한다.

  • 쉬움(Easy)은 '가까이 있음(near)'을 의미한다. 즉, 우리에게 익숙하거나, 손에 닿기 가깝거나, 이해하기 편한 도구를 뜻한다. Fork/Join 프레임워크를 이용한 우리의 첫 번째 해결책은 명백히 '쉬운' 길이었다. 병렬 처리는 현대 개발자에게 매우 친숙한 개념이며, Java 7이라는 주어진 환경에서 가장 손쉽게 적용할 수 있는 강력한 망치였다. 우리는 눈앞의 못(느린 속도)을 보고, 가장 가까이 있는 망치를 집어 들었을 뿐이다.
  • 단순함(Simple)은 '하나의 꼬임(one-fold)'을 의미한다. 이는 여러 요소가 복잡하게 얽혀있지 않은 상태, 즉 하나의 역할, 하나의 개념, 하나의 책임만을 갖는 본질적인 상태를 말한다. 그 반대는 여러 실이 뒤엉켜 풀 수 없게 된 '복잡함(complex)'이다.

우리의 근본적인 문제는 데이터 접근 방식이 처음부터 '복잡하다'는 것이었다. 하나의 계약을 갱신하기 위해 20개가 넘는 테이블 전체를 스캔해야 하는 로직 자체가 여러 책임과 맥락이 뒤얽힌 복잡성의 결정체였다. 첫 번째 해결책은 이 복잡한 구조를 그대로 둔 채, '쉬운' 병렬 처리 기술을 덧씌워 실행 속도라는 '증상'만을 완화한 것이었다. 우리는 비효율적인 작업을 더 빨리 수행하게 되었을 뿐, 작업 자체의 비효율을 제거하지는 못했다. 이는 마치 엉킨 실타래를 풀 생각은 않고, 그저 더 많은 실을 감아 겉보기에만 그럴듯하게 만든 것과 같았다.

차원 해결책 1: 병렬 처리 (쉬운 길) 해결책 2: 복합 인덱싱 (단순한 길)
핵심 해결 문제 증상 (실행 시간) 근본 원인 (데이터 접근 경로)
기저의 복잡성 높음 (비효율적인 FTS를 감추고 확장함) 낮음 (FTS 자체를 제거함)
자원 사용량 CPU 집약적, I/O에 대한 높은 스레드 경합 I/O 효율적, 최소한의 자원 사용
유지보수성 복잡한 동시성 프레임워크에 대한 이해 필요 표준 SQL 및 DB 지식 필요
해법의 미학 무차별 대입 - 더 큰 망치 외과적 정밀함 - 더 날카로운 메스

 

이 표는 두 가지 접근 방식의 철학적 차이를 명확히 보여준다. 우리는 I/O 대기가 문제의 본질임에도 불구하고, CPU를 더 많이 사용하는 방식으로 문제를 풀려 했다. 도서관 전체를 뒤져야 하는 비효율적인 업무 프로세스는 그대로 둔 채, 사서들에게 더 빠른 운동화를 신겨준 셈이다. 진정으로 우아한 해결책은 복잡함을 더 잘 관리하는 것이 아니라, 복잡함 자체를 제거하는 데서 나온다.

장인정신의 발현: '왜?'라는 질문의 무게

이러한 깨달음은 누구의 지시도, 어떤 비즈니스 요구사항도 아니었다. 그것은 더 나은 해결책이 존재할 것이라는 엔지니어로서의 직감이자, '충분히 좋음'에 안주하기를 거부하는 장인정신의 발현이었다. 드라이퍼스 기술 습득 모델에 빗대자면, 주어진 문제를 해결하는 '중급자(Competent)'의 단계를 넘어, 문제의 본질과 더 넓은 맥락을 이해하는 '숙련자(Proficient)'로 나아가려는 몸부림이었을지도 모른다. 숙련자는 단순히 규칙을 따르는 것을 넘어, 그 규칙이 왜 존재하는지, 그리고 때로는 그 규칙을 깨야 하는 이유를 이해한다.

 

여기에 후배가 시작한 개선 작업을 선배로서 더 나은 방식으로 마무리지어야 한다는 책임감도 한몫했다. 그가 훌륭하게 첫 전투를 승리로 이끌었다면, 나는 이 지루한 전쟁을 완전히 끝내야 할 의무가 있었다. 이 문제는 이제 해결해야 할 업무가 아니라, 지적 호기심을 충족시키고 기술적 완결성을 추구하기 위한 개인적인 도전 과제가 되었다. 나는 문제의 본질을 해결하기 위해, 유령을 완전히 퇴치하기 위한 마지막 구마 의식을 준비하기 위해 다시 한번 키보드 앞에 앉았다.

제4장: 최후의 구마 의식

기본으로의 회귀: 현미경과 나침반

두 번째 여정은 처음과 근본적으로 달랐다. 화려한 병렬 처리 프레임워크나 최신 기술 라이브러리를 검색하는 대신, 나는 가장 기본적인 도구 두 가지만을 챙겨 들었다. 하나는 시스템의 가장 작은 세포까지 들여다볼 수 있는 '현미경', 즉 데이터베이스 스키마와 비즈니스 로직 그 자체였고, 다른 하나는 길을 잃지 않게 해 줄 '나침반', 즉 "왜?"라는 근본적인 질문이었다.

 

나는 기존의 가설과 제약, 심지어 1차 최적화의 성공 경험까지 모두 백지상태로 되돌렸다. 마치 처음 보는 시스템을 분석하는 신입의 마음으로, 테이블 구조와 데이터의 흐름, 그리고 그 사이를 흐르는 쿼리들을 면밀히 재검토했다. 이전의 접근이 '어떻게 하면 이 느린 작업을 더 빨리 실행시킬까?'에 대한 고민이었다면, 이번의 질문은 '애초에 이 작업은 왜 느릴 수밖에 없는가?'로 바뀌었다. 문제는 데이터에 있고, 답 또한 데이터 안에 있을 것이라는 믿음, 그것이 유일한 등대였다.

유레카의 순간: 범죄 현장의 재구성

결정적인 돌파구는 시스템을 하나의 거대한 범죄 현장으로 보고, 용의자(기존계약번호)의 행적뿐만 아니라 범행 패턴 전체를 분석하면서 찾아왔다. 계약번호가 유일한 식별자는 맞지만, 데이터를 조회할 때 사용할 수 있는 유일한 '단서'는 아니라는 사실을 깨달은 것이다. 각 테이블에는 계약번호 외에도 계약일, 멤버십 종류, 상태 코드, 계약 시작일 및 종료일 등 다양한 속성들이 마치 현장에 남겨진 지문처럼 존재했다.

그리고 가장 중요한 발견은, 우리가 매일 아침 처리해야 할 1,000여 건의 데이터가 무작위로 흩어진 집합이 아니라, 명확한 시간적 패턴을 따르는 연쇄 사건이라는 점이었다. 비즈니스 로직을 형사의 집요함으로 파고들자, 수정 대상이 되는 20여 개 테이블의 데이터가 원본 계약이 생성된 '계약일'로부터 3일 이내에 함께 생성된 데이터라는 사실이 드러났다.

 

이것은 단순한 단서를 넘어, 사건의 전모를 밝혀줄 결정적 증거였다. 우리는 더 이상 수억 건의 데이터가 쌓인 광활한 도시 전체를 이 잡듯 뒤지며 용의자를 찾을 필요가 없었다. '3일'이라는 명확한 시간과 장소로 용의자의 동선이 특정된 것이다. 탐색 범위는 도시 전체에서 작은 동네 몇 군데로 획기적으로 좁혀졌다. 이제 우리에게 필요한 것은 그 동네를 가장 효율적으로 수색할 수 있는 지도, 즉 새로운 인덱스 전략이었다.

기술 심층 탐구: 전화번호부와 파르테논 신전 - 복합 인덱스 설계의 미학

이 결정적 단서를 활용할 무기는 바로 복합 인덱스(Composite Index)였다. 이는 둘 이상의 컬럼을 묶어 하나의 정렬된 지도를 만드는 기술이다. 이 기술의 정교함을 이해하기 위해, 우리는 '전화번호부'라는 익숙한 비유에서 시작해 '파르테논 신전'이라는 건축학적 비유로 나아가 볼 필요가 있다.

전화번호부와 좌측 접두사 규칙: B+Tree의 물리적 현실

복합 인덱스의 작동 원리는 '성(Last Name), 이름(First Name)' 순으로 정렬된 전화번호부와 정확히 일치한다. 이 규칙을 좌측 접두사 규칙(Left-Prefix Rule)이라 부른다.

  • WHERE last_name = '김' : 매우 효율적이다. 전화번호부의 'ㄱ' 섹션으로 바로 가면 된다.
  • WHERE last_name = '김' AND first_name = '민준' : 역시 효율적이다. '김'씨들을 찾은 뒤, 그 안에서 '민준'을 찾으면 된다.
  • WHERE first_name = '민준' : 완전히 비효율적이다. '민준'이라는 이름은 책 전체에 흩어져 있으므로, 전화번호부 전체를 한 장씩 넘겨봐야 한다.

이 규칙은 데이터베이스의 인덱스가 실제로 B+Tree라는 자료구조로 구현되기 때문에 발생한다. B+Tree는 데이터를 물리적으로 정렬하여 저장하는데, (계약일, 계약번호) 순서의 인덱스는 데이터를 먼저 '계약일' 순으로 차곡차곡 쌓고, 같은 계약일 안에서는 다시 '계약번호' 순으로 정렬해 놓는다. 데이터베이스가 계약일 정보 없이 계약번호만으로 데이터를 찾으려는 시도는, 첫 글자를 모른 채 사전을 찾으려는 것과 같은 막막한 일이 되어버린다. 결국 데이터베이스는 지도를 포기하고 마을 전체를 걸어 다니는 풀 테이블 스캔(Full Table Scan)을 선택할 수밖에 없었던 것이다.

파르테논 신전과 컬럼 순서의 미학: 카디널리티와 선택도의 예술

그렇다면 복합 인덱스의 컬럼 순서는 어떻게 정해야 할까? 여기서 우리는 데이터베이스 설계자에서 고대 그리스의 건축가로 변신해야 한다. 가장 중요한 원칙은 가장 선택도가 높은(highly selective) 컬럼을 맨 앞에 두는 것이다.

  • 카디널리티(Cardinality): 컬럼이 가진 고유한 값의 개수다. 모든 값이 다른 주민등록번호는 카디널리티가 매우 높고, '남/여' 두 값만 가진 성별은 매우 낮다.
  • 선택도(Selectivity): 특정 값으로 조회했을 때, 얼마나 적은 수의 행이 선택되는지를 나타내는 척도. 카디널리티가 높을수록 선택도도 높다.

복합 인덱스를 설계하는 것은 파르테논 신전을 짓는 것과 같다. 가장 넓고 튼튼하며 하중을 잘 견디는 도리아식 기둥(가장 선택도가 높은 컬럼)을 건물의 가장 아래, 즉 인덱스의 맨 앞에 놓아야 한다. 그 위에 좀 더 장식적인 이오니아식 기둥(다음 선택도 컬럼), 그리고 가장 위에는 화려한 코린트식 기둥(가장 선택도가 낮은 컬럼)을 배치하는 순서다.

복합 인덱스를 설계하는 것은 파르테논 신전을 짓는 것과 같다.

 

데이터베이스의 뇌, 즉 쿼리 옵티마이저(Query Optimizer)는 이 견고한 구조를 아주 좋아한다. 옵티마이저는 맨 앞의 도리아식 기둥(계약일 BETWEEN 어제 AND 오늘) 조건으로 수백만 개의 데이터 행을 수백 개로 단번에 걸러낸다. 그리고 그 좁혀진 결과 집합 내에서만 다음 이오니아식 기둥(계약번호 = '...') 조건으로 다시 한번 거르는 방식으로 극도의 효율을 추구한다. 이는 비효율적인 랜덤 I/O를 최소화하고, 예측 가능한 순차 I/O로 작업을 전환하여 I/O 비용을 극적으로 낮추는, 데이터베이스 세계의 연금술과도 같다.

 

이 원칙에 따라 나는 20여 개의 각 테이블 특성을 분석했다. 그리고 (계약일, 멤버십이름, 기존계약번호) 와 같이, 조회 범위를 가장 효과적으로 좁힐 수 있는 컬럼들의 최적의 조합과 순서를 하나하나 설계했다. 이는 단순히 인덱스를 추가하는 행위가 아니라, 각 테이블의 데이터 접근 경로를 새로 디자인하는 건축 행위에 가까웠다.

협업의 촉매: DBA와의 두 번째 대화

완벽하게 설계된 20여 개의 복합 인덱스 설계안과, 이 인덱스를 사용했을 때와 아닐 때의 예상 실행 계획(Execution Plan) 분석 자료를 들고 나는 다시 DBA를 찾아갔다. 이번의 대화는 첫 번째와는 질적으로 달랐다. 나는 더 이상 "인덱스가 필요합니다"라는 막연한 요구를 하는 개발자가 아니었다.

 

"이 테이블에는 (계약일, 멤버십이름) 순서의 복합 인덱스를 제안합니다. 계약일 조건으로 조회 대상을 99% 이상 줄일 수 있어 선택도가 매우 높고, 이를 통해 풀 테이블 스캔을 인덱스 범위 스캔(Index Range Scan)으로 변경할 수 있습니다. 또한, 계약일을 인덱스 선두에 둠으로써 쓰기 작업이 인덱스의 특정 '핫스팟'에 몰리는 현상을 방지하고, 과거에 우려하셨던 쓰기 부하와 잠금 경합(Lock Contention) 문제 역시 최소화할 수 있습니다. 이로 인한 성능 향상 효과가 쓰기 작업의 유지보수 비용을 압도적으로 상회할 것으로 기대됩니다."

 

이는 DBA의 전문성과 그들의 우려(쓰기 부하, 유지보수)를 깊이 존중하고 이해했음을 보여주는 접근이었다. 개발자가 데이터베이스의 원리를 이해하고 DBA의 언어로 소통할 때, 그들은 더 이상 요청자와 승인자라는 수직적 관계가 아닌, 시스템의 성능을 함께 고민하는 수평적 파트너가 될 수 있다. 사일로(silo)가 허물어지고 진정한 데브옵스(DevOps) 문화가 싹트는 순간이었다. DBA는 제안을 흔쾌히 받아들였고, 우리는 함께 인덱스 생성 작업을 진행했다.

최종 결과: 유령의 소멸

새로운 복합 인덱스가 적용되고, 배치 애플리케이션의 쿼리가 이 인덱스를 사용하도록 수정한 뒤, 결과는 놀라웠다. 10분 넘게 걸리던 작업이 1분 미만으로 단축되었다. 최초 45분과 비교하면 98% 이상의 성능 개선을 이룬 것이다. 시스템 자원 사용량은 극적으로 줄었고, 매일 아침 우리를 괴롭히던 45분의 유령은 마침내 한 줌의 재도 남기지 않고 완전히 소멸했다. 이 개선은 단순히 빨라진 것을 넘어, 시스템의 안정성과 신뢰도를 높이고 잠재적인 운영 리스크를 제거하는, 측정 가능한 비즈니스 가치를 창출했다.

단계 접근 방식 사용 기술 처리 시간 개선율 누적 개선율
초기 상태 해당 없음 단일 스레드 풀 테이블 스캔 약 45분 - -
1단계 병렬 처리 Java Fork/Join Framework 약 10분 약 78% 약 78%
2단계 근본적 DB 튜닝 복합 인덱스 (Composite Indexes) 1분 미만 1단계 대비 90% 이상 초기 대비 98% 이상

결론: 단순함과 장인정신에 대한 교훈

이 길었던 유령 퇴치 작업의 끝에서, 나는 몇 가지 중요한 교훈을 얻었다. 45분에서 10분으로의 단축은 노력의 승리였다. 손에 잡히는 '쉬운(easy)' 도구를 기민하게 적용하여 가시적인 성과를 냈고, 팀에 새로운 활력을 불어넣었다. 하지만 10분에서 1분 미만으로의 단축은 사고(思考)의 승리였다. 문제의 본질을 파고들어 얽혀있지 않은 '단순한(simple)' 해법을 찾아냈기 때문이다. 첫 번째 해결책이 울퉁불퉁한 길을 더 빨리 달리게 해주는 강력한 엔진을 단 것이었다면, 두 번째 해결책은 길 자체를 매끄럽게 포장하는 작업에 가까웠다. 더 이상 강력한 엔진이 필요 없을 정도로 말이다.

 

이 경험은 소프트웨어 공학의 세계에서 가장 화려하고 복잡한 기술이 항상 최고의 해결책은 아님을 다시 한번 각인시켜 주었다. 데이터베이스가 실제로 어떻게 데이터를 읽고 쓰는가에 대한, 어찌 보면 고루하게 느껴질 수 있는 근본 원리의 이해는, 최신 프레임워크를 피상적으로 적용하는 것보다 훨씬 더 강력한 힘을 발휘한다. 마치 유행하는 요리법을 수십 개 아는 것보다, 소금과 불을 다루는 기본기를 마스터한 요리사가 결국 더 훌륭한 음식을 만드는 것과 같은 이치다. 우리가 적용한 복합 인덱스는 수십 년 된 기술이었지만, 문제의 핵심을 정확히 겨눴기에 그 어떤 최신 병렬 처리 기술보다 우아하고 효과적이었다. 이는 "단순하게, 바보야(Keep it simple, stupid)"라는 KISS 원칙의 정수를 보여준다. 가장 아름다운 해결책은 종종 가장 적은 부품으로, 가장 조용하게 작동한다.

 

진정한 엔지니어링의 탁월함은 주어진 업무를 완수하는 데 그치지 않는다. 그것은 "왜?"라고 질문하는 지적 호기심과, '이만하면 됐다'는 안일한 타협점을 넘어 문제의 진정한 해결에 도달할 때까지 파고드는 주인의식(Ownership)에서 비롯된다. 이는 단순히 코드를 작성하는 '코더'와 시스템의 건강을 책임지는 '엔지니어'를 가르는 결정적 차이다. 컴퓨터 과학의 거장 도널드 크누스(Donald Knuth)는 "최고의 프로그램은 기계가 빠르게 실행할 수 있도록, 그리고 동시에 인간이 명확하게 이해할 수 있도록 작성된 것"이라고 말했다. 우리의 두 번째 해법은 기계의 부담을 극적으로 줄였을 뿐만 아니라, 그 논리가 데이터의 본질적 패턴에 명확히 기대고 있기에, 훗날 이 시스템을 마주할 다른 개발자가 이해하기에도 훨씬 쉬운, 좋은 프로그램에 더 가까워졌다.

 

마지막으로, 이 모든 과정은 기술만으로는 결코 완성될 수 없었다는 점을 강조하고 싶다. 후배 개발자에게는 스스로의 힘으로 문제를 분석하고, 때로는 실패하더라도 다시 도전하며, 마침내 성공의 기쁨을 맛볼 수 있는 안전한 환경을 제공하는 멘토십이 있었다. 또한, 데이터베이스 관리자와의 관계는 이 이야기의 숨은 주인공이다. 첫 번째 대화가 전문 영역이라는 보이지 않는 벽을 사이에 둔 요청과 거절이었다면, 두 번째 대화는 각자의 전문성을 존중하며 공동의 목표를 향해 나아가는, 진정한 협업의 시작이었다. 이는 개발자가 데이터베이스의 원리를 이해하고 DBA의 언어로 소통할 때, 그들이 더 이상 문지기와 침입자가 아닌, 시스템이라는 배를 함께 항해하는 동료가 될 수 있음을 보여주는 작은 데브옵스(DevOps)의 실천 사례이기도 했다.

 

결국 45분의 유령은 은 탄환으로 잡아야 할 괴물이 아니었다. 그것은 우리에게 기술 부채의 이자가 얼마나 무서운지, 무차별 대입의 한계가 어디까지인지, 잘 설계된 인덱스 하나가 얼마나 조용하고 강력한 힘을 발휘하는지, 그리고 복잡한 세상 속에서 단순함을 추구하는 것이 얼마나 가치 있는 일인지를 가르쳐준, 고마운 스승이었다. 그 유령 덕분에 우리는 더 나은 엔지니어가 되었고, 우리 팀은 더 단단해졌다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함