관측
이번 포스팅에서는 관측에 대한 이야기를 해보려고 한다.
필자는 회사에서 Sentry를 오래 써왔다. 이슈가 올라오면 스택 트레이스를 열고, 릴리스와 태그로 범위를 좁히고, 재현 조건을 찾는 일은 익숙한 작업이다. 그런데 정작 이 블로그에는 에러 모니터링이 없었다. 개인 블로그 쪽에서 필자가 다뤄온 것은 Google 계열의 도구였다. Analytics로 방문자를 보고, Search Console로 어떤 검색어에서 들어오는지 보고, 거기에 맞춰 제목과 설명을 고치는 일. 그러니까 사용자를 보는 도구는 있었고, 서버가 어떻게 실패하는지 보는 도구가 없었다.
도구를 몰라서 그런 것은 아니었다. 미뤄진 이유는 다른 데 있었다. 회사 프로젝트에서는 이미 누군가 깔아둔 계측 위에 얹으면 됐지만, 이 블로그에서는 처음부터 정해야 했다. 어떤 도구를 쓸지, 서버와 브라우저 중 어디에 심을지, 무엇을 실패로 볼지. 전부 설계 판단이었고 그 판단을 하려면 이 블로그가 어떻게 돌아가는지부터 다시 봐야 했다. 매번 그 문턱에서 다음으로 밀었다.
그러다 AI 에이전트와 함께 이 작업을 시작했고, 하루 만에 끝났다. 머지한 PR 네 개와 머지하지 않고 닫은 검증용 PR 두 개였다. 그런데 정작 이 글을 쓰게 만든 것은 "빨리 끝났다"가 아니었다. 붙여서 재보니 필자가 알고 있다고 믿었던 것 중 몇 가지가 틀려 있었다는 사실이다.
필자는 이 블로그가 잘 돌아가고 있다고 생각했다. 응답은 200이었고 페이지는 잘 떴다. 그런데 계측을 심고 나서 보니, 방문자 통계는 이미 조용히 비어 있었고 그 앞에서 서버가 1분 넘게 매달리고 있었다. 그러니까 관측 도구를 붙이는 일의 가치는 도구를 붙였다는 데 있지 않았다. 붙이지 않으면 알 방법이 없었던 것들을 알게 된 데 있었다.
그래서 이 글은 두 갈래로 간다. 하나는 서비스 안정성 쪽이고, 다른 하나는 사용자를 이해하는 쪽이다. 둘 다 결국 같은 이야기에 도달한다.
관측이라는 말부터 정리하고 가자
모니터링(monitoring)과 관측 가능성(observability)은 자주 섞여 쓰이지만 가리키는 대상이 다르다.
Honeycomb 창업자이자 이 분야의 논의를 오랫동안 주도해온 Charity Majors는 자신의 블로그에서 모니터링을 미리 확인할 항목을 정해두고 임계값을 걸어두는 방식이라고 설명한다. CPU가 90%를 넘으면 알림, 에러율이 1%를 넘으면 알림 같은 것들이다. 반면 관측 가능성은 이렇게 정의한다.
외부에서 질문을 던지는 것만으로 시스템 내부에서 무슨 일이 일어나는지, 시스템이 빠질 수 있는 어떤 내부 상태든 이해할 수 있는가?
can you understand what is happening inside the system — can you understand ANY internal state the system may get itself into, simply by asking questions from the outside?
바깥에서 질문을 던져서 시스템이 빠질 수 있는 임의의 내부 상태를 이해할 수 있는가. 여기서 핵심은 "임의의"다. 모니터링은 미리 물어볼 질문을 정해둔 것이고, 관측 가능성은 미리 정해두지 않은 질문에도 답할 수 있는 상태다. Majors는 다른 글에서 이 차이를 known-unknowns와 unknown-unknowns로 정리한다. 아는 것 중에 모르는 것과, 모른다는 것조차 모르는 것의 차이인 셈이다.
필자가 겪은 일은 정확히 후자였다. "GA 호출이 실패하면 어떻게 되지?" 라는 질문을 필자는 미리 던지지 않았다. 던질 생각을 못 했다.
한 가지 덧붙이면, 관측을 "로그, 메트릭, 트레이스 세 축"으로 소개하는 설명을 자주 보게 되는데, Majors 본인은 이 프레이밍을 여러 글에서 비판적으로 다뤄왔다. OpenTelemetry 공식 문서도 pillar 대신 signal이라는 표현을 쓴다. 세 가지를 다 모으면 관측이 된다는 식으로 이해하면 도구는 갖췄는데 질문에는 답하지 못하는 상태가 되기 쉽다. (필자도 처음엔 도구 목록으로 접근했다가 이 지점에서 방향을 다시 잡았다)
관측 도구를 다루는건 너무 어렵다.
그렇다면 관측 도구를 붙이는 일은 구체적으로 무엇이 어려웠던 걸까. 필자가 미뤄온 이유를 되짚어보면 두 종류의 어려움이 있었다.
첫째는 브라우저가 값을 어떻게 만드는지 알아야 한다는 것이다. 이건 프론트엔드에서 사용자 쪽을 관측할 때 특히 그렇다. 예를 들어 방문자가 체감하는 성능을 직접 재려면 PerformanceObserver부터 알아야 한다. 그런데 이 API는 옵션 조합에 제약이 있다. MDN 문서를 보면 이미 지나간 항목까지 받아오는 buffered 옵션은 type만 쓸 수 있고 entryTypes와는 함께 쓸 수 없다. 스크립트를 페이지 최상단에 두지 않으면 초기 지표를 통째로 놓치는 일이 여기서 갈린다.
지표의 정의 자체도 직관과 다르다. 레이아웃이 밀리는 정도를 재는 CLS는 페이지에서 일어난 모든 이동을 더한 값이 아니다. web.dev의 CLS 문서는 이 값을 세션 윈도우 중 가장 큰 묶음 하나로 정의한다. 이동 사이 간격이 1초 미만이면 한 묶음으로 치고, 한 묶음의 최대 길이는 5초다. 게다가 사용자 입력 후 500밀리초 안에 일어난 이동은 hadRecentInput 표시가 붙어 제외된다. 사용자가 버튼을 눌러서 아코디언이 펼쳐지는 것은 잘못이 아니기 때문이다. 이 규칙을 모르고 직접 합산하면 값이 다르게 나오는데, 어디가 틀렸는지 알기 어렵다.
지표의 구성 자체도 바뀐다. 입력 지연을 재던 FID는 2024년 3월 12일부터 INP로 대체됐다. 상호작용 하나의 첫 반응만 보던 지표에서, 페이지 수명 전체의 상호작용 응답성을 보는 지표로 바뀐 것이다.
물론 이 계산을 직접 하지 않아도 된다. Google이 만든 web-vitals 라이브러리를 쓰면 된다. 다만 그 문서를 읽어보면 라이브러리를 써도 남는 함정이 정리되어 있다. 이 API들은 iframe 안을 들여다보지 못해서, iframe을 쓰는 페이지는 라이브러리가 잰 값과 Chrome 사용자 경험 보고서(CrUX)의 값이 갈린다. 백그라운드 탭에서 로드된 페이지는 CLS와 FCP와 LCP가 아예 보고되지 않는다. 뒤로 가기 캐시에서 복원되면 지표가 다시 보고된다. 그러니까 값이 예상과 다를 때, 그게 사이트가 느려서인지 측정 규칙 때문인지 가르려면 결국 브라우저 쪽 지식이 필요하다.
둘째는 어디에 계측을 심을지 판단하는 일이다. 이 구분은 OpenTelemetry 문서가 명확하게 설명해준다. 문서는 소스를 고치지 않고 에이전트 형태로 붙이는 방식을 zero-code instrumentation이라 부르는데, 그 범위에 대한 설명이 이렇다.
일반적으로 zero-code instrumentation은 사용하는 라이브러리에 계측을 추가한다. 즉 요청과 응답, 데이터베이스 호출, 메시지 큐 호출 등이 계측된다. 반면 애플리케이션 코드는 일반적으로 계측되지 않는다. 코드를 계측하려면 code-based instrumentation을 사용해야 한다.
Typically, zero-code instrumentation adds instrumentation for the libraries you're using. This means that requests and responses, database calls, message queue calls, and so forth are what are instrumented. Your application's code, however, is not typically instrumented. To instrument your code, you'll need to use code-based instrumentation.
자동 계측이 공짜로 알려주는 것은 라이브러리 경계다. HTTP 요청이 들어왔고, DB 호출이 나갔다는 사실까지다. 내 애플리케이션 코드가 무슨 판단을 했는지는 대개 알려주지 않는다. 그걸 알려면 손으로 심어야 한다.
그리고 필자에게 필요했던 계측이 정확히 후자였다. "GA 클라이언트가 호출됐다"가 아니라 "이 블로그의 통계 조회 함수가 실패를 삼키고 기본값을 반환했다"를 알아야 했다.
AI를 쓰면서 달라진 것
그래서 AI가 무엇을 바꿨는가. 미리 밝혀두면 필자에게는 비교군이 없다. 같은 일을 혼자 해본 적이 없으니 "하루"라는 숫자는 성과가 아니라 그저 착수 문턱이 낮아졌다는 이야기로만 읽어야 한다. 그걸 감안하고 적으면 두 가지다.
하나는 위에 적은 규칙들을 전부 외우고 있지 않아도 된다는 점이다. 예전에는 값이 이상할 때 이게 필자의 코드 탓인지 측정 규칙 탓인지 가르는 데만 반나절이 갔다. 지금은 관측한 값을 그대로 들고 가서 "이 조건에서 이 지표는 어떻게 계산되는가"를 문서와 대조하며 좁힐 수 있다. 물론 그대로 믿으면 안 된다. 필자는 이 글을 쓰면서도 AI가 논문에 없는 문장을 그럴듯하게 만들어내는 것을 한 번 잡았다. 그래서 근거로 쓸 문장은 전부 원문에서 다시 확인했다. 다만 어디를 확인해야 하는지 아는 것과 그것을 다 외우고 있는 것은 다른 문제이고, 후자의 부담이 확실히 줄었다.
다른 하나는 계측 지점을 함께 훑는 일이다. 후보를 늘어놓고 왜 거기여야 하는지 근거를 주고받는 과정이 혼자 할 때보다 빨랐다. 다만 무엇을 실패로 볼지 정하는 판단은 끝까지 필자의 몫이었다. 뒤에 나오는 이야기가 정확히 그 판단이 틀렸던 기록이다.
실패했는데 성공으로 보고되고 있었다
처음 계획은 단순했다. 통계 API 라우트 핸들러의 catch에 에러 보고를 넣는다. 그러면 GA 호출이 실패할 때 알 수 있다. 합리적으로 들렸다.
그런데 로컬 프로덕션 빌드에서 잘못된 서비스 계정 키를 주입해 일부러 실패시켜보니, 에러가 라우트의 catch에 도달하지 않았다. 한 층 아래에 있는 통계 조회 모듈의 catch 블록 네 곳이 먼저 잡아서 기본값을 반환하고 있었다. 결과적으로 응답은 이렇게 나갔다.
HTTP 200 OK
{ "slug": "/260610", "views": 0 }
방문자에게는 통계가 0으로 보이고, 서버는 정상이라고 답한다. 계측하지 않으면 알 방법이 없는 상태다. (이 계층 구조에 대해서는 에러 핸들링에서 다룬 적이 있는데, 그때는 "어디서 잡아야 하는가"를 이야기했고 이번엔 "잡았는데 아무도 모른다"를 만난 셈이다)
그래서 계측 지점을 라우트가 아니라 그 네 곳으로 옮겼고, 각각 어떤 쿼리에서 터진 것인지 구분할 수 있게 태그를 달았다. 이 태그가 뒤에서 결정적인 역할을 한다.
관측의 비대칭
필자는 이걸 "실패가 실패로 안 보이는 상황" 정도로 부르고 있었는데, 찾아보니 이미 정확한 이름이 붙어 있었다. Microsoft와 Azure 팀이 2017년 HotOS에서 발표한 논문 Gray Failure: The Achilles' Heel of Cloud-Scale Systems가 그것이다.
논문은 클라우드에서 벌어지는 큰 가용성 사고가 대개 완전히 멈추는 종류가 아니라고 말한다. 부품이 맞게 동작하거나 아예 멈추거나 둘 중 하나라고 보는 단순한 실패 모델을 가정한 복구 장치는 이런 상황 앞에서 부적절하고, 때로는 상황을 악화시킨다는 것이다. 그리고 그 핵심 특징을 이렇게 규정한다.
우리는 gray failure의 핵심 특징이 differential observability, 즉 시스템의 실패 감지기가 애플리케이션이 피해를 보고 있는데도 문제를 알아차리지 못할 수 있다는 점이라고 주장한다.
we argue that a key feature of gray failure is differential observability: that the system's failure detectors may not notice problems even when applications are afflicted by them.
differential observability, 즉 관측의 비대칭이다. 한 주체는 실패로 인해 피해를 입고 있는데 다른 주체는 그 실패를 인지하지 못하며, 문제는 후자가 바로 실패 감지와 복구를 책임지는 쪽이라는 것이다. 논문이 든 예시가 인상적이었다. 요청 처리 모듈은 멈췄는데 하트비트 모듈은 살아 있으면, 하트비트에 의존하는 에러 처리 모듈은 시스템을 건강하다고 판단하고 서비스를 요청한 클라이언트는 실패로 판단한다.
논문은 해결의 방향도 제시한다. 서로 다른 구성 요소가 무엇을 실패로 보는가에 대해 갖고 있는 인식의 격차를 메우는 데 집중해야 한다는 것이다. 필자가 계측 지점을 라우트에서 아래 계층으로 내린 일이 정확히 그 격차를 메우는 작업이었다.
그리고 진짜 장애가 잡혔다
계측을 붙인 뒤 처음 올라온 실제 프로덕션 이슈가 이 이야기의 다음 장면이다.
GA 호출이 65.877초 후에 DEADLINE_EXCEEDED로 실패하고 있었다. 그런데 앞서 본 구조 때문에 응답은 여전히 200이었다. 홈은 동적 렌더링이고 통계 영역을 스트리밍으로 흘려보내기 때문에 페이지 자체는 바로 뜬다. 대신 그 자리가 1분 넘게 로딩 상태로 남아 있다가 조용히 0으로 채워진다.
원인을 파보니 사용하는 GA 클라이언트 라이브러리의 설정 파일에 이렇게 박혀 있었다.
"RunReport": { "timeout_millis": 60000, "retry_params_name": "default" }라이브러리 기본 RPC 타임아웃이 60초다. 그리고 필자의 코드는 다섯 개 호출 지점 어디에도 타임아웃을 넘기지 않고 있었다. 그때 필자는 관측된 65.877초를 60초에 연결과 로드밸런서 오버헤드가 붙은 값이라고 읽었다. (이 해석이 나중에 흔들리는데, 그 이야기는 뒤에서 한다)
여기서 필자가 배운 것은, 이건 필자만의 실수가 아니라 널리 경고되어온 종류의 실수라는 점이다. Google SRE 팀의 Gráinne Sheerin이 쓴 gRPC 공식 블로그의 deadline 글은 제목 아래 첫 줄이 "TL;DR: Always set a deadline"이다. deadline을 주지 않으면 진행 중인 모든 요청이 자원을 붙들고 최대 타임아웃까지 갈 수 있으며, 그 결과 메모리가 고갈되고 지연이 늘고 최악의 경우 프로세스가 죽을 수 있다고 설명한다. 필자가 쓰는 GA 클라이언트도 gRPC 기반이니, 같은 원리를 이미 문서가 경고하고 있었는데 호출 지점에서 지키지 않은 것이다.
수정은 타임아웃을 5초로 고정해 다섯 개 호출 지점 전부에 넘기는 것이었다. 그리고 응답하지 않는 로컬 TCP 서버를 세워 결정적으로 재현해봤다.
| 조건 | 경과 시간 | 에러 메시지 |
|---|---|---|
| 타임아웃 미지정 (수정 전) | 60.04초 | Deadline exceeded after 60.000s |
timeout: 5000 (수정 후) |
5.00초 | Deadline exceeded after 5.000s |
숫자가 정확히 설명대로 움직였다. 이 표를 얻고 나서야 타임아웃 설정이 실제로 코드에 닿는다는 것까지는 확인했다고 말할 수 있게 됐다. (그 전까지는 "타임아웃이 없어서 그렇겠지"라는 추측이었다. 다만 이것이 프로덕션의 원인까지 확인한 것은 아니었다는 사실이 뒤에서 드러난다)
한 가지 더. 5라는 숫자 자체에 근거가 있는 것은 아니다. GA가 정상일 때의 응답 지연 분포를 재보지 않았으니 5초는 사실상 임의로 고른 값이다. 다만 판단의 방향에는 기댈 곳이 있었다. Google SRE 책은 3장 Embracing Risk에서 100%는 결코 옳은 신뢰성 목표가 아니라고 말한다. 달성이 불가능할뿐더러, 대개 사용자가 원하거나 알아차리는 수준보다 과한 신뢰성이기 때문이다. 이 블로그에서 방문자 수치는 부가 정보다. 정확하게 받아오는 것보다 빠르게 포기하고 기본값을 그려주는 편이 방문자 경험에 낫다. 신뢰성 목표를 100%로 두지 않기로 한 결정인 셈이다.
고쳤다고 생각한 뒤에 다시 재봤다
여기까지가 원래 이 글의 결말이 될 예정이었다. 원인을 찾았고, 재현했고, 고쳤으니까.
그런데 이 글을 쓰면서 습관처럼 이슈 목록을 다시 열어봤다. 수정 커밋이 배포된 릴리스에서, 같은 계열의 DEADLINE_EXCEEDED가 백 건 넘게 쌓여 있었다. 가장 최근 것은 몇 시간 전이었다.
가장 최근 100건을 뽑아 보고된 시간의 분포를 봤다. 한 가지 짚어둘 것은 이 값이 GA가 실제로 응답에 쓴 시간이 아니라는 점이다. 마감 시각을 걸어둔 순간부터 그 타이머가 실제로 울린 순간까지의 wall-clock time(실제 경과 시간)이다. 이 구분이 뒤에서 중요해진다.

