카테고리

스토리

사람 손이 붙어 있던 다섯 곳을 끊다: 설정 드리프트·변경 리뷰·인덱스 판정·인시던트 리포트·월간 점검

이기종 DBMS 운영 관리 플랫폼 DBTower 9편. 현업 DBA의 병목이 어디에 남는지를 렌즈로, 사람 손이 선형으로 붙던 다섯 지점을 기능으로 끊었습니다. (1) 설정 드리프트 이력: 파라미터 diff의 공간축("A와 B가 다른가")에 시간축("언제부터 무엇이 바뀌었나")을 붙였습니다. 거울 테이블과 변경 로그로 무변경 주기엔 스냅샷 한 줄만 쌓이게 했고, work_mem 4096→8192 실변경을 감지해 카드를 쐈습니다. 검증 중 MongoDB parameters()가 $clusterTime 같은 응답 gossip 필드를 흘려 매번 오탐이 나던 기존 버그도 잡았습니다. (2) 스키마 변경 리뷰 게이트: 배포 전 DDL을 규칙으로 판정(락 위험·DEFAULT 없는 NOT NULL·DROP·WHERE 없는 대량 변경)하고 실제 행수로 락 위험을 확정한 뒤 AI 1차 소견을 붙여 ADMIN 승인·자동 감사까지. 실행은 하지 않고 gh-ost 경로만 안내합니다. (3) 인덱스 사용 통계 주기 영속: "이 인덱스 지워도 되나"는 재시작 누적 카운터의 순간값으론 못 답합니다. 5기종 스캔 통계를 6시간 주기로 영속하고(Oracle은 미지원 정직), lakehouse가 first-vs-last 델타·리셋 클램프로 분기 창 판정 마트를 짓습니다. (4) 인시던트 리포트: 장애 구간을 주면 시점 비교·설정 변경·플랜 플립·대기·가용성을 한 장으로 재구성하고 AI가 재료 내 사실만으로 요약합니다. (5) 월간 점검 리포트: 헬스·백업·Advisor·용량·낭비·설정 변경을 매월 자동 발행합니다. 다섯 개 전부 읽고 판정·기록까지가 몫이고 대상 DB는 바꾸지 않습니다. 신규 모듈 하나(review)는 이벤트로 alert에 카드를 위임하고, 공개 파사드 둘(score·finops)로 Modulith 경계를 순환 없이 유지했습니다. 테스트 514건, VERIFICATION 110개 절.

관제탑과 대화하고 두 저장소를 잇다. 이모지로 진단을 부르고, 창고가 계산한 평소로 오탐을 지운다

이기종 DBMS 운영 관리 플랫폼 DBTower. 앞부분은 알림에서 진단까지의 왕복을 완성합니다. 회귀·이상·운영 경보가 밋밋한 텍스트에서 구조화된 Discord embed 카드가 됩니다(심각도 색·담당 팀·AI 1차 분석·질문이 미리 채워진 진단 딥링크). 알림에 돋보기 이모지를 달면 봇이 그 인스턴스를 AI로 진단해 답글을 붙이죠. 여기엔 함정이 둘 있었습니다. 왼쪽 돋보기(U+1F50D)와 오른쪽 돋보기(U+1F50E)가 서로 다른 유니코드라는 점, 그리고 웹훅이 쓴 메시지의 embed를 봇이 읽으려면 특권 인텐트가 필요하다는 점입니다. 후자는 발사 시점에 message_id를 인스턴스에 매핑해 권한 0개로 풀었습니다. 대상이 하필 죽어 있어 진단 도구가 전부 빈손이었을 때, AI 답글은 수치를 지어내는 대신 "근본원인을 확정하지 못했습니다"라고 답했습니다. 이번 실측에서 가장 인상적인 대목이었습니다. MCP 정적 토큰은 OAuth 2.1 브라우저 로그인으로 바꿨고(302 대신 401 함정, redirect_uri userinfo 우회), Vault 동적 자격증명으로 유출 창을 TTL 2분으로 줄였습니다. 뒷부분은 lakehouse(장기 분석계)와의 루프를 양방향으로 닫습니다. 받아오는 쪽에서는, lakehouse가 수개월 이력으로 계산한 요일×시간대 베이스라인을 V24 테이블로 받아 14일 창에 충분통계량 복원(Σx=n·m, Σx²=(n−1)s²+n·m²)으로 가중 병합했습니다. 실측 스파이크(psql 3,000회)의 판정이 장기 테이블 내용에 따라 뒤집혀 z=7.42로 발화했고, 관측 수가 101(장기 100+단기 1)로 찍히며 병합이 실제로 작동한 자국이 남았습니다. 내보내는 쪽에서는, 대기 이벤트(V25)와 오브젝트 크기(V26)를 주기 영속하는 잡을 신설하고 plan_snapshot 보존에 48시간 하한을 병행했습니다. 자연어 서빙은 Metabot이 Cloud 전용이라 생긴 갭을 MCP 도구 두 개(장기 마트 SELECT·Metabase 카드 생성)로 메웠고, 카드 76이 실제로 생성되어 bar 차트가 143ms에 렌더됐습니다.

7일이면 버려지는 스냅샷을 장기 이력으로 살려낸 dbtower-lakehouse 실측 총정리

DBTower가 7일 뒤 버리는 쿼리 스냅샷을 컬럼형 저장소로 내려 장기 이력을 만드는 ELT 파이프라인의 전체 기록을 한 편에 정리합니다. 오케스트레이션은 Airflow, 적재는 MinIO의 Parquet입니다. 변환은 dbt가 맡고 질의는 DuckLake로 합니다. 문제 정의(7일 시야로는 '지난달 대비 느려진 쿼리'에 못 답함)에서 시작해, 원천·적재·조회 3자 일치로 검증한 멱등 추출(닫힌 창 07-05=149,259·07-06=79,894), 누적 카운터를 일간 델타로 접는 변환, 조용한 오답을 막는 4축 fail-closed 게이트, lake를 house로 올리는 DuckLake 타임트래블, 아카이브가 자신을 지우던 치명 결함의 차단, 1년치를 합성해 407.62초 재빌드를 4초로 줄인 규모 실측, Kafka를 넣지 않은 근거를 담았습니다. 여기에 남이 그대로 띄우는 셀프호스트 어플라이언스, 두 저장소가 손잡는 되쓰기, 그리고 이 창고만 할 수 있는 여섯 가지 판정(용량 D-day·플랜 회귀·백업 공백·미사용 인덱스·설정 변경 상관·가용성 SLO)까지, 파이프라인이 답을 만드는 공정에서 판정을 내리는 창고로 자란 과정을 이었습니다. 라이브에서 MSSQL 두 인스턴스가 63퍼센트대 가용성에 평균 ping 2에서 7초로 목표 미달, 나머지는 99.9퍼센트로 목표를 지킵니다. 모든 수치는 직접 측정했고 재현 기록이 저장소에 있습니다.

창고가 데이터를 내리는 데서 판정을 내리는 데까지, 여섯 가지 판정을 채우다

창고에 데이터를 내리기만 하고 판정을 안 하고 있었습니다. plan_snapshot과 fct_query_daily를 둘 다 갖고도 상관시키지 않아 신고마다 30분씩 플랜 이력을 뒤졌고, 백업 공백은 판정 컬럼이 없어 복구하다 발견했습니다. 셋 다 신규 수집 없이 이미 내린 데이터를 판정 컬럼까지 잇는 일이었습니다. 플랜 회귀는 일 단위 대표 플랜의 뒤집힘을 전후 N일 지연과 겹치되 관측이 덜 차면 PENDING, 창이 오염되면 AMBIGUOUS로 지어내지 않고, 백업 공백은 유니버스를 query 팩트에서 잡아 기록 없는 인스턴스도 행으로 드러내며 기준일을 벽시계가 아닌 창고 최신 dt로 잡습니다. 이 셋을 주간 보고 한 장으로 접었습니다. 그리고 판정에 "왜"가 빠졌음을 깨달아 설정 드리프트를 원인 후보로 붙이고, 상관을 플랜 뒤집힘 한 축에서 지연·볼륨까지 넓히고, change_review는 저빈도라 자리만 열어 두었습니다. 여러 마트가 달던 "기종 축이 없다"는 각주는 이미 읽던 database_instance의 name·type로 회수해 instance_id 1이 local-mysql (MYSQL)로 읽히게 했고, 마지막으로 DBTower가 35일 뒤 지우는 up 여부를 장기 가용성 SLO로 만들었습니다. 라이브에서 MSSQL 두 인스턴스가 63퍼센트대 가용성에 평균 ping 2에서 7초로 목표 미달, 나머지는 99.9퍼센트로 목표를 지킵니다. 이걸로 관리 대상 DB에 관해 창고가 답할 판정 여섯이 한 바퀴 찼습니다. 발화는 여전히 남에게 맡기고 판정 컬럼까지만 정직하게 계산합니다.

폰에서 Claude를 돌리려고 텔레그램 봇을 만들었다

이미 쓰던 Claude 구독을 폰에서도 쓰고 싶어서 만든 봇입니다. API 키로 따로 과금하는 대신 Claude Code 구독 인증(claude -p)을 그대로 호출하고, 메시지를 보내면 작업 과정을 실시간으로 흘려줍니다. 디스코드 봇을 이미 돌리고 있으면서도 이번엔 텔레그램을 고른 이유, 코딩 모드에서 확인 없이 파일을 고치는 대신 넣은 취소 장치, 같은 서버의 다른 서비스를 못 건드리게 막은 격리까지 정리했습니다.

화면 공유를 켜야 출석으로 친다: 디스코드 스터디 출석봇

