1. 배경
handDoc은 청각장애인을 위한 대면·비대면 진료 플랫폼이다. 환자의 수어를 실시간으로 텍스트로 변환하고, 의사의 음성은 자막으로 제공한다. 또한 구음을 사용하는 환자의 음성은 교정 후보 문장으로 제시하며, WebRTC 기반 화상 진료와 진료 후 GPT 요약 기능까지 제공한다.
서비스 기능만 보면 수어 인식, 음성 인식, 화상 진료, GPT 요약 기능이 결합된 플랫폼이다. 하지만 인프라 관점에서 보면 더 중요한 특징이 있다. 여러 애플리케이션이 하나의 EC2 위에서 함께 실행되고, 실시간 처리와 외부 API 호출이 함께 얽혀 있다는 점이다.
현재 handDoc의 구조는 다음과 같다.
Client
|
| HTTP / WebSocket / WebRTC
v
Nginx Reverse Proxy
|
|-- /api -> Spring Boot Backend
|-- /ws -> WebSocket
|-- /ai-sign -> Sign Language FastAPI
|-- /ai-voice -> Voice Recognition FastAPI
|
v
Single AWS EC2
|
|-- Spring Boot Container
|-- Sign Language FastAPI Container
|-- Voice Recognition FastAPI Container
|
|-- AWS RDS MySQL
|-- MongoDB Atlas
|-- OpenAI API
|-- Naver CLOVA API
초기에는 서버가 정상적으로 실행되는지, API가 응답하는지 정도만 확인하면 된다고 생각했다. 하지만 실제 구조를 보면 단순 헬스체크만으로는 부족했다.
수어 인식이 느려졌을 때 그 원인이 Spring Boot 서버인지, FastAPI AI 서버인지, EC2 리소스 부족인지, MySQL 연결 문제인지 구분할 수 있어야 했다. 특히 handDoc은 실시간성이 중요한 서비스이기 때문에 수어 번역이나 음성 자막이 몇 초만 늦어져도 사용자 경험에 직접적인 영향을 줄 수 있다.
그래서 이번에는 handDoc에 Prometheus와 Grafana 기반의 모니터링 환경을 붙이고, 실제로 장애를 발생시켜 서비스 상태가 어떻게 관측되는지 확인해보았다.
이번 글에서는 다음 내용을 정리한다.
1. handDoc에 옵저버빌리티가 필요했던 이유
2. Prometheus + Grafana 기반 모니터링 구성 과정
3. Spring Boot와 FastAPI 메트릭 노출 방식
4. Prometheus scrape 설정에서 겪은 문제
5. Grafana 대시보드 구성
6. FastAPI, Spring Boot, MySQL 장애 시뮬레이션
7. 실습 과정에서 얻은 인사이트
8. 이후 확장 방향
2. 왜 옵저버빌리티가 필요했는가
handDoc은 단일 EC2 위에서 여러 서비스가 함께 실행되는 구조다. 이 구조에서는 하나의 서비스에서 발생한 문제가 다른 서비스에도 영향을 줄 수 있다.
예를 들어 PyTorch 기반 수어 인식 서버나 Whisper 기반 음성 인식 서버가 CPU를 많이 사용하면, 같은 EC2에서 실행 중인 Spring Boot 백엔드에도 영향을 줄 수 있다. 하지만 단순히 EC2의 CPU 사용률만 보면 어떤 컨테이너가 문제를 만들고 있는지 알기 어렵다.
또한 handDoc은 외부 API 의존성도 가지고 있다. 진료 요약에는 OpenAI API를 사용할 수 있고, 음성 처리에는 Naver CLOVA 같은 외부 API가 연결될 수 있다. 외부 API 호출이 느려지면 사용자 입장에서는 handDoc이 느린 것처럼 보인다. 하지만 실제 원인은 내부 서버가 아니라 외부 의존성일 수 있다.
따라서 다음 질문에 답할 수 있는 구조가 필요했다.
지금 어떤 서비스가 죽었는가?
서버 프로세스는 살아있지만 내부 의존성이 죽은 상태는 아닌가?
Spring Boot와 FastAPI 중 어느 쪽에서 문제가 발생했는가?
AI 서버의 추론 지연이 실제로 증가하고 있는가?
Prometheus가 보는 서비스 상태와 실제 애플리케이션 health 상태는 같은가?
장애 발생 후 복구 여부를 대시보드에서 확인할 수 있는가?
이번 실습에서는 전체 옵저버빌리티 중에서 먼저 메트릭 기반 모니터링을 적용했다. 로그 중앙화와 분산 트레이싱은 이후 단계로 확장할 예정이다.
3. 적용 범위
처음부터 Prometheus, Grafana, Loki, Tempo, OpenTelemetry까지 모두 붙이기보다는, 가장 기본이 되는 메트릭 수집부터 시작했다.
이번 실습에서 적용한 범위는 다음과 같다.
적용 완료:
- FastAPI /metrics endpoint 구성
- Spring Boot Actuator + Micrometer Prometheus 구성
- Prometheus Docker Compose 구성
- Grafana Docker Compose 구성
- Prometheus target 등록
- Grafana Prometheus data source 연결
- 서비스별 up metric 대시보드 구성
- FastAPI 서버 장애 시뮬레이션
- Spring Boot 서버 장애 시뮬레이션
- MySQL 의존성 장애 시뮬레이션
이후 확장 예정:
- Loki + Promtail 기반 로그 중앙화
- OpenTelemetry + Tempo 기반 분산 트레이싱
- Alertmanager 또는 Grafana Alert 설정
- k6 및 WebSocket 부하 테스트
- SLO 기반 알림 규칙 정의
4. FastAPI 메트릭 노출
먼저 FastAPI AI 서버에서 Prometheus가 읽을 수 있는 /metrics 엔드포인트를 열었다.
FastAPI에서는 prometheus-client를 사용했다. Prometheus는 애플리케이션이 직접 데이터를 보내는 방식이 아니라, 정해진 주기마다 메트릭 엔드포인트를 직접 긁어가는 pull 방식으로 동작한다. 따라서 FastAPI 서버는 Prometheus 형식의 메트릭을 /metrics에서 노출해야 한다.
로컬에서 FastAPI 서버를 실행한 뒤 다음 명령어로 확인했다.
curl.exe http://localhost:8000/metrics
처음에는 연결 실패가 발생했다. 원인은 /metrics 코드 문제가 아니라 FastAPI 서버 자체가 실행 중이지 않았기 때문이었다. FastAPI 서버를 다시 실행한 뒤 같은 명령어를 실행하자 정상적으로 메트릭이 출력되었다.
출력 결과에는 Python 런타임 관련 기본 메트릭뿐만 아니라 handDoc에서 직접 정의한 FastAPI 관련 메트릭도 포함되었다.
확인한 주요 메트릭은 다음과 같다.

