이미지 로딩 중...
AI Generated
2025. 11. 12. · 6 Views
ChatGPT 클론 REST API 엔드포인트 및 배포 완벽 가이드
ChatGPT를 바닥부터 만들어보는 26편 시리즈! REST API 엔드포인트 설계부터 실전 배포까지, 실무에서 바로 사용할 수 있는 완전한 가이드를 제공합니다. 초급 개발자도 쉽게 따라할 수 있도록 친절하게 설명합니다.
목차
- REST API 엔드포인트 설계
- FastAPI 라우터 구성
- 요청/응답 스키마 정의
- 스트리밍 응답 구현
- 에러 핸들링 및 예외 처리
- 환경 변수 관리
- Docker 컨테이너화
- Nginx 리버스 프록시 설정
- HTTPS 인증서 적용
- API 모니터링 및 로깅
1. REST API 엔드포인트 설계
시작하며
여러분이 ChatGPT 같은 대화형 AI 서비스를 만들 때, 가장 먼저 고민하게 되는 것이 무엇일까요? 바로 "어떻게 클라이언트와 서버가 효율적으로 대화를 주고받을 수 있을까?"입니다.
많은 초급 개발자들이 모든 기능을 하나의 엔드포인트에 때려 넣거나, 반대로 너무 세분화해서 관리가 어려워지는 실수를 합니다. 특히 채팅 서비스는 단순 CRUD와 달리 실시간 스트리밍, 대화 이력 관리, 모델 선택 등 복잡한 요구사항이 있죠.
바로 이럴 때 필요한 것이 체계적인 REST API 엔드포인트 설계입니다. OpenAI의 API 구조를 참고하여, 확장 가능하고 유지보수하기 쉬운 구조를 만들어봅시다.
개요
간단히 말해서, REST API 엔드포인트 설계는 클라이언트가 서버의 어떤 기능을 어떤 방식으로 호출할지 정의하는 청사진입니다. 채팅 AI 서비스에서는 크게 세 가지 핵심 엔드포인트가 필요합니다.
첫째, 새로운 대화를 시작하는 /chat/completions, 둘째, 대화 이력을 관리하는 /conversations, 셋째, 사용 가능한 모델 목록을 조회하는 /models입니다. 이러한 구조는 OpenAI API와 호환성을 유지하면서도 여러분만의 기능을 추가할 수 있게 해줍니다.
기존에는 모든 기능을 /api/chat 하나로 처리했다면, 이제는 RESTful한 리소스 기반으로 분리하여 각 엔드포인트가 명확한 책임을 가지도록 합니다. 핵심 특징은 첫째, HTTP 메서드(GET, POST, DELETE)를 의미에 맞게 사용하고, 둘째, URL 경로가 리소스를 직관적으로 표현하며, 셋째, 버전 관리(/v1/chat)를 통해 하위 호환성을 보장한다는 점입니다.
이러한 특징들이 API를 장기적으로 유지보수 가능하게 만들어줍니다.
코드 예제
from fastapi import APIRouter, HTTPException
from typing import List, Optional
# 라우터 인스턴스 생성 - API 버전 관리
router = APIRouter(prefix="/v1", tags=["chat"])
# 채팅 완성 엔드포인트 - POST 메서드 사용
@router.post("/chat/completions")
async def create_chat_completion(request: ChatRequest):
"""새로운 채팅 응답을 생성합니다"""
return await process_chat(request)
# 대화 이력 조회 - GET 메서드로 리소스 조회
@router.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str):
"""특정 대화의 전체 이력을 반환합니다"""
return await fetch_conversation(conversation_id)
# 모델 목록 조회 - 복수형 리소스명 사용
@router.get("/models")
async def list_models():
"""사용 가능한 AI 모델 목록을 반환합니다"""
return {"models": ["gpt-3.5-turbo", "gpt-4"]}
설명
이것이 하는 일: 이 코드는 ChatGPT 스타일의 REST API 구조를 FastAPI로 구현한 기본 뼈대입니다. 클라이언트가 채팅 요청, 대화 이력 조회, 모델 정보 확인 등을 각각 다른 엔드포인트로 호출할 수 있게 합니다.
첫 번째로, APIRouter를 사용하여 /v1 접두사를 설정합니다. 이렇게 하면 나중에 API 버전 2를 만들 때 /v2로 쉽게 분리할 수 있죠.
tags=["chat"]는 자동 생성되는 API 문서에서 관련 엔드포인트를 그룹화하는 역할을 합니다. 그 다음으로, POST /chat/completions 엔드포인트가 실행됩니다.
왜 POST를 사용할까요? 채팅 메시지를 보내는 것은 서버에 새로운 리소스(응답)를 생성하는 행위이기 때문입니다.
GET은 데이터를 조회만 하지만, POST는 서버 상태를 변경할 수 있습니다. 내부적으로 process_chat 함수가 실제 AI 모델 호출을 처리합니다.
세 번째로, GET /conversations/{conversation_id} 엔드포인트는 경로 매개변수를 사용합니다. 중괄호 {}로 감싼 부분이 동적으로 변하는 값이죠.
예를 들어 /conversations/abc123으로 호출하면 conversation_id에 "abc123"이 들어갑니다. 이는 RESTful 설계의 핵심인 "리소스 식별"을 구현한 것입니다.
마지막으로, GET /models 엔드포인트가 사용 가능한 모델 목록을 반환합니다. 실제로는 데이터베이스나 설정 파일에서 읽어오겠지만, 여기서는 간단히 하드코딩된 목록을 보여줍니다.
최종적으로 클라이언트는 이 목록을 보고 어떤 모델로 채팅할지 선택할 수 있습니다. 여러분이 이 코드를 사용하면 OpenAI API와 호환되는 구조를 가지면서도, 독자적인 기능을 쉽게 추가할 수 있는 유연성을 얻게 됩니다.
URL만 보고도 어떤 기능인지 직관적으로 알 수 있어, 팀원들과의 협업이나 API 문서화가 훨씬 쉬워집니다.
실전 팁
💡 엔드포인트 이름은 항상 복수형 명사를 사용하세요(/model이 아닌 /models). 이는 REST API 설계의 일반적인 컨벤션으로, 리소스 컬렉션을 다룬다는 것을 명확히 합니다.
💡 버전 관리는 처음부터 적용하세요. 나중에 추가하려면 모든 클라이언트 코드를 수정해야 합니다. /v1을 사용하면 나중에 /v2로 새 기능을 안전하게 배포할 수 있습니다.
💡 HTTP 메서드를 의미에 맞게 사용하세요. GET은 조회만, POST는 생성, PUT/PATCH는 수정, DELETE는 삭제. 잘못 사용하면 캐싱 문제나 보안 이슈가 발생할 수 있습니다.
💡 에러가 발생할 수 있는 부분에는 명확한 예외 처리를 추가하세요. 존재하지 않는 conversation_id를 조회하면 404 에러를, 잘못된 요청 형식에는 400 에러를 반환하도록 합니다.
💡 API 문서는 자동으로 생성되도록 FastAPI의 docstring을 활용하세요. 각 함수에 """설명"""을 추가하면 /docs에서 보기 좋은 문서가 만들어집니다.
2. FastAPI 라우터 구성
시작하며
여러분이 ChatGPT 클론 프로젝트를 진행하다 보면, 하나의 파일에 모든 엔드포인트를 작성하게 되어 코드가 수백 줄로 늘어나는 경험을 하게 됩니다. 스크롤을 한참 내려야 원하는 함수를 찾을 수 있고, 팀원과 동시에 작업하면 Git 충돌이 끊임없이 발생하죠.
이런 문제는 프로젝트가 커질수록 심각해집니다. 채팅 관련 코드, 사용자 관리 코드, 모델 설정 코드가 한 파일에 뒤섞여 있으면 유지보수가 악몽이 됩니다.
버그를 수정하려다 전혀 관련 없는 기능을 망가뜨리는 일도 생기죠. 바로 이럴 때 필요한 것이 FastAPI의 라우터 모듈화입니다.
기능별로 파일을 분리하고, 각 라우터를 메인 앱에 등록하는 방식으로 코드를 체계적으로 관리할 수 있습니다.
개요
간단히 말해서, FastAPI 라우터는 관련된 엔드포인트들을 하나의 모듈로 묶어주는 기능입니다. 실무에서는 채팅 관련 엔드포인트를 routers/chat.py에, 사용자 인증을 routers/auth.py에, 관리자 기능을 routers/admin.py에 분리합니다.
각 파일은 자신만의 APIRouter 인스턴스를 가지고, 메인 앱(main.py)에서 app.include_router()로 모두 연결하죠. 이렇게 하면 한 팀원은 채팅 기능을, 다른 팀원은 인증 기능을 동시에 개발할 수 있습니다.
기존에는 하나의 거대한 main.py 파일에 모든 것을 작성했다면, 이제는 기능별로 분리된 모듈을 조합하는 방식으로 전환합니다. 핵심 특징은 첫째, 각 라우터가 독립적인 접두사(prefix)와 태그를 가져 자동 문서화가 깔끔해지고, 둘째, 의존성(dependencies)을 라우터 레벨에서 공유할 수 있어 인증 로직 등을 쉽게 적용할 수 있으며, 셋째, 테스트 시 개별 라우터만 분리해서 테스트할 수 있다는 점입니다.
이러한 특징들이 대규모 프로젝트를 관리 가능하게 만들어줍니다.
코드 예제
# routers/chat.py - 채팅 관련 엔드포인트만 관리
from fastapi import APIRouter, Depends
from schemas import ChatRequest, ChatResponse
router = APIRouter(
prefix="/v1/chat", # 이 라우터의 모든 경로 앞에 붙음
tags=["chat"], # API 문서에서 그룹 이름
dependencies=[Depends(verify_api_key)] # 모든 엔드포인트에 인증 적용
)
@router.post("/completions", response_model=ChatResponse)
async def create_completion(request: ChatRequest):
"""채팅 완성 요청을 처리합니다"""
return await generate_response(request)
# main.py - 메인 애플리케이션에서 라우터 등록
from fastapi import FastAPI
from routers import chat, auth, admin
app = FastAPI(title="ChatGPT Clone API")
# 각 라우터를 앱에 포함시킴
app.include_router(chat.router)
app.include_router(auth.router)
app.include_router(admin.router)
설명
이것이 하는 일: 이 코드는 ChatGPT 클론의 엔드포인트들을 기능별 모듈로 분리하고, 메인 애플리케이션에서 하나로 조합하는 구조를 보여줍니다. 첫 번째로, routers/chat.py 파일에서 독립적인 APIRouter 인스턴스를 생성합니다.
prefix="/v1/chat"를 설정하면, 이 라우터 안의 모든 경로 앞에 자동으로 이 접두사가 붙습니다. 예를 들어 @router.post("/completions")는 실제로는 POST /v1/chat/completions가 되는 것이죠.
이렇게 하면 각 엔드포인트마다 전체 경로를 반복해서 쓸 필요가 없습니다. 그 다음으로, tags=["chat"] 설정이 중요한 역할을 합니다.
FastAPI는 자동으로 /docs에서 Swagger UI 문서를 생성하는데, 태그별로 엔드포인트를 그룹화해서 보여줍니다. 채팅 관련 기능끼리, 인증 관련 기능끼리 모아서 보여주니까 API 사용자가 훨씬 이해하기 쉽죠.
세 번째로, dependencies=[Depends(verify_api_key)] 부분이 정말 강력합니다. 이 라우터의 모든 엔드포인트에 자동으로 API 키 검증이 적용됩니다.
각 함수마다 Depends(verify_api_key)를 반복해서 쓸 필요가 없어지는 것이죠. 인증, 로깅, 속도 제한 등 공통 로직을 한 곳에서 관리할 수 있습니다.
마지막으로, main.py에서 app.include_router(chat.router)로 모든 라우터를 등록합니다. 이때 순서는 중요하지 않지만, 같은 경로가 중복되면 먼저 등록된 것이 우선됩니다.
최종적으로 여러분은 기능을 추가할 때 새로운 라우터 파일을 만들고 한 줄만 추가하면 됩니다. 여러분이 이 구조를 사용하면 팀 협업이 원활해지고, Git 충돌이 줄어들며, 각 모듈을 독립적으로 테스트할 수 있습니다.
프로젝트가 100개의 엔드포인트로 커져도 파일 하나가 500줄을 넘지 않게 관리할 수 있죠. 또한 특정 기능을 다른 프로젝트에서 재사용하고 싶을 때 해당 라우터 파일만 복사하면 됩니다.
실전 팁
💡 라우터 파일명과 접두사를 일관되게 유지하세요. routers/chat.py는 /chat 접두사를, routers/auth.py는 /auth 접두사를 사용하는 식으로 규칙을 정하면 코드를 찾기 쉽습니다.
💡 공통 의존성은 라우터 레벨에서 적용하세요. 인증, 로깅, 속도 제한 등은 dependencies 매개변수로 한 번만 설정하면 모든 엔드포인트에 자동 적용됩니다.
💡 순환 import를 조심하세요. routers/chat.py가 routers/auth.py를 import하고, auth.py가 다시 chat.py를 import하면 에러가 납니다. 공통 로직은 별도의 services/ 폴더에 분리하세요.
💡 라우터를 등록할 때 추가 옵션을 활용하세요. app.include_router(chat.router, prefix="/api")처럼 메인 앱에서 추가 접두사를 붙일 수도 있습니다.
💡 개발 환경과 프로덕션 환경에서 다른 라우터를 로드할 수 있습니다. if os.getenv("ENV") == "dev": app.include_router(debug.router) 식으로 디버깅용 엔드포인트를 조건부로 추가하세요.
3. 요청/응답 스키마 정의
시작하며
여러분이 API를 개발할 때 가장 많이 겪는 버그가 무엇일까요? 바로 "클라이언트가 보낸 데이터 형식이 예상과 다르다"는 문제입니다.
문자열을 보내야 하는데 숫자를 보내거나, 필수 필드를 빠뜨리거나, 이메일 형식이 잘못되었거나 하는 식이죠. 이런 문제는 런타임에 발견되기 때문에 특히 위험합니다.
서버는 잘못된 데이터를 받아서 처리하려다 예기치 않은 에러를 뱉고, 사용자는 무슨 일이 일어났는지 이해하지 못합니다. 디버깅하려면 로그를 뒤지고 요청 내용을 일일이 확인해야 하죠.
바로 이럴 때 필요한 것이 Pydantic 스키마를 사용한 데이터 검증입니다. 요청과 응답의 형식을 클래스로 정의하면, FastAPI가 자동으로 검증하고 변환하며 문서까지 생성해줍니다.
개요
간단히 말해서, Pydantic 스키마는 API 요청과 응답 데이터의 "계약서"입니다. 어떤 필드가 필수이고, 어떤 타입이어야 하며, 어떤 검증 규칙을 통과해야 하는지 명확히 정의합니다.
ChatGPT 클론에서는 ChatRequest 스키마로 사용자가 보내는 메시지 형식을 정의하고, ChatResponse 스키마로 AI가 반환하는 응답 형식을 정의합니다. 예를 들어, 메시지는 반드시 리스트여야 하고, 각 메시지는 role(사용자/어시스턴트)과 content(내용)를 가져야 한다는 규칙을 코드로 표현하죠.
Pydantic은 이 규칙을 자동으로 검증하고, 위반 시 명확한 에러 메시지를 반환합니다. 기존에는 딕셔너리로 데이터를 다루면서 if "messages" not in data: 같은 검증 코드를 반복했다면, 이제는 타입 힌트만 추가하면 모든 검증이 자동으로 이루어집니다.
핵심 특징은 첫째, 타입 안정성을 제공하여 런타임 에러를 컴파일 타임에 잡을 수 있고, 둘째, 자동 문서화로 API 사용자가 어떤 데이터를 보내야 하는지 즉시 알 수 있으며, 셋째, 복잡한 검증 로직(이메일 형식, 문자열 길이 제한 등)을 선언적으로 표현할 수 있다는 점입니다. 이러한 특징들이 API를 안정적이고 사용하기 쉽게 만들어줍니다.
코드 예제
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Literal
# 채팅 메시지 한 개의 구조
class Message(BaseModel):
role: Literal["user", "assistant", "system"] # 역할은 3가지만 허용
content: str = Field(..., min_length=1, max_length=4000) # 1~4000자 제한
@validator('content')
def content_not_empty(cls, v):
"""공백만 있는 메시지는 거부"""
if not v.strip():
raise ValueError('메시지는 공백만 포함할 수 없습니다')
return v.strip()
# 클라이언트가 보내는 요청 형식
class ChatRequest(BaseModel):
model: str = "gpt-3.5-turbo" # 기본값 설정
messages: List[Message] = Field(..., min_items=1) # 최소 1개 메시지 필요
temperature: float = Field(0.7, ge=0, le=2) # 0~2 범위로 제한
max_tokens: Optional[int] = Field(None, gt=0, le=4000) # 양수이고 4000 이하
stream: bool = False # 스트리밍 여부
# 서버가 반환하는 응답 형식
class ChatResponse(BaseModel):
id: str # 응답 고유 ID
model: str
choices: List[dict] # 생성된 응답들
usage: dict # 토큰 사용량 정보
설명
이것이 하는 일: 이 코드는 ChatGPT API의 요청과 응답 데이터 형식을 엄격하게 정의하고, 잘못된 데이터가 들어오면 자동으로 거부하는 검증 시스템을 구축합니다. 첫 번째로, Message 클래스에서 Literal["user", "assistant", "system"]을 사용합니다.
이것은 TypeScript의 유니온 타입과 비슷하게, role 필드가 정확히 이 세 가지 값 중 하나만 가질 수 있다는 뜻입니다. 만약 클라이언트가 "admin" 같은 값을 보내면 Pydantic이 자동으로 422 에러를 반환하죠.
Field(..., min_length=1, max_length=4000)는 메시지 길이를 제한하여 너무 긴 입력으로 인한 성능 문제를 방지합니다. 그 다음으로, 커스텀 validator인 content_not_empty 함수가 실행됩니다.
Pydantic의 기본 검증만으로는 " "(공백만 있는 문자열)을 걸러낼 수 없기 때문에, 추가 로직을 작성한 것입니다. @validator('content') 데코레이터가 content 필드에 대해 이 함수를 자동으로 호출하고, ValueError가 발생하면 클라이언트에게 명확한 에러 메시지를 전달합니다.
또한 v.strip()으로 앞뒤 공백을 자동 제거하는 정규화도 수행합니다. 세 번째로, ChatRequest에서 다양한 제약 조건을 정의합니다.
messages: List[Message] = Field(..., min_items=1)은 메시지 리스트가 비어있으면 안 된다는 뜻이고, temperature: float = Field(0.7, ge=0, le=2)는 온도 값이 0 이상 2 이하여야 한다는 뜻입니다(ge는 greater or equal, le는 less or equal). Optional[int]는 이 필드가 생략 가능하다는 의미로, 클라이언트가 보내지 않으면 None이 됩니다.
마지막으로, ChatResponse 스키마가 서버 응답 형식을 보장합니다. FastAPI의 response_model=ChatResponse와 함께 사용하면, 여러분의 코드가 실수로 잘못된 형식을 반환하더라도 Pydantic이 에러를 발생시켜줍니다.
최종적으로 API 사용자는 항상 일관된 형식의 응답을 받게 됩니다. 여러분이 이 스키마를 사용하면 수동으로 작성했던 검증 코드 수십 줄을 제거할 수 있고, FastAPI의 /docs에서 자동으로 예제가 생성되며, IDE의 자동완성이 정확하게 작동합니다.
또한 프론트엔드 개발자가 어떤 데이터를 보내야 하는지 문서를 찾을 필요 없이 코드만 보고 이해할 수 있습니다.
실전 팁
💡 Field의 description 매개변수로 각 필드에 설명을 추가하세요. temperature: float = Field(0.7, description="응답의 창의성 조절 (0=결정적, 2=매우 창의적)")처럼 작성하면 API 문서가 훨씬 친절해집니다.
💡 중첩된 스키마는 별도 클래스로 분리하세요. Message처럼 독립적인 모델을 만들면 재사용성이 높아지고, 각 모델의 책임이 명확해집니다.
💡 Config 클래스로 스키마 동작을 커스터마이징하세요. class Config: extra = "forbid"를 추가하면 정의되지 않은 필드가 포함된 요청을 거부할 수 있습니다.
💡 응답 스키마에는 example을 추가하여 API 문서에 실제 예시를 보여주세요. class ChatResponse(BaseModel): class Config: schema_extra = {"example": {...}}처럼 작성합니다.
💡 환경 변수도 Pydantic으로 관리하세요. BaseSettings를 상속하면 .env 파일의 값을 자동으로 타입 변환하고 검증할 수 있습니다.
4. 스트리밍 응답 구현
시작하며
여러분이 ChatGPT를 사용할 때 가장 인상적인 기능이 무엇인가요? 바로 답변이 한 글자씩 실시간으로 나타나는 스트리밍 효과입니다.
만약 전체 응답이 완성될 때까지 몇 초간 아무것도 보이지 않는다면, 사용자는 "서버가 멈춘 건가?"라고 불안해하겠죠. 일반적인 REST API는 모든 데이터를 한 번에 반환합니다.
AI 모델이 500단어 응답을 생성하는 데 10초가 걸린다면, 사용자는 10초 동안 빈 화면만 보게 됩니다. 이는 사용자 경험을 크게 해치고, 사용자가 이탈할 가능성을 높입니다.
바로 이럴 때 필요한 것이 Server-Sent Events(SSE) 방식의 스트리밍 응답입니다. AI 모델이 토큰을 하나씩 생성할 때마다 클라이언트로 즉시 전송하여, 마치 사람이 타이핑하는 것처럼 자연스러운 경험을 제공합니다.
개요
간단히 말해서, 스트리밍 응답은 데이터를 한 번에 보내지 않고 조금씩 나눠서 실시간으로 전송하는 기술입니다. FastAPI에서는 StreamingResponse와 제너레이터 함수를 사용하여 구현합니다.
AI 모델이 토큰을 생성할 때마다 yield 키워드로 데이터를 반환하고, FastAPI는 이를 즉시 클라이언트로 전송합니다. OpenAI API는 data: {...}\n\n 형식의 SSE 프로토콜을 사용하는데, 우리도 동일한 형식을 따라 호환성을 유지하죠.
클라이언트는 JavaScript의 EventSource나 fetch의 스트림 모드로 이를 받아서 실시간으로 화면에 표시합니다. 기존에는 모든 데이터를 메모리에 쌓았다가 한 번에 반환했다면, 이제는 생성되는 즉시 전송하여 메모리 효율성도 높이고 응답 시간도 단축합니다.
핵심 특징은 첫째, 사용자가 즉각적인 피드백을 받아 체감 성능이 향상되고, 둘째, 서버 메모리 사용량이 줄어들며(전체 응답을 저장할 필요 없음), 셋째, 연결이 끊겨도 이미 받은 부분은 유지되어 재시도 비용이 감소한다는 점입니다. 이러한 특징들이 실시간성이 중요한 AI 챗봇에 필수적입니다.
코드 예제
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
import json
async def generate_chat_stream(messages: List[dict], model: str):
"""AI 모델 응답을 스트리밍으로 생성하는 제너레이터"""
# 실제로는 OpenAI API나 자체 모델을 호출
full_response = "안녕하세요! 무엇을 도와드릴까요?"
for i, char in enumerate(full_response):
# SSE 형식으로 데이터 포맷팅
chunk = {
"id": f"chatcmpl-{i}",
"choices": [{
"delta": {"content": char}, # 한 글자씩 전송
"index": 0
}]
}
# OpenAI 호환 형식: "data: " 접두사와 줄바꿈 2개
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.05) # 타이핑 효과를 위한 딜레이
# 스트림 종료 시그널
yield "data: [DONE]\n\n"
@router.post("/chat/completions")
async def chat_completions(request: ChatRequest):
if request.stream:
# 스트리밍 모드: StreamingResponse 반환
return StreamingResponse(
generate_chat_stream(request.messages, request.model),
media_type="text/event-stream" # SSE 콘텐츠 타입
)
else:
# 일반 모드: 완성된 응답 한 번에 반환
return await generate_complete_response(request)
설명
이것이 하는 일: 이 코드는 AI 모델의 응답을 한 글자씩 실시간으로 클라이언트에게 전송하는 스트리밍 시스템을 구현합니다. 사용자는 전체 응답을 기다리지 않고 즉시 결과를 보기 시작합니다.
첫 번째로, async def generate_chat_stream() 제너레이터 함수를 정의합니다. 일반 함수는 return으로 값을 한 번 반환하고 종료되지만, 제너레이터는 yield로 값을 여러 번 반환할 수 있습니다.
여기서는 AI 응답의 각 글자를 하나씩 yield합니다. async def를 사용한 이유는 비동기 작업(실제 AI 모델 호출)을 기다리면서도 다른 요청을 처리할 수 있게 하기 위함입니다.
그 다음으로, SSE 형식으로 데이터를 포맷팅합니다. f"data: {json.dumps(chunk)}\n\n" 형식은 Server-Sent Events 표준 프로토콜로, 각 메시지는 "data: "로 시작하고 줄바꿈 2개로 끝나야 합니다.
choices[0].delta.content에 한 글자씩 담아서 보내는 것은 OpenAI API의 구조를 따른 것이죠. 클라이언트는 이 형식을 파싱하여 화면에 추가합니다.
세 번째로, await asyncio.sleep(0.05)로 약간의 딜레이를 줍니다. 실제 AI 모델도 토큰 생성에 시간이 걸리지만, 여기서는 타이핑 효과를 시뮬레이션하는 것입니다.
너무 빠르면 스트리밍 효과가 눈에 보이지 않고, 너무 느리면 답답하니까 적절한 속도를 찾는 것이 중요합니다. 마지막으로, "data: [DONE]\n\n"으로 스트림이 끝났음을 알립니다.
클라이언트는 이 시그널을 받으면 연결을 닫고 더 이상 데이터를 기다리지 않습니다. StreamingResponse의 media_type="text/event-stream"은 브라우저에게 이것이 SSE라고 알려주는 HTTP 헤더를 설정합니다.
여러분이 이 코드를 사용하면 사용자 경험이 극적으로 개선됩니다. 긴 응답도 즉시 출력이 시작되어 체감 대기 시간이 거의 없고, 서버는 큰 응답을 메모리에 전부 쌓지 않아도 되며, 네트워크가 느려도 이미 받은 부분은 표시됩니다.
실제 ChatGPT 사용 경험과 거의 동일한 느낌을 줄 수 있습니다.
실전 팁
💡 실제 프로덕션에서는 타임아웃을 설정하세요. 스트리밍이 무한정 계속되면 리소스가 낭비됩니다. asyncio.wait_for()로 최대 시간을 제한하세요.
💡 에러 처리를 스트림 중간에도 구현하세요. try-except로 감싸고, 에러 발생 시 yield f"data: {json.dumps({'error': '...'})}\\n\\n"으로 클라이언트에게 알립니다.
💡 클라이언트 연결 끊김을 감지하세요. request.is_disconnected()를 주기적으로 확인하여 사용자가 페이지를 닫았으면 스트림을 중단합니다.
💡 청크 크기를 조절하여 네트워크 효율을 높이세요. 한 글자씩이 아니라 단어 단위나 5-10글자 단위로 보내면 HTTP 오버헤드가 줄어듭니다.
💡 프론트엔드에서는 EventSource 대신 fetch의 ReadableStream을 사용하세요. EventSource는 GET만 지원하지만, fetch는 POST도 가능하여 메시지를 body에 담을 수 있습니다.
5. 에러 핸들링 및 예외 처리
시작하며
여러분이 API를 운영하다 보면 온갖 예상치 못한 상황을 마주하게 됩니다. AI 모델 서비스가 갑자기 다운되거나, 데이터베이스 연결이 끊기거나, 사용자가 이상한 형식의 데이터를 보내는 식이죠.
이때 제대로 된 에러 처리가 없다면 어떻게 될까요? 서버는 500 에러만 덜렁 반환하고, 사용자는 무슨 일이 일어났는지 전혀 모릅니다.
개발자인 여러분도 로그를 뒤져봐야 원인을 알 수 있고, 같은 에러가 반복되어도 매번 수동으로 대응해야 합니다. 심지어 에러가 발생했다는 사실조차 모를 수도 있죠.
바로 이럴 때 필요한 것이 체계적인 에러 핸들링 및 예외 처리 시스템입니다. FastAPI의 예외 핸들러를 활용하여 각 에러 유형별로 적절한 응답을 반환하고, 상세한 로깅으로 문제를 즉시 파악할 수 있게 합니다.
개요
간단히 말해서, 에러 핸들링은 예상 가능한 문제 상황에 대비하여 사용자 친화적인 응답을 제공하고, 개발자가 문제를 신속히 해결할 수 있도록 정보를 수집하는 시스템입니다. ChatGPT 클론에서는 크게 네 가지 에러 유형을 구분해야 합니다.
첫째, 클라이언트 에러(400번대) - 잘못된 요청 형식, 인증 실패, 속도 제한 초과 등. 둘째, 서버 에러(500번대) - AI 모델 호출 실패, 데이터베이스 오류 등.
셋째, 외부 서비스 에러 - OpenAI API가 다운된 경우. 넷째, 비즈니스 로직 에러 - 사용자 할당량 초과, 금지된 콘텐츠 감지 등.
각각에 대해 명확한 에러 코드와 메시지를 정의하고, 로그 레벨을 다르게 설정합니다. 기존에는 try-except Exception으로 모든 에러를 한 곳에서 처리했다면, 이제는 에러 유형별로 전용 핸들러를 만들어 정확한 진단과 대응을 합니다.
핵심 특징은 첫째, HTTP 상태 코드를 의미에 맞게 사용하여(400, 401, 429, 500 등) 클라이언트가 재시도 여부를 판단할 수 있고, 둘째, 구조화된 에러 응답으로 프론트엔드가 에러를 파싱하여 적절히 표시할 수 있으며, 셋째, 상세한 로깅으로 문제의 근본 원인을 빠르게 찾을 수 있다는 점입니다. 이러한 특징들이 서비스의 안정성과 디버깅 효율을 크게 높여줍니다.
코드 예제
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
# 커스텀 예외 클래스 정의
class AIModelError(Exception):
"""AI 모델 호출 실패 시 발생"""
def __init__(self, message: str, model: str):
self.message = message
self.model = model
# Pydantic 검증 에러 핸들러 - 400 에러
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""잘못된 요청 형식에 대한 친절한 에러 메시지"""
logger.warning(f"검증 실패: {exc.errors()}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"error": {
"message": "요청 형식이 올바르지 않습니다",
"type": "invalid_request_error",
"details": exc.errors() # 어떤 필드가 잘못되었는지 상세 정보
}
}
)
# 커스텀 예외 핸들러 - 502 에러
@app.exception_handler(AIModelError)
async def ai_model_exception_handler(request: Request, exc: AIModelError):
"""AI 모델 호출 실패 시"""
logger.error(f"AI 모델 에러: {exc.model} - {exc.message}")
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={
"error": {
"message": f"AI 모델({exc.model}) 호출에 실패했습니다",
"type": "api_error",
"code": "model_unavailable"
}
}
)
# 모든 예외를 잡는 최후의 핸들러 - 500 에러
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""예상치 못한 에러 처리"""
logger.exception("예상치 못한 에러 발생") # 전체 스택 트레이스 로깅
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": {
"message": "서버 내부 오류가 발생했습니다",
"type": "internal_error"
}
}
)
설명
이것이 하는 일: 이 코드는 다양한 에러 상황을 감지하고, 각각에 맞는 HTTP 상태 코드와 사용자 친화적인 메시지를 반환하며, 개발자를 위한 상세한 로그를 남기는 종합적인 에러 처리 시스템입니다. 첫 번째로, 커스텀 예외 클래스 AIModelError를 정의합니다.
Python의 내장 Exception만으로는 에러의 맥락을 충분히 표현할 수 없기 때문에, 어떤 모델에서 문제가 발생했는지 같은 추가 정보를 담을 수 있는 전용 예외를 만듭니다. 이렇게 하면 나중에 raise AIModelError("타임아웃", "gpt-4")처럼 구체적인 상황을 전달할 수 있죠.
그 다음으로, RequestValidationError 핸들러가 Pydantic 검증 실패를 처리합니다. 사용자가 필수 필드를 빠뜨리거나 잘못된 타입을 보내면 이 핸들러가 작동합니다.
exc.errors()는 어떤 필드가 왜 잘못되었는지 리스트로 알려주는데, 이를 그대로 클라이언트에게 전달하여 프론트엔드가 해당 입력 필드를 강조 표시할 수 있게 합니다. logger.warning은 심각한 에러는 아니지만 기록할 가치가 있다는 의미입니다.
세 번째로, AIModelError 핸들러가 외부 서비스 문제를 처리합니다. 502 Bad Gateway 상태 코드는 "우리 서버는 정상이지만 의존하는 다른 서비스가 문제"라는 의미로, 클라이언트가 재시도할지 판단하는 데 도움이 됩니다.
logger.error는 이것이 즉시 대응이 필요한 문제라는 것을 나타내고, 모니터링 시스템이 알림을 보낼 수 있습니다. 마지막으로, 일반 Exception 핸들러가 모든 예상치 못한 에러를 잡습니다.
여기서 핵심은 logger.exception()인데, 이것은 에러 메시지뿐만 아니라 전체 스택 트레이스를 로그에 남겨서 어디서 에러가 발생했는지 정확히 추적할 수 있게 합니다. 클라이언트에게는 보안을 위해 상세 정보를 숨기고 일반적인 메시지만 보냅니다.
여러분이 이 시스템을 사용하면 서비스 안정성이 크게 향상됩니다. 사용자는 무슨 문제인지 이해할 수 있는 에러 메시지를 받고, 개발자는 로그만 봐도 즉시 원인을 파악하며, 모니터링 시스템은 심각도에 따라 적절히 알림을 보냅니다.
실제로 프로덕션 환경에서는 에러 핸들링이 잘 되어있는지가 서비스 품질을 좌우합니다.
실전 팁
💡 에러 응답 형식을 OpenAI API와 일치시키세요. {"error": {"message": "...", "type": "...", "code": "..."}}구조를 사용하면 기존 클라이언트 라이브러리와 호환됩니다.
💡 민감한 정보를 에러 메시지에 포함하지 마세요. 스택 트레이스나 데이터베이스 쿼리는 로그에만 남기고, 클라이언트에는 일반적인 메시지만 보냅니다.
💡 재시도 가능 여부를 에러 응답에 명시하세요. "retryable": true 필드를 추가하여 클라이언트가 자동 재시도 로직을 구현할 수 있게 합니다.
💡 에러 발생 빈도를 모니터링하세요. 같은 에러가 짧은 시간에 반복되면 심각한 문제의 징후일 수 있습니다. Sentry나 DataDog 같은 도구를 연동하세요.
💡 개발 환경에서는 상세한 에러를 표시하세요. if settings.ENV == "dev": content["traceback"] = traceback.format_exc()처럼 환경별로 다르게 설정합니다.
6. 환경 변수 관리
시작하며
여러분이 ChatGPT 클론을 개발하고 배포할 때 가장 위험한 실수 중 하나가 무엇일까요? 바로 OpenAI API 키를 코드에 하드코딩하는 것입니다.
GitHub에 실수로 푸시하면 몇 분 안에 봇이 발견해서 악용하고, 여러분의 API 비용이 천문학적으로 늘어날 수 있죠. 또 다른 문제는 개발 환경과 프로덕션 환경의 설정이 달라야 한다는 점입니다.
로컬에서는 SQLite를 쓰다가 배포 시 PostgreSQL로 바꾸고, 디버그 모드를 끄고, 로그 레벨을 조정해야 합니다. 이런 설정들을 코드에 박아놓으면 환경마다 코드를 수정해야 하는 악몽이 펼쳐집니다.
바로 이럴 때 필요한 것이 환경 변수를 사용한 설정 관리입니다. .env 파일에 민감한 정보와 환경별 설정을 분리하고, Pydantic의 BaseSettings로 자동 로드 및 검증을 구현합니다.
개요
간단히 말해서, 환경 변수 관리는 코드와 설정을 분리하여 보안을 강화하고 다양한 환경에서 유연하게 실행할 수 있게 하는 기법입니다. 실무에서는 API 키, 데이터베이스 연결 문자열, 서비스 포트, 로그 레벨 등을 모두 환경 변수로 관리합니다.
.env 파일은 Git에 커밋하지 않고(.gitignore에 추가), 대신 .env.example 파일로 필요한 변수 목록만 공유합니다. Pydantic의 BaseSettings를 사용하면 환경 변수를 자동으로 읽어서 타입 변환하고, 필수 값이 누락되면 시작 시점에 에러를 발생시켜줍니다.
기존에는 os.getenv("API_KEY")로 일일이 읽고 기본값을 설정했다면, 이제는 설정 클래스를 한 번 정의하면 모든 곳에서 타입 안전하게 사용할 수 있습니다. 핵심 특징은 첫째, 민감한 정보가 코드 저장소에 노출되지 않아 보안이 강화되고, 둘째, 환경별로 다른 설정을 코드 수정 없이 적용할 수 있으며, 셋째, 설정 값의 타입과 검증을 자동화하여 잘못된 설정으로 인한 런타임 에러를 방지한다는 점입니다.
이러한 특징들이 12-Factor App 원칙을 따르는 현대적인 애플리케이션의 기본입니다.
코드 예제
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""애플리케이션 설정을 환경 변수에서 로드"""
# API 서버 설정
app_name: str = "ChatGPT Clone"
debug: bool = False # 프로덕션에서는 False
host: str = "0.0.0.0"
port: int = 8000
# OpenAI API 설정
openai_api_key: str # 필수 값 - 없으면 에러 발생
openai_model: str = "gpt-3.5-turbo"
# 데이터베이스 설정
database_url: str = "sqlite:///./chat.db"
# 로깅 설정
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR
# CORS 설정 (허용할 프론트엔드 도메인)
cors_origins: list[str] = ["http://localhost:3000"]
class Config:
env_file = ".env" # .env 파일에서 자동 로드
case_sensitive = False # 대소문자 구분 안 함
# 싱글톤 패턴으로 설정 인스턴스 재사용
@lru_cache()
def get_settings() -> Settings:
"""설정 객체를 한 번만 생성하여 재사용"""
return Settings()
# 사용 예시
settings = get_settings()
print(f"API 키: {settings.openai_api_key[:10]}...") # 앞 10자만 출력
설명
이것이 하는 일: 이 코드는 애플리케이션의 모든 설정을 환경 변수에서 자동으로 읽어오고, 타입 변환 및 검증을 수행하며, 싱글톤 패턴으로 효율적으로 관리하는 설정 시스템입니다. 첫 번째로, BaseSettings를 상속하여 설정 클래스를 정의합니다.
일반 Pydantic 모델과 달리, BaseSettings는 환경 변수를 자동으로 찾아서 필드에 매핑합니다. 예를 들어 openai_api_key 필드는 환경 변수 OPENAI_API_KEY에서 값을 가져옵니다(대소문자 구분 안 함).
타입 힌트 str은 Pydantic에게 이 값을 문자열로 변환하라고 알려주죠. 그 다음으로, 필수 값과 선택 값을 구분합니다.
openai_api_key: str처럼 기본값이 없으면 필수 값이고, 환경 변수에 없으면 앱 시작 시 ValidationError가 발생합니다. 반면 debug: bool = False는 환경 변수가 없으면 기본값 False를 사용합니다.
이렇게 하면 "API 키를 설정 안 했는데 서버가 실행되어서 나중에 에러 나는" 상황을 방지할 수 있습니다. 세 번째로, class Config에서 env_file = ".env"를 설정합니다.
이렇게 하면 프로젝트 루트의 .env 파일에서 변수를 읽어옵니다. .env 파일 예시: ``` OPENAI_API_KEY=sk-abc123...
DATABASE_URL=postgresql://user:pass@localhost/chatdb DEBUG=true ``` 이 파일은 .gitignore에 추가하여 Git에 커밋되지 않도록 합니다. 마지막으로, @lru_cache() 데코레이터로 설정 인스턴스를 캐싱합니다.
Settings() 객체를 생성하는 것은 비용이 들기 때문에(환경 변수 읽기, 파일 파싱 등), 한 번만 생성하고 재사용하는 것이 효율적입니다. lru_cache()는 함수 결과를 메모리에 저장하여, 같은 함수를 다시 호출하면 저장된 값을 즉시 반환합니다.
여러분이 이 방식을 사용하면 개발 환경에서는 .env에 DEBUG=true를 설정하고, 프로덕션에서는 환경 변수로 DEBUG=false를 주입하여 코드 변경 없이 동작을 바꿀 수 있습니다. Docker 컨테이너나 Kubernetes에서는 환경 변수 주입이 표준 방식이므로, 이 구조가 배포를 훨씬 쉽게 만들어줍니다.
실전 팁
💡 .env.example 파일을 만들어 Git에 커밋하세요. 실제 값 대신 OPENAI_API_KEY=your_key_here 같은 플레이스홀더를 넣어서, 새로운 팀원이 어떤 변수가 필요한지 알 수 있게 합니다.
💡 민감한 변수는 출력하지 마세요. __repr__ 메서드를 오버라이드하거나 SecretStr 타입을 사용하여 실수로 로그에 API 키가 찍히는 것을 방지합니다.
💡 환경별 설정 파일을 분리하세요. .env.dev, .env.prod로 나누고, env_file = os.getenv("ENV_FILE", ".env.dev")처럼 동적으로 로드할 수 있습니다.
💡 타입 변환을 활용하세요. cors_origins: list[str]에 환경 변수로 CORS_ORIGINS=http://localhost:3000,https://example.com을 주면 Pydantic이 자동으로 리스트로 파싱합니다.
💡 설정 값을 FastAPI 의존성으로 주입하세요. def endpoint(settings: Settings = Depends(get_settings))처럼 사용하면 테스트 시 설정을 모킹하기 쉬워집니다.
7. Docker 컨테이너화
시작하며
여러분이 로컬에서 완벽하게 작동하는 ChatGPT 클론을 만들었습니다. 이제 서버에 배포하려고 하는데, "내 컴퓨터에서는 잘 되는데요?"라는 악몽 같은 상황이 펼쳐집니다.
서버의 Python 버전이 다르고, 필요한 라이브러리가 설치 안 되어 있고, 환경 변수 설정도 달라서 온갖 에러가 쏟아지죠. 팀원과 협업할 때도 문제입니다.
한 사람은 macOS, 다른 사람은 Windows, 또 다른 사람은 Linux를 사용하는데, 각자 환경 설정하느라 반나절을 보냅니다. 새로운 팀원이 합류하면 온보딩 문서를 보며 수십 개의 명령어를 실행해야 하고, 중간에 하나라도 실패하면 디버깅 지옥이 시작됩니다.
바로 이럴 때 필요한 것이 Docker 컨테이너화입니다. 애플리케이션과 모든 의존성을 하나의 이미지로 패키징하여, 어디서든 동일하게 실행할 수 있는 환경을 만듭니다.
개요
간단히 말해서, Docker는 애플리케이션을 실행하는 데 필요한 모든 것(코드, 런타임, 라이브러리, 환경 변수)을 하나의 컨테이너로 묶어서 어디서나 일관되게 실행할 수 있게 하는 기술입니다. 실무에서는 Dockerfile에 이미지 빌드 방법을 정의합니다.
기본 Python 이미지를 가져와서, 의존성을 설치하고, 애플리케이션 코드를 복사하고, 실행 명령어를 지정하죠. docker build로 이미지를 만들고, docker run으로 컨테이너를 실행하면 어떤 환경에서든 동일하게 작동합니다.
또한 .dockerignore 파일로 불필요한 파일(.git, __pycache__ 등)을 제외하여 이미지 크기를 최소화합니다. 기존에는 서버마다 수동으로 Python 설치, pip install, 환경 변수 설정을 반복했다면, 이제는 한 번 빌드한 이미지를 여러 서버에 배포하기만 하면 됩니다.
핵심 특징은 첫째, 환경 일관성을 보장하여 "내 컴퓨터에서는 잘 되는데" 문제를 제거하고, 둘째, 의존성을 이미지에 포함시켜 별도 설치가 필요 없으며, 셋째, 격리된 환경으로 여러 애플리케이션이 서로 간섭하지 않고 동시에 실행될 수 있다는 점입니다. 이러한 특징들이 현대 클라우드 네이티브 애플리케이션의 표준이 되었습니다.
코드 예제
# Dockerfile - Docker 이미지 빌드 방법 정의
# 1단계: 베이스 이미지 선택 (Python 3.11의 슬림 버전)
FROM python:3.11-slim
# 2단계: 작업 디렉토리 설정
WORKDIR /app
# 3단계: 의존성 파일만 먼저 복사 (레이어 캐싱 최적화)
COPY requirements.txt .
# 4단계: 의존성 설치
RUN pip install --no-cache-dir -r requirements.txt
# 5단계: 애플리케이션 코드 복사
COPY . .
# 6단계: 비root 사용자 생성 (보안)
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# 7단계: 포트 노출 (문서화 목적)
EXPOSE 8000
# 8단계: 컨테이너 시작 시 실행할 명령어
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# .dockerignore - 이미지에 포함하지 않을 파일/폴더
__pycache__
*.pyc
.git
.env
.venv
node_modules
*.md
.dockerignore
Dockerfile
설명
이것이 하는 일: 이 Dockerfile은 ChatGPT 클론을 실행하는 데 필요한 모든 것을 포함한 Docker 이미지를 빌드하는 레시피입니다. 이미지를 만들고 나면 개발 서버, 스테이징 서버, 프로덕션 서버 어디서든 동일하게 실행됩니다.
첫 번째로, FROM python:3.11-slim으로 베이스 이미지를 선택합니다. slim 버전은 불필요한 패키지를 제거하여 이미지 크기를 줄인 것으로, 일반 Python 이미지보다 훨씬 가볍습니다.
예를 들어 일반 이미지가 900MB라면 slim은 200MB 정도죠. WORKDIR /app은 컨테이너 내부의 작업 디렉토리를 설정하여, 이후 모든 명령어가 /app에서 실행되게 합니다.
그 다음으로, COPY requirements.txt .와 RUN pip install을 코드 복사보다 먼저 실행하는 것이 중요합니다. Docker는 레이어 캐싱을 사용하는데, 의존성은 자주 바뀌지 않지만 코드는 자주 바뀝니다.
의존성 설치를 먼저 하면, 코드만 수정했을 때 의존성 레이어는 캐시에서 재사용되어 빌드 속도가 크게 향상됩니다. --no-cache-dir는 pip 캐시를 저장하지 않아 이미지 크기를 줄입니다.
세 번째로, RUN useradd -m -u 1000 appuser로 비root 사용자를 생성합니다. 기본적으로 컨테이너는 root로 실행되는데, 이는 보안 위험이 큽니다.
만약 애플리케이션이 해킹당하면 공격자가 컨테이너 전체를 장악할 수 있죠. 전용 사용자를 만들고 USER appuser로 전환하여 최소 권한 원칙을 따릅니다.
마지막으로, CMD로 컨테이너 시작 시 실행할 명령어를 지정합니다. uvicorn main:app --host 0.0.0.0은 FastAPI 서버를 모든 네트워크 인터페이스에서 접근 가능하게 시작합니다(컨테이너 내부의 localhost만 열면 외부에서 접근 불가).
EXPOSE 8000은 실제로 포트를 열지는 않고, 문서화 목적으로 "이 컨테이너는 8000번 포트를 사용해요"라고 알려줍니다. 여러분이 이 Dockerfile로 빌드한 이미지는 docker build -t chatgpt-clone . 명령어 하나로 만들어지고, docker run -p 8000:8000 --env-file .env chatgpt-clone로 실행됩니다.
새로운 팀원은 Docker만 설치하면 복잡한 환경 설정 없이 즉시 개발을 시작할 수 있고, 배포 시에도 동일한 이미지를 사용하여 예기치 않은 문제를 방지할 수 있습니다.
실전 팁
💡 멀티 스테이지 빌드를 사용하여 이미지 크기를 더 줄이세요. 빌드 도구는 첫 단계에서만 사용하고, 최종 이미지에는 런타임만 포함시킵니다.
💡 헬스체크를 추가하세요. HEALTHCHECK CMD curl --fail http://localhost:8000/health || exit 1로 컨테이너가 정상 작동하는지 주기적으로 확인합니다.
💡 버전 태그를 명시적으로 지정하세요. python:3.11-slim 대신 python:3.11.5-slim처럼 정확한 버전을 쓰면 예기치 않은 업데이트를 방지합니다.
💡 Docker Compose로 여러 서비스를 함께 관리하세요. FastAPI 서버, PostgreSQL, Redis를 한 번에 실행하고 네트워크를 자동 구성할 수 있습니다.
💡 이미지 크기를 정기적으로 확인하세요. docker images 명령어로 크기를 체크하고, 불필요한 파일이 포함되었는지 dive 도구로 분석합니다.
8. Nginx 리버스 프록시 설정
시작하며
여러분의 ChatGPT 클론을 Docker로 컨테이너화했고, 8000번 포트에서 잘 실행되고 있습니다. 이제 실제 도메인(chatgpt-clone.com)으로 접속하려면 어떻게 해야 할까요?
사용자에게 "chatgpt-clone.com:8000"으로 접속하라고 할 수는 없죠. 또 다른 문제는 FastAPI의 uvicorn이 개발용 서버라는 점입니다.
정적 파일 제공, SSL/TLS 암호화, 로드 밸런싱, 속도 제한 같은 프로덕션 기능이 부족합니다. 트래픽이 많아지면 성능 문제가 생기고, DDoS 공격에 취약하며, 로그 관리도 어렵습니다.
바로 이럴 때 필요한 것이 Nginx 리버스 프록시입니다. 클라이언트 요청을 먼저 받아서 FastAPI 서버로 전달하고, 응답을 다시 클라이언트에게 돌려주면서 중간에 다양한 기능을 추가합니다.
개요
간단히 말해서, 리버스 프록시는 클라이언트와 백엔드 서버 사이에서 중개자 역할을 하며, 보안, 성능, 관리 기능을 제공하는 서버입니다. 실무에서는 Nginx를 프론트엔드 서버로 두고, 80번(HTTP)과 443번(HTTPS) 포트를 리스닝합니다.
사용자가 chatgpt-clone.com으로 접속하면, Nginx가 요청을 받아서 백엔드(uvicorn)의 8000번 포트로 전달하죠. 이 과정에서 gzip 압축으로 응답 크기를 줄이고, 정적 파일은 캐싱하며, 요청 속도를 제한하여 남용을 방지합니다.
또한 여러 백엔드 서버로 로드 밸런싱도 가능합니다. 기존에는 애플리케이션 서버를 직접 인터넷에 노출했다면, 이제는 Nginx를 방패로 두어 다양한 공격을 막고 성능을 최적화합니다.
핵심 특징은 첫째, SSL/TLS 종료를 담당하여 백엔드는 암호화를 신경 쓰지 않아도 되고, 둘째, 정적 파일을 효율적으로 제공하여 애플리케이션 서버의 부담을 줄이며, 셋째, 요청 로깅, 속도 제한, IP 차단 등 보안 기능을 중앙에서 관리할 수 있다는 점입니다. 이러한 특징들이 프로덕션 배포의 필수 요소입니다.
코드 예제
# nginx.conf - Nginx 설정 파일
# HTTP 블록 - 전역 설정
upstream fastapi_backend {
# 백엔드 서버 주소 (여러 개 추가하면 로드 밸런싱)
server localhost:8000; # Docker Compose 사용 시 서비스명 사용
}
# 속도 제한 존 정의 - IP당 초당 10개 요청 제한
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
listen 80; # HTTP 포트
server_name chatgpt-clone.com www.chatgpt-clone.com;
# HTTP를 HTTPS로 리다이렉트 (프로덕션에서 활성화)
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2; # HTTPS 포트, HTTP/2 활성화
server_name chatgpt-clone.com www.chatgpt-clone.com;
# SSL 인증서 경로 (Let's Encrypt 사용 시)
ssl_certificate /etc/letsencrypt/live/chatgpt-clone.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chatgpt-clone.com/privkey.pem;
# 보안 헤더 추가
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
# API 엔드포인트 프록시
location /v1/ {
# 속도 제한 적용
limit_req zone=api_limit burst=20 nodelay;
# FastAPI로 요청 전달
proxy_pass http://fastapi_backend;
# 필요한 헤더 전달
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 스트리밍 지원 (SSE)
proxy_buffering off; # 버퍼링 비활성화
proxy_cache off;
}
# 정적 파일 제공 (프론트엔드)
location / {
root /var/www/html;
try_files $uri $uri/ /index.html;
}
}
설명
이것이 하는 일: 이 Nginx 설정은 클라이언트 요청을 안전하고 효율적으로 처리하여 FastAPI 백엔드로 전달하고, 프로덕션 환경에 필요한 보안 및 성능 기능을 제공합니다. 첫 번째로, upstream fastapi_backend 블록에서 백엔드 서버 풀을 정의합니다.
여기서는 한 개의 서버만 있지만, server localhost:8000; server localhost:8001;처럼 여러 개를 추가하면 Nginx가 자동으로 요청을 분산시킵니다(라운드 로빈 방식). Docker Compose를 사용한다면 server fastapi:8000처럼 서비스 이름을 직접 사용할 수 있죠.
그 다음으로, limit_req_zone으로 속도 제한을 설정합니다. $binary_remote_addr는 클라이언트 IP를 의미하고, 10m은 10MB 메모리를 IP 추적에 사용한다는 뜻이며, rate=10r/s는 IP당 초당 10개 요청만 허용한다는 의미입니다.
이렇게 하면 단일 사용자가 API를 과도하게 호출하여 서버를 마비시키는 것을 방지할 수 있습니다. 세 번째로, 첫 번째 server 블록에서 HTTP(80번 포트) 요청을 HTTPS로 리다이렉트합니다.
return 301은 영구 리다이렉트를 의미하고, 브라우저가 이후에는 자동으로 HTTPS로 접속하게 캐싱합니다. 이렇게 하면 사용자가 실수로 http://로 접속해도 안전한 https://로 자동 전환됩니다.
네 번째로, 두 번째 server 블록에서 HTTPS를 처리합니다. proxy_pass http://fastapi_backend가 핵심으로, /v1/로 시작하는 모든 요청을 백엔드로 전달합니다.
proxy_set_header 지시어들은 원본 클라이언트 정보(IP, 호스트명 등)를 백엔드에 전달하여, FastAPI가 실제 클라이언트를 식별할 수 있게 합니다. 그렇지 않으면 모든 요청이 Nginx의 IP에서 온 것으로 보이죠.
마지막으로, proxy_buffering off가 SSE 스트리밍에 매우 중요합니다. 기본적으로 Nginx는 응답을 버퍼에 쌓았다가 한 번에 보내는데, 이렇게 하면 스트리밍이 작동하지 않습니다.
버퍼링을 끄면 FastAPI에서 yield할 때마다 즉시 클라이언트로 전송되어, 실시간 채팅 효과가 구현됩니다. 여러분이 이 설정을 사용하면 프로덕션 수준의 웹 서비스를 운영할 수 있습니다.
SSL로 암호화되고, 속도 제한으로 남용을 방지하며, 여러 백엔드 서버로 확장 가능하고, 정적 파일은 Nginx가 직접 제공하여 FastAPI의 부담을 줄입니다. 또한 Nginx의 강력한 로깅 기능으로 트래픽을 분석하고 문제를 디버깅할 수 있습니다.
실전 팁
💡 gzip 압축을 활성화하여 응답 크기를 줄이세요. gzip on; gzip_types application/json text/plain;로 JSON 응답을 압축하면 대역폭을 크게 절약합니다.
💡 정적 파일 캐싱을 설정하세요. location ~* \.(jpg|png|css|js)$ { expires 1y; add_header Cache-Control "public, immutable"; }로 브라우저가 파일을 캐싱하게 합니다.
💡 WebSocket 지원이 필요하면 추가 헤더를 설정하세요. proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";로 WebSocket 연결을 프록시할 수 있습니다.
💡 로그를 구조화된 형식으로 남기세요. JSON 로그 포맷을 사용하면 ELK 스택이나 CloudWatch로 분석하기 쉽습니다.
💡 보안을 강화하려면 fail2ban을 설정하세요. 반복된 실패 요청을 감지하여 해당 IP를 자동으로 차단합니다.
9. HTTPS 인증서 적용
시작하며
여러분의 ChatGPT 클론이 이제 도메인으로 접속 가능합니다. 하지만 브라우저 주소창에 "안전하지 않음" 경고가 뜹니다.
사용자들은 불안해하며, "이 사이트에 API 키를 입력해도 되는 걸까?" 의심하죠. HTTP는 평문으로 데이터를 전송하기 때문에, 중간자 공격(MITM)으로 누군가 여러분의 메시지를 엿볼 수 있습니다.
특히 공용 WiFi에서는 매우 위험합니다. 또한 현대 브라우저는 HTTPS가 아닌 사이트의 기능을 제한하고, SEO 순위도 낮춥니다.
심지어 일부 API(예: 위치 정보)는 HTTPS에서만 작동하죠. 바로 이럴 때 필요한 것이 SSL/TLS 인증서를 통한 HTTPS 적용입니다.
Let's Encrypt를 사용하면 무료로 인증서를 발급받고, 자동 갱신까지 설정할 수 있습니다.
개요
간단히 말해서, HTTPS는 HTTP에 SSL/TLS 암호화를 추가하여 클라이언트와 서버 간의 모든 통신을 안전하게 보호하는 프로토콜입니다. 실무에서는 Let's Encrypt의 Certbot 도구를 사용하여 인증서를 발급합니다.
Certbot이 도메인 소유권을 검증하고(DNS 또는 HTTP 챌린지), 인증서 파일을 자동으로 생성하여 Nginx에 설정합니다. 인증서는 90일마다 만료되지만, Certbot의 자동 갱신 기능으로 걱정할 필요 없습니다.
HTTPS를 적용하면 모든 데이터가 암호화되어 중간에서 가로채도 읽을 수 없고, 인증서로 사이트의 진위를 확인할 수 있습니다. 기존에는 유료 인증서를 구매하거나, 자체 서명 인증서로 브라우저 경고를 감수했다면, 이제는 Let's Encrypt로 무료이면서도 신뢰받는 인증서를 얻을 수 있습니다.
핵심 특징은 첫째, 모든 통신이 암호화되어 데이터 유출을 방지하고, 둘째, 브라우저가 사이트를 신뢰하여 보안 경고가 사라지며, 셋째, SEO와 사용자 신뢰도가 향상되어 서비스 품질이 높아진다는 점입니다. 이러한 특징들이 현대 웹 서비스의 필수 요구사항입니다.
코드 예제
# 1. Certbot 설치 (Ubuntu/Debian 기준)
# Nginx용 Certbot 플러그인 함께 설치
sudo apt update
sudo apt install certbot python3-certbot-nginx
# 2. 도메인에 대한 인증서 발급 및 Nginx 자동 설정
# Certbot이 도메인 소유권 검증 후 인증서 발급 및 Nginx 설정 업데이트
sudo certbot --nginx -d chatgpt-clone.com -d www.chatgpt-clone.com
# 대화형 프롬프트에서:
# - 이메일 입력 (갱신 알림용)
# - 약관 동의
# - HTTP를 HTTPS로 리다이렉트할지 선택 (권장: Yes)
# 3. 자동 갱신 테스트 (dry-run)
# 인증서는 90일마다 갱신 필요
sudo certbot renew --dry-run
# 4. 자동 갱신 크론잡은 이미 설정됨
# /etc/cron.d/certbot 확인
cat /etc/cron.d/certbot
# 출력 예: 0 */12 * * * root certbot renew --quiet
# 5. 인증서 정보 확인
sudo certbot certificates
# 수동으로 Nginx 설정 확인 (Certbot이 자동 추가한 부분)
# /etc/nginx/sites-available/default 또는 커스텀 설정 파일
ssl_certificate /etc/letsencrypt/live/chatgpt-clone.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chatgpt-clone.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Nginx 재시작
sudo systemctl restart nginx
설명
이것이 하는 일: 이 과정은 여러분의 도메인에 대해 신뢰받는 SSL/TLS 인증서를 발급하고, Nginx에 적용하며, 자동 갱신을 설정하여 영구적으로 HTTPS를 유지하는 시스템을 구축합니다. 첫 번째로, Certbot을 설치합니다.
python3-certbot-nginx 플러그인은 Nginx 설정 파일을 자동으로 읽고 수정하는 기능을 제공하여, 수동으로 설정을 편집할 필요가 없습니다. Certbot은 ACME(Automatic Certificate Management Environment) 프로토콜을 사용하여 Let's Encrypt CA와 통신합니다.
그 다음으로, sudo certbot --nginx -d chatgpt-clone.com을 실행하면 마법이 일어납니다. Certbot이 다음 단계를 자동으로 수행하죠: (1) 도메인 소유권 검증 - Nginx 설정을 보고 해당 도메인에 임시 파일을 생성하여 Let's Encrypt 서버가 접근할 수 있게 함 (2) 인증서 발급 - 검증이 성공하면 인증서와 개인 키를 /etc/letsencrypt/live/ 디렉토리에 저장 (3) Nginx 설정 업데이트 - SSL 관련 지시어를 자동으로 추가하고 HTTP를 HTTPS로 리다이렉트하는 설정 삽입.
세 번째로, 인증서는 90일 후 만료되므로 자동 갱신이 중요합니다. Certbot 설치 시 자동으로 cron job이 생성되어 하루에 두 번 갱신을 체크합니다.
만료 30일 전부터 갱신이 가능하며, 갱신 시 Nginx를 자동으로 재로드하여 다운타임 없이 새 인증서를 적용합니다. --dry-run으로 실제 갱신 없이 과정을 테스트할 수 있습니다.
마지막으로, Certbot이 추가한 설정을 확인합니다. fullchain.pem은 인증서 체인 전체를 포함하고, privkey.pem은 개인 키입니다.
options-ssl-nginx.conf는 Let's Encrypt가 권장하는 안전한 SSL 설정(TLS 버전, 암호화 알고리즘 등)을 담고 있습니다. ssl_dhparam은 Diffie-Hellman 키 교환을 위한 파라미터로, Perfect Forward Secrecy를 제공합니다.
여러분이 이 과정을 완료하면 브라우저 주소창에 자물쇠 아이콘이 나타나고, 사용자들이 안심하고 서비스를 사용할 수 있습니다. 모든 API 요청이 암호화되어 중간자 공격을 방지하고, 검색 엔진 순위도 올라가며, 최신 웹 API를 제약 없이 사용할 수 있습니다.
무엇보다 3개월마다 수동으로 인증서를 갱신할 필요가 없어 운영 부담이 크게 줄어듭니다.
실전 팁
💡 와일드카드 인증서를 발급하려면 DNS 챌린지를 사용하세요. certbot certonly --manual --preferred-challenges dns -d *.chatgpt-clone.com으로 모든 서브도메인을 커버할 수 있습니다.
💡 갱신 실패 알림을 설정하세요. Certbot에 입력한 이메일로 만료 알림이 오지만, 추가로 모니터링 도구(UptimeRobot 등)를 사용하면 더 안전합니다.
💡 HSTS(HTTP Strict Transport Security)를 활성화하세요. Nginx에서 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;를 추가하면 브라우저가 항상 HTTPS만 사용하게 강제합니다.
💡 SSL Labs 테스트로 보안 등급을 확인하세요. https://www.ssllabs.com/ssltest/에서 A+ 등급을 목표로 설정을 최적화합니다.
💡 Docker를 사용한다면 Certbot 컨테이너를 사용하세요. certbot/certbot 이미지로 인증서를 발급하고, 볼륨으로 /etc/letsencrypt를 Nginx 컨테이너와 공유합니다.
10. API 모니터링 및 로깅
시작하며
여러분의 ChatGPT 클론이 드디어 프로덕션에 배포되었습니다! 사용자들이 들어오기 시작하는데...
갑자기 "서버가 느려요"라는 불만이 쏟아집니다. 하지만 정확히 무엇이 느린지, 어떤 엔드포인트에서 문제가 생기는지 알 수가 없습니다.
또 다른 상황: 새벽 3시에 알람이 울립니다. "서버 다운!" 부랴부랴 로그를 확인하려는데, 며칠 치 로그가 뒤섞여 있고 어디서부터 봐야 할지 막막합니다.
에러가 언제부터 시작되었는지, 얼마나 많은 사용자가 영향을 받았는지 파악하는 데만 한 시간이 걸립니다. 바로 이럴 때 필요한 것이 체계적인 API 모니터링 및 로깅 시스템입니다.
각 요청의 응답 시간, 에러율, 처리량을 실시간으로 추적하고, 구조화된 로그로 문제를 빠르게 진단할 수 있게 합니다.
개요
간단히 말해서, 모니터링은 서비스의 건강 상태를 실시간으로 관찰하는 것이고, 로깅은 무슨 일이 일어났는지 상세히 기록하여 문제 발생 시 원인을 찾을 수 있게 하는 것입니다. 실무에서는 미들웨어를 사용하여 모든 요청의 메트릭을 자동 수집합니다.
응답 시간, 상태 코드, 엔드포인트별 호출 횟수 등을 Prometheus나 Datadog 같은 도구로 전송하고, Grafana로 시각화합니다. 로깅은 Python의 logging 모듈을 사용하되, JSON 형식으로 구조화하여 Elasticsearch나 CloudWatch에서 쉽게 검색할 수 있게 합니다.
또한 요청 ID를 생성하여 분산 시스템에서도 한 요청의 전체 흐름을 추적할 수 있습니다. 기존에는 문제가 발생한 후에야 로그를 뒤지며 원인을 찾았다면, 이제는 대시보드를 보고 실시간으로 이상 징후를 감지하고 선제적으로 대응합니다.
핵심 특징은 첫째, 성능 병목 지점을 데이터 기반으로 식별하여 최적화 우선순위를 정할 수 있고, 둘째, 장애 발생 시 평균 복구 시간(MTTR)을 크게 단축하며, 셋째, 사용 패턴을 분석하여 비즈니스 인사이트를 얻을 수 있다는 점입니다. 이러한 특징들이 안정적인 서비스 운영의 기반입니다.
코드 예제
from fastapi import FastAPI, Request
import time
import logging
import json
from datetime import datetime
import uuid
# 구조화된 JSON 로깅 설정
class JSONFormatter(logging.Formatter):
"""로그를 JSON 형식으로 포맷팅"""
def format(self, record):
log_data = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
}
if hasattr(record, 'request_id'):
log_data['request_id'] = record.request_id
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
return json.dumps(log_data, ensure_ascii=False)
# 로거 설정
logger = logging.getLogger("chatgpt_clone")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logger.addHandler(handler)
app = FastAPI()
# 요청 로깅 및 메트릭 수집 미들웨어
@app.middleware("http")
async def log_requests(request: Request, call_next):
# 요청 ID 생성 - 분산 추적용
request_id = str(uuid.uuid4())
request.state.request_id = request_id
# 요청 시작 시간
start_time = time.time()
# 요청 정보 로깅
logger.info(
f"요청 시작: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"client_ip": request.client.host
}
)
# 실제 엔드포인트 처리
try:
response = await call_next(request)
# 응답 시간 계산
process_time = time.time() - start_time
# 응답 정보 로깅
logger.info(
f"요청 완료: {request.method} {request.url.path} - {response.status_code}",
extra={
"request_id": request_id,
"status_code": response.status_code,
"process_time": round(process_time, 3)
}
)
# 응답 헤더에 요청 ID 추가 (디버깅용)
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = str(process_time)
return response
except Exception as e:
# 예외 발생 시 에러 로깅
logger.exception(
f"요청 실패: {request.method} {request.url.path}",
extra={"request_id": request_id}
)
raise
# 헬스체크 엔드포인트 - 모니터링 도구용
@app.get("/health")
async def health_check():
"""서버 상태 확인"""
return {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat()
}
설명
이것이 하는 일: 이 코드는 모든 API 요청의 생명주기를 자동으로 추적하고, 구조화된 형식으로 로그를 남기며, 성능 메트릭을 수집하여 서비스 상태를 가시화하는 종합 모니터링 시스템입니다. 첫 번째로, JSONFormatter를 만들어 로그를 JSON 형식으로 출력합니다.
일반 텍스트 로그는 사람이 읽기 쉽지만, 프로그램이 파싱하기 어렵습니다. JSON 형식은 Elasticsearch나 CloudWatch Insights 같은 로그 분석 도구에서 쉽게 검색하고 필터링할 수 있죠.
timestamp, level, message 같은 필드를 일관되게 유지하여, 나중에 level:ERROR로 모든 에러만 빠르게 찾을 수 있습니다. 그 다음으로, 미들웨어에서 각 요청마다 고유한 request_id를 생성합니다.
UUID v4는 충돌 확률이 극히 낮은 무작위 ID를 만들어줍니다. 이 ID를 request.state에 저장하여, 엔드포인트 내부 어디서든 접근할 수 있게 합니다.
만약 한 요청이 여러 마이크로서비스를 거친다면, 이 ID를 헤더로 전달하여 전체 흐름을 추적할 수 있죠. 세 번째로, time.time()으로 요청 처리 시작과 끝 시간을 측정하여 process_time을 계산합니다.
이 값이 특정 임계값(예: 1초)을 초과하면 성능 문제의 신호입니다. 응답 헤더에 X-Process-Time을 추가하면, 클라이언트나 프론트엔드 개발자도 서버 처리 시간을 확인할 수 있습니다.
느린 요청을 재현하기 위해 요청 ID를 함께 전달하면, 서버 로그에서 정확히 해당 요청을 찾을 수 있습니다. 마지막으로, /health 엔드포인트를 제공하여 모니터링 도구가 서버 생존 여부를 확인할 수 있게 합니다.
로드 밸런서나 Kubernetes는 주기적으로 이 엔드포인트를 호출하여, 응답이 없으면 해당 서버를 트래픽에서 제외합니다. 더 정교한 헬스체크는 데이터베이스 연결, 외부 API 상태 등도 확인할 수 있습니다.
여러분이 이 시스템을 사용하면 문제 발생 시 "요청 ID abc123을 검색"하는 것만으로 해당 요청의 전체 로그를 즉시 찾을 수 있습니다. 대시보드에서 "평균 응답 시간이 증가하고 있네"라고 조기에 감지하여, 사용자가 불만을 제기하기 전에 스케일 아웃할 수 있습니다.
또한 "어떤 엔드포인트가 가장 많이 호출되나"를 분석하여 비즈니스 의사결정에 활용할 수 있습니다.
실전 팁
💡 로그 레벨을 환경에 맞게 조정하세요. 개발에서는 DEBUG, 프로덕션에서는 INFO 이상만 출력하여 노이즈를 줄입니다.
💡 민감한 정보를 로그에 남기지 마세요. API 키, 비밀번호, 개인정보는 마스킹하거나 완전히 제외합니다. password라는 단어가 포함된 필드는 자동으로 ***로 치환하는 필터를 만드세요.
💡 로그 로테이션을 설정하세요. 무제한 로그는 디스크를 가득 채웁니다. RotatingFileHandler로 크기나 날짜 기준으로 로그 파일을 분할하세요.
💡 메트릭을 Prometheus 형식으로 노출하세요. prometheus-fastapi-instrumentator 라이브러리로 /metrics 엔드포인트를 자동 생성하고, Grafana로 시각화합니다.
💡 분산 추적에는 OpenTelemetry를 사용하세요. 요청 ID보다 훨씬 상세한 스팬(span) 정보를 수집하여, 마이크로서비스 간 호출 관계를 시각화합니다.