모든 글
약 16분 분량 개인 프로젝트/DBTower

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

목차

0. 들어가며, “되는 것”과 “남이 쓸 수 있는 것”은 다르다

지난 편들까지, DBTower는 기능적으로 레퍼런스로 삼은 사례를 넘어섰습니다. 5기종 통합, 시점 비교, 회귀 자동 감지, MCP, 웹 콘솔, 자연어 진단까지. 그런데 문득 다른 질문이 떠올랐습니다. “이걸 내가 아닌 누군가가 깃허브에서 클론해 실제로 자기 인프라에 띄울 수 있나?”

물어보니 답이 아니었습니다. 기능이 되는 것과 남이 쓸 수 있는 것은 완전히 다른 문제였습니다. 그래서 이 프로젝트를 포트폴리오에서 실제 셀프호스트 제품으로 끌어올리기로 했습니다. 아무도 못 쓰는 상태 → 한 명 → 한 팀 → 여러 팀 → 수천 대. 이 순서로 벽을 하나씩 허무는 긴 아크의 시작이고, 이번 편의 앞부분은 그 첫 계단, “아무도 못 쓰는 이유” 를 없앤 기록입니다.

1. 준비도 감사, 세 방향으로 나를 훑다

혼자 훑으면 자기 코드에 관대해집니다. 서브에이전트 셋에게 각각 다른 축을 맡겨 병렬로 감사했습니다. 온보딩·배포(남이 클론→실행할 수 있나), 운영·보안 설정(비밀·TLS·자기 백업), 라이선스·릴리스 위생(법적으로 배포 가능한가) 셋입니다. 각 축은 코드를 정독하고 근거를 웹서칭으로 대조했습니다.

결과는 좋은 소식 반, 나쁜 소식 반이었습니다. 좋은 쪽은 이미 탄탄한 게 있었습니다. 공개 GHCR 멀티아치 이미지가 익명으로 pull되고, admin 부트스트랩은 랜덤 비밀번호에 admin/admin을 명시적으로 거부하고, 커밋된 실제 비밀은 없었습니다. 나쁜 쪽은, 정작 배포를 성립시키는 것들이 통째로 비어 있었습니다.

2. 반복된 착각, 대상을 향한 것은 됐는데 나 자신을 향한 것이 없다

감사에서 가장 인상적이었던 건 결함들이 공유하는 패턴이었습니다. DBTower는 관리 대상 DB를 향한(target-facing) 기능은 갖췄는데, 플랫폼 자신·사용자를 향한(platform-facing) 기능이 비어 있었습니다. 이름이 비슷해서 “그것도 이미 됐겠지”라는 착각을 반복해 불렀습니다.

  • 대상 DB 접속은 TLS로 암호화합니다(useTls, 마이그레이션까지 있음). 그런데 사용자가 웹 콘솔에 접속하는 웹 HTTPS는 없습니다. 평문 8080입니다.
  • 대상 DB 백업을 원격 S3에 오프사이트 보관합니다. 그런데 사용자·모든 자격증명·정책·이력을 담은 플랫폼 자신의 메타 DB 백업은 없습니다. 볼륨 하나에만 의존합니다.
  • 로그인 실패를 감사 로그로 기록합니다. 그런데 로그인 rate-limit·계정 잠금은 없습니다. 브루트포스가 그대로 뚫립니다.

이 패턴을 알고 나니 남은 감사 결과가 전부 이 렌즈로 다시 읽혔습니다. “관제탑이 대상은 지키는데 자기 자신은 못 지키고 있었다.” 이번 아크는 그 platform-facing 절반을 채워가는 이야기이고, 그 시작이 Phase 0, 배포 자체를 막고 있던 네 개의 블로커입니다.

3. Phase 0, 남이 못 쓰던 이유 넷

넷 중 하나는 코드가 아니라 파일 하나였다

라이선스가 없었습니다. 루트에도, 소스 헤더에도, README에도. 저작권 기본값은 “All Rights Reserved”입니다. 공개 저장소에 올리고 GHCR로 이미지를 배포해도 그건 “사용 허가”가 아닙니다. 남이 복사·수정·재배포·셀프호스트하는 것이 법적으로 금지된 상태였습니다. 기능을 아무리 쌓아도 이 파일 하나가 없으면 나머지 전부가 “있어도 못 쓰는” 상태입니다.

Apache-2.0 전문을 LICENSE로 넣고, NOTICE에 번들 재배포 고지를 달았습니다. 이미지가 MySQL 드라이버(GPLv2 + Universal FOSS Exception)와 Oracle 드라이버(독점 Free Use Terms), mysqldump·pg_dump 같은 CLI를 함께 담아 배포하기 때문에 그 라이선스들을 고지할 의무가 따라옵니다.