FastAPI 요청 수
WebSocket 연결 수
WebSocket 메시지 수
수어 프레임 처리 지연 시간
AI 추론 지연 시간
FastAPI 에러 수
예를 들어 handDoc의 수어 인식 서버에서는 단순 요청 수보다 다음과 같은 지표가 더 중요하다.
ai_sign_frames_total
ai_sign_inference_total
ai_sign_frame_processing_duration_seconds
ai_sign_inference_duration_seconds
fastapi_ws_connections_total
fastapi_ws_messages_total
fastapi_ws_errors_total
수어 인식은 일반적인 HTTP API와 다르게 WebSocket을 통해 프레임을 계속 주고받는다. 그래서 단순히 /metrics가 열려 있는지만 보는 것으로는 부족하다. 실제 WebSocket 연결 수, 메시지 수, 프레임 처리 시간, 모델 추론 시간이 따로 관측되어야 한다.
이미지 첨부 예시는 다음과 같이 정리했다.

5. Spring Boot 메트릭 노출
Spring Boot 서버는 Actuator와 Micrometer Prometheus registry를 사용해 메트릭을 노출했다.
Spring Boot에서는 다음 엔드포인트를 확인했다.
curl.exe http://localhost:8080/actuator/health
curl.exe http://localhost:8080/actuator/prometheus
/actuator/health는 애플리케이션의 health 상태를 확인하는 엔드포인트이고, /actuator/prometheus는 Prometheus가 scrape할 수 있는 형식으로 메트릭을 노출하는 엔드포인트다.
Spring Boot에서 확인한 주요 메트릭은 다음과 같다.
system_cpu_usage
spring_security_http_secured_requests_seconds
tomcat_sessions_active_current_sessions
tomcat_sessions_created_sessions_total
jvm_memory_used_bytes
jvm_threads_live_threads
여기서 중요한 점은 Actuator와 Micrometer의 역할이 다르다는 것이다.
Actuator는 애플리케이션의 상태, health, metrics 같은 운영 정보를 외부로 노출하는 기능을 제공한다. Micrometer Prometheus registry는 이 메트릭을 Prometheus가 읽을 수 있는 형식으로 변환해준다.
Spring Boot에서는 단순히 서버가 켜져 있는지뿐만 아니라 JVM 상태, Tomcat 상태, HTTP 요청 상태, DB 연결 상태를 함께 확인할 수 있다. 특히 handDoc은 MySQL과 MongoDB를 함께 사용하기 때문에 health endpoint를 통해 내부 의존성 상태를 확인하는 것이 중요했다.
[Spring Boot /actuator/prometheus 출력 화면]