알고리즘 스터디의 출석 체크를 봇에게 넘긴 기록입니다. 음성채널에 들어와 있는 시간이 아니라 화면 공유를 켠 시간만 구간 합산으로 세고, 누적 60분이면 출석, 주 3회 미달이면 경고, 경고 3회면 자동으로 자리를 정리합니다. 공지는 실시간이지만 판정은 정산 크론에서만 하도록 갈라 둔 덕에 봇이 재시작돼도 기록이 안 깨집니다. 후반부는 '시간대 제한을 풀고 24시간 돌리자'는 설정 한 줄짜리 변경인 줄 알았던 일이 함정 세 개(24:00이라는 크론에 없는 시각, 일요일 출석을 못 보는 주간 점검, DB에 남은 옛 세션 키로 죽는 재시작 복구)로 번진 이야기입니다.

여럿이 쓰는 관제탑: 팀 경계와 멀티노드, 그리고 호스트 차원

이기종 DBMS 운영 관리 플랫폼 DBTower의 멀티테넌시와 호스트 차원 기록입니다. 앞부분은 여러 팀이 한 콘솔을 쓰기 시작하는 국면을 다룹니다. 팀 사용자는 자기 팀 인스턴스와 전역만 보고, 남의 팀 인스턴스는 id로 직접 찔러도 403이 아니라 404를 받습니다(존재 자체를 숨김). 강제 지점은 단 한 곳(RegistryService)입니다. 세션을 메타 DB로 옮겨 재시작에도 로그인이 살아남게 했고, 그 과정에서 Boot 자동구성이 인메모리로 조용히 폴백하는 함정을 밟았습니다. 이렇게 준비한 노드를 실제로 늘렸더니, 분산 락 하나 때문에 두 번째 노드가 놀고 있었습니다. 샤드별 락으로 바꾸니 두 노드가 수집을 나눠 들고, 한 노드를 죽이면 남은 노드가 설정 변경 없이 전 샤드를 인수하며, 같은 쿠키로 로그인도 유지됩니다. 로그인 잠금 카운터도 메타 DB로 옮겨, 노드 A에서 두 번·B에서 한 번 틀리자 네 번째가 잠기는 것을 실측했습니다. 최대 볼륨 테이블은 월별로 파티셔닝해 보존 정리를 DELETE 1.9초에서 DROP 12.8ms로 줄이고 블로트를 아예 없앴으며, 커넥션은 온디맨드로 바꿔 격리 대상의 유휴 커넥션을 1개 영구에서 0으로 만들었습니다. 뒷부분은 호스트 차원입니다. 디스크 포화 예측은 잔량이 아니라 속도를 봅니다. 여유가 76.8%나 남았는데 치명 경보가 뜨는 화면을 실쓰기 부하로 직접 만들었습니다. 초당 17MB씩 줄고 있으면 20시간 뒤 장애이므로 이 경보가 맞습니다. 여기서도 node-exporter가 rootfs 마운트 없이 컨테이너 자기 자신만 보고 있던 함정과, mountpoint="/" 고정이 데이터 전용 마운트를 쓰는 실무와 어긋나는 설계 함정을 만났습니다. 마지막은 서버 공유 인지입니다. 등록 단위는 DB인데 물리 단위는 서버라, 같은 서버에 DB 두 개를 등록하면 세션·복제·데드락 경보가 두 번 울립니다. 이를 그룹당 1회로 줄이되 "누구에게 해당하는지"를 명시하고, 헬스 스코어(위험 귀속)는 일부러 dedup하지 않은 선 긋기를 기록했습니다.

아무도 못 쓰던 프로젝트를 셀프호스트 제품으로 끌어올리고, 화면 패리티까지 맞추다

이기종 DBMS 운영 관리 플랫폼 DBTower 프로덕션화·화면 패리티 편. 기능은 레퍼런스로 삼은 사례를 넘어섰는데, 정작 "남이 클론해서 실제로 쓸 수 있나"를 물으니 답이 아니었습니다. 블로커는 넷이었습니다. 라이선스가 없어 법적으로 아무도 못 썼습니다. 암호화 fail-closed가 하필 셀프호스트가 쓰는 docker 프로필만 비껴가, 대상 DB 비밀번호가 평문으로 저장됐습니다. 비밀번호 컬럼이 든 옛 H2 파일은 커밋된 채였고, AI 판단 규칙 파일은 이미지에서 빠져 빈 프롬프트로 돌고 있었습니다. Phase 0(배포 블로커) 넷을 없애고, 관제탑이 자기 자신을 지키기 시작하고(로그인 잠금·메타 백업·웹 HTTPS), 문의에 참조 테이블 스키마를 붙였습니다. 이어서 레퍼런스 발표의 화면 11장을 컬럼 단위로 전수 대조하며 표 컬럼 패리티를 맞췄고, 그 과정에서 함정 셋을 만났습니다. 단위 테스트 382건이 초록인데도 웹 콘솔 전체가 백화된 채 커밋돼 있었습니다. "카탈로그 재구성(근사)" 배지는 알고 보니 게으름의 라벨이었습니다. CPU 그래프를 붙이다 보니 활동 그래프가 9시간 미래의 빈 구간을 조회하고 있었습니다. 전부 라이브 실측과 함께 기록합니다.

백업이 진짜가 되기까지: 로그 백업 5기종과 시점 복구, 그리고 정석까지의 네 걸음

이기종 DBMS 운영 관리 플랫폼 DBTower 6편. 백업 대장정입니다. 전반부는 로그 백업이 MSSQL만 되던 것을 MySQL binlog, PostgreSQL WAL, MongoDB oplog, Oracle 아카이브 로그까지 다섯 기종 전부로 넓히며 "기종이 못 하는 것"과 "하다가 깨진 것"을 구분하는 UNSUPPORTED 상태를 만들고, 최신 파일 하나만 수집하면 체인에 조용한 구멍이 난다는 것을 "마지막 이전 전부" 보충 수집으로 고치고, 생성한 복원 안내문을 실제로 실행해 SQL Server와 PostgreSQL에서 목표 시점의 상태를 정확히 재현한 기록입니다. 후반부는 그걸 현업의 정석 쪽으로 옮겨 가는 네 걸음입니다. Mongo oplog 증분($gte로 일부러 만든 겹침 한 건이 체인 무결의 증거, 산출물 28분의 1), pg_receivewal 스트리밍(복제 슬롯 덕에 수신자를 죽여도 재시작 사이 유실 0, 그리고 죽은 프로세스를 산 것처럼 보이게 하던 docker exec -i 함정), MySQL 물리 백업 XtraBackup(MYSQL_PWD를 안 읽고 /dev/stdin defaults를 조용히 무시하는 함정 두 겹, 검증은 파일 존재가 아니라 실제 prepare 실행), 그리고 AES-256-GCM 산출물 암호화(변조된 백업은 조용히 오염되는 대신 명확히 실패한다)까지입니다.

남이 그대로 띄우게 만들고, 두 저장소가 되쓰기로 손잡다

여기까지 "전부 로컬에서 e2e 재현 가능"이라고 여러 번 써 왔는데, 남이 clone하면 아무것도 안 떴습니다. 원천이 없어 인스턴스 0개로 조용히 빈 결과가 나기 때문입니다. 그래서 데모 위성을 떼어내 셀프호스트 어플라이언스로 만들었습니다. docker-compose.standalone.yml과 demo 프로필로 DBTower 없이도 offload부터 게이트, dbt, 발행까지 e2e가 돌고, 격리 실측에서 외부 의존은 0이었습니다. 범용 도구로 넓히지는 않았습니다. DBTower를 셀프호스트하는 사람이 자기 관제 옆에 이 창고를 같이 띄우는 것까지가 목표라 초점을 좁힌 결정입니다. 그다음 두 저장소가 손을 잡았습니다. 원천을 query_snapshot 하나만 내리던 걸 테이블 스펙 레지스트리로 일반화해 백업·플랜·대기 이력까지 편입하는데, 전제가 셋 다 깨졌습니다. wait event는 원천에 영속 테이블이 없고, plan_snapshot은 카운트 기반 보존이라 하루가 닫히기 전 지워질 수 있고, backup_run은 verify가 나중에 UPDATE하는 사후 변이 테이블입니다. 그래서 워터마크·불변성·게이트 프로필을 스펙의 일부로 넣었고, 게이트를 그대로 재면 백업 안 도는 인스턴스가 정상인데도 completeness FAIL로 정상이 차단되는 걸 확인해 프로필로 그 축을 SKIP으로 남겼습니다. 방향이 반대인 일도 하나 했습니다. 장기 dow×hour 베이스라인을 계산해 원천 쪽으로 되쓰는 경로입니다. readonly 봉인을 깨지 않으려 별도 역할에 해당 테이블만 권한을 주고 단일 트랜잭션으로 32,498행 왕복을 실측했으며, 그 역할로 query_snapshot을 읽으면 permission denied가 나는 것까지 확인했습니다.

며칠치로는 규모를 증명 못 해서 1년치를 만들었고, 축을 돌려 다시 쟀습니다

지금까지 모든 실측은 닫힌 dt 세 개, 수십만 행에서 돌았습니다. 그 규모에선 전부 초 단위라 "규모에서도 버틴다"고 말하고 싶어지는데, 며칠치로는 증명이 안 됩니다. 마트(fct)는 매일 전체 이력을 다시 계산하는데 이력이 3일이라 안 아팠을 뿐입니다. 그래서 닫힌 dt를 날짜 시프트로 복제해 365dt×6인스턴스=2,190파일(54.5M행)을 격리 프리픽스에 만들어 어디가 먼저 무너지는지 쟀습니다. 병목은 fct 전체 재빌드 407.62초 하나였고, 나머지는 규모에서도 초 단위였습니다. 그 수치가 정당화하니 dt 단위 독립성을 살려 delete+insert로 증분화했고, 컴파일 타임 워터마크 리터럴로 파티션 프루닝을 걸어 407.62초를 4초로 줄였습니다. 그런데 규모의 축은 시간 하나가 아닙니다. 셀프호스터가 몇백 대를 관제하는 사람이라면 늘어나는 축은 dt가 아니라 인스턴스 수입니다. 로드맵에 "소파일이 먼저 무너지고 그다음 추출, 컴퓨트는 마지막"이라고 외삽해 뒀었는데 재보지 않은 문장이었습니다. 총량을 고정하고 축만 돌려 300대(52.2M행)로 다시 재보니, 증분 fct는 8.03초로 건재했고 급소는 소파일이 아니라 full-refresh였습니다. 제 외삽 두 개가 틀렸고, 그걸 수치가 바로잡았습니다. 최적화는 병목 수치가 정당화한 곳만 건드렸습니다.