가장 아팠던 건, 가드가 하필 그 경로만 비껴간 것

두 번째가 이 아크에서 제일 뼈아팠습니다. 인스턴스 접속 비밀번호는 AES-256-GCM으로 암호화해 저장합니다. 키가 없으면 조용히 평문으로 저장되는 사고를 막으려고, 운영 프로필에서는 키가 없으면 기동을 거부하는 fail-closed 가드를 이미 만들어 뒀습니다(CWE-312 방어).

그런데 그 가드는 프로필이 정확히 prod일 때만 작동했습니다. 정작 셀프호스트 컨테이너는 docker 프로필로 뜨고, 저장소 어디에도 prod 프로필은 없습니다. 즉 README대로 키 없이 띄우면 가드가 있는데도 WARN 한 줄 찍고 정상 부팅한 뒤 등록한 모든 대상 DB의 접속 비밀번호를 메타 DB에 평문으로 저장했습니다. 가드가 없었던 게 아니라 잘못된 문에 걸려 있었던 겁니다.

판정 대상을 prod 하나에서 배포 프로필 집합 {prod, docker}로 넓혔습니다. 다만 함정이 하나 더 있었습니다. “dev·test 외 전부 fail-closed”로 더 세게 잡고 싶었는데, 키 없이 뜨는 @SpringBootTest 컨텍스트가 여덟 개나 있어 그렇게 하면 테스트가 부팅 단계에서 무너졌습니다. 그래서 blank/dev/test는 유지하고 배포 프로필만 명시적으로 막되, 셀프호스트 경로는 compose에서 ${DBTOWER_ENCRYPTION_KEY:?}로 한 번 더 막는 이중 방어로 갔습니다.

직접 확인해봤습니다. docker 프로필에 키를 비우고 기동하니 exit 1로 거부됐고, 로그에 정확히 이렇게 남았습니다.

IllegalStateException: 배포 프로필(docker)에서 DBTOWER_ENCRYPTION_KEY가 없습니다
— 인스턴스 비밀번호 평문 저장을 막기 위해 기동을 거부합니다.

이건 하위호환을 깨는 변경이라 CHANGELOG에 업그레이드 노트를 남겼습니다. 기존에 평문으로 운영하던 사용자는 키를 설정하면 기동이 거부되므로, 키 생성 → 기동 → 각 인스턴스 한 번 재저장(그때 새 키로 암호화됨) 절차를 안내했습니다. 보안 수정이 사용자를 갑자기 막아 세우는 것도 부채라서, 막는 것과 안내하는 것을 같이 해야 했습니다.

공개 저장소에 나가면 안 되는 것이 하나 있었다

세 번째는 data/dbhub.mv.db였습니다. 2.3MB짜리 옛 H2 데이터베이스 파일이 커밋돼 추적되고 있었고, 안에 USERS(USERNAME, PASSWORD) 테이블이 들어 있었습니다. 앱이 쓰지 않는(프로덕션은 PostgreSQL 메타 DB, 테스트는 인메모리 H2) 고아 파일이었지만, 비밀번호 컬럼이 든 바이너리 DB는 공개 저장소에 나갈 물건이 아닙니다. git rm으로 제거하고 data/를 gitignore에 넣었습니다. (히스토리 세척은 파괴적이라 공개 직전 사용자가 결정할 일로 남겨뒀습니다. 임의로 히스토리를 다시 쓰지 않습니다.)

내세운 기능이 배포 이미지에서 조용히 죽어 있었다

네 번째는 발견하기 전엔 몰랐을 종류였습니다. README가 내세운 “판단 기준 문서를 프롬프트로 쓰는 일관된 AI 분석”이, 정작 셀프호스트 이미지에서는 항상 빈 프롬프트로 돌고 있었습니다. .dockerignoredocs 디렉터리를 통째로 제외하는데, AI 판단 규칙 파일(ai-analysis-rules.md)이 바로 그 안에 있었기 때문입니다. 코드는 파일이 없으면 빈 문자열로 넘어가게 방어돼 있어 크래시는 안 났지만, 그 방어가 오히려 “조용히 가치가 사라지는” 걸 만들었습니다.