6. Prometheus와 Grafana 실행
Prometheus와 Grafana는 별도의 handdoc-observability 폴더에 Docker Compose로 구성했다.
실행 명령어는 다음과 같다.
docker compose -f .\handdoc-observability\docker-compose.yml up -d
컨테이너가 정상적으로 실행되었는지는 다음 명령어로 확인했다.
docker compose -f .\handdoc-observability\docker-compose.yml ps
Prometheus와 Grafana 컨테이너가 모두 실행된 뒤, Prometheus 설정 파일에 FastAPI와 Spring Boot target을 등록했다.
로컬 PC에서 FastAPI는 8000번 포트, Spring Boot는 8080번 포트로 실행 중이었다. 하지만 Prometheus는 Docker 컨테이너 안에서 실행되고 있었기 때문에 target 주소를 localhost로 쓰면 안 됐다.
처음에 헷갈렸던 부분이 바로 이 지점이었다.
Docker 컨테이너 내부에서 localhost는 내 PC가 아니라 해당 컨테이너 자기 자신을 의미한다. 즉 Prometheus 컨테이너 안에서 localhost:8000을 바라보면 내 PC에서 실행 중인 FastAPI가 아니라 Prometheus 컨테이너 내부의 8000번 포트를 바라보게 된다.
그래서 Windows 로컬 환경에서는 host.docker.internal을 사용했다.
Prometheus 설정은 다음과 같이 작성했다.
scrape_configs:
- job_name: "fastapi"
metrics_path: "/metrics"
static_configs:
- targets: ["host.docker.internal:8000"]
- job_name: "spring-boot"
metrics_path: "/actuator/prometheus"
static_configs:
- targets: ["host.docker.internal:8080"]
이후 Prometheus Targets 화면에서 다음 job들이 모두 UP 상태로 표시되는 것을 확인했다.
prometheus
fastapi
spring-boot
[Prometheus Targets 화면]

7. Grafana 대시보드 구성
Grafana에서는 Prometheus를 data source로 연결한 뒤 handDoc 모니터링 대시보드를 구성했다.
처음에는 복잡한 대시보드보다 서비스 생존 여부와 기본 상태를 확인하는 데 집중했다.
초기 대시보드에 추가한 패널은 다음과 같다.
FastAPI scrape 상태
Spring Boot scrape 상태
Spring Boot CPU 사용률
Spring Boot 요청량
Prometheus target 상태
가장 기본적으로 사용한 PromQL은 다음과 같다.
up{job="fastapi"}
up{job="spring-boot"}
up metric은 Prometheus가 해당 target의 metrics endpoint를 scrape할 수 있는지를 나타낸다.
up = 1: Prometheus가 target scrape 성공
up = 0: Prometheus가 target scrape 실패
Spring Boot CPU 사용률은 다음과 같은 지표로 확인할 수 있었다.
system_cpu_usage
FastAPI 요청량을 확인하기 위해 처음에는 다음 쿼리를 사용하려고 했다.
sum(rate(fastapi_http_requests_total[1m]))
하지만 바로 그래프가 표시되지 않았다. 원인은 아직 충분한 요청 샘플이 쌓이지 않았거나, 1분 범위 안에 Prometheus scrape 데이터가 충분하지 않아 rate() 계산이 되지 않았기 때문으로 보였다.
그래서 초기 상태 확인에는 다음 지표를 우선 사용했다.
up{job="fastapi"}
scrape_duration_seconds{job="fastapi"}
이 과정을 통해 Grafana에서 Prometheus 메트릭을 조회하고, 서비스 상태 변화를 시간 흐름으로 확인할 수 있었다.
[ Grafana 대시보드 화면]