코드보다 데이터 계약을 먼저 쓰고, 조용한 오답을 게이트로 막으며 파이프라인을 지었습니다

DBTower가 7일 뒤 버리는 쿼리 스냅샷을 컬럼형 저장소로 내려 장기 이력을 만드는 파이프라인을, 짓는 순서대로 담았습니다. DAG를 짜기 전에 무엇을 어떤 계약으로 옮길지부터 못박고, 원천 인덱스 선두를 타는 인스턴스별 등치 질의와 파티션 통째 덮어쓰기로 어제치를 안전하고 멱등하게 내립니다(닫힌 창 재실행에 79,894행 불변). 누적 카운터 raw로는 "느려진 쿼리"에 답할 수 없어, 지문 충돌 12,743키를 SUM으로 접고 하루 양 끝 차분에 리셋 클램프를 걸어 일간 델타로 바꿉니다. 반쪽 파티션 위 랭킹이 조용히 오답을 내는 걸 막으려 reconciliation·completeness·freshness·드리프트 4축 게이트를 다운스트림 앞에 세워 fail-closed로 dbt를 아예 실행하지 않게 했습니다(장애 주입 시 exit 2). 덮어쓰기만으로는 ACID도 타임트래블도 없어 lake일 뿐이라, 카탈로그를 이미 쓰던 PostgreSQL에 두고 데이터는 S3 parquet에 두는 DuckLake를 얹어 과거 버전 복원과 롤백을 실증했습니다. 막았다는 사실을 아무도 모르면 마트가 조용히 낡으니 컨테이너 안 dbt e2e·webhook·retry·CHECKPOINT로 운영을 경화했고, 마지막으로 Metabase가 DuckLake를 read-only로 읽게 해 처음 던진 "지난달 대비 느려진 쿼리"에 화면이 답하게 했습니다. 모든 수치는 직접 측정했습니다.

DB 내부 ⑫ (회고): 나는 왜 DB를 밑바닥부터 만들었나, 만들어서 증명한 것들

이 글은 db-hobby의 회고이자, 이 시리즈 전체의 포트폴리오다. 출발점은 하나의 자각이었다. '인덱스는 B+Tree라 빠르다'고 말할 수는 있는데, 왜 이진 트리가 아니라 B+Tree인지, 커밋의 fsync가 정확히 무엇을 보장하는지 설명하지 못했다. 설명할 수 없으면 모르는 것이다. 그래서 페이지 한 장부터 Raft 합의까지 C로 직접 만들었고, 모든 주장을 직접 측정한 수치로 바꿨다: 인덱스는 10만 행에서 풀 스캔보다 416배 빨랐고, 같은 5천 행 적재가 fsync 횟수에 따라 23배 갈렸고, 12코어 병렬화는 2.16배가 천장이었는데 그 천장의 정체(버퍼 풀 latch의 I/O 직렬화)를 A/B 실험으로 확증하고 고치자 콜드 스캔이 4워커 기준 1.08배에서 2.39배가 됐다(8워커는 0.62배에서 1.36배). 성능 작업 중 조용히 틀린 답을 내던 집계 버그를 발굴했고, 구현과 검증을 분리한 적대적 리뷰가 '테스트는 초록인데 원리상 틀린' 합의 버그 일곱을 잡았다. 694개 테스트, ThreadSanitizer/ASan 계측, 크래시 주입, 결정적 시뮬레이션. 무엇을 만들었는지가 아니라 어떻게 검증했고 왜 그 선택을 했는지의 기록이다. 무엇을 안 했는지(정직한 경계)까지 포함해서.

DBTower 포트폴리오 총정리: 이기종 DBMS 5기종을 인터페이스 하나로 관제·진단하기까지, 실측 117절

MySQL, PostgreSQL, SQL Server, Oracle, MongoDB를 하나의 관제탑에서 등록하고 진단하고 백업하고 자율 감시하는 컨트롤 플레인 DBTower의 포트폴리오 총정리입니다. 도구 파편화와 DBA 반복 문의라는 문제 정의에서 출발합니다. 추상화 경계를 SQL이 아니라 운영 행위에 그은 설계 결정과, 그 결정을 성격이 정반대인 두 기종을 실제로 추가해 코어 0줄 수정으로 증명한 과정을 담았습니다. 자기 자신을 관리 대상으로 등록해 자기 풀스캔을 잡은 도그푸딩(21.269ms에서 0.062ms), 따옴표 하나로 인덱스가 죽는 암시적 형변환을 추정 대 실제 괴리 300배로 지목하고 정합성 사고까지 증명한 심층 진단, FULL 앵커와 LOG 체인이 병행하는 정석 백업과 실제 시점 복구, 결함 20건 이상을 스스로 감사해 FIX와 SKIP을 가른 하드닝까지. 모든 성능 수치는 개선 전후를 직접 잰 실측이고, 재현 절차는 저장소 VERIFICATION.md 117개 절에 있습니다.

v1.0.0 이후, 다섯 기종을 더 깊이 판 심화 아크들과 내가 만든 걸 감사한 기록

이기종 DBMS 운영 관리 플랫폼 DBTower 심화 편. v1.0.0을 찍은 뒤 문서에 정직한 잔여로 남겨둔 것들을 다시 붙잡았습니다. 그중 셋을 닫았습니다. 쿼리도 데이터도 그대로인데 갑자기 느려지는 플랜 플립을 PostgreSQL 16의 GENERIC_PLAN으로 감지하고, 로컬 백업을 S3 호환 오프사이트로 올려 3-2-1을 채웠으며, TLS 강제 관리형 서비스에 붙되 인증서 검증 우회 옵션은 일부러 만들지 않았습니다. 여기서 심화 아크 넷으로 들어갑니다. 플랜 플립은 기종마다 다른 획득 경로를 shape 정규화 한 겹으로 통일해 다섯 기종으로 넓혔고, p95의 정직 등급은 누적에서 최근 구간으로, 미지원에서 추정으로 끌어올리되 못 올리는 Oracle은 라벨로 대비시켰습니다. 설정 변경 없이 세 기종에서 데드락을 읽었고, 관제가 부하가 되지 않도록 스케일을 다섯 축으로 제어했습니다. 끝으로 만든 것을 스스로 감사해, 동시성·정확성·보안·수명주기 네 축을 훑고 OWASP·CWE·벤더 문서와 대조해 FIX와 SKIP을 갈랐습니다.

인증부터 셀프호스트 v1.0.0까지, 운영 안전 8축으로 프로덕션에 올리다

이기종 DBMS 운영 관리 플랫폼 DBTower의 운영 안전 편입니다. 네트워크에 닿는 누구나 인스턴스를 등록·삭제·백업할 수 있던 결격 사유에서 출발해, 세션+토큰 이중 인증과 역할 분리, 비밀번호 AES-256-GCM 암호화, ddl-auto 드리프트의 Flyway 이관(Boot 4 스타터의 조용한 미실행 함정 포함), 실측으로 확정한 최소 권한 계정, 스케줄러 분산 락, 복원 검증까지 운영 안전을 8개 축으로 닫았습니다. Phase C에서는 멱등 등록 PUT을 종점 삼아 K8s(CloudNativePG e2e)·Ansible(changed=0)·Terraform(validate)을 관제탑에 이었고, 진단이 대상 DB의 부하가 되지 않도록 쿼리 타임아웃·MongoDB 소켓 상한·죽은 DB 지수 백오프 가드레일을 달았습니다. 마무리는 SaaS의 네 벽을 피해 Grafana처럼 셀프호스트 배터리 포함 이미지로 찍은 v1.0.0입니다.

아카이브가 자기 자신을 지우던 치명 경로를 막고, CI와 deadman과 dbt contracts로 신뢰를 마저 묶은 이야기

코드 감사에서 받은 결함 목록의 1번이 치명이었습니다. offload의 멱등 재적재는 '파티션을 통째로 지우고 다시 쓴다'인데 삭제가 원천 0행 체크보다 먼저라, 원천 보존(7일) 밖의 dt를 backfill이나 Clear로 재실행하면 아카이브 유일본 parquet를 지운 뒤 아무것도 안 쓰고 exit 0으로 '성공'합니다. 1부에서 이 경로를 실제로 재현하고 fail-closed 가드(ArchiveSelfDestructError, exit 1 → 재시도·webhook 경로 탑승)로 막았습니다. 같은 감사에서 나온 나머지 셋, 곧 게이트의 원천 Seq Scan(332ms/31k버퍼 → 인스턴스별 인덱스 루프 20ms/76버퍼), publish 혼합 버전(개별 커밋 → 단일 트랜잭션), 유지보수 DAG의 데모 테이블 하드 참조도 걷어내고 pytest 35개로 고정했습니다. 2부는 그 신뢰를 커밋·침묵·계약 세 축으로 마저 묶습니다. CI(GitHub Actions 3관문: ruff·pytest·dbt)가 임베디드 DuckDB 덕에 MinIO도 PG도 없는 러너에서 tiny 픽스처 parquet로 dbt build를 e2e로 돌리고(PASS=25), dbt unit test로 델타 로직 엣지 4개를 정적 입력→기대 출력으로 못박고, deadman heartbeat가 30h 침묵을 실제 경보 발화로 잡고(기한 26h), dbt contracts가 마트 컬럼 타입을 DB 레벨로 강제해 latency_increase_ms를 VARCHAR로 바꾸자 빌드가 'data type mismatch'로 막혔습니다. 회귀는 없었습니다. verify는 ALL MATCH(149,259/79,894행), pytest는 53개 통과입니다.