읽어보면 이렇다. 하한은 지켜졌다. 5초보다 짧게 끊긴 건이 하나도 없고, 가장 짧은 것이 5.16초다. 상한을 넘기지 않았을 때의 재현값이 60초였던 것과 비교하면, 5초 설정 자체는 코드에 닿아 있다. 그런데 위로는 8분 24초까지 올라가고, 중앙값이 61초다. 더 이상한 것은 값이 어느 구간에도 몰리지 않는다는 점이다. 실제로 GA가 느려서 생긴 지연이라면 상한 근처에 쌓여야 하는데 그렇지 않다.
태그가 더 많은 것을 알려줬다. 100건에 찍힌 태그는 stats와 popular 둘뿐이고, 대체로 두 건이 쌍으로 올라온다. 그런데 이 두 경로에는 공통점이 하나 있다. 둘 다 한 시간짜리 캐시 뒤에 있는 재검증 경로다. 반면 캐시 없이 요청을 받아 그 자리에서 GA를 부르는 나머지 두 경로(page, pages)는 100건 안에 한 번도 등장하지 않는다.
이게 중요하다. 실패가 방문자의 요청을 처리하는 중에 일어나는 게 아니라, 응답이 끝난 뒤 캐시를 다시 채우는 작업에서만 일어나고 있다는 뜻이기 때문이다.
그리고 이 관찰은 앞 절에서 필자가 쓴 문장 하나를 흔든다. 통계 자리가 1분 넘게 로딩에 머문다고 썼는데, 실패가 응답 이후 경로에서만 난다면 방문자는 그 시간을 기다리지 않았을 수도 있다. 캐시가 비어 있는 첫 요청에서는 이야기가 다르겠지만, 지금 가진 데이터로는 어느 쪽인지 가리지 못한다. 재보지 않고 쓴 문장이 하나 더 있었던 셈이다.
한 가지가 더 걸린다. 앞 절에서 필자는 65.877초를 60초 타임아웃에 오버헤드가 붙은 값이라고 읽었는데, 그 이벤트에 적힌 오버헤드 항목을 다시 열어보니 합쳐서 2밀리초 남짓이었다. 그러니까 그때의 해석도 지금 보면 근거가 약하다. 같은 종류의 부풀림이 그때도 있었을 수 있다.
지금 필자가 세워둔 가설은 이렇다. 이 블로그는 서버리스 함수 위에서 돌아가는데, 서버리스 함수는 응답을 보내고 나면 다음 호출까지 실행 환경이 얼어붙는다. 그동안 타이머도 함께 멈췄다가 함수가 깨어날 때 뒤늦게 발화한다면, 실제로 기다린 시간이 아니라 wall-clock time 기준으로 부풀려진 값이 찍힐 수 있다. 하한이 정확히 5초에 붙어 있는 것도, 위쪽 값이 아무 데도 몰리지 않는 것도 그렇게 보면 설명이 된다. 실패가 응답 이후의 작업에서만 나온다는 앞의 관찰과도 맞는다.
다만 여기서 한 번 더 조심해야 한다. 분포가 가설과 어긋나지 않는다는 것과 가설을 지지한다는 것은 다르다. 타이머가 뒤늦게 발화하는 시나리오는 여럿이다. 서버리스 동결 말고도 무거운 렌더링이 이벤트 루프를 물고 있었을 수도 있고, 컨테이너가 CPU를 조이고 있었을 수도 있다. 셋 다 지금 본 것과 같은 모양의 분포를 만든다. 그러니 이 그래프는 후보를 좁혀주지 않는다.
다른 설명도 후보로 두고 있다. 라이브러리 설정에는 재시도까지 포함한 총 예산이 600초로 잡혀 있는데, 관측된 최댓값 504초가 그 안에 들어온다. 다만 이 메서드는 재시도 대상 코드 목록이 비어 있어서 그 경로를 타지 않는 것으로 읽힌다. 어느 쪽이든 아직 확인하지 못한 가설이다. 그리고 확인하는 방법을 고르는 데도 함정이 있었다. 처음 떠올린 것은 호출 직전과 직후의 시각을 재는 것이었는데, 그건 답을 주지 못한다. 함수가 얼어 있는 동안에도 wall-clock time은 그대로 흐르기 때문에 이미 가진 숫자를 다시 만들 뿐이다. 갈라주는 것은 같은 구간의 CPU 사용 시간이다. wall-clock time으로 61초가 지나는 동안 CPU 시간이 거의 0이라면 그 시간은 기다린 것이 아니라 멈춰 있던 것이다. 다음 작업은 그것이 될 것 같다.
필자가 이 절을 굳이 남겨두는 이유가 있다. 필자는 이 문제를 고쳤다고 생각하고 있었다. 재현했고, 표까지 만들었으니 확실하다고 여겼다. 그런데 다시 열어보니 아니었다. 계측을 붙이는 일이 한 번의 작업이라면, 관측은 계속 재보는 일이다. 그리고 그 둘을 같은 것으로 착각하면 지금 필자가 한 것과 같은 착각을 하게 된다.
한 가지 덧붙이면, 위의 이슈 목록과 태그 분포와 시간 값을 필자는 대시보드를 열어서 본 것이 아니라 에이전트에게 물어서 받았다. Sentry가 공식 MCP 서버를 내놓았고, 이걸 붙여두면 에디터에서 이슈와 이벤트를 그대로 조회할 수 있다. 코드를 보고 있던 자리에서 프로덕션 이슈를 함께 펼쳐놓는 일이 클릭 몇 번 없이 가능해졌다는 뜻이다. 계측을 붙이는 비용만 내려간 게 아니라 쌓인 데이터를 열어보는 비용도 내려갔다.
붙이는 것과 쓰는 것은 다른 일이다
여기까지 오고 나서 필자가 한 일이 하나 더 있다. 이 도구를 어디까지 쓸 수 있는 것인지 문서를 처음부터 다시 훑어봤다. 이번 조사에서 결정적이었던 게 결국 태그 하나였는데, 그건 계측을 붙일 때 지나가듯 달아둔 것이었기 때문이다. 지나가듯 달아둔 것이 그 정도 값을 했다면, 작정하고 켜는 것들은 무엇을 답해주는가.