8. 장애 시뮬레이션 기준 상태 확인
모니터링 환경을 구성한 뒤에는 실제로 장애를 발생시켜 관측 결과가 어떻게 바뀌는지 확인했다.
먼저 정상 상태를 기준으로 잡았다.
정상 상태에서는 Prometheus Targets 화면에서 다음 job들이 모두 UP으로 표시되었다.
prometheus: UP
spring-boot: UP
fastapi: UP
Grafana에서도 각 서비스의 up 값이 1로 표시되는 것을 확인했다.
이 상태를 장애 발생 전 기준 상태로 두고, 이후 FastAPI 서버 장애, Spring Boot 서버 장애, MySQL 의존성 장애를 순서대로 시뮬레이션했다.
9. 장애 시뮬레이션 1: FastAPI 서버 중단
첫 번째로 FastAPI 서버 장애를 시뮬레이션했다.
FastAPI 서버를 실행 중이던 터미널에서 Ctrl + C로 서버를 중단했다. 그러자 Prometheus Targets 화면에서 fastapi job이 DOWN 상태로 바뀌었다.
Grafana에서도 다음 쿼리의 값이 1에서 0으로 떨어졌다.
up{job="fastapi"}
이 실험을 통해 확인한 것은 다음과 같다.
FastAPI 서버 프로세스가 중단된다.
Prometheus가 /metrics endpoint scrape에 실패한다.
Prometheus Targets 화면에서 fastapi job이 DOWN으로 바뀐다.
Grafana에서 up{job="fastapi"} 값이 0으로 떨어진다.
즉, 애플리케이션 프로세스 자체가 죽은 경우에는 Prometheus의 up metric만으로도 장애를 쉽게 감지할 수 있었다.
이미지 첨부 예시는 다음과 같다.


10. 장애 시뮬레이션 2: Spring Boot 서버 중단
두 번째로 Spring Boot 서버 장애를 시뮬레이션했다.
Spring Boot를 실행 중이던 bootRun 터미널에서 Ctrl + C로 서버를 중단했다. 그러자 Prometheus Targets 화면에서 spring-boot job이 DOWN으로 바뀌었다.
Spring Boot의 /actuator/prometheus endpoint에 Prometheus가 접근하지 못하게 되었기 때문에 scrape 실패가 발생한 것이다.
Grafana에서도 다음 쿼리의 값이 1에서 0으로 떨어졌다.
up{job="spring-boot"}
이 실험을 통해 확인한 것은 다음과 같다.
Spring Boot 서버 프로세스가 중단된다.
Prometheus가 /actuator/prometheus scrape에 실패한다.
Prometheus Targets 화면에서 spring-boot job이 DOWN으로 바뀐다.
Grafana에서 up{job="spring-boot"} 값이 0으로 떨어진다.
FastAPI 장애와 마찬가지로, Spring Boot 프로세스 자체가 죽은 경우에도 up metric으로 장애를 감지할 수 있었다.
이미지 첨부 예시는 다음과 같다.