분산 시스템, 먼저 제대로 배우고 Kafka를 만든다: MIT 6.5840

자작 스택(커널·DB·JVM)이 다 단일 노드라, 다음 축은 분산이다. 분산을 배우려면 그냥 분산 시스템 하나를 밑바닥부터 만들면 될 것 같지만 거기엔 치명적 구멍이 있다. 네트워크 분단·크래시 같은 실패를 스스로 테스트할 수가 없다. 그래서 MIT 6.5840로 fundamentals를 먼저 정면돌파하고, 그 토대 위에 Kafka를 짓기로 했다. 학습 플랜, Go를 고른 이유, 첫날 MapReduce 셋업까지.

왜 Kafka를 직접 만드나: 내 첫 분산 시스템

미니 RDBMS(db-hobby)를 만들며 남긴 WAL을 보다가 생각이 하나로 모였다. 이 로그를 주인공으로 승격시켜 여러 노드에 복제하면 그게 Kafka다. Java로 바닥부터 만드는 토이 Kafka 연재의 1편. 코드를 쓰기 전에 끝낸 설계 결정들(분산 로그가 뭔지, 왜 Java인지, 단일 브로커 먼저 왜 그런지)과, 이번엔 단위 테스트 대신 "상황 시뮬레이션"으로 검증하기로 한 이유를 정리한다.

DB 내부 ⑪: SQL 실행기, 텍스트에서 행까지, 조인 3형제와 3값 논리

SQL은 '무엇을'만 말하는 선언형 언어다. 그 텍스트가 실제 행이 되기까지의 파이프라인을 해부한다. 렉서(maximal munch, 예약어는 왜 생기나), 재귀 하강 파서(문법 규칙 하나 = 함수 하나, 서브쿼리에서 파서가 자기 자신을 부른다, 손 파서 vs bison/Lemon의 갈림), 그리고 실행기에서 스트리밍과 materialize가 갈리는 이유. 본론은 조인 3형제로, 모든 조인의 출발점인 중첩 루프(이중 루프), 안쪽을 점 조회로 바꾸는 인덱스 NLJ, O(N+M)의 해시 조인, 그리고 안 만든 정렬 병합까지 선택 기준을 표로. GROUP BY가 왜 정렬(또는 해시)을 요구하는지(GroupAggregate vs HashAggregate), HAVING은 왜 그룹의 WHERE인지. 마지막으로 BETWEEN이 실행기 0줄로 끝난 이유(문법 설탕), LIKE의 백트래킹 two-pointer 매칭과 LIKE '%x%'가 B-tree를 못 타는 이유(트라이그램·역색인으로의 탈출구), uncorrelated 서브쿼리의 1회 실행 캐시, 그리고 NULL의 3값 논리(UNKNOWN, NOT IN + NULL 함정)까지, 시리즈의 실행기 축을 완결한다.

DB 내부 ⑩: Raft, primary가 죽으면 누가 결정하는가, 합의에서 HA DB까지

복제의 최대 약점은 'primary가 죽으면?'이다. replica 승격을 사람이 하면 밤에 전화가 오고, 자동으로 하면 split-brain이 온다. 이건 결국 합의(consensus) 문제고, 답이 Raft다. 리더 선출(임기·과반·무작위 타임아웃), 로그 복제(prevLogIndex 정합 검사), 그리고 '과반에 있어도 현재 임기여야만 커밋'이라는 §5.4.2의 미묘함까지, 다섯 안전성 성질을 결정적 시뮬레이션 네트워크(분단·크래시·재정렬을 재현 가능하게 주입) 위에서 확인한다. 이어서 프로덕션의 조각들: 재시작 후 이중 투표를 막는 지속성(currentTerm/votedFor fsync, §5.1), 무한히 크는 로그를 자르는 스냅샷(§7, 그리고 오프셋 0이 no-op이라 기존 테스트가 버그를 못 잡는 함정), 돌아가는 중에 노드를 넣고 빼는 멤버십 변경(§6, 겹치는 과반), 상태기계 복제(SMR)로 진짜 SQL 엔진을 복제해 리더가 죽어도 살아남는 HA DB, 마지막으로 파티션된 옛 리더가 낡은 값을 주지 않게 하는 선형화 읽기(ReadIndex, §8)까지. 적대적 리뷰가 잡아낸 실제 버그들(스냅샷 미설치 발산, apply 에러 무시, read barrier의 epoch 부재)이 각 절의 정직한 경계다.

유저 스레드: 커널 없이 갈아타기

스레드는 꼭 커널이 만들어줘야 할까. 한 프로세스 안에서, 커널의 도움 없이 유저 공간만으로 실행 흐름을 갈아타는 협조적 유저 스레드를 만든다. 이건 이 시리즈에서 한 번 실패했다 되돌렸던 주제다. 진짜 원인인 단일 페이지 모델의 전역 변수 제약, 그리고 힙 스택 위에 트랩 프레임을 저장하다 나는 커널 페이지 폴트를 찾아내고, 커널을 전혀 바꾸지 않고 두 가지 우회로 해결한다.

TCP: 신뢰성은 번호 매기기에서 온다

UDP는 패킷을 던지고 잊는다. TCP는 "내가 어디까지 보냈고 어디까지 받았나"를 시퀀스 번호로 추적해, 순서와 도착을 보장한다. net 스택 위에 TCP 세그먼트와 3-way 핸드셰이크를 올려, 수동 개방 서버를 만든다. 호스트가 접속해 보낸 데이터를 게스트 커널이 받아 에코하고, 그 왕복으로 핸드셰이크부터 종료까지를 검증한다. 그리고 우리가 일부러 빼놓은 재전송, 혼잡 제어, 윈도우의 트레이드오프까지 다룬다.

DB 내부 ⑨: 복제, 복구의 redo를 스트림으로, base backup + WAL 스트리밍까지

복제의 첫 통찰은 '복제가 거의 공짜'라는 것이다. no-force WAL이 이미 커밋의 순차 스트림이라, replica가 할 일은 크래시 복구의 redo를 '파괴적 일회성'이 아니라 '증분·연속'으로 돌리는 것뿐이다. PostgreSQL에서 walreceiver가 WAL을 받아 쓰고 startup process가 replay하는 그 구조. 이를 소켓에 올리면 walsender/walreceiver가 되는데, 스트림엔 경계가 없어 길이 프레이밍이 필요하고, 수신자는 읽기 전용 fd에 쓸 수 없어 실제로 교착을 밟았다. 마지막은 캡스톤, 곧 진짜 엔진의 커밋이 복제본에서 SELECT되기까지다. 코드보다 어려웠던 건 조용히 깨지는 landmine 셋: 재오픈이 WAL을 truncate하고 LSN을 리셋해 replica가 신규 커밋을 조용히 스킵하는 것, base 스냅샷에 steal된 미커밋 페이지가 딸려오는 것, 카탈로그의 낡은 next_txn이 복제된 행을 미커밋으로 보이게 하는 것. 셋을 정리하면 필연적으로 실제 시스템과 같은 모델, 곧 pg_basebackup + streaming replication에 도달한다. 구조가 그렇게 강제한다.

저널링 파일시스템: 절반만 쓰이지 않게

쓰기 도중 전원이 나가면 파일시스템은 어떻게 될까. 데이터는 썼는데 디렉터리는 못 썼다면 디스크가 깨진다. 저널링(write-ahead log)은 이걸 막는다. 변경을 제자리에 바로 쓰지 않고 먼저 로그에 모은 뒤, 커밋 표시를 찍고, 그제서야 설치한다. 크래시가 나도 "전부 반영" 또는 "전혀 반영 안 됨" 둘 중 하나라 디스크는 항상 일관적이다. 여기에 삭제(rm)와 빈 블록 재사용까지 더해 읽기 전용이던 FS를 진짜 읽기-쓰기로 키운다.

Copy-on-Write fork: 복사를 미루는 기술

fork는 주소공간을 통째로 복사한다. 하지만 대부분의 자식은 곧장 exec해 그 복사본을 버린다. Copy-on-Write는 이 낭비를 없앤다. fork 때는 페이지를 복사하지 않고 부모와 읽기 전용으로 공유하다가, 누군가 쓰는 "그 순간"에만 복제한다. 물리 페이지 참조 카운트와 쓰기 페이지 폴트로 이걸 구현하고, 우리 커널만의 설계 제약(스택은 공유 못 한다)과 그 트레이드오프까지 짚는다.

네트워킹: virtio-net과 미니 TCP/IP 스택

토이 커널에 네트워크를 붙인다. virtio-net 드라이버로 프레임을 주고받고, 이더넷·ARP·IP·UDP·ICMP를 직접 구현해, QEMU user 네트워킹(SLIRP)을 상대로 게이트웨이 MAC을 ARP로 알아내고, ICMP로 ping을 왕복하고, DNS로 도메인을 해석한다. 패킷 하나가 바이트로 어떻게 조립되는지를 손으로 만져 이해하는 마지막 단계.

DB 내부 ⑧: 병렬 실행: latch를 계층별로 걷어내고, 자로 재고, 병목을 고치기

