실무 사고 사례 — Spring·JPA·MSA에서 실제로 터지는 것
앞 편들에서 본 원리가 현장에서 어떻게 사고가 되는지를 모았다. 신기하게도 대부분 두 뿌리에서 나온다 — "프록시"(자기 호출로 우회되는 것)와 "분산"(상태가 흩어져 어긋나는 것). 원리를 알면 다시는 안 밟고, 밟더라도 빨리 찾는다. 각 사고를 무엇이 터지나 → 왜(로우레벨) → 고침 순으로 본다.
① @Transactional이 안 걸렸다 — 자기 호출
public void place(Order o) {
save(o); // this.save() — 프록시를 통과하지 않음
}
@Transactional public void save(Order o) { ... }
무엇이 터지나 — 같은 클래스 안에서 save()를 부르면 트랜잭션이 안 걸려 롤백·격리가 사라진다. 왜 — 컨테이너가 주입한 건 프록시인데, place() 안의 save(o)는 컴파일되면 this에 대한 invokevirtual, 곧 원본 객체 자신의 호출이라 프록시를 거치지 않는다(02편). AOP는 프록시를 거치는 외부 호출만 가로채므로 어드바이스가 끼어들 자리가 없다. @Cacheable·@Async도 같은 이유로 무력화된다. 고침 — 트랜잭션 메서드를 다른 빈으로 분리해 주입 호출하거나(가장 깔끔), 자기 주입, TransactionTemplate, AspectJ 위빙. (출처: Spring — Understanding AOP Proxies.)
② 목록 화면이 갑자기 느려졌다 — N+1
무엇이 터지나 — 목록을 한 번 조회한 뒤 각 항목의 LAZY 연관을 루프에서 건드려 항목 수만큼(N) 쿼리가 추가로 나간다 → 100건이면 101번 DB 왕복. 데이터가 늘수록 조용히 느려진다. 왜 — LAZY 프록시는 접근하는 순간 DB를 치는데, 루프에서 하나씩 접근하니 하나씩 쿼리가 나가는 것이다(05편). 고침 — join fetch·@EntityGraph·배치 페치(in (?, ?, …))로 한 번에 로드. 연관은 전부 LAZY로 두고 필요할 때만 함께 가져온다.
③ 검사 예외를 던졌는데 롤백이 안 됐다
@Transactional
public void save() throws IOException {
repo.insert(...);
throw new IOException(); // 커밋된다!
}
무엇이 터지나 — Spring 기본 롤백 규칙은 RuntimeException·Error만 롤백, 검사 예외는 커밋이다. IOException을 던져도 데이터가 그대로 저장돼 정합이 깨진다. 왜 — EJB 시절의 관례(검사 예외 = 처리 가능한 비즈니스 상황)를 따른 기본값이다. 고침 — @Transactional(rollbackFor = Exception.class). 알고 써야 하는 기본값이다(06편).
④ 자정에 시스템이 얼어붙었다 — 커넥션 풀 고갈
무엇이 터지나 — HikariPool-1 - Connection is not available, request timed out after 30000ms가 뜨더니, 00:13 첫 타임아웃 → 00:15 요청 절반 실패 → 00:18 완전 정지. 한 사례는 DB에 슬롯이 100개나 남았는데도 풀의 10개 커넥션이 다 쓰이고 847개 요청이 대기하고 있었다(피해액 $47K). 왜 — 풀이 작아서가 아니라(HikariCP 기본 풀이 10인데 설정을 안 했다), 더 흔하게는 커넥션이 제때 반납되지 않아서다 — 긴 트랜잭션, 트랜잭션 안의 느린 외부 호출, OSIV가 커넥션을 오래 붙잡는다. 그리고 풀이 마르면 재시도 폭풍이 원래 문제를 몇 배로 증폭한다(LinkedIn 4시간 장애·Stripe 결제 실패가 같은 패턴). 고침 — 무작정 풀을 키우지 말고(05편: 코어×2+스핀들) 커넥션을 빨리 돌려준다 — OSIV off·트랜잭션 단축·외부 호출 분리·누수 탐지(leak-detection-threshold). (출처: Connection Pool Exhaustion Crashed Us at Midnight.)
⑤ 트래픽이 늘자 메모리가 터졌다 — @Async 기본 설정
@Async // 커스텀 Executor 없이
public void sendMail(...) { ... }
무엇이 터지나 — 로컬·QA에서는 멀쩡하던 서비스가, 운영 트래픽이 몰리자 메모리가 치솟고 응답이 느려지다 OutOfMemoryError: Java heap space로 죽는다. 왜 — @Async에 커스텀 Executor를 등록하지 않으면 Spring이 쓰는 기본 동작이 위험하다. 기본 ThreadPoolTaskExecutor의 *큐 용량이 Integer.MAX_VALUE(무한)*라, 코어 스레드(기본 8개)가 다 바쁘면 작업이 큐에 무한히 쌓이다 힙을 채워 GC 폭주 끝에 OOM이 난다(혹은 SimpleAsyncTaskExecutor로 떨어지면 호출마다 새 OS 스레드를 만들어 스레드가 폭증한다). 고침 — 명시적 ThreadPoolTaskExecutor(예: core 32·max 64·큐 상한 200·이름 접두사·keep-alive)를 등록하고, 큐가 차면 거부 정책으로 빠르게 실패시킨다(15편). (출처: Prevent OutOfMemoryError with @Async.)
⑥ 결제는 됐는데 알림이 안 갔다 — 이중 쓰기
무엇이 터지나 — "DB 갱신 + 이벤트 발행"을 따로 하면, DB는 커밋됐는데 발행이 실패(또는 반대)해 상태와 이벤트가 어긋난다. 분산에서 가장 흔한 정합 붕괴다. 왜 — DB 트랜잭션과 메시지 브로커는 서로 다른 시스템이라 하나의 원자적 단위로 묶이지 않는다(그렇다고 2PC를 쓰면 블로킹·단일 장애점, 09편). 고침 — Outbox 패턴: 이벤트를 비즈니스 데이터와 같은 트랜잭션으로 Outbox 테이블에 기록 → 별도 릴레이(CDC)가 발행. 둘이 함께 커밋/롤백된다(07·09편).
⑦ 같은 주문이 두 번 처리됐다 — 중복 소비
무엇이 터지나 — Kafka·메시지 기본은 at-least-once라, 컨슈머가 처리 후 오프셋 커밋 전에 죽으면 재시작 시 같은 메시지를 다시 처리한다 → 중복 결제·중복 적립. 왜 — "처리 완료"와 "오프셋 커밋"이 원자적이지 않은 틈에 장애가 끼면, 브로커는 그 메시지를 아직 안 읽은 것으로 알고 다시 준다(07편). 고침 — 컨슈머를 멱등하게 — 메시지 고유 id로 중복 검사·UPSERT·조건부 갱신. 정확히 한 번보다 멱등 + at-least-once가 단순·견고하다.
⑧ 싱글톤 빈에 상태를 뒀다 — 동시성 데이터 섞임
@Service
class ReportService {
private List<Row> buffer = new ArrayList<>(); // 싱글톤 빈의 가변 필드
void add(Row r) { buffer.add(r); } // 여러 요청이 공유 → 섞임
}
무엇이 터지나 — 빈에 요청별 가변 상태를 뒀더니 데이터가 섞이고 ArrayList 동시 수정으로 깨진다. 왜 — Spring 빈은 기본 싱글톤이라 모든 요청·스레드가 단 하나의 인스턴스를 공유한다(02편). 공유 가변 상태는 곧 동시성 버그다(jcode 동시성의 그 문제). 고침 — 빈은 *무상태(stateless)*로. 요청 상태는 메서드 지역 변수·파라미터로 들고 다닌다(꼭 필요하면 request 스코프).
⑨ DB를 바꿨더니 멈췄다 — 격리수준·데드락
무엇이 터지나 — MySQL에서 잘 돌던 배치가 PostgreSQL로 옮기니 데드락과 직렬화 실패가 잦아진다. 왜 — 둘은 기본 격리수준이 다르고(MySQL=REPEATABLE READ, PostgreSQL=READ COMMITTED) 동시성 구현도 다르다(InnoDB의 next-key 락 vs PG의 MVCC). 게다가 여러 트랜잭션이 자원을 제각각 다른 순서로 잠그면 순환 대기로 데드락이 난다(06편). 고침 — 락 획득 순서를 일관되게 하고, 트랜잭션을 짧게, 필요한 경우 재시도(낙관적 락 충돌·직렬화 실패는 재시도가 정석).
한 줄 교훈
| 사고 | 한 줄 |
|---|---|
| ① @Transactional 자기 호출 | 내부 this.method()는 프록시 우회 → 별도 빈/자기주입 |
| ② N+1 | LAZY 루프 접근 = 1+N 쿼리 → fetch join/EntityGraph |
| ③ 검사 예외 롤백 안 됨 | 기본은 런타임만 롤백 → rollbackFor |
| ④ 커넥션 풀 고갈 | 풀 키우기 ✗ → 빨리 반납(OSIV off·짧은 트랜잭션·누수 탐지) |
| ⑤ @Async OOM | 기본 큐 무한 → 명시 Executor + 큐 상한 + 거부 정책 |
| ⑥ 이중 쓰기 | 상태·이벤트 불일치 → Outbox |
| ⑦ 메시지 중복 | at-least-once → 멱등 컨슈머 |
| ⑧ 싱글톤 빈 상태 | 공유 가변 = 동시성 버그 → 무상태 빈 |
| ⑨ 격리·데드락 | DB마다 기본 격리·구현 다름 → 락 순서 일관·짧게·재시도 |
이 아홉을 관통하는 교훈은 둘이다 — 프록시 기반 기능은 자기 호출에서 새고(①⑤, 그리고 캐시), 분산·동시성에서는 상태가 어긋난다(④⑥⑦⑧⑨). 원리를 알면 증상만 보고도 원인을 짚는다.