인프라 2026.06 말, 배포 직후
로컬에서는 정상인데 서버에서만 실패하던 문제
증상: 노트북에서 정상 동작하던 봇을 24시간 운영할 서버로 옮기자, OpenAI를 호출하는 기능이 전부 실패했습니다. 음성 전사와 회의록 생성 양쪽 모두였습니다.
원인: OpenAI 서버는 응답을 gzip으로 압축해서 보냅니다. 봇이 그 압축을 해제하는 과정이 서버에 설치된 Node 24에서만 오작동해, 응답을 다 받기 전에 연결이 끊겼습니다.
바꾼 것: 요청 헤더에 Accept-Encoding: identity를 추가해 압축되지 않은 응답을 받도록 했습니다.
배포 직후 봇이 남긴 에러는 이것 하나였습니다.
FetchError: Invalid response body while trying to fetch ... Premature close
code: ERR_STREAM_PREMATURE_CLOSE
원인을 좁히기 어려웠던 이유는 나머지가 전부 정상이었기 때문입니다.
| 확인한 것 | 결과 |
| API 키·크레딧 | 정상 |
서버에서 curl로 직접 호출 | HTTP 200 |
| 노트북(Node 25)에서 봇 실행 | 정상 |
| 서버(Node 24)에서 봇 실행 | 전부 실패 |
단서는 에러 스택에 찍힌 Gunzip이었습니다. gzip은 데이터를 압축해 전송량을 줄이는 방식이고, Gunzip은 받은 쪽에서 그 압축을 되돌리는 단계입니다.
정리하면 이렇습니다. OpenAI 서버는 응답을 gzip으로 압축해 보내고, 봇은 그것을 해제해서 읽습니다. 이 해제를 담당하는 것이 OpenAI SDK v4 내부의 node-fetch v2인데, 서버에 설치된 Node 24에서 압축 해제 도중 스트림이 조기 종료됐습니다. 제 노트북은 Node 25라 같은 코드가 정상 동작했습니다. 라이브러리 버전이 달랐던 것이 아니라, 그 라이브러리를 실행하는 Node 버전이 달랐습니다.
수정은 한 줄입니다. HTTP 요청에는 "압축된 응답을 받을 수 있다"고 상대 서버에 알리는 Accept-Encoding 헤더가 있습니다. 이 값을 identity(압축 없음)로 지정하면 OpenAI 서버가 원본을 그대로 보냅니다. 압축 해제 단계를 거치지 않으므로 문제가 되는 코드에 도달하지 않습니다.
압축을 끄면 주고받는 데이터 양은 늘어납니다. 네트워크·CPU·메모리 세 가지로 나눠 따져 봤습니다.
| 관점 | 원본을 그대로 받으면 |
| 네트워크 | 회의록 응답 한 건이 4.2KB → 8.7KB로 약 4.4KB 증가한다. 하루 100건을 처리해도 7MB 수준이다. 봇이 음성을 업로드하는 양이 발화 10분당 18MB인 것과 비교하면 무시할 수 있다. |
| CPU | 오히려 줄어든다. 압축된 응답을 해제하는 연산이 사라지기 때문이다. 이 서버는 공유 vCPU를 쓰기 때문에 CPU가 가장 아쉬운 자원이다. |
| 메모리 | e2-micro의 1GB 기준에서 KB 단위 증가는 영향을 줄 수 있는 범위가 아니다. |
SDK 버전을 올려 여러 곳의 코드를 수정하고 전체를 재검증하는 것보다, 압축을 쓰지 않고 원본을 그대로 받는 편이 낫다고 판단하였고, 현재도 이 방식으로 운영 중에 있습니다.
봇의 라이브러리는 노트북과 서버가 똑같았습니다. 다른 것은 그 라이브러리를 실행하는 Node 버전이었습니다. 의존성과 실행 환경을 모두 고려해서 환경을 구성해야 한다는 것을 배웠습니다. 원인을 찾는 데 며칠이 걸렸고 수정은 한 줄이었으며, 에러 메시지 본문보다 스택에 찍힌 Gunzip 한 단어가 결정적인 단서였습니다.
품질 2026.07.02
회의록이 자꾸 세 줄로 요약되던 문제
증상: 1시간 회의를 정리했는데, 회의록의 "논의 내용"이 소제목 5개에 한두 줄씩만 남았습니다. 회의에 참석한 사람이 보기에 내용이 거의 남지 않은 분량이었습니다.
원인: 프롬프트가 문제라고 생각했는데, 아니었습니다. 쓰고 있던 모델(gpt-4o) 자체가 원인이었습니다.
바꾼 것: 모델과 프롬프트를 하나씩 바꿔 가며 실제 전사본으로 비교한 끝에 gpt-4o → gpt-4.1로 선정했습니다.
2026년 7월 2일 데일리 스크럼이었습니다. 1시간 회의를 받아쓴 전사본이 18,060자. 재료는 충분했는데, 나온 회의록의 "논의 내용"은 소제목 5개에 한 줄짜리 항목이 한두 개씩 붙은 게 전부였습니다.
처음에는 프롬프트를 의심했습니다. "요약이 아니라 복원이다. 논점을 하나도 삭제·누락하지 마라. 절대 줄이거나 뭉치지 마라."라는 규칙을 프롬프트에 명시적으로 추가했습니다. 그런데 gpt-4o가 내놓은 회의록은 1,047자였습니다. 규칙을 넣기 전과 달라진 게 없었습니다.
여러 번 프롬프트를 수정해 보았으나 회의록의 품질이 크게 향상되지 않았고, 저는 프롬프트와 전사본은 고정해 놓고 모델 변경을 시도했습니다. gpt-4.x 계열 중 4.1 모델이 3,856자 회의록을 작성하며 품질이 가장 좋았습니다. API 비용 이야기, 논의하다 접은 대안, 필드를 왜 그렇게 설계했는지까지 그날 데일리 스크럼에서 나눈 내용이 자세하게 적혀 있었습니다. 프롬프트가 아니라 모델 자체의 문제였던 것입니다.
모델 선정이 어려운 이유는 트레이드오프의 중간점을 잘 잡아야 하기 때문입니다. 성능이 좋은 모델은 회의 한 번에 소모되는 토큰 비용이 올라갑니다. 이 비용은 서버를 쓰는 사람이 자신의 API 키로 부담합니다. 반대로 저렴한 모델을 쓰면 회의록의 밀도가 떨어집니다. 한 시간을 논의했는데 "논의가 있었다" 수준의 문장 몇 개로 끝나면 회의록을 작성할 이유가 없습니다.
| 시기 | 모델 | 변경 사유 |
| 초기 | gpt-4o-mini | 가장 저렴하다. 다만 서술이 빈약하고 화자 구분이 부정확했다. |
| 06.30 | gpt-4o | 품질을 위해 상향했다. 서술·화자 구분은 개선됐으나 논의 내용을 몇 줄로 축약하는 경향이 남았다. |
| 07.02~ | gpt-4.1 | 같은 프롬프트에서 논점을 훨씬 충실히 복원한다. 단가 또한 gpt-4o보다 저렴하다. |
OpenAI의 토큰 단가를 확인해 봤습니다. gpt-4.1은 논점을 더 충실히 복원하면서 토큰 단가도 저렴했습니다. 보통은 품질을 얼마나 포기하고 비용을 얼마나 아낄지 저울질해야 하는데, gpt-4.1이 gpt-4o보다 품질과 가격에서 모두 우수하여 모델을 변경했습니다. 설정에서 모델 이름 한 줄만 바꿨고 프롬프트는 수정하지 않았습니다. 현재 1시간 회의 한 건당 회의록 비용은 약 $0.15입니다(음성을 텍스트로 변환하는 gpt-4o-mini-transcribe까지 합치면 $0.3 안팎).
모델 선정에서 함께 검토한 값이 하나 더 있습니다. temperature입니다. 이건 모델이 답을 얼마나 자유롭게 쓸지 정하는 값입니다. 낮을수록 사실에 기반하여 글을 쓰고, 높을수록 미사여구를 포함해 창의적으로 글을 작성합니다. 송지아는 이 값을 작업별로 다르게 고정해 뒀습니다. 회의록 본문은 temperature 0.3, 안건 목차를 추출하거나 "다음 회의 일시"를 집어내는 것처럼 없는 내용을 생성하면 안 되는 작업은 temperature 0, 음성 전사도 0으로 설정했습니다.
회의록 모델로 gpt-5.x 계열도 함께 논의했습니다. 다만 이 모델들에는 temperature 파라미터가 존재하지 않았습니다. 사실에 근거해 쓸 것인지, 자유롭게 글을 쓸지를 사용자가 정할 수 없었습니다. 또 gpt-5.x는 reasoning 계열이라 답을 출력하기 전에 내부적으로 오래 추론하고, 그 추론 분량까지 토큰으로 과금됩니다. 수학이나 코드 디버깅처럼 답을 새로 도출해야 하는 작업에는 유효하지만, 회의록은 이미 전사본에 적혀 있는 내용을 정리하는 작업이라 성격이 달랐습니다. 결과적으로 디스코드 회의록에 적합한 모델이 아니라고 판단해 배제했습니다.
다만 gpt-4.1이 항상 정답이라는 뜻은 아닙니다. 바로 다음 문제에서 이 모델 하나로는 부족해지고, 더 저렴한 gpt-4.1-mini와 역할을 분담하는 구조로 다시 변경됩니다.
모델을 선정할 때 회의록의 품질과 가격 양측을 고민하며 비교해 보았습니다. gpt-4.1 모델이 송지아 봇에 가장 적합하다 판단했고, 두 마리 토끼를 모두 잡을 수 있었습니다. 프롬프트만 수정하는 동안 모델은 한 번도 바꿔 보지 않았던 것이 시간을 끈 원인이었습니다.
설계 2026.07.06 ~ 07.10
2시간 회의는 회의록이 아예 나오지 않던 문제
증상: 짧은 회의는 정상인데, 2시간짜리 긴 회의만 넣으면 회의록이 나오지 않았습니다. 봇은 "회의록 생성 중 오류"만 표시했습니다.
원인: 모델마다 1분간 보낼 수 있는 양(TPM)이 정해져 있는데, 긴 전사본은 요청 하나가 그 한도의 두 배였습니다.
바꾼 것: 한 모델에 전부 맡기지 않고 모델 하이브리드 설계를 적용했습니다. 목차 추출은 gpt-4.1-mini, 본문 작성은 gpt-4.1이 담당합니다.
VM 로그를 확인하니 실제 에러는 이것이었습니다.
429 Request too large ... TPM Limit 30000, Requested 60943
TPM(Tokens Per Minute)은 "이 모델에 1분간 이만큼까지만 보낼 수 있다"는 한도입니다. 제 등급(Tier 1)에서 gpt-4.1은 30,000인데, 2시간 회의 전사본은 요청 하나가 60,943이었습니다.
여기서 429라는 같은 번호가 두 가지 상황을 가리킨다는 점이 중요합니다.
| 종류 | 언제 발생하나 | 기다리면 되나 |
| 요청 하나가 한도보다 큼 | 6만 토큰을 통째로 보냈을 때 | 안 된다. 1분을 기다려도 요청 크기는 그대로 6만이다 |
| 1분 동안 너무 많이 보냄 | 작은 요청을 연달아 보냈을 때 | 된다. 잠시 기다렸다 다시 보내면 통과한다 |
긴 회의는 첫 번째에 해당했습니다. 봇은 자동으로 재시도하도록 만들어져 있었지만, 몇 번을 다시 보내도 요청 크기가 줄지 않으니 영원히 실패했습니다.
제 계정의 Tier를 2로 올려서 한도를 늘릴 수도 있었습니다. 그러나 송지아는 서버마다 유저가 각자의 OpenAI API 키를 등록해서 사용하는 구조(BYOK, Bring Your Own Key)입니다. 회의록을 만드는 비용도, 그 키에 걸린 한도도 사용하는 쪽의 것입니다. 제 계정 등급을 올려 봐야 다른 사용자에게 아무 의미가 없었고, 오히려 처음 키를 발급한 사람은 대부분 Tier 1이므로 그 조건에서 동작하지 않으면 봇 자체를 아예 쓸 수 없는 상황이 발생하였습니다.
그래서 Tier 1을 기준으로 고정해 놓고 회의 시간에 관계 없이 무조건 봇이 작동하도록 해결 방법을 찾았습니다. 가장 낮은 등급에서 되면 그보다 높은 등급에서는 당연히 됩니다.
1차 시도 — 전사본을 나눠서 보내기
긴 글을 여러 조각으로 나눠 각각 요약한 뒤 그 결과를 합치는 map-reduce 방식을 적용했습니다. 전사본을 조각(청크)으로 쪼개 하나씩 정제하고, 그 결과를 모아 회의록을 작성합니다.
이것으로 "영원히 실패"는 없어졌습니다. 요청 하나하나가 한도 밑으로 들어가니, 위 표의 두 번째 상황으로 바뀐 것입니다. 조각을 연달아 보내다 1분 한도에 걸리면 OpenAI가 "몇 초 뒤에 다시 오라"고 알려주고, 봇이 그만큼 기다렸다가 다시 보냅니다. 기다리기만 하면 언젠가는 끝납니다.
그런데 이번에는 회의록의 품질이 떨어졌습니다. 조각 하나의 크기를 정하는 계산에 이유가 있습니다.
사용하는 모델 gpt-4.1의 한도 30,000 토큰은 단순히 "입력"만의 크기가 아닙니다.
한 번의 요청 = 지시문 + 전사본 조각 + 출력에 예약해 둔 크기 ≤ 30,000
출력분을 미리 잡아두는 이유는, 모델이 답을 얼마나 길게 쓸지 보내는 시점에는 알 수 없기 때문입니다. "최대 이만큼 쓰겠다"고 예약해서 함께 신고해야 합니다.
조각 하나의 크기는 16,000토큰으로 정해 두었습니다. 여기에 출력 예약 6,000과 정제 지시문 461을 더하면 22,461이고, 상한 27,000 안에 들어갑니다. 당시 코드 주석에도 16k + 6k출력 + system < 27k라고 적어 두었습니다.
| 항목 | 토큰 |
gpt-4.1의 한도 | 30,000 |
| 코드가 잡은 상한 | 27,000 (여유 3,000) |
| 조각 입력 | 16,000 |
| 출력 예약 | 6,000 |
| 정제 지시문 (매 요청마다 함께 전송) | 461 |
| 합계 | 22,461 |
한도 30,000에 딱 붙이지 않고 27,000으로 잡은 것은, 토큰 개수를 정확한 계산기 대신 넉넉하게 어림잡아 세기 때문입니다. 적게 잡았다가 한도를 넘으면 계속 실패하지만, 많게 잡으면 조각이 조금 작아질 뿐이라 안전한 쪽으로 기울였습니다.
여기서 문제가 생깁니다. 조각에 16,000토큰을 넣었는데 답은 6,000토큰까지만 쓸 수 있습니다. 모델은 내용이 알차든 비었든 2.7분의 1로 줄여서 내놓는 것 말고 방법이 없습니다. 잡담만 있는 조각이면 줄여도 손해가 없지만, 논점이 빽빽한 구간일수록 더 많이 버려집니다.
출력 예약을 늘리는 방법도 생각해 봤지만, 합이 27,000을 넘을 수 없으니 그만큼 조각이 작아질 뿐입니다. 조각이 작아지면 개수가 늘고, 각 조각이 앞뒤 맥락을 더 못 봅니다. 어느 쪽으로 조절하든 한도가 정해져 있기 때문에 한계가 명확했습니다.
여기에 문제가 두 가지 더 겹쳤습니다.
| 문제 | 내용 |
| 원문 접근 상실 | 최종 회의록을 쓰는 모델이 전사 원문을 한 번도 보지 못한다. 다른 모델이 줄여 놓은 메모만 읽고 작성하는 구조다. |
| 손실의 누적 | 아래는 문제가 됐던 2시간 회의(전사본 60,943토큰)를 기준으로 한 계산이다. 조각 4개를 각각 정제해 합치면 24,000토큰이 된다. 그런데 최종 회의록을 쓰는 요청에는 회의록 지시문 1,604와 출력 예약 8,000이 함께 들어가야 해서, 입력에 쓸 수 있는 자리는 17,396뿐이다. 24,000은 여기에 안 들어간다. 그래서 이미 줄인 글을 한 번 더 줄이게 되고, 손실이 곱해진다. |
회의가 짧으면 여기까지 가지 않습니다. 전사본이 한 요청에 들어가면 조각내는 단계 자체가 없고, 조각이 두어 개면 정제를 한 번만 거쳐도 한도 안에 들어옵니다. 반대로 회의가 길수록 줄이는 횟수가 늘어납니다. 정제는 최대 3번까지 반복하고, 그래도 한도에 안 들어가면 앞에서부터 잘라냅니다. 줄일수록 손실이 겹겹이 쌓입니다.
화자의 말투, 발언의 뉘앙스, 구체적인 수치가 단계를 지날 때마다 사라졌습니다.
2차 시도 — 전사본을 자르지 말고, 모델의 역할을 나누기
저는 TPM이 모델마다 다르다는 점에 착안하여 2차에서 접근을 바꿔보았습니다.
| 모델 | Tier 1 TPM | 입력/출력 단가(100만 토큰) |
gpt-4.1 | 30,000 | $2 / $8 |
gpt-4.1-mini | 200,000 (6.7배) | $0.40 / $1.60 |
mini는 한도가 6.7배라 6만 토큰짜리 전사본을 쪼개지 않고 한 번에 읽을 수 있습니다. 그렇다면 앞 단계에 내용을 줄이는 일을 시키지 않아도 됩니다. 대신 "어디부터 어디까지가 무슨 얘기인지" 표시하는 일만 맡기면 됩니다. 실제로 어떻게 도는지 보겠습니다.
첫째, 전사본에 줄 번호를 붙입니다. 이건 코드가 합니다.
1 지현: 오늘 배포 건부터 얘기하죠.
2 성혁: 서버 비용이 문제입니다. 지난달 12만원 나왔어요.
3 지현: e2-micro로 내리면 얼마죠?
… (중략) …
340 지현: 그럼 다음 안건, 로그인 화면 갑시다.
341 미연: 지금 로그인 단계가 세 번이라 이탈이 큽니다.
… (중략) …
680 성혁: 그럼 다음 회의는 화요일 3시로 하죠.
둘째, mini가 이걸 전부 읽고 목차만 내놓습니다.
- 서버 비용 절감 :: 1-339
- 로그인 화면 개편 :: 340-612
- 다음 회의 일정 :: 613-680
mini는 회의록을 쓰지 않습니다. 내용을 줄이지도 않습니다. 하는 일은 위 세 줄을 만드는 것뿐입니다. 책의 목차 페이지를 만드는 작업에 해당합니다. 출력이 이것뿐이라 줄일 일이 없고, 그래서 잃을 것도 없습니다. 이 단계는 temperature를 0으로 고정했습니다. 줄 번호를 찾는 일에는 창의성이 필요 없고, 같은 전사본이면 항상 같은 목차가 나와야 하기 때문입니다.
셋째, gpt-4.1을 안건 수만큼 나눠서 부릅니다.
| 호출 | 넣는 것 | 시키는 것 |
| 1번째 | 1~339줄 원문 그대로 | 이 구간으로 서버 비용 절감 섹션을 작성 |
| 2번째 | 340~612줄 원문 그대로 | 이 구간으로 로그인 화면 개편 섹션을 작성 |
| 3번째 | 613~680줄 원문 그대로 | 이 구간으로 다음 회의 일정 섹션을 작성 |
여기가 핵심입니다. gpt-4.1은 전사본 전체를 볼 필요가 없습니다. 한 번에 안건 하나치 원문만 받으면 되고, 그 정도는 한도 안에 들어갑니다. 그리고 받는 것이 줄여 놓은 요약본이 아니라 원문이라 축약 손실이 없습니다.
넷째, 코드가 완성된 섹션들을 이어 붙입니다. 그 위에 메모와 회의 마무리 구간을 합쳐 요약·결정사항·할 일을 채우면 회의록이 완성됩니다.
1차와 2차의 차이를 정리하면 이렇습니다.
| 1차 (map-reduce) | 2차 (목차 + 원문 재읽기) |
| 앞 단계가 하는 일 | 내용을 줄인다 | 위치를 표시한다 |
| 회의록 쓰는 모델이 보는 것 | 줄여 놓은 메모 | 원문 그대로 |
| 손실 | 줄일 때마다 발생 | 없음 |
이렇게 한 작업에 성격이 다른 두 모델을 섞어 쓰는 구조를 모델 하이브리드 설계라고 부릅니다. 전체를 훑는 일은 한도가 큰 저렴한 모델이, 글을 쓰는 일은 서술이 좋은 모델이 맡습니다.
안건 하나가 아주 길면 어떻게 되느냐는 문제도 아직 존재했습니다. 한 주제로 한 시간을 이야기할 수도 있으니까요. 이 경우 그 안건의 줄 범위를 반으로 쪼갭니다. 그래도 예산을 넘으면 또 반으로 나눠, 들어갈 때까지 반복합니다. 쪼개진 조각에는 로그인 화면 개편 (1/3)처럼 번호가 붙어 같은 안건이 이어진다는 것을 알 수 있게 했습니다. 여기서도 내용을 줄이지는 않습니다. 원문은 그대로 두고 범위만 나누므로 손실이 없습니다.
어느 단계가 실패해도 논의 내용 본문은 건드리지 않도록 3중 폴백을 넣었습니다. 목차 추출이 실패하면 모델 없이 기계적으로 줄을 나누고, 조립 입력이 한도를 넘으면 중첩 항목 제거 → mini 재정제 → 앞부분 절단 순으로 물러섭니다.
검증은 카나리아 방식으로 했습니다. 카나리아란 옛날 광부들이 유독가스를 감지하려고 갱도에 데리고 들어가던 새입니다. 먼저 반응하는 표시를 미리 심어두고 이상을 감지하는 방식을 이렇게 부릅니다.
완성된 회의록을 직접 읽는 것만으로는 무엇이 누락됐는지 알 수 없습니다. 원본 전체를 기억하고 있지 않기 때문입니다. 심어둔 표시가 몇 개 남았는지 세면 손실 여부가 수치로 나옵니다.
합성 전사본에 확인용 사실 17개를 심었습니다. $120→$80, 62%→94%, 850ms→120ms, 다음 주 화요일 오후 3시 같은 것들입니다. 이 전사본으로 짧은 회의 경로와 긴 회의 경로 양쪽을 실행했습니다. 17개 모두 보존됐고 문서 구조 11개 항목도 전부 정상이었습니다. 긴 회의 한 건의 비용은 약 $0.06입니다.
다만 한 가지 문제는 그대로 남았습니다. 바로 속도입니다. 2시간 회의라면 안건이 여덟 개쯤 되는데, gpt-4.1을 여덟 번 순차로 호출하고 1분 한도에 걸릴 때마다 기다립니다. 실제로 호출을 한 번 측정해 보니 6.2초에 초당 87.8토큰을 생성했습니다. 이 속도로 여덟 번을 돌리면 글 쓰는 시간은 2~3분인데, 한도 때문에 기다리는 시간이 3.2분이라 결국 3분 남짓이 걸립니다. 모델이 느려서가 아니라 분당 보낼 수 있는 양이 정해져 있어서 생기는 시간입니다.
회의가 길어지면 이 시간도 비례해서 늘어납니다. 호출 한 번의 크기는 안건 하나치 원문만 담으므로 회의 길이와 무관하게 약 11,850토큰으로 일정한데, 안건 개수가 늘어나기 때문입니다.
| 회의 길이 | 안건 수 | 요청 합계 | 소요 |
|---|
| 1시간 | 4 | 47,400 토큰 | 1.6분 |
| 2시간 | 8 | 94,800 | 3.2분 |
| 4시간 | 16 | 189,600 | 6.3분 |
| 6시간 | 24 | 284,400 | 9.5분 |
안건 수는 시간당 4개로 가정한 값이며, 실제로는 회의 내용에 따라 달라집니다.
2차 재설계로 고친 것은 속도가 아니라 품질입니다. 기다리는 시간은 같지만, 그 시간 동안 만들어지는 회의록의 내용이 깎이지 않게 만들었습니다.
그렇다면 이 구조는 몇 시간짜리 회의까지 감당할까요. 목차 단계가 한 번에 받을 수 있는 양은 176,587토큰입니다. 1시간 회의의 전사본이 18,060자였으니, 줄 번호와 화자 이름이 붙는 몫까지 넉넉하게 잡아도 약 6~7시간짜리 회의를 쪼개지 않고 한 번에 훑을 수 있습니다. 그보다 길어지면 목차만 나눠서 뽑는데, 목차는 "몇째 줄부터 몇째 줄까지가 어떤 안건인지"일 뿐이라 나눠도 내용이 사라지지 않습니다.
처음에는 "어떻게 하면 잘 축약할까"만 생각했습니다. 방향을 바꾼 것은 TPM이 모델마다 다르다는 사실을 확인한 뒤였습니다. 성능이 좋은 모델은 글을 쓸 때만 사용하고 전체를 훑는 작업은 저렴한 모델에 맡기니, 축약할 필요 자체가 없어졌습니다. 한 모델에 전부 맡기지 않고 작업 성격에 따라 나누는 방법을 배웠습니다.
신뢰 2026.07.08
회의록에 아무도 하지 않은 말이 들어간 문제
증상: 회의록에 그날 아무도 말한 적 없는 문장이 들어갔습니다. 클라우드 아키텍트, 프론트엔드, LangGraph… 같은 줄이었습니다.
원인: 받아쓰기 정확도를 올리려고 팀 용어집을 STT 모델에 미리 알려주는데, 조용한 구간을 만나면 모델이 그 용어집을 자기가 들은 말처럼 그대로 출력했습니다.
바꾼 것: 전사 결과가 사실상 용어집을 되뱉은 것(프롬프트 에코)인지 판정해 그 구간을 통째로 버리는 필터를 넣었습니다.
STT는 음성을 텍스트로 바꾸는 모델입니다. 송지아는 gpt-4o-mini-transcribe를 씁니다.
한국어 회의에서 이 모델이 자주 틀리는 것이 전문용어와 사람 이름입니다. LangGraph를 "랭그래프"로 적거나 아예 다른 말로 옮깁니다. 그래서 용어집과 참석자 이름을 프롬프트로 미리 알려주는 기능을 넣었습니다. "이런 단어들이 나올 겁니다"라고 귀띔해 주면 인식률이 올라갑니다.
그런데 여기에 함정이 있었습니다. Whisper 계열 모델은 침묵이나 잡음 구간을 만나면, 방금 받은 프롬프트를 자기가 들은 말인 양 그대로 뱉습니다. 흔히 프롬프트 에코라고 부르는 환각입니다.
정확도를 높이려고 넣은 장치가, 없던 내용이 들어오는 통로가 된 것입니다.
| 구간 | 의도 | 실제로 벌어진 일 |
| 말이 있는 구간 | 전문용어를 정확히 받아쓴다 | 의도대로 동작 |
| 조용한 구간 | 받아쓸 것이 없으니 빈 값 | 용어집이 통째로 전사본에 들어감 |
이게 위험한 이유는 회의록이 그럴듯해 보이기 때문입니다. 없는 내용이 눈에 띄게 이상한 문장으로 들어오면 바로 발견할 수 있는데, 실제로는 회의에서 쓸 법한 전문용어가 나열되어 들어옵니다. 읽는 사람이 "이런 논의도 있었나" 하고 넘어갈 수 있습니다.
해결은 "버릴 조건"을 정하는 일이었습니다. 핵심은 용어를 빼고 나면 무엇이 남는가입니다. 에코는 용어 나열이 전부라 지우면 아무것도 안 남습니다. 진짜 발언은 용어를 빼도 나머지 말이 남습니다.
전사 결과에서 용어집 단어와 참석자 이름을 지운다
↓
① 서로 다른 용어가 3개 이상 나왔고
② 지우고 남은 글자가 원래의 20%도 안 되면
↓
프롬프트 에코로 보고 그 구간을 버린다
실제로 판정해 보면 이렇게 갈립니다.
| 전사 결과 | 용어 개수 | 지운 뒤 남는 글자 | 판정 |
클라우드 아키텍트, 프론트엔드, 백엔드, LangGraph, Pydantic AI | 5개 | 35자 → 0자 (0%) | 버림 |
프론트엔드 쪽 로그인 화면을 다음 주까지 고치기로 했습니다. | 1개 | 25자 → 20자 (80%) | 남김 |
성혁 님이 백엔드랑 LangGraph 연동을 맡기로 했어요. | 3개 | 26자 → 12자 (46%) | 남김 |
두 조건을 모두 걸어야 하는 이유가 세 번째 줄에 있습니다. 용어가 3개 나와서 ①은 통과하지만, 지우고도 님이 랑 연동을 맡기로 했어요가 남아 46%입니다. ①만 걸었다면 이 발언이 사라졌을 겁니다.
잘못 버릴 위험도 따져봤습니다. 진짜 발언이 용어 나열만으로 이루어질 확률은 낮습니다. 설령 그런 구간이 있어도 "프론트엔드, 백엔드, LangGraph" 같은 나열뿐이라 회의록에 담을 정보가 없습니다. 잘못 버려도 잃는 것이 거의 없다고 판단했습니다.
STT 자체의 temperature도 0으로 고정했습니다. 들리는 대로만 적고 추측하지 않게 하기 위해서입니다.
기능을 넣는 코드보다 결과를 버릴 조건을 정하는 코드가 더 어려웠습니다. 너무 느슨하면 없는 말이 회의록에 남고, 너무 빡빡하면 실제로 한 말이 사라집니다. 회의록은 "빠짐없이 적는 것"만큼 "없는 말을 적지 않는 것"이 중요하다는 것을 배웠습니다. 없는 말이 한 줄이라도 섞이면, 나머지 내용까지 믿을 수 없게 됩니다.
운영 2026.07.14
아무도 안 쓰는데 CPU 99% 경고가 오던 문제
증상: 아무도 봇을 사용하지 않을 때도 CPU 부하 99% 경고 알림이 시간대와 무관하게 계속 왔습니다. 그때 녹음 중인 회의는 0개, 메모리는 183MB였습니다.
원인: CPU 사용률이라고 믿고 사용한 값이 loadavg였습니다. 이것은 비율이 아니라 개수이고, 대상도 봇이 아니라 서버 전체였습니다.
바꾼 것: process.cpuUsage()로 봇 프로세스가 실제로 사용한 CPU만 측정하도록 변경했습니다.
저는 서버의 안정성과 모니터링을 위해, 서버 자원이 임계치를 넘으면 저에게 DM으로 알려주는 기능을 넣었습니다. 임계값은 세 가지입니다.
| 항목 | 임계 | 근거 |
| CPU | 코어 1개의 80% 이상 | 봇이 코어 하나를 80% 이상 계속 점유하면 다른 작업이 밀린다 |
| 메모리 | 800MB 이상 | 전체가 1GB다 |
| 동시 녹음 | 8개 이상 | 그 이상은 전사 대기열이 밀린다 |
문제는 아무도 봇을 사용하지 않을 때도 이런 알림이 시간대와 상관없이 계속 왔다는 것입니다. 새벽에도 오고 낮에도 왔습니다.
⚠️ 부하 경고
→ CPU 부하 99%
(녹음 0개 · 메모리 183MB · 부하 99% · 서버 1개)
동시 음성 회의가 몰리는 중이에요. 계속되면 VM 스펙업을 고려해 주세요.
녹음이 0개인데 "동시 음성 회의가 몰리는 중"이라고 안내하고 있습니다. 메모리도 183MB로, 1GB 중 충분한 여유가 있었습니다. 수치만 거짓이 아니라 원인 설명까지 거짓이었습니다.
loadavg를 사용률로 읽은 것이 원인
저는 os.loadavg()를 CPU 사용률로 알고 사용했습니다. 이름에 load가 들어가 있어 그렇게 해석했습니다. 그런데 이 둘은 단위부터 다릅니다.
CPU 사용률은 시간의 비율입니다. 60초 중 6초를 계산에 사용했다면 10%입니다. 반면 loadavg는 개수입니다. 해당 서버에서 실행을 기다리며 대기 중인 작업이 몇 개인지 세는 값이라, 3개가 대기 중이면 3.0이 나옵니다.
| loadavg | CPU 사용률 |
| 측정 대상 | 실행을 기다리는 작업의 개수 | 실제로 계산에 사용한 시간의 비율 |
| 범위 | 0부터 무제한 | 0~100% |
| 대상 | 서버 전체 | 지정한 프로세스 |
당시 loadavg는 0.99였습니다. 저는 이 값을 사용률로 알고 있었으므로 100을 곱해 99%로 표시했습니다. 그런데 0.99는 "대기 중인 작업이 0.99개"라는 뜻이지 99%가 아닙니다. 애초에 곱하면 안 되는 수치였습니다.
그리고 그 대기열은 서버 전체의 대기열입니다. 제 VM은 e2-micro입니다. vCPU(가상 CPU)는 물리 서버 한 대를 나누어 제공하는 단위인데, 이 등급은 공유 코어(shared-core) 방식이라 같은 물리 CPU를 다른 사용자의 서버와 나눠 쓰고, 짧은 시간 동안만 2 vCPU까지 끌어 쓸 수 있습니다. 그래서 다른 VM의 작업도 같은 대기열에 포함됩니다. 여기에 CPU steal(제 몫이지만 다른 VM에 할당된 시간)까지 더해지면 봇이 아무 작업도 하지 않아도 0.8~1.0이 나옵니다.
봇은 정상인데 감시 장치가 다른 VM의 부하를 봇의 부하로 집계하고 있었습니다.
측정 대상을 봇 자신으로
process.cpuUsage()는 해당 프로세스가 실제로 사용한 CPU 시간을 반환합니다. 서버 전체가 아니라 봇 자신만입니다.
직전 측정 이후 봇이 사용한 CPU 시간 ÷ 그 사이에 경과한 시간 = 봇의 CPU 사용률
실측으로 확인했습니다.
| 상태 | 측정값 | 경고 |
| 대기 중일 때 | 0.1% | 없음 |
| 의도적으로 한 코어를 점유했을 때 | 92.5% | 발생 |
loadavg가 같은 시각에 1.0에 가까운 값을 가리키던 상황에서 봇 자신은 0.1%였습니다. 거짓 경고가 사라졌습니다.
알림 문구도 함께 수정했습니다. 이제는 실제로 임계를 넘긴 항목에서 안내 문구를 생성합니다. 녹음이 0개인데 "회의가 몰리는 중"이라고 안내하는 일은 없습니다.
그 수정이 만든 새로운 함정
위 계산식을 다시 보면, "그 사이에 경과한 시간"이 얼마나 긴지에 따라 결과가 달라집니다.
봇에는 CPU를 짧은 시간에 집중적으로 사용하는 정상 작업이 있습니다. 회의 마무리 처리와 음성 파일 변환이 그렇습니다. 코드 주석에 예시로 적어 둔 값이 "5초 동안 CPU를 4초어치 사용"입니다. 같은 작업인데 측정 구간에 따라 다음과 같이 갈립니다.
| 측정 구간 | 그 안에 포함된 CPU 사용 | 계산 | 결과 |
| 60초 (감시 주기) | 4초 | 4 ÷ 60 | 6.7% → 정상 |
| 5초 (조회 시점에 즉시 측정) | 4초 | 4 ÷ 5 | 80% → 과부하 경고 |
같은 봇, 같은 작업인데 6.7%와 80%로 갈립니다.
제가 DM으로 상태를 조회할 때마다 그 시점에 다시 측정하면 구간이 5초로 짧아집니다. 그 5초 안에 마무리 처리가 포함되면 80%가 나오고, 수정했던 거짓 경고가 그대로 재발합니다.
그래서 측정 주체를 하나로 고정했습니다. 60초 주기 감시만 측정하고, 조회 시에는 그 값을 읽기만 합니다. 최대 60초 이전의 값이지만, 봇의 현재 상태를 확인하는 데는 충분합니다.
loadavg는 삭제하지 않고 참고 지표로 남겼습니다. 서버 전체가 혼잡한 상황을 파악하는 것도 유용하므로, 경고의 근거로는 사용하지 않고 표시만 합니다.
그렇다면 몇 개까지 감당할까
봇이 실제로 CPU를 사용하는 작업은 음성 변환 하나뿐입니다. 전사와 회의록 작성은 OpenAI 서버가 수행합니다. 봇은 요청을 보내고 대기하므로 그동안 CPU를 거의 사용하지 않습니다.
그 음성 변환도 120초 발화를 0.13초에 처리합니다. 실시간보다 923배 빠릅니다. 앞의 92.5%는 의도적으로 CPU를 점유해서 만든 값이지, 정상 작동으로는 나오지 않는 수치입니다.
그래서 CPU는 병목이 아닙니다. 제약은 다른 두 가지입니다.
| 자원 | 계산 | 판단 |
| 메모리 | 녹음 중에는 원본 음성이 메모리에 적재된다. 한 사람이 최대 22MB(120초에서 끊어 전사로 전송하므로 그 이상 적재되지 않음). 8명이 동시에 발언해도 176MB | 경고 임계 800MB에 충분히 못 미친다 |
| STT 동시 호출 | 코드가 동시에 3개까지만 OpenAI로 전송한다. 그 이상은 대기열에 들어간다 | 여기가 실질 상한이다 |
동시 녹음이 많아지면 봇이 중단되는 것이 아니라 전사가 밀립니다. 회의록 생성이 지연되는 형태로 느려집니다.
이론적으로는 보수적으로 산정하여 동시 녹음 8개를 기준으로 임계를 잡아두었습니다. 봇이 상용화되어 그 이상으로 사용자가 많아진다면 서버를 이전할 생각입니다. 지금 등급(e2-micro)은 회의록 봇의 사용 패턴 — 평소에는 사용되지 않다가 회의 시간에만 집중적으로 사용되는 — 에 맞춰 선택한 것이라, 사용량이 늘면 조건이 달라집니다.
다만 위 수치는 실측이 아닙니다. 지금까지 서버 한 곳에서만 운영했습니다. 코드에 정의된 상수와 음성 변환 측정값에서 계산한 값입니다.
감시가 부정확하면 존재하지 않는 장애를 쫓게 됩니다. 실제로 봇은 정상이었는데 경고만 계속 왔고, 원인을 찾기 전까지는 그것이 진짜 문제인지 아닌지 판단할 수 없었습니다. 그리고 수정한 뒤에 알게 된 것이 하나 더 있습니다. 측정을 고칠 때는 "무엇을 측정하느냐"만이 아니라 "언제, 어디에서 측정하느냐"까지 정해야 한다는 것입니다. 같은 코드라도 측정 구간이 달라지면 다른 값이 나옵니다.
사용성 2026.07.27 ~ 07.28
명령어 체계 재설계 및 UX 전면 개선
증상: 세부 명령이 눈에 띄지 않아 있는 줄 모르고 지나쳤고, 찾아서 쓰려면 여러 단계를 거쳐야 했습니다.
원인: 세부 명령이 있는 명령과 없는 명령이 섞여 있어서, 쓸 때마다 이 명령은 세부 명령이 필요한지 확인해야 했습니다. 게다가 세부 명령은 접힌 채 +1로만 표시되어 눈에 띄지도 않습니다.
바꾼 것: 대표 명령어만 남기고 세부 명령 10개를 전부 삭제했습니다. 이제 대표 명령어를 입력하면 현재 상태와 버튼이 답변으로 표시됩니다.
개발 막바지에 명령어를 어떻게 하면 더 쉽고 간편하게 쓸 수 있을지 고민하고 있었습니다.
가장 걸린 것이 세부 명령이었습니다. 디스코드 슬래시 명령에는 세부 명령(subcommand)과 입력칸(option)을 붙일 수 있습니다. 처음에는 그 기능을 그대로 사용했습니다.
| 명령 | 딸려 있던 것 |
/용어집 | 세부 명령 4개 — 보기 · 추가 · 삭제 · 초기화 |
/알림 | 세부 명령 3개 — 설정 · 보기 · 취소 |
/메일 | 세부 명령 3개 — 등록 · 확인 · 삭제 |
/db등록 | 입력칸 2개 — link: · 종류: |
문제는 이 세부 명령이 눈에 띄지 않는다는 점이었습니다. 디스코드 입력창에서 명령을 고르면 세부 명령은 접힌 채 +1 같은 표시로만 나타납니다. 무엇이 있는지 한눈에 들어오지 않아 지나치기 쉽고, 찾아 들어가는 과정도 번거롭습니다.
반대로 펼쳐 놓으면 이번에는 목록이 길어집니다. /용어집 보기, /용어집 추가, /용어집 삭제, /용어집 초기화가 나란히 늘어서는데, 앞부분이 전부 같아서 한눈에 구분되지 않습니다. 명령 하나를 쓰려고 비슷하게 생긴 줄을 훑어야 하니 그 자체로 피로합니다.
단계도 깁니다. 명령 이름을 입력하면 세부 명령 목록이 나타나고, 하나를 고르면 필수 입력칸이 자동으로 표시되며, 거기에 값을 입력합니다.
/용어집 → 삭제 → GCP, 슬랙
① ② ③
세부 명령을 고르면
words: 칸이 자동으로 붙는다
한 번 사용하는 데 세 단계입니다. /메일로 다른 사람의 메일을 대신 등록할 때는 사정이 다릅니다. user:는 선택 입력칸이라 자동으로 붙지 않고, 입력칸 목록에서 직접 골라 추가해야 해서 단계가 하나 더 늘었습니다.
더 불편한 것은 명령마다 규칙이 다르다는 점이었습니다. /memo는 내용만 바로 입력하면 되는데 /용어집은 세부 명령을 먼저 골라야 하고, /db등록은 입력칸 이름부터 지정해야 합니다. 쓰는 사람 입장에서는 매번 "이 명령은 어떤 방식이었지"를 확인해야 합니다. 자주 쓰는 명령이면 외워지지만, 설정 명령처럼 가끔 쓰는 것은 그때마다 다시 확인하게 됩니다.
세부 명령 중에는 상태만 보여주는 것도 있었습니다. /용어집 보기, /알림 보기, /메일 확인 세 개입니다. 세부 명령은 반드시 하나를 골라야 하는 구조라, 현재 상태를 확인하는 데도 한 단계를 더 들어가야 했습니다.
그래서 세부 명령어 없이 어떻게 하면 간편하게 명령어를 쓸 수 있을까 고민해보았습니다. 대표 명령어만 남기고 세부 명령어는 전부 없애되, 대표 명령어를 입력하면 세부 명령이 버튼으로 표시되는 방식을 고안하였습니다. 버튼은 답변에 그대로 노출되므로 무엇을 할 수 있는지 한눈에 확인할 수 있고, 전에는 일일이 입력해야 했던 세부 명령을 버튼 하나로 처리할 수 있습니다. 이 판단으로 명령어 체계 재설계에 착수했습니다.
먼저 정한 기준
모든 명령을 버튼으로 바꾸면 오히려 단계가 늘어나는 경우가 있습니다. /memo 회의실 예약 완료처럼 값을 바로 입력하는 편이 빠른 명령이 그렇습니다. 그래서 세부 명령이 딸린 명령만 버튼으로 전환하고, 값 하나만 받는 명령은 그대로 두기로 했습니다. 지금도 /memo·/meeting_start·/수정·/save·/delete 다섯 개는 입력칸을 그대로 유지하고 있습니다.
설정 명령 네 개를 버튼으로
/db등록·/용어집·/알림·/메일 네 개를 재설계했습니다. 세부 명령 10개와 입력칸 2개가 전부 사라졌고, 이제 이름만 입력하면 현재 상태가 먼저 표시되고 그 아래에 버튼이 붙습니다.
앞서 말한 보기·확인 세 개는 이 과정에서 자연히 없어졌습니다. 명령을 실행하는 순간 현재 상태가 답변으로 표시되므로 별도 항목이 필요 없어졌습니다.
다른 사람의 메일을 대신 등록하는 기능은 삭제하지 않고, 버튼을 누르면 나타나는 입력창의 선택 항목으로 옮겼습니다. 쓰는 사람이 드물다고 해서 기능 자체를 없애면, 그 기능을 쓰던 사람은 대안이 없습니다.
/list에도 버튼을 넣었습니다. 전에는 회의 ID를 확인한 뒤 /save id:에 옮겨 적어야 했는데, 이제 목록에서 선택하면 됩니다.
명령과 버튼이 같은 코드를 사용하도록 로직을 분리했습니다. 두 경로가 각자 구현을 갖고 있으면 한쪽만 수정되어 동작이 갈립니다.
배포 순서를 잘못 잡아 생긴 사고
작업 중에 사고가 있었습니다. 새 명령을 디스코드에 먼저 등록하고 코드를 나중에 배포했습니다. 그 사이에 실제 서버에서 명령 네 개가 동작하지 않았습니다.
디스코드는 슬래시 명령의 이름과 입력 구조를 미리 등록해 두는 방식입니다. 등록만 하고 그것을 처리할 코드가 없으면, 사용자에게는 명령이 보이는데 눌러도 응답이 없습니다. 명령 등록은 반드시 코드 배포 이후에 해야 합니다.
직접 써 보며 고친 두 가지
서비스를 공개하기 전에 봇 초대부터 회의록 저장까지, 사용자가 거치는 전 과정을 엔드투엔드로 직접 점검했습니다. 그 과정에서 두 가지를 발견했습니다.
첫째, 성공할 수 없는 경로를 사용자에게 열어 두고 있었습니다. 노션에서 봇에게 권한을 주지 않았다면, 링크를 정확히 붙여넣더라도 해당 노션에 접근할 수 없었습니다. 사용자는 이를 알지 못한 채 링크를 입력하고, 결국 실패 안내만 받게 됩니다. 입력칸이 있으니 시도해 볼 수는 있으나, 처음부터 성공할 수 없는 경로였습니다. 지금은 봇이 실제로 접근 권한을 가진 표만 목록으로 제시하고 그중에서 선택하도록 했습니다. 선택할 항목이 없으면 권한 부여 절차를 먼저 안내합니다.
둘째, 한 번에 될 일을 세 번 나눠 놓고 있었습니다. 용어집은 추가·삭제·비우기를 따로 눌러야 했는데, 점검하는 과정에서 입력창에 현재 목록을 그대로 띄워 주면 거기서 지우고 더하면 되는 일이라는 것을 알았습니다. 버튼 세 개가 [✏️ 용어 편집] 하나가 됐습니다. 다만 목록 전체를 교체하는 방식이라, 실수로 삭제한 항목을 인지하지 못할 위험이 있습니다. 그래서 무엇이 추가되고 삭제됐는지 결과에 반드시 표시하도록 했습니다.
실제로 얻은 것
명령 개수 자체는 17개 그대로입니다. 줄어든 것은 그 아래에 있던 세부 명령 10개와 입력칸 2개입니다. 더 중요한 변화는 단계가 아니라 규칙이었습니다. 명령마다 달랐던 사용 방식이 하나로 통일됐습니다. 이제 모든 명령은 이름만 입력하면 되고, 그다음에 무엇을 할 수 있는지는 화면이 알려줍니다. 사용자가 기억할 것이 없어졌습니다.
서비스를 공개하기 전에 이렇게까지 반복해서 확인한 이유가 있습니다. 송지아는 사용자가 자신의 OpenAI API 키를 등록해서 쓰는 BYOK(Bring Your Own Key) 구조입니다. 봇이 제대로 동작하지 않거나 중간에 실패하면 그 비용은 사용자가 부담합니다. 실패해도 이미 쓴 토큰은 청구됩니다. 사용자는 결과물 없이 비용만 부담하게 됩니다. 그래서 공개 전에 안정성과 완성도를 최대한 끌어올린 뒤에 상용화했습니다.
테스트 2026.07.27 ~ 07.29
테스트가 실행할 때마다 다른 곳에서 실패하던 문제
증상: 테스트를 돌릴 때마다 실패하는 항목이 매번 바뀌었습니다. 코드는 그대로인데 어제와 오늘 다른 검사가 실패했습니다. 실패한 것만 따로 다시 돌리면 항상 통과했습니다.
원인: 테스트가 쓸 데이터베이스를 따로 지정하지 않아, 전부 제 노트북의 개발용 데이터베이스 하나를 공유하고 있었습니다.
바꾼 것: 테스트마다 자기만의 메모리 기반 임시 데이터베이스를 쓰도록 했습니다. 파일을 따로 생성하지 않고, 메모리에만 작성 후 테스트가 끝나면 사라집니다.
테스트를 돌리면 약 8% 확률로 하나가 실패했습니다. 이상한 것은 실패하는 항목이 매번 달랐다는 점입니다. 코드를 고치지 않았는데도 실행할 때마다 다른 검사가 실패했고, 그것만 따로 실행하면 항상 통과했습니다.
실패한 실행의 출력 전체
실패의 원인을 규명할 때까지 반복적으로 실행하면서, 실패가 나온 순간의 출력 전체를 확보했습니다. 그러자 테스트 결과가 아니라 그 위에 진짜 에러가 있었습니다.
Error: database is locked
at src/db/database.ts:14
테스트 하나가 실행되는 순서는 이렇습니다.
1. 테스트 프로그램이 실행된다
2. 그 프로그램이 db/database.ts 를 불러온다
3. 데이터베이스를 연다 ← 여기서 실패
4. 검사를 하나씩 실행한다
에러는 3번에서 났습니다. 4번은 시작조차 하지 못했습니다. 검사 코드는 한 줄도 실행되지 않았습니다.
그런데 테스트 도구는 이것을 "그 파일이 실패했다"로 기록합니다. 프로그램이 정상 종료하지 못했기 때문입니다. 그래서 화면에는 도움말 검사 실패 같은 결과가 표시되는데, 실제로는 도움말을 검사해 본 적조차 없는 것입니다.
어떤 파일이 걸릴지는 타이밍 문제입니다. 여러 개가 거의 동시에 시작해서, 먼저 도달한 하나가 데이터베이스를 독차지하고 나머지가 튕깁니다. 실행할 때마다 순서가 미세하게 달라지므로 매번 다른 파일이 실패로 기록됐습니다.
14번 줄은 이 줄이었습니다.
db.exec('PRAGMA journal_mode = WAL;');
PRAGMA는 SQLite의 동작 방식을 지정하는 명령이고, WAL은 여러 곳에서 동시에 읽고 쓸 때 성능이 나은 저장 방식입니다. 그런데 이 방식으로 전환하는 순간에는 데이터베이스를 잠시 혼자 독차지해야 합니다. 다른 프로그램이 이미 열어 두었다면 그 자리에서 실패합니다.
1차 수정 — 순서 때문에 무효가 된 설정
PRAGMA busy_timeout이라는 설정이 있습니다. "데이터베이스가 잠겨 있으면 즉시 실패하지 말고 이만큼 기다려라"는 뜻입니다. 이것을 5초로 넣었습니다.
그런데도 실패가 계속됐습니다. 코드를 다시 보니 이렇게 되어 있었습니다.
14줄 PRAGMA journal_mode = WAL; ← 여기서 독차지하려다 실패
15줄 PRAGMA foreign_keys = ON;
...
19줄 PRAGMA busy_timeout = 5000; ← 기다리라는 설정은 여기
기다리라고 알려주기도 전에 독차지하러 간 것입니다. busy_timeout은 연결마다 따로 적용되는 설정이라, 그 줄을 지나기 전에는 아무 효력이 없습니다.
순서를 바로잡았습니다. 프로그램 60개를 동시에 실행해 확인하니 옛 순서는 3회 실패했고, 새 순서는 0회였습니다.
여기서 끝낼 뻔한 이유
실패가 사라졌으니 해결된 것처럼 보였습니다. 그런데 질문이 하나 남았습니다. 애초에 왜 프로그램 여럿이 같은 데이터베이스를 열고 있는가.
확인해 보니 세 가지가 겹쳐 있었습니다.
| 사실 | 결과 |
| 테스트 도구는 파일마다 프로그램을 따로 띄운다 | 테스트 파일 수만큼 동시에 실행된다 |
db/database.ts는 불러오는 것만으로 테이블 생성이 실행된다 | 그 프로그램들이 전부 데이터베이스를 연다 |
| 테스트가 쓸 데이터베이스를 따로 지정하지 않았다 | 기본값인 제 노트북의 개발용 데이터베이스를 열었다 |
당시 테스트 파일은 12개였습니다. 파일마다 프로그램이 하나씩 뜨므로, 테스트를 한 번 돌릴 때마다 12개가 거의 동시에 같은 데이터베이스를 열려고 했습니다.
참고로 이 데이터베이스는 VM 서버의 것과 다릅니다. 파일 이름은 같지만, 하나는 실제 사용자 데이터가 든 서버의 것이고 하나는 제 노트북에서 개발하며 만든 시험용입니다. 문제가 된 것은 노트북 쪽이고, 서버 데이터는 영향을 받지 않았습니다.
하나의 원인에서 나온 두 가지 문제
이 지점에서 겉으로 드러난 문제가 전부가 아니라는 것을 알게 됐습니다.
테스트가 쓸 데이터베이스를 지정하지 않았다
↓
기본값인 개발용 데이터베이스를 사용
↓
┌───────┴───────┐
A B
개발 데이터가 프로그램 열두 개가
오염됐다 같은 파일에 몰렸다
B는 에러를 냈기 때문에 눈에 띄었습니다. 8% 실패가 그것입니다. A는 아무 에러도 내지 않고 조용히 쌓이기만 했습니다.
A의 흔적이 남아 있었습니다. 봇은 디스코드 서버마다 설정을 따로 저장하는데, 그 목록에 제 실제 서버 옆으로 가짜 서버 5개가 쌓여 있었습니다.
1401234567890123456 ← 진짜. 제가 봇을 넣어 둔 디스코드 서버
test-guild-glossary ← 가짜. 용어집 저장 검사가 만든 것
test-guild-provision ← 가짜. 노션 페이지 생성 검사가 만든 것
test-guild-imgcap ← 가짜. 사진 첨부 검사가 만든 것
gtest ← 가짜
이런 것이 생기는 이유가 있습니다. 봇은 서버마다 설정을 따로 저장하므로, 용어집 저장이 잘 되나를 검사하려면 어느 서버의 용어집인지 지정해야 합니다. 진짜 서버 ID를 쓰면 제 실제 데이터가 망가지니 가짜 ID를 지어내 씁니다.
테스트 파일 12개가 전부 서버를 만든 것은 아닙니다. 서버별 설정을 다루는 검사만 가짜 서버가 필요합니다. 문구를 비교하거나 형식을 변환하는 검사는 데이터베이스를 아예 열지 않아도 되므로 흔적이 남지 않습니다.
테스트가 끝난 뒤에도 검사용으로 넣은 데이터가 지워지지 않고 남아, 제 개발 데이터베이스에 더미 데이터가 쌓였습니다.
읽기 전용으로는 해결되지 않는 이유
여기서 "테스트가 읽기만 하게 하면 되지 않나"를 생각해 볼 수 있습니다. 두 가지 이유로 안 됩니다.
첫째, 테스트는 읽기만 할 수 없습니다. 메모를 저장하면 잘 저장되나를 검사하려면 실제로 저장해 봐야 합니다. 위의 가짜 서버들이 그렇게 생긴 것입니다.
둘째, 읽기만 했어도 중단됐습니다. 문제는 데이터를 다루는 단계가 아니라 데이터베이스를 여는 단계였습니다. 앞의 순서에서 3번이 그 지점입니다. 저장 방식을 바꾸는 작업이라, 읽으려는 것이든 쓰려는 것이든 그 순간만은 혼자여야 합니다. 12개가 동시에 도달하니 하나를 빼고 전부 튕겼습니다. 접근 종류를 제한하는 것으로는 해결되지 않는 문제였습니다.
근본 원인 제거
테스트를 메모리 데이터베이스로 실행하도록 바꿨습니다. 파일을 전혀 만들지 않고 메모리 안에만 존재하며, 프로그램이 끝나면 사라집니다.
test: DATABASE_URL=sqlite::memory: tsx --test src/**/*.test.ts
메모리를 고른 것은 우회가 아니라 원래 맞는 방식이었습니다. 봇은 꺼졌다 켜져도 회의 기록이 남아 있어야 하므로 파일에 저장합니다. 반면 테스트는 검사가 끝나면 그 데이터가 쓸모없어집니다. 오히려 남아 있으면 다음 실행에 영향을 주므로 없는 편이 낫습니다. 디스크에 쓰지 않으니 더 빠르기도 합니다. 원래 메모리에서 실행됐어야 할 것을 그동안 파일에 쓰고 있었던 것입니다.
이 한 번의 변경으로 A와 B가 동시에 사라졌습니다. 파일을 쓰지 않으니 오염될 데이터가 없고, 프로그램마다 자기 것을 가지니 몰릴 파일도 없습니다.
15회 연속 통과했고, 테스트 전후로 개발용 데이터베이스가 바이트 단위로 동일한 것도 확인했습니다. 현재 테스트는 81개이며 전부 통과합니다.
두 수정은 각각 다른 문제를 담당합니다. 메모리 데이터베이스 격리는 테스트끼리의 충돌을 없앴고, busy_timeout 순서 수정은 VM에서 봇이 돌아가는 중에 운영 스크립트를 실행할 때를 대비합니다. 후자는 테스트 격리로 해결되지 않으므로 그대로 남겼습니다.
확률을 낮추는 수정과 원인을 없애는 수정은 다릅니다. 순서만 고치고 끝냈다면 실패가 줄어서 해결한 줄 알았을 것입니다. 그러나 그것은 B의 발생 빈도를 낮췄을 뿐이고, A는 그대로 남아 있었을 것입니다. 증상이 사라진 것과 원인이 사라진 것은 같지 않습니다.