커넥션마다 스레드를 줘도 실행이 굵은 latch 하나로 직렬화돼 있으면 멀티코어는 한 코어처럼 돈다. 그 latch를 계층별로 걷어내는 여정: B+Tree latch crabbing(노드별 rwlock, 자식이 안전하면 조상 해제), 병렬 풀 스캔(워커가 disjoint 페이지 범위를 스레드 안전 버퍼 풀 위에서 동시에, 락 없는 워커별 지역 결과), 실제 SELECT 배선(워커는 가시성+WHERE 판정, 출력은 leader, 직렬과 바이트 동일), 병렬 집계(하다가 조용히 틀리던 materialize cap 절단 버그를 발견해 고침), 그리고 진짜 부분 집계(행을 안 모으고 누적만, PostgreSQL의 Partial→Finalize Aggregate, 메모리 O(1)). 그다음 자로 쟀다. 워밍 최고 2.16배(4워커, 이상 4배 대비), 2~4워커 정점 후 하락. 콜드의 천장은 CPU가 아니라 공유 latch였다: 콜드 캐시에서 풀 latch가 디스크 I/O를 직렬화해 8워커가 0.62배(직렬보다 느림). read-in-progress로 I/O를 latch 밖으로 빼자 같은 조건이 2.39배로 올랐다. 측정→수정→재측정의 A/B로 인과를 확증했다. 모든 단계는 ThreadSanitizer로 data race 0을 계측하며.

멀티코어로 가다: SMP와 락

단일 코어 커널을 여러 코어가 함께 도는 SMP 커널로 키운다. SBI HSM으로 보조 코어를 깨워 부팅하고, 스핀락으로 공유 자료구조를 보호하고, 컨텍스트 스위치를 가로지르며 락을 넘기는 baton 방식으로 멀티코어 스케줄러를 만든다. 그 과정에서 만난 가장 고약한 버그, 곧 유저가 tp 레지스터를 덮어 코어 번호가 틀어지던 문제와 그 해결까지 다룬다.

demand paging, mmap, 그리고 쓰기 가능 파일시스템

작은 유닉스 위에 메모리 관리를 깊이 올린다. 예외 핸들러를 "출력만 하던 것"에서 "실제로 페이지를 만드는 것"으로 키워, sbrk 기반 지연 할당(demand paging)과 파일을 메모리처럼 읽는 mmap을 구현한다. 그리고 읽기 전용이던 파일시스템을 블록 할당과 디스크 쓰기로 쓰기 가능하게 만들어, 파일이 재부팅에도 살아남게 한다. 페이지 폴트가 RISC-V에서 어떻게 S-mode로 전달되는지(medeleg)부터 차근히.

DB 내부 ⑦: 저장 엔진의 세 철학: 힙 vs 클러스터드 vs LSM, 그리고 USING lsm

같은 행들을 저장하는 세 가지 철학이 있다. PostgreSQL의 힙(순서 없이 쌓고 인덱스가 RID로 가리킴), InnoDB의 클러스터드(데이터 자체를 PK 순서로, 보조 인덱스는 PK를 들고 이중 조회), RocksDB의 LSM(제자리에서 절대 안 고침: memtable→SSTable→compaction). 한 코드베이스에 셋을 세워 실측하면 교과서의 문장들이 숫자가 된다: PK 점 조회 1.2배·범위 3.8배 클러스터드 우세(지역성), 보조 점 조회는 2배 열세(이중 조회), LSM은 쓰기가 순차화되는 대신 읽기가 여러 SSTable을 뒤진다(read amplification). 마지막은 이 시리즈의 캡스톤이다. LSM을 진짜 엔진의 PK 인덱스로 배선하며(CREATE TABLE ... USING lsm) 부딪힌 벽: MVCC 다중버전 때문에 인덱스는 비유니크 멀티맵이어야 하고, unique LSM으로는 담을 수 없어 dedup 단위를 (key,val)로 바꾼 멀티값 모드가 필요했다. 자체 WAL이 없어 트랜잭션 롤백에 못 끼는 이 LSM 인덱스를 'heap에서 재구축되는 파생 가속기'로 다루는 설계까지. MyRocks가 테이블 단위로 저장 엔진을 고르게 하듯, pluggable 엔진 API의 축소판이다.

Balruno MVP 후기

게임 밸런싱 스프레드시트 + 문서 워크스페이스 Balruno의 백엔드 설계와 운영을 한 글에 정리합니다. PostgreSQL JSONB 채택(50,000 시트 환경에서 MySQL/PG/Mongo 직접 측정 — Sheet GET p95: PG 16ms / MySQL 25ms / Mongo 45ms, Name UPDATE p95: Mongo 37ms / PG 40ms / MySQL 63ms. 쓰기만 보면 MongoDB가 조금 빨랐지만, 한정된 인프라 안에서 DB를 둘로 나누지 않고 하나로 운영하는 편이 더 합리적이라고 판단해 PostgreSQL 선택), 시트 셀 + 시트 트리 + 문서 트리 3영역 통합 동기화 알고리즘(Baserow + Linear + Outline 합본), OCI Always Free 4대 + Ansible 자동화 + Cloudflare R2 3-2-1 백업으로 매니지드 대비 예상 회피 비용 연 약 $1,860, OAuth-only + 자체 발급 JWT(Auth0 대비 연 약 $2,880), Grafana + Loki + Alloy + Prometheus + InfluxDB 셀프 호스트 모니터링(Datadog 대비 연 약 $720), nginx blue/green 무중단 배포(첫 cutover 21초 → 두 번째부터 0초), 시트 도메인 100% 서버 진실원 전환(약 80,000 라인 정리)까지 포함합니다.

DB 내부 ⑥: 비용 기반 옵티마이저, 플래너가 멍청해지는 순간을 통계로 고치기

'인덱스가 있으면 무조건 쓴다'는 규칙은 id > 100 앞에서 무너진다. 행마다 한 번씩 901번의 힙 페치가 7페이지 순차 스캔보다 백 배 비싸게 과금되기 때문이다. 비용 기반 최적화(CBO)의 세 재료를 순서대로 짓는다: ANALYZE(행 수·페이지 수·PK min/max를 재는 통계), 선택도(균등분포 가정으로 매칭 행 수 추정, 히스토그램이 필요한 이유까지), 비용 모델(순차 = 페이지 수, 인덱스 = 1 + 매칭 행수만큼의 힙 페치). 그러면 같은 PK 범위 조건이 매칭 양에 따라 점 조회/인덱스/순차로 갈리는 크로스오버가 실제로 나타난다. '인덱스를 걸었는데 왜 안 타요?'의 정답이다. 후반부는 다중 테이블의 진짜 고민, 조인 순서다: 순서 하나가 2.8배를 가르고, n!을 2ⁿ으로 줄이는 Selinger의 부분집합 DP(1979), 교차곱을 피하는 연결성 규칙, 조인 방법(인덱스 NLJ vs 해시)까지 한 번에 고르는 계획기를 짓는다. EXPLAIN이 실행기와 같은 결정 함수를 공유해 '플랜이 거짓말하지 않는' 원칙도 함께.

채널을 갈아끼우고 5기종을 인터페이스 뒤로 숨긴, 스스로 진단하는 관제탑

이기종 DBMS 운영 관리 플랫폼 DBTower. 하나의 분석 코어를 세 갈래 채널로 냅니다. 사람이 보는 웹 콘솔, AI 에이전트가 호출하는 MCP 도구, 온콜에게 곧장 꽂히는 웹훅 push. '새 기종 = Operator 구현체 1개'라는 주장은 SQL도 JDBC도 없는 MongoDB와 상용 Oracle을 실제로 붙여 검증했고, DBA가 장애 때 가장 먼저 보는 Wait Event를 5기종으로 통합하면서 'JPA로 통일하면 되지 않냐'는 질문에도 답합니다. 고정 임계 없는 이상 감지(z=378)와 암시적 형변환을 code=12345로 지목하는 심층 원인 진단까지, 플랫폼이 스스로 보고 판단하되 대상 DB는 건드리지 않는 자율 진단 스택을 실측과 함께 담았습니다.

데이터베이스 인덱스 ⑥: 운영과 한계

인덱스 시리즈 마무리. 운영 환경의 인덱스 작업은 DDL이 DB를 멈출 수 있다는 사실에서 출발합니다. CREATE INDEX CONCURRENTLY의 4단계 phase와 제약, 장기 트랜잭션이 클러스터 전반의 VACUUM/IOS에 미치는 영향, 인덱스 bloat과 REINDEX, 수십억 행을 위한 파티셔닝/샤딩, Bloom Filter, 그리고 6가지 안티패턴까지 1차 자료 기준으로 정리합니다.

데이터베이스 인덱스 ③: Covering Index와 Index-Only Scan

plan에 Index Only Scan이 잡혔다고 진짜 IOS는 아닙니다. PostgreSQL의 IOS는 covering(쿼리 컬럼이 인덱스에)과 visibility(VM all-visible) 두 단계 조건을 모두 만족해야 Heap Fetches가 0이 됩니다. INCLUDE 절은 covering을, VACUUM은 visibility를 충족시키는 도구입니다. INCLUDE의 leaf-only 저장 메커니즘과 인덱스 타입별 IOS 지원, 그리고 PG12 이전 insert-only Mandrill 함정까지 다룹니다.

데이터베이스 인덱스 ②: 스캔의 종류와 옵티마이저의 선택

인덱스가 있다고 모든 쿼리가 같은 방식으로 그 인덱스를 쓰는 것은 아닙니다. PostgreSQL의 Sequential / Index / Index-Only / Bitmap Scan 4가지 전략과, 옵티마이저가 통계 기반 셀렉티비티 추정으로 그 중 하나를 고르는 메커니즘을 1차 자료 기준으로 정리합니다. correlation, work_mem, BitmapAnd, Index Cond vs Filter까지.

데이터베이스 인덱스 ①: 인덱스 기초와 EXPLAIN 읽기

인덱스는 검색용 보조 자료구조이고, 그 인덱스를 쓸지 말지를 결정하는 것은 옵티마이저입니다. 옵티마이저의 결정을 검증하는 도구가 EXPLAIN이고, 실측까지 더하는 것이 EXPLAIN ANALYZE입니다. cost가 임의 단위라는 점, 추정과 실측의 격차가 진단의 핵심 신호라는 점, 인덱스가 있어도 안 쓰이는 4가지 패턴까지 1차 자료 기준으로 정리합니다.