릴리스와 커밋이 첫 번째 칸이다. 이 블로그는 배포 커밋 해시가 릴리스로 붙어 있어서 어느 배포부터 문제가 시작됐는지는 안다. 여기서 한 걸음 더 나가면 릴리스에 커밋 목록을 함께 올릴 수 있고, 그러면 suspect commits가 동작한다. 스택 트레이스의 애플리케이션 프레임마다 해당 파일과 줄 번호의 blame 정보를 확인해서, 가장 최근 커밋이 1년 이내면 용의자로 지목하는 방식이다. 그리고 그 커밋의 작성자를 담당자로 제안하거나 자동 배정까지 한다. 커밋을 릴리스에 연결하면 커밋 메시지에 적어둔 이슈 ID로 그 릴리스에서 이슈가 해결된 것으로 표시되기도 한다. 이 블로그는 소스맵을 이미 올리고 있으니 절반은 갖춰둔 셈인데, 커밋 연결은 해두지 않았다.
소유 규칙은 개인 블로그에서는 쓸 일이 없지만 구조가 흥미롭다. 문서를 보면 파일 경로, 모듈, 요청 URL, 특정 태그 값을 유닉스 글롭으로 매칭해서 담당자나 팀을 지정한다. path:src/api/* 는 백엔드 팀, 같은 식이다. 이걸 처음 봤을 때 필자가 든 생각은 이게 알림 라우팅 기능이 아니라 소유권을 코드로 적어두는 장치라는 것이었다. 이슈가 올라왔을 때 누가 봐야 하는지 매번 사람이 정하면, 바쁜 날에는 아무도 안 본다.
트레이싱은 질문의 성격이 다르다. Sentry 문서는 트레이스를 애플리케이션에서 나온 연결된 이벤트와 작업의 기록으로, 스팬을 이름과 시간이 붙은 하나의 작업으로 정의한다. 요청 하나를 여러 서비스와 데이터베이스와 함수에 걸쳐 따라가면서 각 구간이 시간을 얼마나 썼는지를 본다는 것이다. 에러 보고가 "무엇이 깨졌는가"에 답한다면 트레이싱은 "시간이 어디서 사라졌는가"에 답한다. 앞 절에서 필자가 막힌 지점이 정확히 후자였다. 보고된 경과 시간이 실제 대기 시간인지 아닌지를 가르려면 호출 구간의 시작과 끝이 필요하다. 이 블로그는 비용을 아끼려고 트레이스 표본을 10%만 받도록 해뒀는데, 그 판단이 여기서 아쉬웠다.
그리고 예약 작업 모니터가 이 글의 논지와 가장 잘 맞는 카드다. 에러 보고는 일어난 일만 잡는다. 일어나지 않은 일은 잡지 못한다. Cron 모니터는 작업이 시작할 때 진행 중이라고 알리고 끝날 때 성공이나 실패를 알리는 방식으로 동작하는데, 여기서 중요한 건 세 번째 상태다. 문서는 예정된 시간에 신호가 오지 않은 경우를 놓친 실행(missed)으로 따로 분류한다. 작업 스케줄러가 잘못 설정됐거나 작업을 아예 시작하지 못한 경우가 여기 들어간다.
필자에게 이게 남의 얘기가 아니었다. 이 블로그는 매주 월요일에 Search Console 데이터를 자동으로 수집한다. 뒤에서 다룰 관측 층 하나가 통째로 그 작업 위에 얹혀 있다. 그런데 어느 주에 그 작업이 조용히 돌지 않아도 지금은 알 방법이 없다. 실패한 게 아니라 아무 일도 일어나지 않은 것이라서, 에러가 나지 않기 때문이다. 관측 데이터를 모으는 장치 자체가 사각지대에 있었던 셈이다.
정리하면 이렇다. 도구를 붙이는 일은 하루면 되지만, 그 도구가 답할 수 있는 질문을 넓히는 일은 계속 남는다. 그리고 어느 층을 켤지는 기능 목록을 훑어서 정해지지 않는다. 무엇을 실패로 볼 것인가를 먼저 정해야 어느 층이 필요한지 알 수 있다. 이 블로그의 경우 "주간 수집이 돌지 않은 것"을 실패로 인정하는 순간 켜야 할 층이 하나 생겼다.
문서가 시사한 일을 옵션이 하지 않을 때
곁가지로 하나 더 적어두고 싶은 사례가 있다. 성격이 조금 다르다.
소스맵을 업로드한 뒤에는 빌드 산출물에서 .map 파일을 지워야 했다. 이유는 용량이다. 서버 소스맵이 57MB로 서버 JS(15MB)보다 큰데, 그대로 두면 배포 함수 번들에 전부 실린다. 마침 업로드 후 소스맵을 삭제해주는 옵션이 있어서 그걸 켰다.
그런데 실측해보니 업로드 직후에도 서버 .map 파일이 57MB 그대로 남아 있었다. 그 옵션은 정적 산출물 디렉터리만 지우고, 정작 용량을 차지하는 서버 디렉터리는 건드리지 않았다. 결국 삭제 대상 경로를 직접 지정하는 방식으로 바꿨다.
같은 이유로 업로드 로그도 조건부로 남기게 바꿨다. 로그를 항상 꺼두면 토큰이 만료돼 업로드가 통째로 실패해도 조용히 넘어가고, 다음에 읽을 수 없는 스택 트레이스를 볼 때까지 아무도 모른다.
이건 실패 감지의 문제가 아니라 이름과 실제 동작 범위가 어긋난 문제다. 그래서 gray failure로 묶지는 않는다. 다만 교훈은 같은 쪽을 가리킨다. 문서를 읽고 옵션을 켠 것과, 그 옵션이 기대한 일을 했는지 확인한 것은 다른 일이다.
에러만 관측 대상은 아니다
여기까지가 안정성 쪽 이야기다. 그런데 관측 정보를 잘 다루는 일의 효용은 장애를 잡는 데서 끝나지 않는다. 처음에 두 갈래로 간다고 했던 나머지 한쪽, 사용자를 이해하는 쪽으로 넘어가보자.
OpenTelemetry 문서의 신뢰성 정의가 이 전환을 잘 잡아준다. 신뢰성은 "서비스가 사용자가 기대하는 일을 하고 있는가"에 답한다는 것이다. 기준이 서버 지표가 아니라 사용자의 기대다. 그렇다면 사용자가 실제로 무엇을 겪고 있는지도 측정해야 한다.
이 블로그는 결과적으로 세 층으로 관측하고 있다.

세 층이 답해주는 질문이 각각 다르다. 첫 층은 무엇이 깨졌는지, 둘째 층은 방문자가 얼마나 기다렸는지, 셋째 층은 애초에 어떤 검색어로 들어왔는지에 답한다.
두 번째 층에서 중요한 판단이 하나 있었다. 성능 지표를 재는 방법에는 통제된 환경에서 페이지를 열어보는 방식과, 실제 방문자 전원을 관측하는 방식이 있다. web.dev는 앞을 lab data, 뒤를 field data라 부르며 둘의 차이를 정리한 문서에서 둘 다 있으면 field data로 우선순위를 정하라고 권한다. 실제 사용자가 겪는 것을 대표하기 때문이다. Lighthouse 점수가 좋아도 실제 방문자의 분포는 다를 수 있다. 그래서 이 블로그는 점수를 재보는 데서 멈추지 않고 실사용자 값을 GA4로 보낸다.
기준선은 web.dev의 Web Vitals 문서가 제시한다. LCP는 2.5초 이내, INP는 200밀리초 이하, CLS는 0.1 이하이고, 판단은 페이지 로드의 75번째 백분위수를 모바일과 데스크톱으로 나눠서 본다. 평균이 아니라 느린 쪽 25%가 넘어서는 경계값을 본다는 뜻이다. (평균으로 보면 느린 사용자가 통째로 지워진다는 걸 이 기준을 이해하고 나서 알았다)
순위가 내려갔는데 클릭이 늘었다
세 번째 층인 검색 데이터에서는 필자의 예상이 한 번 더 뒤집혔다.
이 블로그는 Search Console 데이터를 주기적으로 수집해 최근 28일과 직전 28일을 비교한다. 그렇게 쌓인 데이터에서 오래된 글 하나가 눈에 걸렸다.

노출은 11% 줄었고 평균 순위는 8.9위에서 11.6위로 밀렸다. 두 지표만 보면 나빠진 글이다. 그런데 클릭은 2회에서 13회로 늘었고 클릭률은 0.87%에서 6.37%가 됐다.
먼저 김을 좀 빼두는 게 정직하겠다. 이 패턴 자체는 검색 데이터를 다뤄본 사람에게 낯선 것이 아니다. 평균 순위는 노출로 가중한 평균이라, 위쪽에 뜨지만 아무도 누르지 않던 노출이 빠지면 평균 순위는 나빠지고 클릭률은 기계적으로 올라간다. 검색어 구성이 바뀌었을 뿐인데 반전처럼 보일 수 있다는 뜻이다. 그리고 늘어난 클릭의 절대량은 28일 동안 11회다. 작은 수다.
그걸 감안하고도 남는 것이 있다. 어쨌든 이 글로 들어온 사람은 늘었고, 노출과 순위만 보고 있었다면 그 사실을 몰랐을 것이다. 필자가 이 수치에서 얻은 것은 특정 글에 대한 결론이 아니다. 무엇을 지표로 삼느냐가 결론을 뒤집는다는 사실이다. 순위를 성과로 잡았다면 이 글은 손봐야 할 대상이었을 것이고, 클릭을 성과로 잡으면 잘된 글이다. 앞 절에서 본 신뢰성 정의와 같은 얘기다. 기준을 사용자 쪽에 두면 보이는 것이 달라진다.
이 블로그에서 필자가 오래 고쳐온 것이 제목과 설명이었다. 검색 결과에 노출되는 문장을 실제 검색어에 맞춰 다시 쓰는 작업이다. 이 수치가 그 작업의 효과를 증명해주지는 않는다. 순위가 밀렸는데 클릭률이 오른 배경에는 계절성이나 검색어 구성 변화 같은 다른 요인도 얼마든지 있을 수 있다. 다만 재보지 않았으면 이런 방향의 변화가 있었다는 것조차 몰랐을 것이다.
79KB를 포기한 결정
세 층 이야기를 하면서 빼놓을 수 없는 게 브라우저 계측을 넣지 않은 결정이다.
필자는 클라이언트 에러 모니터링도 켜고 싶었다. 브라우저에서만 나는 에러를 못 보는 상태가 계속 마음에 걸렸다. 그래서 켜보고 번들을 실측했다. clean build 기준으로 클라이언트 JS의 gzip 총합을 비교한 결과다.
| 구성 | client JS (gzip) | 증가분 |
|---|---|---|
| 미적용 | 181.6 KB | 기준 |
| 서버 전용 (현재) | 182.3 KB | +0.7 KB |
| 클라이언트 포함 | 260.4 KB | +78.8 KB |
서버 계측은 사실상 공짜인데, 브라우저 계측은 78.8KB를 요구했다. 번들 최적화 옵션도 시도해봤지만 수치는 그대로였다. 클라이언트 비용을 줄이는 방법은 브라우저 초기화 파일을 아예 두지 않는 것뿐이었다.
지금 다시 보면 이 측정에도 흠이 있다. 옵션을 켰는데 한 바이트도 줄지 않았다는 것은 그 옵션이 먹지 않았다는 신호일 수도 있는데, 그때는 결론 쪽으로만 읽었다. 정적 산출물 전체를 합산한 값이라 방문자 한 명이 실제로 내려받는 양과도 다르다. 그러니 정확히 쓰면 "브라우저 관측은 79KB다"가 아니라 **"필자의 설정에서는 그 아래로 내리지 못했다"**가 맞다.
이 블로그의 도입 목적은 서버에서 조용히 실패하는 호출을 잡는 것이었고, 그 부분은 공짜였다. 그리고 이 블로그에서 로딩 성능은 곧 사용자 경험이자 검색 노출의 전제다. 그래서 포기했다. 재미있는 건 이 결정이 앞 절의 논리와 이어진다는 점이다. 사용자 기대를 기준으로 신뢰성을 정의하면, 관측을 더 촘촘히 하는 것이 항상 옳은 선택은 아니게 된다. 관측 자체가 사용자 경험을 깎을 수 있기 때문이다.
그런데 이렇게 쓰고 나면 앞에서 트레이스 표본을 10%로 줄인 것을 아쉬워한 것과 말이 어긋나 보인다. 둘 다 비용 때문에 관측을 덜 산 결정인데 하나는 후회고 하나는 잘한 일이라니. 필자가 뒤늦게 정리한 기준은 그 비용을 누가 내는가다. 트레이스 표본을 줄여 아낀 것은 필자의 요금이고, 브라우저 계측으로 늘어나는 78.8KB는 방문자의 데이터와 시간이다. 내 쪽이 내는 비용이라면 더 사두는 편이 대체로 낫고, 사용자가 내는 비용이라면 그 관측이 사용자에게 무엇을 돌려주는지 물어봐야 한다.
알림은 무엇에 걸어야 할까
계측을 붙이면 다음 질문이 바로 따라온다. 어디에 알림을 걸 것인가.
Google SRE 초기에 Rob Ewaschuk이 쓴 알림 철학 문서는 사람을 호출하는 알림이 긴급하고 중요하며 조치 가능하고 실재해야 한다고 못 박는다. 그리고 원인이 아니라 증상에 알림을 걸라고 권한다. 500 응답이나 사용자에게 보이는 오류처럼 겉으로 드러나는 신호에 걸라는 것이다.
그런데 이 원칙과 필자가 겪은 일 사이에 미묘한 긴장이 있다. 필자의 증상은 500이 아니었다. 200에 빈 통계였다. 증상 기반 알림은 실패가 상태 코드로 드러난다는 전제 위에 서 있는데, 앞서 본 differential observability가 바로 그 전제를 깨는 상황이다.
그래서 필자는 이 원칙을 반박해야 한다고 생각하지 않는다. 오히려 "무엇을 증상으로 정의할 것인가"가 이 일의 진짜 어려운 부분이라는 결론에 도달했다. 이 블로그에서 증상은 상태 코드가 아니라 "통계 조회 함수가 기본값을 반환했다"였고, 그건 손으로 심어야만 증상이 될 수 있었다.
같은 문서가 덧붙이는 조언도 새겨둘 만하다. 시끄러운 알림은 지우는 쪽으로 기울라는 것이다. 과잉 모니터링이 과소 모니터링보다 풀기 어려운 문제이기 때문이다. 참고로 SRE 책은 사후 회고를 작성해야 하는 트리거 목록에 모니터링 실패 자체를 포함시켜 두었다. 소스맵 업로드가 조용히 실패하지 않도록 로그를 조건부로 남기게 바꾼 판단이 같은 방향을 가리키고 있었다.
AI 시대에 이 일이 왜 더 중요해지는가
여기까지 읽으면 자연스럽게 떠오르는 질문이 하나 있다. 이건 그냥 관측 도구를 잘 붙이자는 이야기 아닌가. AI와는 무슨 상관인가.
필자 생각에는 상관이 크다. 두 가지 이유에서다.
첫째, 업계 데이터가 그렇게 말한다. Nathen Harvey와 Derek DeBellis가 이끈 2025 DORA 리포트는 전 세계 기술 실무자 약 5천 명의 설문과 100시간 넘는 정성 데이터를 바탕으로 하는데, AI 도입이 처리량과 제품 성과에는 긍정적인 관계를 보였다고 적으면서 곧바로 이 문장을 붙여두었다.
그러나 AI 도입은 소프트웨어 배포 안정성과 계속해서 부정적인 관계를 보인다.
However, AI adoption does continue to have a negative relationship with software delivery stability.
배포 안정성과는 여전히 부정적 관계라는 것이다. 빨라지는 만큼 흔들린다. DORA는 별도 인사이트 글에서 그 메커니즘을 이렇게 설명한다. 생성 단계에서 절약한 시간이 검증 오버헤드로 재배치되고, 리뷰해야 할 코드가 만들어지는 속도 자체가 올라간다는 것이다. 같은 리포트가 내린 요약이 이 상황을 가장 잘 표현한다. AI는 팀을 고쳐주지 않고 이미 있는 것을 증폭한다는 것이다. 관측이 없는 상태에서 배포 속도만 올리면, 증폭되는 것은 조용한 실패다.
둘째, 그리고 이게 필자에게 더 무겁게 남은 이유인데, 자기 체감을 믿을 수 없다는 증거가 있다. METR이 2025년에 발표한 연구는 숙련된 오픈소스 개발자 16명에게 246건의 실제 이슈를 주고 이슈별로 AI 사용 허용 여부를 무작위 배정했다.
개발자에게 AI 도구 사용이 허용되면, 이슈를 완료하는 데 19% 더 오래 걸린다…
When developers are allowed to use AI tools, they take 19% longer to complete issues…
그런데 정말로 눈에 걸리는 부분은 그다음이다. 개발자들은 사전에 AI가 24% 빠르게 해줄 것이라 예상했고, 실제로 느려진 것을 경험한 뒤에도 20% 빨라졌다고 믿었다. 필자는 이 연구를 AI 프론트엔드 엔지니어를 쓸 때도 인용했는데, 그때는 생산성 논의의 맥락에서 읽었다. 지금은 다르게 읽힌다. 이 수치는 AI를 쓰지 말라는 근거가 아니라, 체감이 실제와 어긋난다는 근거다.
그리고 체감을 믿을 수 없다면 남는 방법은 하나다. 재보는 것이다. 필자는 이 블로그가 잘 돌아가고 있다고 체감했는데 재보니 GA 호출이 1분 넘게 매달리고 있었고, 그걸 고쳤다고 체감했는데 다시 재보니 아직 아니었다.
관측의 대상도 넓어지고 있다
마지막으로 최근 흐름 하나를 적어두고 싶다.
관측해야 할 대상 자체가 바뀌고 있다. OpenTelemetry는 생성형 AI를 위한 semantic convention을 별도 저장소로 분리해 정리하는 중인데, GenAI 클라이언트뿐 아니라 MCP(Model Context Protocol) 호출까지 계측 대상에 들어와 있다. 아직 초기 단계이고 스키마도 정리 중이라 지금 도입을 권할 단계는 아니라고 본다. 다만 방향은 분명하다. 우리가 AI 에이전트에게 도구를 붙여 쓰기 시작한 만큼, 그 호출도 관측 대상이 된다. AI 에이전트 도구에서 MCP의 동작 원리를 다뤘고 Harness(Systems) Engineering에서 eval 이야기를 했는데, 그 둘이 만나는 자리가 여기인 것 같다.
에러 모니터링 서비스들도 같은 방향으로 움직인다. Sentry는 Seer라는 AI 디버깅 에이전트를 내놓았는데, 이슈 상세와 트레이스, 로그, 프로파일 컨텍스트를 함께 써서 근본 원인을 찾고 수정 PR까지 만든다고 설명한다. 필자는 아직 이걸 본격적으로 써보지 않았고, 정확도에 대한 공식 수치도 문서에서 찾지 못했다. 그래서 성능을 단정하지는 못한다. 다만 앞에서 MCP로 이슈를 열어본 경험과 같은 방향인 것은 분명하다. 관측 데이터를 해석하는 비용이 내려가는 중이다.
마치며
정리하면 이렇다.
AI와 함께 작업하면서 관측 도구를 붙이는 문턱이 실제로 낮아졌다. 오래 미뤄온 일이 하루에 끝났고, 브라우저가 지표를 어떻게 만드는지 같은 지식의 부담도 예전만큼 크지 않다. 다만 필자가 얻은 것은 도구가 아니었다. 라우트의 catch만 잡으면 된다는 가정, 이름이 그렇게 붙은 옵션이 그 일을 할 거라는 가정, 타임아웃을 넣었으니 이제 끝났다는 가정이 전부 틀려 있었다는 사실이었다. 그리고 그건 재보기 전에는 알 수 없는 것들이었다.
그래서 필자는 관측을 이렇게 이해하게 됐다. 관측은 장애가 났을 때 원인을 찾는 도구이기도 하지만, 그 전에 필자가 시스템에 대해 갖고 있는 인식과 실제 사이의 격차를 재는 도구다. Gray Failure 논문의 표현을 빌리면, 서로 다른 주체가 무엇을 실패로 보는지에 대한 격차를 메우는 일이다. 그리고 그 격차는 안정성 쪽에도 있고, 사용자가 실제로 무엇을 겪고 있는지에 대해서도 있다.
생성이 싸질수록 이 격차는 벌어지기 쉬워진다. 만드는 속도는 올라가는데 확인하는 속도는 그만큼 올라가지 않기 때문이다. 그래서 필자는 요즘 AI를 더 적극적으로 쓰기 위해 필요한 것이 관측이라고 생각하게 됐다. 순서가 거꾸로인 것 같지만, 빠르게 만들 수 있게 되면 빠르게 확인할 수 있어야 한다.
물론 이 글에 적은 것들은 개인 블로그라는 작은 규모에서 얻은 관찰이다. 트래픽과 팀 규모가 다르면 판단도 달라질 것이다. 78.8KB를 포기하는 결정도 다른 서비스에서는 반대로 내려야 할 수 있다. 정답이 있는 영역은 아닌 것 같다. 다만 하나는 말할 수 있을 것 같다. 지금 잘 돌아가고 있다고 느끼는 것과 실제로 잘 돌아가는 것은 다르고, 그 차이를 아는 방법은 재보는 것밖에 없다. 이 글을 읽는 독자 분들도 각자의 서비스에서 재보지 않은 채 믿고 있는 것이 무엇인지, 그리고 고쳤다고 믿고 다시 열어보지 않은 것이 무엇인지 한 번 떠올려보면 좋겠다.
📚함께 읽으면 좋은 글
상태 관리
2026. 5. 18. · 36 min read
이번 포스팅에서는 상태 관리(State Management) 에 대한 이야기를 해보려고 한다. 라이브러리 비교 글은 아니다. 어떤 도구가 더 좋은지를 가리는 것보다, 상태라는 것을 어떻게 바라보고, 어디에 경계를 그어야 하는지에 대한 감각을 정리하는 글이다. 요즘 AI 도구들(Claude, ChatGPT, Cursor, Gemini, Copilot)이 우리...
도메인 모델
2026. 4. 18. · 53 min read
이번 포스팅에서는 도메인(Domain) 에 대한 이야기를 해보려고 한다. 필자는 개발을 하면서 "도메인(Domain)" 이라는 단어를 꽤 자주 접해왔다. 그런데 막상 "도메인이 정확히 뭔데?"라고 물어보면 명쾌하게 대답하기가 쉽지 않다. (솔직히 개발을 처음 시작했을때 도메인은 www 을 의미하는 줄 알았다.) 도메인에 대한 정보를 찾아보면 자연스럽게 도메...
Toss Frontend Fundamentals 모의고사 2회차 리팩토링 후기
2026. 3. 28. · 25 min read
이번 포스팅에서는 Toss Frontend Fundamentals 모의고사 2회차에 참여하며 진행한 리팩토링 경험에 대한 이야기를 해보려고 한다. 평소 코드 리뷰나 리팩토링에 관심이 있었던 필자는, 토스에서 공개한 Frontend Fundamentals 모의고사라는 흥미로운 형식의 과제를 진행하게되었다. 과제는 회의실 예약 앱이 주어지고, 이를 리팩토링하는...