11. 장애 시뮬레이션 3: MySQL 의존성 장애
세 번째로 MySQL 의존성 장애를 시뮬레이션했다.
이 실험에서는 Spring Boot 서버 자체는 종료하지 않았다. 대신 Docker에서 MySQL 컨테이너만 중단했다.
docker stop handdoc-mysql
이후 Spring Boot의 health endpoint를 확인했다.
curl.exe http://localhost:8080/actuator/health
그 결과 전체 health 상태가 DOWN으로 바뀌었고, component 중 db만 DOWN으로 표시되었다. 세부 오류로는 다음과 같은 메시지가 나타났다.
Failed to obtain JDBC Connection
반면 다른 component들은 UP 상태를 유지했다.
db: DOWN
mongo: UP
diskSpace: UP
ping: UP
이 실험에서 가장 중요했던 점은 Spring Boot 프로세스 자체는 살아있어도 내부 의존성이 죽으면 서비스 health는 DOWN이 될 수 있다는 것이다.
즉 Prometheus의 up metric과 Spring Boot의 /actuator/health는 의미가 다르다.
up{job="spring-boot"}:
Prometheus가 /actuator/prometheus endpoint를 scrape할 수 있는지 확인한다.
Spring Boot /actuator/health:
Spring Boot 애플리케이션과 내부 의존성의 상태를 확인한다.
따라서 up{job="spring-boot"}가 1이라고 해서 서비스가 완전히 정상이라고 판단하면 안 된다. Prometheus가 메트릭 엔드포인트를 긁을 수 있어도, 내부 DB 연결은 실패하고 있을 수 있다.
장애 확인 후에는 MySQL 컨테이너를 다시 실행했다.
docker start handdoc-mysql
이후 /actuator/health 상태가 다시 UP으로 돌아오는 것을 확인했다.
이 실험을 통해 장애 발생, 관측, 복구까지의 흐름을 한 번에 확인할 수 있었다.
12. 트러블슈팅 1: FastAPI /metrics 연결 실패
첫 번째로 막혔던 부분은 FastAPI /metrics 확인 과정이었다.
처음에 다음 명령어를 실행했을 때 연결 실패가 발생했다.
curl.exe http://localhost:8000/metrics
처음에는 /metrics endpoint 코드 문제라고 생각했지만, 실제 원인은 FastAPI 서버가 실행 중이지 않은 것이었다.
FastAPI 서버를 다시 실행한 뒤 같은 명령어를 실행하자 정상적으로 메트릭이 출력되었다.
이 문제를 통해 가장 기본적인 확인 순서를 다시 정리했다.
1. 애플리케이션 서버가 실행 중인지 확인한다.
2. 해당 포트가 열려 있는지 확인한다.
3. 브라우저 또는 curl로 endpoint에 직접 접근해본다.
4. 그 다음 Prometheus scrape 설정을 확인한다.
즉 Prometheus 설정을 의심하기 전에, 애플리케이션의 metrics endpoint가 단독으로 정상 응답하는지 먼저 확인해야 한다.
13. 트러블슈팅 2: Spring Boot가 8080 포트에서 실행되지 않음
두 번째 문제는 Spring Boot 서버 상태 확인 과정에서 발생했다.
처음에는 Spring Boot target이 Prometheus에서 unhealthy처럼 보였다. 하지만 실제 원인은 Spring Boot가 8080 포트에서 실행되고 있지 않은 상태였다.
Spring Boot 서버를 실행한 뒤 다음 엔드포인트를 다시 확인했다.
curl.exe http://localhost:8080/actuator/health
curl.exe http://localhost:8080/actuator/prometheus
정상 실행 후에는 Tomcat이 8080 포트에서 시작되었고, /actuator/prometheus도 정상적으로 응답했다.
이 문제를 통해 Prometheus target이 DOWN일 때는 다음 순서로 확인해야 한다는 것을 알게 되었다.
1. 애플리케이션 프로세스가 실행 중인가?
2. 기대한 포트에서 서버가 떠 있는가?
3. metrics endpoint가 직접 호출되는가?
4. Prometheus target 주소가 맞는가?
5. Prometheus 컨테이너에서 해당 주소에 접근 가능한가?
14. 트러블슈팅 3: Gradle 실행 위치 문제
Spring Boot 서버를 실행하는 과정에서는 Gradle Wrapper 위치 문제도 있었다.
프로젝트 루트에서 다음 명령어를 실행했을 때 gradlew를 찾을 수 없다는 에러가 발생했다.
.\gradlew bootRun
원인은 gradlew 파일이 프로젝트 루트가 아니라 backend 폴더 내부에 있었기 때문이다.
따라서 Gradle Wrapper가 있는 폴더로 이동한 뒤 실행해야 했다.
cd backend
.\gradlew bootRun
이 문제는 단순한 실행 위치 문제였지만, 서버가 실행되지 않으면 이후 Prometheus target도 모두 DOWN으로 보이기 때문에 먼저 잡아야 하는 문제였다.
15. 트러블슈팅 4: Docker 환경에서 localhost를 잘못 이해함
Prometheus 설정에서 가장 헷갈렸던 부분은 localhost였다.
처음에는 Prometheus 설정에 다음과 같이 작성하면 된다고 생각할 수 있다.
targets: ["localhost:8000"]
하지만 Prometheus가 Docker 컨테이너 안에서 실행 중이라면 이 설정은 잘못될 수 있다.
Docker 컨테이너 내부에서 localhost는 내 PC가 아니라 Prometheus 컨테이너 자기 자신을 의미한다. 따라서 Prometheus 컨테이너가 내 PC에서 실행 중인 FastAPI나 Spring Boot에 접근하려면 host.docker.internal을 사용해야 했다.
수정한 설정은 다음과 같다.
scrape_configs:
- job_name: "fastapi"
metrics_path: "/metrics"
static_configs:
- targets: ["host.docker.internal:8000"]
- job_name: "spring-boot"
metrics_path: "/actuator/prometheus"
static_configs:
- targets: ["host.docker.internal:8080"]
이 설정을 적용한 뒤 Prometheus Targets 화면에서 FastAPI와 Spring Boot가 모두 UP 상태로 바뀌었다.
이 문제를 통해 Docker 환경에서는 항상 “어느 네트워크 안에서 접근하는가”를 기준으로 주소를 생각해야 한다는 것을 알게 되었다.
16. 트러블슈팅 5: rate() 쿼리가 바로 표시되지 않음
Grafana에서 FastAPI 요청량을 확인하기 위해 다음 PromQL을 사용했다.
sum(rate(fastapi_http_requests_total[1m]))
하지만 처음에는 그래프가 바로 표시되지 않았다.
이유는 크게 두 가지로 볼 수 있었다.
1. 아직 요청 샘플이 충분히 쌓이지 않았다.
2. 1분 범위 안에 Prometheus scrape 데이터가 충분하지 않아 rate 계산이 어렵다.
rate()는 counter metric의 증가율을 계산하는 함수다. 따라서 일정 시간 동안 값이 증가한 기록이 있어야 의미 있는 결과가 나온다. 요청이 거의 없거나 scrape 샘플이 부족하면 그래프가 비어 보일 수 있다.
그래서 초기 확인용으로는 다음 쿼리를 먼저 사용했다.
up{job="fastapi"}
scrape_duration_seconds{job="fastapi"}
이후 실제 요청을 여러 번 발생시킨 뒤 요청량과 지연 시간 관련 지표를 다시 확인하는 방식이 더 적절했다.
17. 트러블슈팅 6: MySQL 장애 시뮬레이션 순서 문제
MySQL 장애를 실험할 때도 순서가 중요했다.
처음에는 MySQL 컨테이너가 꺼진 상태에서 Spring Boot를 다시 실행했다. 이 경우 Spring Boot가 부팅 과정에서 DB 연결에 실패해 애플리케이션 자체가 정상적으로 시작되지 않을 수 있었다.
하지만 내가 확인하고 싶었던 것은 “운영 중 DB가 죽었을 때 Spring Boot health가 어떻게 변하는가”였다.
그래서 순서를 바꿨다.
1. MySQL 컨테이너를 켠다.
2. Spring Boot 서버를 정상 실행한다.
3. /actuator/health가 UP인지 확인한다.
4. 운영 중인 상태에서 MySQL 컨테이너만 중단한다.
5. /actuator/health에서 db component가 DOWN으로 바뀌는지 확인한다.
실행한 명령어는 다음과 같다.
docker stop handdoc-mysql
이후 /actuator/health에서 db component가 DOWN으로 바뀌는 것을 확인했다.
이 실험을 통해 “앱 시작 시점에 DB가 없는 경우”와 “운영 중 DB가 죽는 경우”가 다르게 나타날 수 있다는 점을 알게 되었다.
앱 시작 시점에 DB가 없음:
Spring Boot 부팅 자체가 실패할 수 있다.
운영 중 DB가 죽음:
Spring Boot 프로세스는 살아있지만 health status가 DOWN이 될 수 있다.
18. 트러블슈팅 7: up metric만으로는 서비스 정상 여부를 판단할 수 없음
이번 실습에서 가장 중요하게 얻은 인사이트는 Prometheus의 up metric이 의미하는 바였다.
처음에는 up 값이 1이면 서비스가 정상이라고 생각했다. 하지만 실제로는 그렇지 않았다.
up metric은 Prometheus가 해당 target의 metrics endpoint를 scrape할 수 있는지를 나타낸다.
up = 1:
Prometheus가 metrics endpoint scrape에 성공했다.
up = 0:
Prometheus가 metrics endpoint scrape에 실패했다.
즉 up{job="spring-boot"}가 1이어도 Spring Boot 내부의 DB 연결은 실패하고 있을 수 있다. 실제로 MySQL 컨테이너를 중단했을 때 Spring Boot 프로세스는 살아 있었고, Prometheus가 /actuator/prometheus를 계속 scrape할 수 있다면 up은 1로 유지될 수 있다. 하지만 /actuator/health에서는 db component가 DOWN으로 표시된다.
따라서 서비스 상태를 정확히 보려면 다음 두 가지를 함께 봐야 한다.
1. Prometheus up metric
- metrics endpoint scrape 가능 여부
2. Spring Boot Actuator health
- 애플리케이션 내부 component와 의존성 상태
이 차이를 이해한 것이 이번 실습에서 가장 큰 수확이었다.
19. 이번 실습으로 확인한 장애 유형
이번에 직접 확인한 장애 유형은 크게 세 가지다.
장애 유형발생 방법관측 결과
| FastAPI 프로세스 장애 | FastAPI 실행 터미널에서 Ctrl + C | Prometheus target DOWN, Grafana up 값 0 |
| Spring Boot 프로세스 장애 | bootRun 터미널에서 Ctrl + C | Prometheus target DOWN, Grafana up 값 0 |
| MySQL 의존성 장애 | docker stop handdoc-mysql | Spring Boot health DOWN, db component DOWN |
이를 통해 프로세스 장애와 의존성 장애를 구분할 수 있었다.
프로세스 장애는 Prometheus scrape 실패로 바로 드러난다. 반면 의존성 장애는 애플리케이션 프로세스가 살아있기 때문에 단순 up metric으로는 놓칠 수 있다. 이런 경우에는 Actuator health나 별도의 dependency metric을 함께 수집해야 한다.
20. 다음 단계: Alert 설정
현재는 Prometheus Targets 화면과 Grafana 대시보드를 사람이 직접 확인해야 장애를 알 수 있다. 다음 단계에서는 Alert를 설정해 자동으로 장애를 감지하는 구조로 확장할 예정이다.
우선 적용할 수 있는 알림 조건은 다음과 같다.
FastAPI target DOWN
Spring Boot target DOWN
Spring Boot health DOWN
FastAPI AI 추론 에러 증가
Spring Boot 5xx 에러율 증가
AI 추론 지연 시간 증가
MySQL 연결 실패
예를 들어 FastAPI target이 1분 이상 DOWN이면 알림을 보내는 규칙은 다음과 같이 잡을 수 있다.
groups:
- name: handdoc-basic-alerts
rules:
- alert: FastAPIDown
expr: up{job="fastapi"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "FastAPI server is down"
description: "Prometheus cannot scrape FastAPI /metrics endpoint for more than 1 minute."
- alert: SpringBootDown
expr: up{job="spring-boot"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Spring Boot server is down"
description: "Prometheus cannot scrape Spring Boot /actuator/prometheus endpoint for more than 1 minute."
다만 MySQL 장애처럼 프로세스는 살아있지만 내부 의존성이 죽은 경우에는 단순 up metric만으로 부족하다. 이 경우 Spring Boot health 상태를 Prometheus metric으로 수집하거나, DB connection 관련 metric을 별도로 확인해야 한다.
예를 들어 HikariCP metric을 활용하면 다음과 같은 지표를 볼 수 있다.
hikaricp_connections_active
hikaricp_connections_idle
hikaricp_connections_pending
hikaricp_connections_timeout_total
이를 통해 DB connection pool 고갈이나 connection timeout 상황까지 확장해서 볼 수 있다.
21. 다음 단계: WebSocket과 AI 추론 지연 관측
handDoc의 핵심 기능은 수어 인식이다. 수어 인식은 WebSocket을 통해 프레임을 계속 주고받고, AI 서버가 이를 처리해 예측 결과를 반환하는 구조다.
따라서 단순 HTTP 요청 수보다 다음 지표가 더 중요하다.
현재 WebSocket 연결 수
WebSocket 메시지 수
프레임 처리 지연 시간
AI 모델 추론 지연 시간
AI 추론 실패 횟수
예측 confidence 분포
특히 평균 지연 시간만 보면 안 된다. 실시간 서비스에서는 일부 요청이 매우 느린 경우에도 사용자 경험이 나빠질 수 있기 때문에 p95, p99 지연 시간을 함께 봐야 한다.
Grafana에서는 다음과 같은 PromQL을 사용할 수 있다.
histogram_quantile(
0.95,
sum(rate(ai_sign_inference_duration_seconds_bucket[5m])) by (le)
)
histogram_quantile(
0.99,
sum(rate(ai_sign_inference_duration_seconds_bucket[5m])) by (le)
)
이를 통해 수어 인식 모델의 추론 시간이 정상 범위 안에 있는지, 부하가 증가했을 때 지연 시간이 얼마나 튀는지 확인할 수 있다.
22. 다음 단계: 로그와 트레이싱으로 확장
이번 실습은 Prometheus와 Grafana를 이용한 메트릭 중심의 모니터링이었다. 하지만 실제 장애 원인을 더 정확히 파악하려면 로그와 트레이싱까지 연결해야 한다.
예를 들어 Grafana에서 ai_sign_inference_duration_seconds가 증가한 것을 발견했다고 하더라도, 그것만으로는 정확한 원인을 알 수 없다.
추가로 확인해야 하는 정보는 다음과 같다.
해당 시점에 어떤 요청이 들어왔는가?
AI 서버 로그에 에러가 남았는가?
특정 입력에서만 추론 시간이 길어졌는가?
Spring Boot에서 FastAPI 호출이 지연되었는가?
외부 API 호출이 함께 느려졌는가?
이를 위해 이후에는 Loki와 Promtail을 붙여 로그를 중앙화할 예정이다.
구상하는 로그 흐름은 다음과 같다.
Spring Boot logs
FastAPI logs
Nginx access logs
|
v
Promtail
|
v
Loki
|
v
Grafana Explore
그 다음 단계에서는 OpenTelemetry와 Tempo를 이용해 분산 트레이싱을 적용할 수 있다.
목표 흐름은 다음과 같다.
Frontend
-> Nginx
-> Spring Boot
-> FastAPI AI Server
-> External API
이 흐름이 trace로 연결되면, 하나의 요청이 어느 구간에서 시간을 많이 사용했는지 확인할 수 있다.
예를 들어 진료 요약 요청이 느렸을 때 다음처럼 구분할 수 있다.
Spring Controller 처리: 30ms
DB 조회: 80ms
OpenAI API 호출: 2100ms
결과 저장: 50ms
전체 요청 시간: 2300ms
이렇게 보면 병목이 Spring Boot 내부 로직인지, DB인지, 외부 API인지 명확하게 구분할 수 있다.
23. 정리
이번 실습에서는 handDoc 프로젝트에 Prometheus와 Grafana 기반의 모니터링 환경을 구성하고, 실제 장애 시뮬레이션을 통해 서비스 상태 변화를 확인했다.
구체적으로는 FastAPI 서버와 Spring Boot 서버의 metrics endpoint를 Prometheus가 scrape하도록 구성했고, Grafana에서 각 서비스의 상태를 시각화했다. 이후 FastAPI 서버 중단, Spring Boot 서버 중단, MySQL 컨테이너 중단 상황을 직접 만들어 Prometheus Target 상태와 Grafana 대시보드, Spring Boot Actuator health 결과가 어떻게 달라지는지 확인했다.
이번 실습에서 가장 중요하게 배운 점은 up metric의 의미였다.
처음에는 up이 1이면 서비스가 정상이라고 생각했지만, 실제로는 Prometheus가 metrics endpoint를 scrape할 수 있다는 의미에 가깝다. 따라서 Spring Boot 프로세스가 살아있고 /actuator/prometheus가 응답하더라도, 내부 DB 연결이 실패하면 실제 서비스 health는 DOWN일 수 있다.
즉 서비스 상태를 제대로 판단하려면 다음을 함께 봐야 한다.
Prometheus up metric:
metrics endpoint scrape 가능 여부
Spring Boot Actuator health:
애플리케이션 내부 component와 의존성 상태
Custom application metric:
handDoc의 실제 기능 품질
Grafana dashboard:
장애 전후 상태 변화의 시각화
handDoc은 단일 EC2 기반이라 구조가 단순해 보일 수 있다. 하지만 실제로는 Spring Boot, FastAPI, AI 모델, Nginx, MySQL, MongoDB, 외부 API가 함께 얽혀 있다. 따라서 장애가 발생했을 때 단순히 “서버가 죽었다”라고 판단하는 것이 아니라, 프로세스 장애인지, 내부 의존성 장애인지, AI 추론 지연인지, 외부 API 지연인지 구분할 수 있어야 한다.
이번 실습은 그 출발점이었다. 앞으로는 Alert 설정, WebSocket 부하 테스트, AI 추론 지연 관측, Loki 기반 로그 중앙화, OpenTelemetry 기반 분산 트레이싱까지 확장해 handDoc의 실제 운영 상태를 더 입체적으로 관측할 수 있는 구조로 발전시킬 예정이다.
'프로젝트 > EWHA 캡스톤 졸업프로젝트' 카테고리의 다른 글
| [K-PaaS 공모전] AWS EC2에서 NCP NKS로: handDoc Kubernetes 마이그레이션 기록 (0) | 2026.09.03 |
|---|---|
| [handDoc] 25-2 Ewha Capstone Design 졸업프로젝트 대상 (0) | 2026.02.25 |
| [handDoc] 수어 인식 모델 구축 튜토리얼 : MediaPipe → BiLSTM 학습까지 전 과정 (0) | 2025.11.24 |
| [handDoc] 배포 환경 구축 정리 (AWS EC2 + Docker + Nginx + FastAPI + Spring Boot) + 트러블슈팅 (0) | 2025.11.23 |
| [handDoc] BE ERD 및 API 설계 (0) | 2025.11.23 |