DB 내부 ⑤: 진짜 psql이 붙는 서버, PostgreSQL wire protocol 해부

DB 서버와 클라이언트 사이엔 '프로토콜이라는 계약'이 있다. 그 계약만 지키면 상대는 내가 진짜 PostgreSQL인지 구분하지 못한다. PostgreSQL wire protocol v3를 바이트로 뜯는다: 유일한 변칙인 startup 핸드셰이크와 SSLRequest 거절, [타입 1B][길이 4B][페이로드] 프레임, RowDescription/DataRow/CommandComplete의 세 가지 응답 모양, 그리고 psql 프롬프트(=> / =*> / =!>)를 바꾸는 ReadyForQuery 상태 바이트 한 개까지. 400줄 서버로 실제 psql 14가 접속했고, 좋은 계층 분리 덕에 SQL 실행기는 한 줄도 안 바꿨다. 클라이맥스는 두 psql 터미널이다. 한쪽이 미커밋 UPDATE를 쥔 채로 다른 쪽이 옛 버전을 막힘 없이 읽는, MVCC의 존재 이유가 네트워크 너머에서 증명되는 장면이다. 커넥션당 스레드와 그 뒤에 숨은 굵은 latch의 한계까지 정리한다.

WikiEngine 총정리: 1,215만 건 검색 엔진의 설계부터 RAG까지

나무위키+한국어 위키백과+영어 위키백과+뉴스+웹텍스트+C4 한국어 코퍼스 1,215만 건 검색 엔진 프로젝트를 2개월간 26편의 기술 블로그로 기록하고 총정리합니다. MySQL LIKE 5,000ms 타임아웃에서 시작하여 임베디드 Lucene + Nori 한국어 형태소 분석으로 전환하고, Caffeine+Redis 2계층 캐시(82% 히트율), MySQL Replication R/W 분리, Nginx 스케일아웃(에러율 13.25%→0%), Debezium+Kafka CDC, Redis 3노드 Consistent Hashing까지 분산 아키텍처를 완성합니다. 검색 품질은 동의어 확장, 오타 교정, UnifiedHighlighter snippet, LTR(NDCG +4.8%p), 카테고리 28개 자동 분류, Aho-Corasick 금칙어 필터링으로 고도화하고, RAG(Gemini SSE 스트리밍)로 AI 검색 요약을 제공합니다. 자동완성 시스템 설계(CQRS + MapReduce + CDC)의 이론과 실제 구현의 매핑, 26편 전체 시리즈 링크, 핵심 수치 총정리를 포함합니다.

AI 검색 요약: RAG 파이프라인 + SSE 스트리밍 + 비용 모니터링

Lucene BM25 검색 결과 Top-5 문서를 LLM 컨텍스트에 주입하는 RAG(Retrieval-Augmented Generation) 파이프라인을 구축합니다. Spring AI 2.0 + Gemini 2.0 Flash로 SSE 스트리밍 답변을 생성하고, 인라인 출처 배지를 파싱하여 게시글 링크로 연결합니다. 할루시네이션 방지(문서 기반 답변 제한 + 인용 강제), AI 요약 트리거 조건(네비게이션 의도 스킵), Redis Token Bucket rate limiting(10 RPM 전역), 동일 쿼리 캐싱(TTL 30분, LLM 비용 40-60% 절감), Grafana 7패널 대시보드(RPM, 응답시간, 토큰, 피드백, 비용 추정)까지 포함합니다. BM25가 이 프로젝트에서 Dense Retrieval보다 적합한 근거와, Hybrid Retrieval 전환 로드맵도 정리합니다.

DB 내부 ④: 격리, 2PL에서 MVCC 스냅샷 격리까지, reader가 writer를 안 막기까지

동시에 여럿이 읽고 쓸 때 DB는 어떻게 안전한가. 두 철학이 있다. 충돌을 미리 막는 2PL(잠금)과 버전을 갈라 충돌을 피하는 MVCC다. MVCC의 두뇌는 가시성 규칙 한 줄(xmin 커밋 AND xmax 미커밋이면 보인다)이고, 그 규칙이 성립하려면 DELETE조차 지우면 안 된다(xmax 도장). 그 순간 힙을 읽는 아홉 갈래 전부에 가시성 게이트가 필요해지고, '인덱스는 MVCC를 모른다'는 원칙이 선다. 일부러 만든 쓰레기(dead tuple)는 VACUUM이 치우는데, PostgreSQL nbtree처럼 병합 없는 lazy 삭제로 충분한 이유, VACUUM해도 파일이 안 줄어드는 이유까지 코드로 확인한다. 마지막으로 reader의 락을 제거하고 BEGIN 시점 스냅샷(PG의 xmax/xip 축소판)을 고정하면, 한 트랜잭션이 미커밋 UPDATE를 쥐고 있어도 다른 쪽이 옛 버전을 막힘 없이 읽는 진짜 스냅샷 격리가 선다. 그 여파로 PK 인덱스가 다중 버전 멀티맵이 되어야 했던 것까지, C 구현으로 확인하며 정리한다.

콘텐츠 필터링: Aho-Corasick 금칙어 탐지와 운영 안전장치

커뮤니티 검색 서비스의 운영 안전장치를 구축합니다. 16,090개 금칙어를 Aho-Corasick O(N+Z) 알고리즘으로 탐지하여 자동완성 결과에서 유해 검색어를 필터링하고, 블라인드 게시글을 Lucene Occur.MUST_NOT으로 검색에서 제외합니다. 영어 금칙어의 Scunthorpe 문제(단어 경계 매칭), Negative Caching(빈 결과 30초 TTL)으로 cache penetration 방지, title_raw StringField로 자동완성 Lucene fallback 품질을 개선합니다.

LTR 재랭킹 + 카테고리 자동 분류: XGBoost4J + LLM-as-a-Judge

BM25 수동 가중치(title:3, content:1)의 한계를 Learning to Rank(LTR)로 극복합니다. 카테고리 28개 자동 분류(키워드 기반, 정확도 83%) → SortedSetDocValuesFacetCounts 네이티브 Facet 전환 → 태그 216만 건 인덱싱을 1회 재색인으로 통합 반영합니다. LLM-as-a-Judge(Gemini)로 학습 데이터 900쌍을 생성하고(1차 실패 98% → 5초 딜레이+지수 백오프로 해결), XGBoost LambdaMART 14개 피처로 학습하여 NDCG@10을 0.6910 → 0.7387(+4.8%p) 개선합니다. XGBoost4J ARM64 네이티브 추론, Rescorer Top-200 재랭킹, RefreshListener 기반 FacetState 캐싱, MultiCollectorManager 단일 패스 수집까지 구현하지만, 2코어 ARM Free Tier에서 LTR ON 시 CPU 포화(72배 악화)를 k6로 실측하여 LTR_ENABLED=false로 비활성화합니다.

쿼리 확장 + Query Understanding: 동의어·오타 교정·snippet 고도화

Lucene 기반 검색 엔진의 Recall과 Precision을 동시에 개선합니다. 동의어 확장(DB 기반 쿼리 타임)으로 "AI" 검색 시 "인공지능" 문서를 포함시키고, DirectSpellChecker로 "프로그래링" → "프로그래밍" 오타 교정을 구현합니다. UnifiedHighlighter + snippetSource 500자 StoredField로 검색어 주변 맥락 snippet을 제공하고, 무중단 전체 재색인 인프라(Directory Swap + SearcherManager 재생성)를 구축하여 12,156,589건(42GB)을 ~2시간 만에 재색인합니다. 인덱스 타임 동의어가 IDF를 왜곡하는 원리, Nori 사용자 사전 158,539개 적용, BM25 변형(BM25+/L/F) 불필요 판단 근거까지 정리합니다.

카테고리 검색 필터링 + Facet 집계: Lucene FILTER 절 설계

1,425만 건 Lucene 검색 엔진에 카테고리 필터링을 추가합니다. categoryId가 이미 LongField로 인덱싱되어 있지만 검색 쿼리(buildQuery)에서 사용하지 않고 있던 구조적 비대칭을 발견하고, Occur.FILTER 절로 해결합니다. DB Post-filter 방식이 pagination을 깨뜨리는 이유, FILTER가 MUST와 달리 스코어에 기여하지 않으면서 bitset 캐싱 대상이 되는 원리, DB GROUP BY 간이 Facet의 한계와 Lucene 네이티브 Facet 전환 계획까지 정리합니다.

분산 안정성 검증: stress 테스트 + 한계점 분석

단일 서버에서 100-150 VU가 한계였던 시스템을 분산 아키텍처(2 App + MySQL Replication + Redis 3샤드 + Kafka CDC)로 전환한 후, stress 테스트(200 VU, 25분)로 한계점을 재탐색합니다. 100 VU에서 P95 200ms(SLA 충족), 200 VU에서 에러율 0.09%(단일 서버 13.25% → 0.09%), 처리량 109 req/s(3.6배↑). App CPU가 여전히 근본 병목임을 소거법으로 확인하고, MySQL/Redis/Kafka/Nginx 모두 여유임을 실측합니다.

Redis 샤딩: Consistent Hashing으로 워크로드 격리

단일 Redis 인스턴스에서 KEYS 블로킹(34.6ms), 배치↔실시간 워크로드 간섭(GET 최악 15.5ms), volatile-lru 보안 위험을 실측하고, KEYS→SCAN 전환 + 3노드 Consistent Hashing + 블랙리스트 전용 인스턴스 격리로 해결합니다. 가상 노드 150개 ConcurrentSkipListMap 라우팅, 노드 장애 시 Lucene fallback, 100 VU 부하 테스트로 검증한 과정을 정리합니다.

