필사 모드: 언어×프레임워크 조합별 실전 장애 사례집: Python·JS/TS·Go·Java + FastAPI·Django·Next.js·NestJS·Gin·Spring Boot
한국어> 이 글은 **디버깅 실전 시리즈 5편** 중 4편이다.
>
> 1. [언어별 디버깅 가이드](/blog/devops/2026-03-07-devops-debugging-by-language-python-javascript-go-java)
> 2. [프레임워크별 디버깅 실전](/blog/devops/2026-03-07-devops-debugging-by-framework-spring-django-fastapi-react-nextjs)
> 3. [IDE별 디버깅 완전정리](/blog/devops/2026-03-07-devops-debugging-by-ide-vscode-intellij-pycharm)
> 4. **[언어×프레임워크 장애 사례집](/blog/devops/2026-03-07-devops-debugging-casebook-language-framework-combos)** ← 현재 글
> 5. [원격 디버깅 실전 가이드](/blog/devops/2026-03-07-devops-remote-debugging-guide-ssh-tunnel-vscode-intellij-pycharm)
왜 "언어×프레임워크 조합"이 따로 필요한가
언어별, 프레임워크별 디버깅 가이드는 많다. 하지만 현업에서 터지는 장애는 **언어 런타임의 특성**과 **프레임워크의 실행 모델**이 겹치는 지점에서 발생한다.
- Python의 GIL + FastAPI의 async event loop = 동기 코드 한 줄이 전체 요청을 멈춘다
- Java의 스레드 풀 + Spring Boot의 auto-configuration = 설정 하나 빠뜨리면 커넥션 풀이 고갈된다
- Go의 goroutine + Gin의 context 전파 = cancel 안 하면 goroutine이 무한 증식한다
- Next.js의 SSR hydration + React의 상태 모델 = 서버/클라이언트 불일치가 화면을 깨뜨린다
이 글은 실전에서 반복적으로 발생하는 **8가지 장애 사례**를 조합별로 분류하고, 각각에 대해 증상부터 재발방지까지 한 번에 정리한다. 이론이 아니라 **"내일 당장 온콜에서 써먹을 수 있는"** 수준으로 작성했다.
8개 사례 요약 비교표
| # | 조합 | 대표 증상 | 핵심 도구 |
| --- | ---------------------- | ------------------------------------------- | ----------------------------------------------------------- |
| 1 | Python + FastAPI | 응답 지연 급증, event loop blocked 경고 | `py-spy`, `asyncio.get_event_loop().slow_callback_duration` |
| 2 | Python + Django | 페이지 로딩 극심 지연, 쿼리 수백 개 발생 | `django-debug-toolbar`, `nplusone`, `EXPLAIN ANALYZE` |
| 3 | JS/TS + Next.js | 하이드레이션 에러, 화면 깜빡임/깨짐 | Chrome DevTools, `next build --debug`, `useEffect` 분석 |
| 4 | JS/TS + NestJS/Express | 메모리 지속 증가, 프로세스 OOM Kill | `--inspect`, Chrome Memory Profiler, `clinic.js` |
| 5 | Go + Gin/Fiber | goroutine 수 무한 증가, 메모리 폭증 | `pprof`, `runtime.NumGoroutine()`, Grafana |
| 6 | Java + Spring Boot | 요청 큐잉/타임아웃, HikariCP 경고 | JFR, `async-profiler`, Micrometer/Prometheus |
| 7 | Java + Spring Data JPA | 단일 API 쿼리 수십 개, flush 시점 예외 | Hibernate SQL 로그, `p6spy`, `EXPLAIN` |
| 8 | React + API Backend | 화면에 이전 데이터 표시, 깜빡임 후 덮어쓰기 | React DevTools, Network 탭, `AbortController` |
사례 1: Python + FastAPI — Event Loop Blocking & Pydantic Validation 병목
사례 1-A: Event Loop Blocking
**조합**: Python 3.11+ / FastAPI 0.110+ / Uvicorn
**증상**: 평소 p99 응답시간 50ms이던 API가 특정 엔드포인트 호출 이후 전체 서버 응답시간이 2~5초로 치솟는다. Uvicorn 로그에 `Slow callback. Running time: 3.2s` 경고가 찍힌다.
**원인**: FastAPI는 `async def` 핸들러를 asyncio event loop 위에서 실행한다. 그런데 핸들러 안에서 **동기 I/O**(파일 읽기, 동기 DB 드라이버, CPU-heavy 연산)를 호출하면 event loop 자체가 블로킹되어 다른 모든 요청이 대기한다.
**재현**:
bad_endpoint.py — 이 코드가 전체 서버를 멈춘다
from fastapi import FastAPI
app = FastAPI()
@app.get("/slow")
async def slow_endpoint():
동기 sleep이 event loop을 3초간 점유
time.sleep(3)
return {"status": "done"}
@app.get("/health")
async def health():
return {"status": "ok"}
터미널 1: 서버 기동
uvicorn bad_endpoint:app --host 0.0.0.0 --port 8000
터미널 2: /slow 호출 중 /health도 3초 걸리는지 확인
curl http://localhost:8000/slow &
sleep 0.5
time curl http://localhost:8000/health
health가 2.5초 걸리면 event loop blocking 확정
**브레이크포인트**:
- `uvicorn.protocols.http.httptools_impl:RequestResponseCycle.receive` — 요청 수신 시점
- 의심되는 핸들러 함수 진입 직후
- `asyncio` 디버그 모드 활성화: `PYTHONASYNCIODEBUG=1 uvicorn ...`
**프로파일링**:
실시간 CPU 스택 확인
py-spy top --pid $(pgrep -f uvicorn)
플레임 그래프 생성
py-spy record -o flamegraph.svg --pid $(pgrep -f uvicorn) -d 30
asyncio slow callback 임계값 조정 (0.1초 이상이면 경고)
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.1 # 100ms
**해결**:
from fastapi import FastAPI
from functools import partial
app = FastAPI()
def cpu_heavy_task(data: dict) -> dict:
"""동기 함수 — CPU 바운드 작업"""
time.sleep(3) # 실제로는 복잡한 계산
return {"result": "processed"}
@app.get("/slow-fixed")
async def slow_endpoint_fixed():
방법 1: run_in_executor로 스레드풀에 위임
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, partial(cpu_heavy_task, {}))
return result
방법 2: def로 선언하면 FastAPI가 자동으로 threadpool에서 실행
@app.get("/slow-fixed-v2")
def slow_endpoint_sync():
time.sleep(3)
return {"status": "done"}
사례 1-B: Pydantic Validation 병목
**증상**: 대량 데이터(수천 개 아이템 리스트)를 받는 POST API의 응답시간이 비정상적으로 느리다. CPU 사용률이 급등한다.
**원인**: Pydantic v1의 validator가 각 필드마다 파이썬 레벨에서 실행되어, 대용량 리스트 입력 시 O(n \* fields) 검증 비용이 발생한다.
**재현**:
from pydantic import BaseModel
from fastapi import FastAPI
from typing import List
class Item(BaseModel):
name: str
price: float
category: str
tags: List[str]
class BulkRequest(BaseModel):
items: List[Item] # 5000개 아이템 → validation만 2초+
app = FastAPI()
@app.post("/bulk")
async def bulk_create(req: BulkRequest):
return {"count": len(req.items)}
5000개 아이템 벤치마크
python -c "
items = [{'name': f'item-{i}', 'price': 9.99, 'category': 'test', 'tags': ['a','b']} for i in range(5000)]
start = time.time()
r = requests.post('http://localhost:8000/bulk', json={'items': items})
print(f'Time: {time.time()-start:.2f}s, Status: {r.status_code}')
"
**프로파일링**:
py-spy record -o pydantic_profile.svg --pid $(pgrep -f uvicorn) -d 10
pydantic.validators 관련 프레임이 대부분을 차지하면 확정
**해결**:
1) Pydantic v2 사용 (Rust 기반 core, 5~50x 빠름)
pyproject.toml: pydantic>=2.0
2) 대량 입력은 스트리밍 처리
from fastapi import Request
@app.post("/bulk-stream")
async def bulk_stream(request: Request):
body = await request.body()
data = orjson.loads(body) # Pydantic 검증 건너뛰고 직접 파싱
필수 필드만 수동 검증
validated = []
for item in data.get("items", []):
if "name" in item and "price" in item:
validated.append(item)
return {"count": len(validated)}
**재발방지 체크리스트**:
- [ ] `async def` 핸들러 안에 동기 I/O 호출이 없는지 코드 리뷰에서 확인
- [ ] `PYTHONASYNCIODEBUG=1`을 staging 환경에 상시 적용
- [ ] Pydantic v2 마이그레이션 완료 여부 확인
- [ ] 1000건 이상 벌크 API는 별도 벤치마크 테스트 추가
- [ ] `py-spy` 프로파일링을 CI의 성능 테스트 단계에 포함
사례 2: Python + Django — N+1 Query & Transaction Boundary 문제
사례 2-A: N+1 Query
**조합**: Python 3.10+ / Django 4.2+ / PostgreSQL
**증상**: 관리자 페이지에서 주문 목록 조회 시 페이지 로딩이 10초 이상 걸린다. DB CPU가 급등한다.
**원인**: Django ORM에서 ForeignKey/ManyToMany 관계를 순회할 때 `select_related`/`prefetch_related`를 누락하면, 각 객체 접근 시마다 별도 쿼리가 발생한다.
**재현**:
models.py
from django.db import models
class Customer(models.Model):
name = models.CharField(max_length=100)
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product_name = models.CharField(max_length=200)
quantity = models.IntegerField()
views.py — N+1 발생 코드
def order_list(request):
orders = Order.objects.all()[:100]
data = []
for order in orders:
data.append({
'customer': order.customer.name, # 쿼리 1번씩 (N)
'items': [i.product_name for i in order.items.all()], # 쿼리 1번씩 (N)
'total': order.total,
})
총 쿼리: 1 + 100 + 100 = 201개
return JsonResponse(data, safe=False)
**브레이크포인트**:
- `django.db.models.sql.compiler:SQLCompiler.execute_sql` — 모든 쿼리 실행 지점
- `django.db.backends.utils:CursorDebugWrapper.execute` — 쿼리 로그
**프로파일링**:
django-debug-toolbar 설치 (개발 환경)
pip install django-debug-toolbar
settings.py
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware')
nplusone 라이브러리로 자동 탐지
pip install nplusone
settings.py
INSTALLED_APPS += ['nplusone.ext.django']
MIDDLEWARE.insert(0, 'nplusone.ext.django.NPlusOneMiddleware')
NPLUSONE_RAISE = True # N+1 발생 시 예외 발생
-- PostgreSQL에서 슬로우쿼리 확인
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
**해결**:
views.py — 수정 후
def order_list(request):
orders = (
Order.objects
.select_related('customer') # JOIN으로 1회
.prefetch_related('items') # IN 쿼리로 1회
.all()[:100]
)
data = []
for order in orders:
data.append({
'customer': order.customer.name,
'items': [i.product_name for i in order.items.all()],
'total': order.total,
})
총 쿼리: 1(orders) + 1(customers JOIN) + 1(items IN) = 2~3개
return JsonResponse(data, safe=False)
사례 2-B: Transaction Boundary 문제
**증상**: 결제 처리 API에서 간헐적으로 주문은 생성되었는데 결제 기록이 없다. 데이터 정합성 깨짐.
**원인**: Django의 `ATOMIC_REQUESTS = False`(기본값)일 때, 뷰 함수 내 여러 DB 작업이 각각 auto-commit되어 중간에 예외가 발생하면 부분만 커밋된다.
**재현 / 해결**:
BAD — 트랜잭션 경계 없음
def create_payment(request):
order = Order.objects.create(customer_id=1, total=100)
여기서 예외 발생하면 order는 이미 커밋됨
payment = Payment.objects.create(order=order, amount=100)
send_notification(order) # 외부 API 호출
return JsonResponse({"order_id": order.id})
GOOD — 명시적 트랜잭션
from django.db import transaction
def create_payment(request):
with transaction.atomic():
order = Order.objects.create(customer_id=1, total=100)
payment = Payment.objects.create(order=order, amount=100)
트랜잭션 밖에서 부수효과 실행
send_notification(order)
return JsonResponse({"order_id": order.id})
**재발방지 체크리스트**:
- [ ] 모든 list/detail 뷰에 `select_related` / `prefetch_related` 적용 여부 확인
- [ ] `nplusone` 또는 `django-auto-prefetch` 라이브러리 적용
- [ ] staging 환경에서 `django-debug-toolbar` SQL 패널로 쿼리 수 모니터링
- [ ] 2개 이상 모델 변경이 있는 뷰에 `transaction.atomic()` 적용
- [ ] `ATOMIC_REQUESTS = True` 전역 설정 검토 (부수효과 위치 주의)
- [ ] CI에 쿼리 수 상한 assertion 테스트 추가
사례 3: JS/TS + Next.js — Hydration Mismatch & Server/Client Boundary 혼동
**조합**: TypeScript / Next.js 14+ (App Router) / React 18+
**증상**: 페이지 로드 시 콘솔에 `Hydration failed because the initial UI does not match what was rendered on the server` 경고. 화면이 깜빡이거나 레이아웃이 순간적으로 깨진다. 심한 경우 전체 클라이언트 사이드 렌더링으로 폴백되어 SEO가 무효화된다.
**원인**: 서버에서 렌더링한 HTML과 클라이언트에서 hydration 시 생성하는 DOM이 불일치한다. 대표적인 원인:
1. `Date.now()`, `Math.random()` 등 비결정적 값을 렌더링에 사용
2. `typeof window !== 'undefined'` 분기로 서버/클라이언트 다른 UI 반환
3. 브라우저 전용 API(`localStorage`, `navigator`)를 초기 렌더링에 사용
**재현**:
// app/dashboard/page.tsx — Hydration Mismatch 발생
export default function Dashboard() {
// 서버: "2026-03-07T09:00:00Z", 클라이언트: "2026-03-07T09:00:03Z"
const now = new Date().toISOString()
// 서버에는 localStorage가 없으므로 null, 클라이언트는 "dark"
const theme = typeof window !== 'undefined' ? localStorage.getItem('theme') : null
return (
)
}
**브레이크포인트**:
- Chrome DevTools > Sources > `next/dist/client/app-index.js` 내 `hydrateRoot` 호출 지점
- React DevTools > Profiler > Hydration 탭에서 mismatch 컴포넌트 식별
- Next.js 에러 오버레이에서 제공하는 서버/클라이언트 HTML diff 확인
**프로파일링**:
Next.js 빌드 분석
ANALYZE=true next build
서버 사이드 렌더링 결과 직접 확인
curl -s http://localhost:3000/dashboard | prettier --parser html > server.html
브라우저에서 document.documentElement.outerHTML 복사 후 비교
diff server.html client.html
**해결**:
'use client'
export default function Dashboard() {
const [now, setNow] = useState<string>('')
const [theme, setTheme] = useState<string>('default')
// 클라이언트 전용 값은 useEffect에서 설정
useEffect(() => {
setNow(new Date().toISOString())
setTheme(localStorage.getItem('theme') ?? 'default')
}, [])
return (
{/* 초기에는 빈 값 → hydration 일치 → useEffect 후 갱신 */}
)
}
// 대안: next/dynamic으로 클라이언트 전용 컴포넌트 분리
const ClientClock = dynamic(() => import('./ClientClock'), {
ssr: false,
loading: () => <p>로딩 중...</p>,
})
export default function Dashboard() {
return (
)
}
**재발방지 체크리스트**:
- [ ] `Date.now()`, `Math.random()` 등 비결정적 값은 `useEffect` 안에서만 사용
- [ ] `typeof window` 분기를 렌더링 반환값에 사용하지 않기
- [ ] `'use client'` 디렉티브가 필요한 컴포넌트 식별 및 명시
- [ ] CI에서 `next build` 경고를 에러로 처리하는 설정 적용
- [ ] E2E 테스트에서 hydration 에러 콘솔 로그를 실패 조건으로 설정
사례 4: JS/TS + NestJS/Express — Memory Leak & Async 에러 누락
사례 4-A: Memory Leak
**조합**: TypeScript / NestJS 10+ / Node.js 20+
**증상**: 서비스 배포 후 시간이 지나면 메모리 사용량이 단조 증가한다. 며칠 뒤 OOM Kill 발생. 재시작하면 일시적으로 정상화되지만 다시 증가.
**원인**: 모듈 스코프 또는 싱글턴 서비스에 데이터를 계속 축적하는 패턴. 대표적으로 캐시 Map에 TTL/크기 제한 없이 추가하거나, EventEmitter 리스너를 반복 등록하는 경우.
**재현**:
// cache.service.ts — 메모리 누수 패턴
@Injectable()
export class CacheService {
// 싱글턴이므로 앱 생애주기 동안 계속 커짐
private cache = new Map<string, any>()
set(key: string, value: any) {
this.cache.set(key, value) // 삭제/만료 로직 없음
}
get(key: string) {
return this.cache.get(key)
}
}
**브레이크포인트**:
- `CacheService.set()` 호출 빈도와 `cache.size` 증가 추이
- Node.js `--inspect` 플래그로 Chrome DevTools 연결
**프로파일링**:
Node.js 인스펙터 모드로 시작
node --inspect dist/main.js
또는 NestJS CLI
nest start --debug
clinic.js로 자동 분석
npx clinic doctor -- node dist/main.js
npx clinic heap -- node dist/main.js
Chrome DevTools에서:
1. Memory 탭 > Heap Snapshot 촬영 (시점 A)
2. 부하를 건다 (`ab -n 10000 -c 50 http://localhost:3000/api/data`)
3. Heap Snapshot 촬영 (시점 B)
4. Comparison 뷰로 누적 객체 확인
**해결**:
// cache.service.ts — 수정
interface CacheEntry {
value: any
expireAt: number
}
@Injectable()
export class CacheService {
private cache = new Map<string, CacheEntry>()
private readonly MAX_SIZE = 10000
private readonly DEFAULT_TTL = 5 * 60 * 1000 // 5분
set(key: string, value: any, ttl = this.DEFAULT_TTL) {
// 크기 제한 초과 시 가장 오래된 항목 제거
if (this.cache.size >= this.MAX_SIZE) {
const firstKey = this.cache.keys().next().value
if (firstKey) this.cache.delete(firstKey)
}
this.cache.set(key, { value, expireAt: Date.now() + ttl })
}
get(key: string) {
const entry = this.cache.get(key)
if (!entry) return null
if (Date.now() > entry.expireAt) {
this.cache.delete(key)
return null
}
return entry.value
}
}
사례 4-B: Async 에러 누락 (Error Swallowing)
**증상**: 특정 API 호출 시 클라이언트는 응답을 영영 받지 못한다(hang). 서버 에러 로그에는 아무것도 찍히지 않는다.
**원인**: `async` 함수의 반환된 Promise에 `.catch()`가 없거나, `try/catch`로 감싸지 않은 비동기 호출이 예외를 삼킨다.
**재현 / 해결**:
// BAD — Promise rejection이 삼켜진다
@Post('/process')
async processData(@Body() data: any) {
// fire-and-forget: 에러가 발생해도 아무 로그 없음
this.heavyService.processInBackground(data);
return { status: 'accepted' };
}
// GOOD — 에러 처리 보장
@Post('/process')
async processData(@Body() data: any) {
// 방법 1: await로 에러 전파
try {
await this.heavyService.processInBackground(data);
} catch (error) {
this.logger.error('Background processing failed', error);
throw new InternalServerErrorException('Processing failed');
}
return { status: 'accepted' };
// 방법 2: fire-and-forget이 필요하면 .catch() 필수
this.heavyService.processInBackground(data)
.catch(err => this.logger.error('Background task failed', err));
return { status: 'accepted' };
}
// main.ts — 전역 unhandled rejection 감시
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
// Sentry 등에 보고
})
**재발방지 체크리스트**:
- [ ] 싱글턴 서비스의 `Map`/`Set`/배열에 크기 제한과 TTL 적용
- [ ] `clinic doctor`/`clinic heap`을 정기적으로 실행하는 CI 단계 추가
- [ ] 모든 `async` 함수 호출에 `await` 또는 `.catch()` 존재 확인
- [ ] ESLint `@typescript-eslint/no-floating-promises` 룰 활성화
- [ ] `process.on('unhandledRejection')` 핸들러 설정
- [ ] Kubernetes 메모리 limit과 Node.js `--max-old-space-size` 일치시키기
사례 5: Go + Gin/Fiber — Goroutine Leak & Context Cancellation 누락
**조합**: Go 1.22+ / Gin 1.9+ (또는 Fiber)
**증상**: 서비스 운영 중 `runtime.NumGoroutine()`이 지속적으로 증가한다. 메모리 사용량도 함께 올라가다가 결국 OOM. 로그에는 특별한 에러가 없다.
**원인**: HTTP 핸들러에서 goroutine을 생성해 외부 API를 호출하면서, 요청의 `context.Context`를 전달하지 않거나 타임아웃 없이 무한 대기하는 goroutine이 쌓인다.
**재현**:
// BAD — goroutine leak
func (h *Handler) FetchData(c *gin.Context) {
results := make(chan string, 3)
for _, url := range []string{urlA, urlB, urlC} {
go func(u string) {
// context 없이 호출 → 클라이언트가 끊어도 goroutine은 살아있음
resp, err := http.Get(u)
if err != nil {
return // 채널에 안 보내고 리턴 → 수신자는 영원히 대기
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
results <- string(body)
}(url)
}
// 3개 모두 올 때까지 대기 — 하나라도 실패하면 영원히 블로킹
var data []string
for i := 0; i < 3; i++ {
data = append(data, <-results)
}
c.JSON(200, gin.H{"data": data})
}
**브레이크포인트**:
- `runtime.NumGoroutine()` 값을 주기적으로 로깅
- Delve 디버거에서 `goroutines` 명령으로 전체 goroutine 스택 덤프
Delve로 실행 중인 프로세스에 attach
dlv attach $(pgrep myservice)
(dlv) goroutines
(dlv) goroutine <id> bt # 특정 goroutine 스택 확인
**프로파일링**:
// main.go에 pprof 엔드포인트 추가
func main() {
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
// ...
}
goroutine 프로파일
go tool pprof http://localhost:6060/debug/pprof/goroutine
10초간 CPU 프로파일
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10
히프 프로파일
go tool pprof http://localhost:6060/debug/pprof/heap
**해결**:
// GOOD — context 전파 + 타임아웃 + errgroup
"context"
"golang.org/x/sync/errgroup"
"net/http"
"time"
)
func (h *Handler) FetchData(c *gin.Context) {
// 요청 context에 타임아웃 추가
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
urls := []string{urlA, urlB, urlC}
results := make([]string, len(urls))
for i, url := range urls {
i, url := i, url
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
results[i] = string(body)
return nil
})
}
if err := g.Wait(); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"data": results})
}
**재발방지 체크리스트**:
- [ ] 모든 goroutine 생성 시 `context.Context` 전달 여부 확인
- [ ] 외부 호출에 `context.WithTimeout` 또는 `context.WithDeadline` 적용
- [ ] `errgroup` 또는 동등한 패턴으로 goroutine 라이프사이클 관리
- [ ] `/debug/pprof/goroutine` 엔드포인트를 모니터링 대시보드에 연결
- [ ] goroutine 수 임계값(e.g., 10,000개) 초과 시 알림 설정
- [ ] `goleak` 라이브러리를 단위 테스트에 적용
// goroutine leak 테스트
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
사례 6: Java + Spring Boot — Thread Pool Starvation & DB Connection Pool 고갈
**조합**: Java 21+ / Spring Boot 3.2+ / HikariCP / Tomcat
**증상**: 트래픽 증가 시 응답시간이 급격히 증가하다가 `Connection is not available, request timed out after 30000ms` 에러가 대량 발생한다. Tomcat 스레드가 모두 `WAITING` 상태로 전환된다.
**원인**: 기본 HikariCP `maximumPoolSize=10`인 상태에서 Tomcat 스레드(기본 200개)가 동시에 DB 커넥션을 요청한다. 커넥션 풀보다 스레드 풀이 훨씬 크므로, 트래픽 증가 시 대부분의 스레드가 커넥션 대기 상태에 빠진다.
**재현**:
application.yml — 문제를 유발하는 기본 설정
spring:
datasource:
hikari:
maximum-pool-size: 10 # 기본값
connection-timeout: 30000 # 30초 대기 후 예외
server:
tomcat:
threads:
max: 200 # 기본값
부하 테스트로 재현
wrk -t12 -c200 -d30s http://localhost:8080/api/orders
또는 간단히
ab -n 5000 -c 200 http://localhost:8080/api/orders
**브레이크포인트**:
- `com.zaxxer.hikari.pool.HikariPool:getConnection` — 커넥션 획득 시점
- `org.apache.tomcat.util.threads.ThreadPoolExecutor:execute` — 스레드 풀 상태
**프로파일링**:
스레드 덤프
jstack $(pgrep -f 'spring-boot') > thread_dump.txt
"WAITING on com.zaxxer.hikari" 패턴 검색
JFR(Java Flight Recorder) 기록
jcmd $(pgrep -f 'spring-boot') JFR.start duration=60s filename=recording.jfr
async-profiler
./asprof -d 30 -f profile.html $(pgrep -f 'spring-boot')
HikariCP 메트릭 로깅 활성화
logging.level.com.zaxxer.hikari=DEBUG
logging.level.com.zaxxer.hikari.HikariConfig=DEBUG
Micrometer 메트릭 확인:
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
curl http://localhost:8080/actuator/metrics/hikaricp.connections.pending
**해결**:
application.yml — 수정
spring:
datasource:
hikari:
maximum-pool-size: 30 # 서버 코어 수 * 2 + 스핀들 수
minimum-idle: 10
connection-timeout: 5000 # 5초로 단축 (빠른 실패)
leak-detection-threshold: 10000 # 10초 이상 반환 안 하면 경고
metrics:
enabled: true
server:
tomcat:
threads:
max: 50 # 커넥션 풀에 맞춰 축소
accept-count: 100
// 비동기 처리로 스레드 점유 최소화
@RestController
public class OrderController {
@GetMapping("/api/orders")
public CompletableFuture<List<Order>> getOrders() {
return CompletableFuture.supplyAsync(() -> {
return orderService.findAll();
}, taskExecutor); // 별도 스레드풀 사용
}
}
**재발방지 체크리스트**:
- [ ] HikariCP `maximumPoolSize`를 Tomcat `threads.max`보다 작거나 같게 설정하지 않도록 공식 적용: `pool_size = (core_count * 2) + spindle_count`
- [ ] `leak-detection-threshold` 활성화
- [ ] Micrometer + Prometheus로 `hikaricp.connections.pending` 모니터링
- [ ] pending > 0 상태가 30초 이상 지속되면 알림 발생
- [ ] 부하 테스트를 배포 파이프라인에 포함
사례 7: Java + Spring Data JPA — N+1, Lazy Loading, Flush Timing
**조합**: Java 21+ / Spring Data JPA / Hibernate 6.x
사례 7-A: N+1 & Lazy Loading
**증상**: 단일 API 호출인데 Hibernate SQL 로그에 수십~수백 개의 SELECT가 찍힌다. 응답시간이 수백ms에서 수초로 증가.
**원인**: `@OneToMany` 관계의 기본 fetch 전략이 `LAZY`인 상태에서 엔티티를 순회하며 연관 컬렉션에 접근하면, 각 접근마다 별도 SELECT가 발생한다.
**재현**:
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
// N+1 발생
@GetMapping("/teams")
public List<TeamDto> getTeams() {
List<Team> teams = teamRepository.findAll(); // 쿼리 1개
return teams.stream().map(team -> new TeamDto(
team.getName(),
team.getMembers().size() // team마다 쿼리 1개씩 추가 (N개)
)).toList();
}
**프로파일링**:
application.yml
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
spring:
jpa:
properties:
hibernate:
generate_statistics: true
p6spy로 실제 바인딩 파라미터 포함 SQL 확인
build.gradle
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
**해결**:
// 방법 1: JPQL fetch join
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// 방법 2: @EntityGraph
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
// 방법 3: Projection DTO (가장 효율적)
@Query("""
SELECT new com.example.dto.TeamDto(t.name, SIZE(t.members))
FROM Team t
""")
List<TeamDto> findAllTeamSummaries();
사례 7-B: Flush Timing 문제
**증상**: `save()` 호출 후 바로 네이티브 쿼리로 조회하면 방금 저장한 데이터가 안 보인다. 또는 `@Transactional` 메서드 끝에서 예상치 못한 UPDATE 쿼리가 실행된다.
**원인**: Hibernate는 dirty checking으로 트랜잭션 커밋 시점에 변경을 flush한다. `save()`는 즉시 INSERT하지 않을 수 있다 (영속성 컨텍스트에 저장만). 또한 엔티티 필드를 수정하면 별도 `save()` 없이도 트랜잭션 종료 시 자동 UPDATE가 발생한다.
**재현 / 해결**:
// BAD — flush 전에 native query 실행
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus("PROCESSED");
orderRepository.save(order); // 아직 DB에 flush 안 됨
// native query는 영속성 컨텍스트를 모름 → 이전 상태 조회
int count = em.createNativeQuery("SELECT count(*) FROM orders WHERE status = 'PROCESSED'")
.getSingleResult();
}
// GOOD — 명시적 flush
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus("PROCESSED");
orderRepository.saveAndFlush(order); // 즉시 DB에 반영
int count = em.createNativeQuery("SELECT count(*) FROM orders WHERE status = 'PROCESSED'")
.getSingleResult();
}
**재발방지 체크리스트**:
- [ ] 모든 `findAll()` 계열 메서드에 fetch join 또는 `@EntityGraph` 적용 여부 확인
- [ ] `hibernate.generate_statistics=true`를 staging에 상시 적용
- [ ] 단일 API 호출 시 쿼리 수 상한(e.g., 10개) 초과하면 테스트 실패 설정
- [ ] native query 사용 전 `entityManager.flush()` 호출
- [ ] 엔티티 setter 사용 시 dirty checking에 의한 자동 UPDATE 인지
- [ ] DTO Projection 우선 사용 원칙 문서화
사례 8: React + API Backend — Race Condition & Stale Response 덮어쓰기
**조합**: React 18+ / 임의 API 백엔드 (REST/GraphQL)
**증상**: 검색창에 빠르게 입력하면 최종 결과가 아닌 중간 결과가 표시된다. 예를 들어 "react"를 입력했는데 "rea"의 검색 결과가 화면에 남는다. 또는 목록에서 빠르게 아이템을 클릭하면 이전 아이템의 상세 정보가 잠깐 보인다.
**원인**: 여러 비동기 요청이 동시에 진행될 때, 먼저 보낸 요청의 응답이 늦게 도착하면 나중에 보낸 요청의 결과를 덮어쓴다 (race condition). 네트워크 지연 시간은 요청 순서와 무관하다.
**재현**:
// BAD — race condition 발생
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
if (!query) return
// 이전 요청을 취소하지 않으므로,
// 느린 응답이 빠른 응답을 덮어쓸 수 있다
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => setResults(data))
}, [query])
return (
{results.map((r) => (
))}
)
}
**브레이크포인트**:
- Chrome DevTools > Network 탭에서 요청 순서와 응답 도착 순서 비교
- React DevTools > Profiler에서 상태 업데이트 타이밍 확인
- `setResults` 호출 직전에 `console.log` 또는 조건부 breakpoint
**프로파일링**:
// 디버깅용: 요청/응답 순서 로깅
useEffect(() => {
if (!query) return
const requestId = Date.now()
console.log(`[REQ ${requestId}] query="${query}"`)
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => {
console.log(`[RES ${requestId}] query="${query}" results=${data.length}`)
setResults(data)
})
}, [query])
// 출력 예시:
// [REQ 1001] query="r"
// [REQ 1002] query="re"
// [REQ 1003] query="rea"
// [RES 1003] query="rea" results=15 ← 먼저 도착
// [RES 1001] query="r" results=100 ← 나중에 도착, "rea" 결과를 덮어씀!
// [RES 1002] query="re" results=50
**해결**:
// GOOD — AbortController로 이전 요청 취소
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
if (!query) return
const controller = new AbortController()
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setResults(data))
.catch((err) => {
if (err.name !== 'AbortError') {
console.error('Search failed:', err)
}
})
// cleanup: 다음 query 변경 시 이전 요청 취소
return () => controller.abort()
}, [query])
return (
{results.map((r) => (
))}
)
}
// 대안: React Query (TanStack Query) 사용 — 자동으로 처리됨
function SearchResults() {
const [query, setQuery] = useState('')
const { data: results = [] } = useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) => fetch(`/api/search?q=${query}`, { signal }).then((r) => r.json()),
enabled: !!query,
})
return (
{results.map((r: any) => (
))}
)
}
**재발방지 체크리스트**:
- [ ] 모든 `useEffect` 내 `fetch`에 `AbortController` 적용
- [ ] `useEffect` cleanup 함수에서 `abort()` 호출 확인
- [ ] TanStack Query 또는 SWR 같은 데이터 패칭 라이브러리 도입 검토
- [ ] 검색/자동완성에 debounce(300ms) 적용
- [ ] E2E 테스트에서 빠른 연속 입력 시나리오 포함
- [ ] 네트워크 쓰로틀링(Slow 3G) 상태에서 수동 테스트
증상별 빠른 진단 가이드
아래 표는 증상을 기준으로 어떤 사례를 먼저 의심해야 하는지 빠르게 찾을 수 있게 정리한 것이다.
| 증상 | 의심 사례 | 첫 번째 확인 명령 |
| --------------------------- | --------------------------------------------- | ------------------------------------- |
| 전체 API 응답 동시 지연 | 사례 1 (event loop blocking) | `py-spy top --pid <PID>` |
| 단일 API만 느림 + 쿼리 폭증 | 사례 2, 7 (N+1 query) | `DEBUG` SQL 로그 활성화 |
| 화면 깜빡임/레이아웃 깨짐 | 사례 3 (hydration mismatch) | Chrome Console 에러 확인 |
| 메모리 단조 증가 → OOM | 사례 4 (memory leak), 사례 5 (goroutine leak) | Heap Snapshot / `pprof goroutine` |
| 간헐적 데이터 정합성 깨짐 | 사례 2-B (transaction boundary) | `transaction.atomic()` 범위 확인 |
| 커넥션 타임아웃 대량 발생 | 사례 6 (connection pool 고갈) | `hikaricp.connections.pending` 메트릭 |
| 검색 결과가 뒤죽박죽 | 사례 8 (race condition) | Network 탭에서 응답 순서 확인 |
| 에러 로그 없이 요청 hang | 사례 4-B (async error swallowing) | `unhandledRejection` 핸들러 추가 |
| `save()` 후 데이터 안 보임 | 사례 7-B (flush timing) | `saveAndFlush()` 변경 후 재시도 |
| goroutine 수 무한 증가 | 사례 5 (goroutine leak) | `go tool pprof .../goroutine` |
통합 재발방지 체크리스트
모든 사례를 관통하는 공통 점검 항목이다. 주기적인 리뷰 또는 새 프로젝트 셋업 시 활용한다.
코드 레벨
- [ ] async 함수 안에서 동기 블로킹 호출 금지 (Python asyncio, Node.js)
- [ ] 모든 비동기 호출에 에러 핸들링 존재 (`try/catch`, `.catch()`)
- [ ] ORM 사용 시 N+1 쿼리 방지 패턴 적용 (`select_related`, `JOIN FETCH`, `@EntityGraph`)
- [ ] 트랜잭션 경계 명시 — 2개 이상 엔티티 변경 시 반드시 atomic/transactional
- [ ] goroutine/스레드 생성 시 context/timeout 전달
- [ ] 프론트엔드 fetch에 AbortController 적용
- [ ] 싱글턴 캐시에 크기 제한 및 TTL 설정
인프라/설정 레벨
- [ ] 커넥션 풀 크기와 스레드 풀 크기 비율 검증
- [ ] 프로파일링 엔드포인트(`/debug/pprof`, `/actuator`) 접근 제어 설정
- [ ] 메모리/CPU/goroutine 수/커넥션 수 기반 알림 설정
- [ ] 부하 테스트를 CI/CD 파이프라인에 포함
모니터링 레벨
- [ ] APM 도구(Datadog, New Relic, Grafana Tempo) 연동
- [ ] 쿼리 수/응답시간 분포(p50/p95/p99) 대시보드 구축
- [ ] 에러율/OOM Kill/재시작 횟수 알림 설정
- [ ] 주기적 프로파일링 결과 비교(배포 전후)
테스트 레벨
- [ ] 단위 테스트에서 goroutine leak 검사 (`goleak`)
- [ ] 통합 테스트에서 쿼리 수 상한 assertion
- [ ] E2E 테스트에서 race condition 시나리오 (빠른 연속 입력, 네트워크 쓰로틀링)
- [ ] ESLint `no-floating-promises`, Python `PYTHONASYNCIODEBUG` 등 정적 분석 활성화
마무리
언어×프레임워크 조합별 장애는 **한쪽만 알아서는 진단이 어렵다**. Python을 잘 알아도 FastAPI의 async 실행 모델을 모르면 event loop blocking을 놓치고, Spring Boot를 잘 알아도 Hibernate의 flush 전략을 모르면 데이터 정합성 문제를 파악할 수 없다.
핵심 원칙은 세 가지다:
1. **재현 먼저** — 추측 디버깅은 시간 낭비다. 위 사례들의 재현 코드를 참고해서 로컬에서 증상을 100% 재현한 뒤 원인을 파악한다.
2. **프로파일링 데이터로 판단** — `py-spy`, `pprof`, `JFR`, `clinic.js`, Chrome DevTools 등 각 조합에 맞는 도구를 꺼내서 숫자로 확인한다.
3. **재발방지를 자동화** — 체크리스트를 CI에 녹이고, 모니터링 알림을 설정하고, 린트 규칙을 강제한다. 사람의 주의력에 의존하는 방어는 반드시 뚫린다.
위 8가지 사례와 체크리스트를 팀 위키에 공유하고, 온콜 시 첫 번째로 참조하는 문서로 활용하기를 권한다.
> 각 언어·프레임워크·IDE의 기초 디버깅 방법은 시리즈 앞선 글을 참고한다.
> 로컬에서 재현이 안 되는 문제는 [원격 디버깅 실전 가이드](/blog/devops/2026-03-07-devops-remote-debugging-guide-ssh-tunnel-vscode-intellij-pycharm)를 참고한다.
현재 단락 (1/690)
언어별, 프레임워크별 디버깅 가이드는 많다. 하지만 현업에서 터지는 장애는 **언어 런타임의 특성**과 **프레임워크의 실행 모델**이 겹치는 지점에서 발생한다.