.dockerignore에 그 파일만 예외로 다시 포함시키고(!docsdocs/*!docs/ai-analysis-rules.md), Dockerfile에 COPY docs/ai-analysis-rules.md 한 줄을 넣었습니다. 이 예외가 실제로 먹는지 미심쩍어서, 전체 이미지 빌드 대신 busybox에 그 파일만 COPY하는 가벼운 빌드로 확인했습니다. 컨텍스트에 파일이 포함돼 빌드가 성공했습니다. compose에는 ANTHROPIC_API_KEY를 배선해 키를 주면 AI가 실제로 켜지게 했습니다.

4. 이번 계단에서 배운 것

Phase 0의 네 항목은 코드 난이도로 보면 시시합니다. 파일 하나, 조건 하나, git rm 하나, COPY 한 줄. 그런데 이 시시한 것들이 없으면 위에 쌓은 모든 기능이 “있어도 못 쓰는” 상태가 됩니다. 제품화의 첫 계단은 배포를 성립시키는 위생이라는 걸 다시 확인했습니다.

그리고 반복된 착각(대상을 향한 기능과 플랫폼 자신을 향한 기능을 이름이 비슷하다고 같은 것으로 여긴 것)이 다음 계단들의 지도가 됐습니다. 다음 Phase 1은 그 platform-facing 절반의 본론입니다.

5. Phase 1, 관제탑이 자기 자신을 지키기 시작하다

Phase 0에서 찾은 지도를 그대로 따라, 대상은 지키는데 자기 자신은 못 지키던 세 곳을 메웠습니다.

로그인이 무제한으로 뚫려 있었습니다. 실패를 감사 로그로 기록만 하고, 정작 반응해 문을 잠그진 않았죠. 계정별 연속 실패가 임계(기본 10회)를 넘으면 일정 시간(기본 15분) 잠그는 LoginAttemptGuard와, 잠긴 계정의 시도를 인증 앞에서 되돌리는 필터를 새 라이브러리 없이 인메모리로 넣었습니다. 감사가 “기록”이라면 이건 “반응”입니다. 대소문자를 바꾼 우회도 막았고, 잠겼을 땐 로그인 화면이 남은 시간을 알려줍니다.

로그인 잠금, 연속 실패 후 잠긴 로그인 화면

직접 잠가봤습니다. admin에 틀린 비밀번호로 열 번을 던지니 계속 일반 실패(?error)로 돌아오다가, 열한 번째에서 ?error=locked&retryAfter=899 로 필터가 인증에 닿기도 전에 되돌렸습니다. 잠긴 동안엔 비밀번호가 맞아도 통과 못 합니다.

관제탑이 자기 백업이 없었습니다. 대상 DB는 원격 S3까지 오프사이트로 보관하면서, 정작 사용자·모든 자격증명·정책·이력을 담은 자신의 메타 DB는 도커 볼륨 하나에만 의존했습니다. 볼륨이 날아가면 플랫폼 상태가 통째로 사라집니다. 이미지에 이미 번들된 pg_dump로 메타 DB를 스스로 덤프하는 잡을 넣었습니다. 대상 백업 이력과는 섞지 않고, 원격 보관이 켜져 있으면 meta/ 네임스페이스로 따로 올립니다. pg_dump가 없거나 메타가 PostgreSQL이 아닌 로컬 개발 환경에선 조용히 건너뜁니다. 자기 백업 때문에 기동이 흔들리면 안 되니까요.

웹은 여전히 평문 HTTP였습니다. 대상 DB 접속은 TLS로 강제하면서 정작 사용자 브라우저는 평문 8080으로 받고 있었죠. 인증서 갱신을 앱에 넣는 대신 리버스 프록시(Caddy/nginx)가 TLS를 종단하는 정석 구성으로 가되, 프록시 뒤에서 scheme·host를 보존하고 쿠키에 Secure 플래그를 붙일 수 있게 설정을 열었습니다.

장면. 라이브가 테스트를 다시 이겼다

이번에도 실측이 한 방 먹였습니다. 로그인 잠금과 메타 백업 설정을 application.yml에 넣는데, dbtower.security 블록을 새로 추가했습니다. 테스트를 돌렸더니 전부 통과했습니다. 그런데 실제로 jar를 띄우니 부팅이 실패했습니다.

found duplicate key security
in 'reader', line 172, column 3:

dbtower.security 블록이 이미 파일 아래쪽에 있었던 겁니다. YAML은 같은 부모 아래 중복 키를 허용하지 않습니다. 그런데 왜 테스트는 통과했을까요? 테스트는 별도의 test/resources/application.yml을 써서 그 중복을 만나지 않았습니다. 테스트와 실제 배포가 다른 설정 파일을 읽는다는 그 사실이, 중복 키를 실제 부팅 전까지 숨겼습니다. 기존 블록에 병합해 해소했고, 실제 부팅이 올라오는 걸 확인했습니다.

4편에서도 그랬듯, 단위 테스트가 초록불이어도 실제로 띄워봐야 보이는 것이 있습니다. 특히 “테스트는 이 파일, 배포는 저 파일”처럼 환경이 갈리는 지점에서요.

재시작마다 바뀌던 토큰

하나 더, 사소해 보이지만 실무에선 성가신 게 있었습니다. API/MCP용 서비스 토큰을 명시적으로 설정하지 않으면 앱은 기동할 때마다 새 토큰을 랜덤 생성했습니다. “설정 안 하면 아무도 모르는 토큰”이라는 의도는 좋았는데, 재시작할 때마다 토큰이 바뀌니 MCP 연동이 매번 끊겼습니다. 최초 생성한 토큰을 플랫폼 메타 DB에 저장해(작은 키-값 테이블 하나) 재시작에도 살아남게 했습니다. 같은 DB 파일로 두 번 띄워 조회하니 두 번 다 같은 값이 나왔습니다.

프로메테우스가 긁어가는 메트릭 경로도 토큰으로 잠글 수 있게 했습니다. 처음엔 거부를 sendError로 냈다가 302 로그인 리다이렉트가 되는 걸 보고, 스크레이퍼가 이해하는 깨끗한 401로 바로잡았습니다. 사람이 아니라 기계가 읽는 경로라 리다이렉트는 오히려 방해였습니다.

여기까지, 관제탑은 이제 로그인을 잠그고, 자기 자신을 백업하고, TLS 뒤에 설 준비를 마쳤고, 토큰이 재시작에도 흔들리지 않습니다. 자기 자신을 향한 절반을 채운 셈입니다. 남은 절반은 그 화면을 레퍼런스와 나란히 놓고 컬럼 단위로 맞추는 일이었습니다.

6. Phase 2, 문의에 스키마를 붙이다

사실 이 프로젝트를 다시 붙잡게 된 처음 질문이 이거였습니다. “DB팀에 문의를 보낼 때, 원본 테이블이 어떤 구성인지·조인된 테이블이 어떤 구성인지 정보도 다 보내줘야 하는 거 아닌가? 그 사이트에서 볼 때도 마찬가지고.”

맞는 지적이었습니다. 그때까지 문의는 쿼리·실행계획·규칙 지적·AI 분석까지 담았는데, 정작 진단의 핵심인 참조 테이블의 컬럼·인덱스와 조인 구성이 빠져 있었습니다. DB팀 입장에선 “이 쿼리 느려요 + 실행계획”까지는 왔는데, “조인 컬럼에 인덱스가 있나? 타입은 맞나?”를 판단할 재료가 없었죠. Seq Scan이 찍혀 있어도 어떤 인덱스가 있고 왜 안 탔는지는 구조를 봐야 압니다.

그래서 쿼리가 참조하는 테이블들의 구조를 함께 붙였습니다. SQL에서 FROM·JOIN 뒤 테이블 이름을 뽑고(조인된 테이블도 당연히 전부), 각 테이블의 컬럼(타입·NULL 여부), 인덱스(유니크 여부), 대략 행수를 인덱스 중심으로 요약합니다. 정규식 파싱은 서브쿼리·CTE·별칭에서 오탐이 나니, 뽑은 후보를 실제 스키마와 교집합해 검증합니다. 존재하지 않는 후보(CTE 이름·별칭)는 그 단계에서 걸러지고, 그래도 못 찾은 건 “구조 미확보”로 정직하게 표기합니다.

데모 PostgreSQL을 붙여 조인 쿼리로 확인해봤습니다. FROM plan_demo JOIN bloat_demo를 주니 두 테이블이 다 나옵니다. plan_demoidx_plan_demo_k(k)와 기본키 plan_demo_pkey[U](id), bloat_demobloat_demo_pkey[U](id), 컬럼은 타입까지(pad text??는 NULL 허용). 조인된 테이블을 빠뜨리지 않는다는 게 핵심이었습니다.

관련 테이블 구조, 조인 쿼리의 FROM·JOIN 테이블 모두의 컬럼·인덱스·행수와 크기·카디널리티를 상세 패널에 표시

문의를 보낼 땐 이 구조가 서버에서 자동으로 첨부되고, 보내기 전 사이트 상세 패널에서도 “관련 테이블 구조” 버튼으로 미리 볼 수 있습니다. 처음 질문의 “그 사이트에서 볼 때도 마찬가지”가 바로 이 부분입니다.

스코프는 솔직하게 조정했습니다. 원래 설계는 참조 테이블만 콕 집어 조회하는 전용 메서드(IN-조회)였는데, 기존 스키마 조회를 재사용해 필터하는 쪽으로 갔습니다. 대부분의 스키마는 조회 상한 안에 들어오고, 상한 밖은 “구조 미확보”로 드러나니까요. 최적화를 더 다듬기보다 지금 쓸 수 있는 가치를 먼저 냈습니다. 데이터 마스킹과 로그 백업 다섯 기종은 다음 조각으로 남겨둡니다.

이 첨부는 그 뒤로 두 번 더 손봤습니다. 한 번은 값을 고쳤습니다. 스냅샷 테이블을 월 파티션으로 바꾼 뒤 문의 카드에 행수가 0으로 찍히는 걸 실물 대조에서 발견했는데, 파티션 부모는 카탈로그 통계에 행수가 없고 실제 행은 리프 파티션에 있기 때문이었습니다. 인덱스 조회도 일반 테이블만 잡아 부모의 인덱스 두 개가 통째로 빠졌고요. 행수·크기는 리프를 합산하고 인덱스는 파티션 부모를 포함하도록 고치니 47만 행이 제대로 나왔습니다. 또 한 번은 형태를 올렸습니다. 컬럼·인덱스 한 줄 요약 대신 테이블 상세 화면과 같은 원천을 써서, CREATE TABLE 전문(기종별 원천 그대로, PostgreSQL은 재구성 라벨을 달아서)과 행수·데이터·인덱스 크기, 인덱스 카디널리티까지 카드에 싣습니다.

DB팀 문의 카드, CREATE TABLE 전문과 행수·데이터·인덱스 크기까지 첨부된 실물

7. 화면을 옆에 놓고 대조하다

앞에서 셀프호스트 블로커를 없앤 뒤, 처음의 레퍼런스 발표 자료를 다시 꺼냈습니다. 이번엔 기능 목록이 아니라 화면 11장을 옆에 놓고 컬럼 단위로 대조했습니다. 결과가 흥미로웠습니다. 뼈대(3탭 구조, 시점 비교, 증감·신규 감지, 활용 사례 세 가지 흐름)는 전부 돌아가는데, 표에 찍히는 컬럼이 달랐습니다.

레퍼런스의 상위 SQL 표는 Call/sec·Latency(ms)·Row Examined(Avg)를 보여주는데 DBTower는 누적 Calls·Total(ms)을 보여줬고, 슬로우 쿼리 표엔 User@host와 Lock_time이 없었고, MongoDB 표엔 인덱스를 탔는지(IXSCAN/COLLSCAN)를 바로 보여주는 Plan 컬럼이 없었습니다. 기능이 된다는 것과 운영자가 매일 보는 화면이 같은 정보 밀도를 갖는 것은 다른 문제였습니다. 이 뒷부분은 그 간극을 메운 기록이고, 메우는 과정에서 만난 함정 셋이 사실 본편입니다.

8. 테이블 상세, DDL의 출처를 속이지 않기

먼저 제일 큰 조각부터. 레퍼런스의 “테이블 상세 정보” 화면은 CREATE TABLE 전문, 기본 통계(엔진·행수·데이터/인덱스 크기·평균 행 길이·생성 시각), 인덱스 정보(타입·카디널리티)를 한 화면에 보여줍니다. 문의에 붙이던 “린 요약”(컬럼·인덱스 이름 수준)을 이 수준으로 끌어올렸습니다.

5기종의 사정이 다 달랐습니다. MySQL은 SHOW CREATE TABLE이 원문을 그대로 주지만, PostgreSQL엔 그런 단일 명령이 없습니다. 그래서 DDL에 출처 라벨을 달았습니다. 엔진이 준 원문이면 NATIVE, 카탈로그에서 조립했으면 RECONSTRUCTED. 재구성한 것을 원문인 척 보여주지 않겠다는 원칙입니다. 카디널리티도 같은 원칙으로, SQL Server처럼 기본 노출이 아닌 기종(DBCC는 무겁고 권한이 필요합니다)은 지어내지 않고 비워둡니다.

기종별 함정도 하나씩 있었습니다. MySQL의 STATISTICS는 복합 인덱스 카디널리티를 컬럼 위치별로 누적해 주므로 마지막 위치 값이 인덱스 전체 카디널리티입니다. PostgreSQL의 pg_stats.n_distinct는 음수면 “행수 대비 비율”이라는 뜻이라 round(-n_distinct × reltuples)로 환산해야 합니다. 여기서 라이브 함정이 하나 있었는데, n_distinct는 float4인데 pgjdbc가 float4→Double 변환을 지원하지 않아 ::float8 캐스팅이 필요했습니다. 그리고 SHOW CREATE TABLE류는 테이블명을 파라미터 바인딩할 수 없어서, 식별자 문자 집합을 강하게 제한하는 검증([A-Za-z0-9_$#]{1,128})이 유일한 주입 방어선입니다. {"table":"users; DROP TABLE users"}를 넣어 거부되는 것까지 라이브로 확인했습니다.

MySQL users 테이블 상세, SHOW CREATE TABLE 원문(NATIVE)과 InnoDB, 카디널리티 8,118

“(근사)“는 게으름의 라벨이었다

처음엔 PG 재구성 DDL에 “카탈로그 재구성(근사)“라는 배지를 달았습니다. 컬럼·PK·인덱스만 조립하고 FK·CHECK를 생략했으니 근사가 맞긴 했습니다. 그런데 “근사가 뭐냐, 정확하게 해야 하는 거 아니냐”는 지적을 받고 다시 보니, 근사일 이유가 없었습니다. PostgreSQL은 pg_get_constraintdef, pg_get_indexdef라는 자체 정의 함수를 제공합니다. pg_dump가 쓰는 바로 그 함수들입니다. 이걸 쓰면 FK·CHECK 절이 엔진이 렌더한 원문 그대로 나옵니다.

FK와 CHECK가 있는 데모 테이블을 만들어 확인하니 CONSTRAINT demo_order_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES demo_customer(id)CHECK ((qty > 0))가 정확히 재조립됐습니다. 배지에서 “(근사)“를 뗐고, 이제 담지 못하는 것은 트리거·파티션 정의뿐이라 실제로 있을 때만 note에 밝힙니다. RECONSTRUCTED는 “단일 명령이 없다”는 사실의 표기이지 부정확의 표기가 아니게 됐습니다. 정직 라벨은 한계를 가리는 데 쓰면 안 되고, 한계를 없앨 수 있으면 없애는 게 먼저라는 걸 배웠습니다.

PostgreSQL demo_order 테이블 상세, FK·CHECK까지 재조립된 DDL로 근사 아님

9. 함정 하나, 테스트 382건 초록에 화면은 전멸

테이블 상세를 검증하려고 브라우저를 열었더니 모든 화면이 “불러오는 중…”에서 멈춰 있었습니다. 인스턴스 목록도, 그래프도, 전부. 콘솔엔 한 줄이 찍혀 있었습니다: Identifier 'fmtBytes' has already been declared.

테이블 상세 렌더를 붙이며 추가한 const fmtBytes가, 파일 저 위에 이미 있던 같은 이름의 선언과 충돌한 겁니다. 자바스크립트에서 const 중복 선언은 실행 중 에러가 아니라 파싱 에러입니다. app.js 전체가 한 글자도 실행되지 못했고, SPA는 아무것도 렌더하지 못했습니다. 그리고 이 상태로 커밋까지 되어 있었습니다. 단위 테스트 382건이 전부 초록이었으니까요.

당연한 이야기지만 Java 단위 테스트도, curl로 하는 API 검증도 프론트 자바스크립트의 파싱을 거치지 않습니다. 4편의 YAML 중복 키(테스트는 통과, 실제 부팅에서 폭발)와 정확히 같은 결입니다. 검증 파이프라인에 node --check(구문 검사)를 넣고, 기능 검증은 API 응답 확인이 아니라 브라우저에서 실제 화면이 그려지는 것까지로 기준을 올렸습니다. 이 습관이 뒤에 나올 세 번째 함정도 잡아냅니다.

10. 표 컬럼 패리티, Call/sec의 정직

컬럼을 맞추는 건 대부분 “데이터는 이미 안에 있는데 노출만 안 된” 문제였습니다. 슬로우 쿼리의 User@host·Lock_time·Rows_sent는 mysql.slow_log에 이미 있는 컬럼이었고, MongoDB의 Plan은 system.profile이 저장하는 planSummary(“IXSCAN { k: 1 }”, “COLLSCAN”)를 그대로 배지로 올리면 됐습니다. COLLSCAN은 빨강, 인덱스를 탄 스캔은 초록입니다. 표에서 바로 “인덱스를 안 탔네”가 보입니다.

MongoDB 슬로우 쿼리, Plan 컬럼으로 인덱스 사용 여부가 바로 보인다 (COLLSCAN 43,000행 스캔)

까다로운 건 상위 SQL 표의 Call/sec 하나였습니다. 평균 Latency와 평균 Row Examined는 누적값을 호출수로 나누면 그만이지만, “초당 호출”은 다릅니다. performance_schema의 calls는 서버 기동 이후 누적 카운터라서, 단일 시점 조회로는 초당 얼마인지 알 수가 없습니다. 시간 창이 필요합니다.

마침 이상 감지가 이미 하고 있던 일이었습니다. 1분마다 쌓는 스냅샷에서 최근 창의 양 끝 배치를 차분해 쿼리별 QPS를 계산하는 로직이요. 그걸 재사용해 Call/sec를 채우되, 스냅샷 이력이 없으면 0으로 지어내지 않고 ”—“로 표기합니다. 수집이 방금 시작된 인스턴스에서 Call/sec가 0으로 보이는 것과 “아직 모름”으로 보이는 것은 운영자에게 완전히 다른 정보입니다.

11. 모니터링 지표 통합, 위임을 접고 내장하다

Monitoring 탭의 CPU·Connections 그래프는 처음에 “의도된 차이”로 분류했습니다. 레퍼런스는 관리형 DB 환경이라 CloudWatch·Performance Insights가 있고, 우리는 exporter·Prometheus·Grafana 스택이니 링크로 위임하면 된다고요. 그런데 다시 보니 레퍼런스도 결국 수집기의 패널을 화면에 임베드한 것이었습니다. 우리 스택도 똑같이, Prometheus HTTP API(query_range)를 앱이 직접 조회하면 됩니다. exporter는 이미 데모 스택에서 돌고 있었고, 없는 건 호스트 CPU를 줄 node_exporter뿐이었습니다.

그래서 내장했습니다. Monitoring 탭 맨 위에 CPU(%)와 Connections 라인 차트, 그리고 “전체 화면으로 보기”는 Grafana로. 원칙 하나를 지켰습니다. Prometheus는 선택 인프라입니다. 미설정이든 연결 불가든 그래프는 사유를 그대로 표기하고(“node_exporter 미수집 — …”), 콘솔의 나머지는 아무 영향이 없습니다. 그래프 한 장 때문에 콘솔이 죽으면 안 되니까요. MSSQL처럼 표준 exporter가 없는 기종은 미지원을 미지원이라고 적습니다.

Monitoring 탭 Metric 카드, CPU%·Connections를 콘솔이 직접 그린다

그리고 레퍼런스 화면의 핵심 인터랙션인 CPU 그래프를 드래그해서 조회·비교 구간을 고르는 것도 가져왔습니다. 기존 드래그 차트는 QPS만 그렸는데, QPS ↔ CPU% 토글을 달아 같은 드래그가 CPU 그래프 위에서도 동작하게 했습니다. 부하가 튄 구간을 CPU 곡선에서 눈으로 집어 드래그하면 그 구간이 시점 비교의 입력이 됩니다.

CPU 그래프에서 조회(초록)·비교(주황) 구간을 드래그로 선택

부수 효과로, Phase 5에 계획해 뒀던 디스크 포화 예측(predict_linear)의 기반인 node_exporter와 Prometheus 클라이언트가 이 작업으로 먼저 생겼습니다.

12. 함정 셋, 9시간 미래를 조회하고 있었다

그런데 metrics API가 자꾸 빈 결과를 돌려줬습니다. Prometheus에 직접 curl을 치면 데이터가 나오는데, 앱을 거치면 빈 배열. 클라이언트 코드를 똑같이 떼어 단독 실행하면 또 됩니다. 앱에서만 안 되는 이유를 쫓다가 로그 타임스탬프가 눈에 들어왔습니다. 호스트는 오후 12시 55분인데 앱 로그는 03:55Z였습니다.

DBTower의 JVM은 의도적으로 UTC에 고정되어 있습니다(하드닝 아크에서, 서버·수집기·대상 DB의 시각 기준이 섞여 쿨다운·베이스라인이 어긋나는 걸 막으려고요). 그런데 프론트는 브라우저 벽시계(한국이면 KST)를 그대로 API에 보내고 있었습니다. 서버는 그 벽시계 숫자를 UTC로 읽습니다. “최근 3시간”을 조회한다는 게 실제로는 9시간 미래의 빈 구간을 조회하고 있던 겁니다.

이건 새 기능의 버그가 아니라 기존 버그였습니다. 활동 그래프도, 드래그로 채운 비교 조회도 같은 스큐를 갖고 있었습니다. 고치는 방향은 한 가지 규칙으로 정리했습니다. 선을 넘는 시각은 UTC, 사람 눈에 닿는 시각은 로컬. API로 보낼 땐 toISOString()으로 변환하고, API가 주는 시각(오프셋 표기가 없는 UTC 벽시계)은 Z를 붙여 진짜 시각으로 만든 뒤 차트 축·입력창은 브라우저 로컬로 렌더합니다. 고치고 나서 CPU 그래프에서 드래그 → KST로 표시된 입력 → UTC로 변환된 요청 → 비교 조회 성공(호출량 +75%, 신규 쿼리 10개)까지 한 번에 이어지는 걸 확인했습니다.

돌아보면 이 버그는 함정 하나(테스트 초록, 화면 전멸)와 같은 종류입니다. 단위 테스트는 서버 안에서만 돌고, 그 안에선 시각이 일관됩니다. 브라우저라는 다른 시간대의 참여자가 끼어야 드러나는 문제였고, 화면까지 열어보는 검증이 또 한 번 값을 했습니다.

13. 남은 조각

화면 11장 기준으로 남은 건 이제 명확합니다. 활용 사례 화면 좌상단의 “Slack Group: 팀명 / 콘솔 딥링크”입니다. 인스턴스마다 담당 팀과 콘솔 URL을 달아주는 메타데이터인데, 멀티팀 접근 제어(Phase 3)의 팀 라벨과 같은 컬럼으로 설계해야 마이그레이션을 두 번 하지 않습니다. AWS SDK를 붙이는 대신 URL 필드 하나로 일반화하면, 관리형 DB를 쓰는 조직은 PI 링크를, 셀프호스트는 Grafana 링크를 넣으면 됩니다. 그리고 앞에서 미뤄둔 데이터 마스킹이 있습니다. 외부로 나가는 쿼리에서 리터럴만 가리는 스캐너는 써뒀고, 발신 지점 네 곳에 배선하는 일이 남았습니다.

“기능이 된다”에서 “같은 화면이 나온다”로 오는 데 함정이 셋이었습니다. 전부 단위 테스트 바깥에서만 보이는 것들이었고요. 다음 편은 아마 마스킹과 팀 라벨, 그러니까 “여러 팀이 한 콘솔을 쓰기 시작할 때”의 이야기가 될 겁니다.

14. 후기, 마지막 여섯 조각까지

이 편을 쓰고 한참 뒤, 레퍼런스 발표 자료 60장을 다시 한 장씩 대조해 남은 조각을 전부 셌습니다. 여섯이 나왔고, 전부 구현해서 이 편의 주제를 끝까지 이어 갔습니다.

인스턴스 목록에 검색창과 기종·팀 필터가 붙었고(레퍼런스의 환경/리전 필터는 AWS 고유라 이렇게 일반화했습니다), 복제 역할 배지가 카드에 인라인으로 표시됩니다. 역할이 확인되는 구성에서만 붙고, 확인 안 되면 조용히 비워둡니다.

인스턴스 목록, 검색·필터와 역할 배지

결과 상단에는 레퍼런스와 같은 문구의 시간 범위 줄이 생겼습니다. 단독 조회면 비교 범위를 -로 표기합니다.

조회하는 시간 범위(KST) 표기 줄

Monitoring 탭에는 명령 종류별 차트가 들어갔습니다. MySQL은 Com_* 카운터의 rate로 SELECT/INSERT/UPDATE/DELETE Commands를, PostgreSQL은 명령별 카운터가 exporter에 없어 행 연산(tup_*)이라는 정직한 등가 지표를 그립니다. 차트마다 Mean/Max/Min 범례가 붙습니다.

명령별 차트 그리드와 Mean/Max/Min 범례

나머지 셋은 화면 밖입니다. Mongo 슬로우 쿼리는 시간대별 샘플링(폭주 시간대가 목록을 독점하지 않게 시간 버킷 라운드로빈)이 들어갔고, Slack Events 인바운드(서명 검증·challenge·이모지 이벤트)와 알림의 “알람 스킵”은 다른 편에서 다룹니다.

그리고 이 후기에도 이 편다운 함정이 하나 있었습니다. 스크린샷을 찍으려 브라우저로 콘솔에 들어가는데, 로그인해도 계속 로그인 페이지로 튕기는 겁니다. 범인은 MCP 연동 카드였습니다. 페이지 로드 때 /mcp를 호출하는데, MCP 인증이 OAuth 전용 체인으로 바뀐 뒤 그 호출이 401이 됐고, 공용 API 래퍼가 401을 보면 로그인으로 보내도록 되어 있었거든요. curl로 API를 아무리 검증해도 멀쩡했고, 화면을 실제로 열어야만 드러나는 회귀였습니다. 이 편의 결론이 한 번 더 증명된 셈입니다. 단위 테스트 바깥, 화면까지 열어보는 검증이 값을 합니다.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.