CDC (Change Data Capture): 이벤트 기반 동기화

PostService의 dual-write 구조(MySQL + Lucene 직접 호출)가 데이터 불일치, 강결합, 불완전한 캐시 무효화를 유발하는 문제를 점진적으로 해결합니다. Spring ApplicationEvent로 디커플링 → @ApplicationModuleListener 비동기 전환(쓰기 5,315ms→33ms) → Debezium + Kafka CDC로 binlog 기반 모든 변경 캡처까지. 100 VU 부하 테스트로 각 전환을 검증하고, dual-write를 원천 차단하여 검색 인덱스 정확성을 보장합니다.

App 스케일아웃: Nginx L7 로드밸런싱 + Lucene Replica

App CPU 100% 병목을 해소하기 위해 App 인스턴스를 2대로 확장합니다. Nginx map 기반 HTTP 메서드 라우팅(least_conn), Lucene Primary/Replica 모드 분리(SnapshotDeletionPolicy + Refresh Pause + rsync), TokenBlacklist Redis 전환, 조회수 Redis INCR 배치 flush 전환까지 적용해 100 VU 기준 에러율 13.25%→0.00%, P95 2,300ms→158ms, 평균 482ms→37ms로 개선합니다.

조회수 Redis INCR + Write-Behind 배치 flush 전환

GET 요청에 포함된 DB UPDATE가 R/W 분리와 충돌하여 500 에러가 발생한 문제를 Redis INCR + 30초 배치 flush로 해결합니다. REQUIRES_NEW, 비관적/낙관적 락, @Async, Caffeine 로컬 카운터 등 5개 대안을 비교 분석하고, Write-Behind 패턴으로 GET에서 DB 쓰기를 완전히 제거하여 에러율 11.10% → 0.00%, 상세 조회 응답시간 36% 개선을 달성합니다. Sentry·YouTube 등 현업 사례와 비용 분석, 면접 Q&A까지 포함합니다.

이기종 DBMS를 하나의 컨트롤 플레인으로: DBTower 설계와 쿼리 회귀 감지

MySQL·PostgreSQL·SQL Server처럼 서로 다른 DBMS를 등록부터 모니터링, 백업까지 한 곳에서 관리하는 컨트롤 플레인 DBTower의 설계편입니다. 기종마다 다른 통계 소스(performance_schema·pg_stat_statements·DMV)를 DbmsOperator 인터페이스 하나로 묶었고, 같은 '백업'이 mysqldump·pg_dump·BACKUP DATABASE로 갈리는 차이도 그 인터페이스 뒤로 감췄습니다. '부하 상위 쿼리가 곧 범인은 아니다'라는 문제의식에서 시점 비교가 나왔고, 플랫폼이 자기 자신을 등록해 병목을 잡은 도그푸딩에서는 Seq Scan을 21ms에서 0.06ms로 줄였습니다. 이 시점 비교를 사람이 아니라 플랫폼이 스스로 돌려 쿼리 회귀를 자동으로 감지하고 Discord로 알립니다. 이런 설계 결정과 실측 수치를 함께 기록했습니다.

DB 내부 ③: WAL과 크래시 복구, redo-only에서 steal + no-force까지

쓰다가 전원이 꺼지면 어떻게 되는가. WAL 프로토콜의 본질(데이터보다 로그 먼저, fsync 한 번이 내구성의 분기점)에서 시작해, 복구 규칙이 'redo 아니면 discard' 하나로 끝나는 가장 단순한 형태를 짓고, 그 단순함의 대가(버퍼 풀보다 큰 트랜잭션이 죽는다)에 부딪혀 steal을 켜고(before-image·undo의 필연), 마지막으로 no-force(커밋 = 로그 fsync 하나)로 옮겨 로그가 진실의 원천이 되기까지. STEAL/FORCE 사분면이 어떤 복구 로직을 필수로 만드는지, 진짜 ARIES(physiological 로깅·pageLSN·CLR·3-패스)와의 거리는 어디인지, fsync의 가격(같은 5천 행 적재가 23배 차이)은 얼마인지, 크래시를 실제로 주입한 테스트와 실측으로 확인하며 정리합니다.

pay 총정리: 실패가 비싼 도메인을 밑바닥부터

Spring Modulith로 만든 결제 시스템 pay의 전체 기록을 한 편에 정리했다. 결제를 고른 이유(타임아웃·중복·미확정·정산 불일치가 실제로 아픈, 실패가 비싼 도메인)에서 시작해 신뢰 경계, PG 타임아웃을 UNKNOWN으로 보존하고 복구가 확정하는 실패 설계, 복식부기 원장·정산·대사, PG 콜을 트랜잭션 밖으로 뺀 체크아웃 사가, 재고 락 3종 실측, 멀티 PG·구독·선불 월렛·가상계좌·FDS·복합결제로의 결제수단 확장, 대기열·유입 제어·암호화 같은 보안 층, 회원과 분쟁·차지백, 그리고 내 코드를 스스로 감사해 잡은 자금 손실 버그까지. 시리즈를 안 읽어도 이 한 편으로 전체가 잡히게 썼고 깊이가 필요한 지점마다 해당 편을 링크했다.

DB 내부 ②: B+Tree 인덱스와 O(log n), 그리고 인덱스는 왜 단순 key→value가 아닌가

인덱스는 왜 이진 트리가 아니라 B+Tree인가. 답은 디스크다(노드=페이지, fan-out이 높이를 무너뜨린다). 가장 어려운 노드 분할(리프는 copy-up, 내부는 push-up), 범위 스캔을 공짜로 만드는 리프 사슬, 그리고 유일성을 내려놓는 순간 부딪히는 문제들, 곧 중복 키의 하한 탐색(>= 한 글자가 조용히 틀린 결과를 만든다)과 '인덱스는 후보일 뿐, 진실은 힙에 있다'는 재검사(recheck)까지. 인덱스 vs 풀 스캔을 실측하면 1천 행에서 11배, 10만 행에서 416배로 벌어진다. O(log n)과 O(n)의 모양 그 자체다. 해시·LSM과의 갈림길, PostgreSQL·InnoDB 대조를 C 구현으로 확인하며 정리한다.

DB 내부 ①: 저장의 뼈대, 페이지·슬롯·행 포맷·힙·버퍼 풀은 어떻게 맞물리는가

관계형 DB의 맨 아래는 다섯 겹이다. 고정 크기 페이지(왜 모든 DB가 블록 단위인가), 슬롯 페이지(가변 행 + 안정적 주소), 행 포맷(null 비트맵), 힙 파일(RID), 그리고 버퍼 풀(캐시인데 안 깨지는 이유). PostgreSQL 8KB·InnoDB 16KB가 왜 그 크기인지, TID/RID가 왜 인덱스의 기반인지, NULL은 왜 값이 아니라 비트로 저장되는지, pin 프로토콜이 어떻게 latch와 분업해 멀티스레드에서 페이지를 지키는지를, C로 미니 DB를 직접 구현하고 ThreadSanitizer로 계측하며 확인한 내용을 1차 자료 기준으로 정리합니다.

빌려조잉 - 삼성 우수상, 그리고 팀원 이탈 속에서 배운 것들

C2C 공유 플랫폼 빌려조잉(6주, 6인 팀, 삼성 우수상)의 백엔드 설계와 운영을 한 글에 정리합니다. 메시징은 Kafka/RabbitMQ/NATS를 제치고 트래픽 1,000배 여유를 근거로 Redis Pub/Sub + MongoDB 영속화를 선택했고, 저장소는 직접 측정(MongoDB Insert ~5ms vs MySQL ~15ms)을 근거로 MySQL+MongoDB+Redis Polyglot Persistence를 채택했습니다. 채팅방 목록 조회는 N+1(51쿼리)을 Fetch Join + Redis MGET + Coroutine 병렬 조회로 4쿼리·1,350ms→85ms (16배)까지 줄였고, 커서 페이지네이션으로 100번째 페이지 450ms→12ms, SimpleBroker 한계를 Redis 세션 관리로 풀어 멀티 인스턴스 확장을 준비했습니다. 인증은 HttpOnly Cookie + Vite 프록시, Redis는 CVE-2025-49844 'RediShell'(CVSS 9.9) 긴급 패치(7.0.15→7.2.11 + 다층 방어). Coroutine+JPA 401 디버깅, 팀원 이탈 대응(토스 에스크로 역방향 로직, 마감 1주 전 프론트 7화면 직접 연동)까지 포함합니다.

전문·TCP와 JSON·REST 사이에 통역기를 세운 아홉 단계, 관문(gwanmun) 총정리

은행 계정계는 고정길이 전문(電文)과 TCP로만, 모바일 앱은 JSON과 HTTP REST로만 말합니다. 둘 다 못 고치니 가운데에 통역기를 세웁니다. 전문과 JSON을 변환하는 연계층, 그리고 그 통로를 지키는 API 게이트웨이층입니다. 이 글은 그 통역기를 목업 계정계까지 세워 직접 만든 9단계의 총정리입니다. 계정계를 실제로 죽여 서킷이 OPEN으로 열리고 503으로 격리하는 과정, RuntimeException 하나(풀 고갈)가 서킷을 열고 원장 4건을 증발시킨 감사 결함과 그 수정(503과 원장 완결), 부하가 서킷 stale 레이스를 staleResultsTotal=197로 실증한 기록, 한계 약 10~12k req/s와 게이트웨이 오버헤드 약 0.21ms, 죽은 백엔드에서 서킷 off 351 vs on 9,425 req/s, 같은 멱등키를 두 번 보내도 계정계 호출 1회에 원장 1행으로 이중거래가 0이었던 것까지 담았습니다. 전부 VERIFICATION에 명령과 출력이 남은 실측이고, 안 만든 것은 왜 안 만들었는지 적었습니다.

재전송의 이중 거래를 멱등키로 막고, 대조된 적 없던 원장을 EOD 대사로 맞추다

4편까지 원장은 거래를 3값(SUCCESS/FAILED/UNKNOWN)으로 적고 UNKNOWN을 수동으로 해소할 수 있었지만, 두 구멍이 남아 있었습니다. 하나, 게이트웨이는 호출자의 재전송을 구분하지 못합니다. 타임아웃(UNKNOWN)을 받은 호출자가 같은 요청을 다시 보내면 그건 새 거래고, 계정계에서 두 번 실행돼 이중 거래가 됩니다. 둘, 원장은 한 번도 계정계와 대조된 적이 없는 진실입니다. 5편은 둘을 채웁니다. 멱등키는 (키+메서드+경로)를 DB 유니크 제약으로 원자적 선점합니다. 동시 재요청은 앱 락으로 못 묶고 DB가 하나로 만듭니다. 처리 중 재요청은 409, 완료된 요청 재수신은 저장된 원응답을 재실행 없이 재반환합니다. 같은 키로 잔액조회를 두 번 보내니 계정계 로그에 요청 수신은 1회, 원장에 그 거래는 1행으로 남아 이중 거래 0을 psql로 확인했습니다. EOD 대사는 계정계 당일 처리내역(가변 전문)을 원장 전량과 거래고유번호로 대조해 양쪽일치 / 금액상이 / 우리만있음 / 저쪽만있음의 4유형으로 가립니다. UNKNOWN은 대조 전에 상태조회·망취소로 자동 해소합니다. 통제된 5건으로 {MATCH:2, MISMATCH:1, LEDGER_ONLY:1, CORE_ONLY:1, UNKNOWN_RESOLVED:1}을 실측했고, 여기서 순서 함정을 하나 발견했습니다. 자동 해소의 망취소가 계정계 기록도 바꾸므로, 계정계 스냅샷은 해소 이후에 떠야 합니다. 마지막으로 원장 PG를 DBTower 관제 대상으로 등록 가능하게 준비했습니다.

감사가 찾은 풀 고갈 3중 오작동과 부하가 드러낸 서킷 stale 레이스를 k6 실측으로 잡다

1부는 방향을 뒤집어 전체 코드 감사를 돌립니다. 최대 발견은 풀 고갈의 3중 오작동입니다. PoolExhaustedException이 RuntimeException으로 모든 처리 경로를 관통해서, 계정계가 멀쩡한데 내부 풀 고갈 3연속이면 서킷이 열리고(오보), 고갈 요청은 500으로 터지며 원장에서 통째로 증발하고, 재시도 경로도 안 탑니다. 동일 시나리오(동시 8건 슬로우 계좌)를 수정 전과 후로 실측했습니다. 수정 전에는 500이 4건에 원장 5행(4건 증발), 서킷 OPEN, 직후 멀쩡한 계좌까지 503 거절이었고, 수정 후에는 503 4건('포화 상태' + 거래ID), 원장 9행 완결, 서킷 CLOSED, 직후 멀쩡한 계좌는 200이었습니다. 유휴 커넥션 TTL, 채번기 자정 재시드, EUC-KR 무음 '?' 치환의 fail-closed 전환, API 키 기동 로그 노출 제거 등 확정 결함을 소탕하고 회귀 테스트 18건으로 고정했습니다(129→147). 2부는 감사가 '난이도 대비 실익이 낮다'며 미룬 A3(서킷 stale 결과 귀속)를 부하가 실증한 이야기입니다. 서킷이 열렸다 닫혔다 하는 15초 부하 창에서 stale 결과 보고가 197번 발생했고(staleResultsTotal=197), acquire()가 상태 세대를 담은 permit 토큰을 발급해 세대가 일치할 때만 상태에 반영하게 고쳤습니다. 그리고 k6로 세 가지를 실측했습니다. 무릎 약 10~12k req/s(6k까지 p95<1ms, 실패율 전 구간 0%), 게이트웨이 경유 오버헤드 약 0.21ms/req(병목은 TCP 풀이 아니라 웹 계층으로, 커넥터 40k vs 전체 경로 12k req/s), 죽은 백엔드에서 서킷 off 351 req/s(p50 8.11s) vs on 9,425 req/s(p50 0.68ms)입니다. CI(GitHub Actions), MIT LICENSE, Spring Boot 3.5.4 업그레이드(150건 그린)까지 함께 소진했습니다.

타임아웃을 실패로 단정하지 않는 3값 원장, 그리고 손으로 짠 서킷브레이커와 망취소

1부는 아무것도 기억하지 못하던 게이트웨이에 기억을 붙입니다. 모든 거래에 거래고유번호를 채번하고(스레드 안전 + 재기동 안전), 결과를 3값 상태(SUCCESS/FAILED/UNKNOWN)로 원장에 적재합니다. 핵심 규칙은 타임아웃을 임의로 실패 처리하지 않는 것입니다. 응답을 못 받은 거래는 계정계에서 처리됐을 수 있어 FAILED가 아니라 UNKNOWN으로 적습니다. 목업 계정계에 응답 지연 모드를 넣어 진짜 read 타임아웃(3.06초 → 504)을 일으키고 원장에 UNKNOWN이 남는 것을 실측했습니다. 적재는 비동기라 거래를 막지 않고, 계좌는 저장 직전 마스킹되며, correlation ID가 로그와 원장을 한 줄로 꿰고, 토큰버킷·커넥션 풀·TCP 왕복이 Prometheus 커스텀 메트릭으로 노출됩니다. 2부는 남은 폭탄 둘을 처리합니다. 계정계 프로세스를 실제로 죽여 손으로 짠 서킷브레이커(CLOSED→OPEN→HALF_OPEN)가 502 → 503×4 즉시 거절로 장애를 격리하고 재기동 후 탐침으로 닫히는 전 과정을 확인했고, UNKNOWN 해소도 완성했습니다. 원거래 전문에 거래고유번호를 실어(30→52byte) 거래상태조회로 처리 여부를 확인해, 처리됐으면 망취소로 CANCELED, 미처리면 그제야 FAILED로 확정합니다. 조회성 거래만 지수 백오프로 제한 재시도하고(변경성 재시도는 이중 거래라 코드가 0회를 강제), 거래 단위 데드라인이 마지막 재시도의 read 제한까지 깎습니다. ddl-auto:update가 체크 제약을 갱신하지 않아 CANCELED UPDATE가 거부된 함정과, 그 실패 순간 이미 나간 망취소를 멱등성이 구해 준 이야기도 정직하게 적었습니다.

완제품 없이 세운 문지기, 인증·라우팅·유량제어 필터 체인부터 가변 프레이밍과 커넥션 풀까지

전문↔JSON 왕복 통로는 열렸지만 아무나 드나들 수 있었습니다. 1부에서 /api/gateway/** 앞에 문지기를 손으로 세웁니다. 인증(X-API-Key, 없으면 401·잘못되면 403), 라우팅(모르는 경로 404), 유량제어(클라이언트별 토큰버킷, 용량 5 초과 시 6번째 429 + Retry-After)입니다. 완제품 프레임워크 없이 GatewayFilter 인터페이스와 체인 실행기, 서블릿 브릿지로 직접 짰고, 직접 짜는 유량제어의 두 함정(벽시계로 인한 시간 역행, 같은 버킷을 치는 스레드 경쟁)을 단조 시계와 버킷 단위 동기화로 막았습니다. 겸사겸사 코드베이스를 Spring Modulith 모듈러 모놀리스로 재정렬해 모듈 경계를 verify()가 강제하게 했습니다. 2부는 통로에 남은 두 구멍을 메웁니다. 거래내역 조회 응답처럼 레코드가 건수만큼 붙어 길이가 매번 다른 전문은 4byte 길이 헤더의 2단계 프레이밍으로 자르고(가변 309B 왕복, 비정상 길이는 fail-closed 거절), 요청마다 소켓을 새로 열던 것은 최대 크기·유휴 반납·검증·고갈 거절을 갖춘 스레드 안전 커넥션 풀로 바꿨습니다. 순차 6회 조회에 소켓 1개 재사용, 동시 10건 폭주에도 created=4(==max)·reused=12를 진짜 실행으로 남겼습니다.

고정길이 전문 파서와 TCP 프레이밍을 손으로 짜며 배운 것, 바이트가 진실이다

은행 계정계의 고정길이 전문(電文)을 모바일 앱이 알아듣는 JSON으로, 그 반대로도 바꾸는 변환 엔진을 만들고(1부), 그 파서를 실제 TCP 소켓 앞에 세웠습니다(2부). 1부의 핵심은 하나입니다. 한글이 섞인 전문을 String.substring으로 자르면 깨진다는 것입니다. EUC-KR에서 한글 한 글자는 2byte라, 자르기도 패딩 제거도 전부 byte[] 위에서 해야 합니다. 필드 레이아웃을 어노테이션 스펙으로 선언하고 오프셋을 자동 계산해 byte[]↔DTO↔JSON을 양방향 변환하는 과정을, 왕복 무손실 테스트와 hex 덤프로 검증했습니다. 2부의 함정은 TCP가 바이트 스트림이라는 사실입니다. '고정 61byte 전문'이라도 소켓 read 한 번이 그걸 온전히 준다는 보장이 없습니다. 반쪽만 왔다가 나머지가 뒤에 오고(partial read), 두 전문이 붙어 오기도 합니다(뭉침). 필요한 바이트가 다 모일 때까지 버퍼에 누적한 뒤에야 한 전문으로 넘기는 프레이밍을 순수 java.net 소켓으로 직접 짜고, 목업 계정계 TCP 서버를 세워 REST→전문→TCP→전문→JSON 왕복을 실제 두 프로세스로 붙였습니다. 반쪽 도착 재조립을 테스트로 강제하고, 소켓을 타고 오간 진짜 hex를 화면과 curl로 남겼습니다.