1. 문제
1) 증상
운영 중 특정 도메인의 API만 응답이 없어지는 문제가 발생했습니다.
타임아웃이 발생했고, 컨테이너를 재시작하면 문제가 해결됐지만 불규칙한 주기로 문제가 재발했습니다.

자세한 문제상황은 다음과 같습니다.
- DB 정상, CPU/메모리도 정상
- Slow query log에도 안잡힘
- 개발서버에서도 간헐적으로 동일 증상 발생
운영 상태에서 발생했기 때문에 원인 파악보다 빠른 복구가 우선이었고, 재시작 후 어떤 상황에서 재발하는지 판단이 어려워 에러 확인이 더욱 어려웠습니다.
2) 원인 후보
가장 먼저 DB 커넥션 풀 고갈을 의심했습니다.
풀 고갈이라면 모든 API가 동시에 막혀야하는데 특정 도메인 API만 문제가 생겼습니다.
DB를 직접 확인해도 문제가 없었고, 살아있는 트랜잭션도 없었습니다.
두번째로는 무거운 쿼리가 문제인가 의심했습니다.
하지만 해당 테이블은 고작 936행이었습니다. 풀 스캔을 해도 100ms도 안걸릴 양이라 쿼리 성능 문제는 아니라고 판단했습니다.
Slow query log에도 잡히지 않았습니다.
사용자가 없는 개발 서버에도 동일하게 재현됐기 때문에, 트래픽 문제또한 아니었습니다.
2. 문제 확인
결론적으로 문제는 Q클래스 생성 시 순환참조로 인한 데드락이었습니다.
문제 원인을 확정하게 된 과정을 나열하겠습니다.
1) 스레드 덤프(Thread Dump)
단서를 찾기 위해 스레드 덤프를 찍어봤습니다.
스레드 덤프는 JVM이 그 순간 가지고 있는 모든 스레드의 상태와 호출 스택을 통째로 찍어낸 스냅샷입니다.
문제가 발생한 도메인은 exeprt_inquiry와 expert_commission으로 expert라는 키워드로 검색해보았습니다.
# Java PID 확인 (도커 컨테이너 안에서 보통 1)
docker exec {컨테이너명} ps -ef | grep java
# 스레드 덤프 생성
docker exec {컨테이너명} jstack 1 > thread-dump.txt
# 키워드로 확인
docker exec {컨테이너명} jstack 1 | grep -B 2 -A 30 "expert\|Expert"


이 스냅샷에서 Class initialization monitor라는 중요한 단서를 얻을 수 있었습니다.
2) <clinit>
원인을 이해하려면 두가지 개념을 알아야합니다.
<clinit>은 클래스 이니셜라이저로 Java에서 클래스가 처음 사용될 때 JVM이 딱 한번 실행하는 정적 초기화 메서드입니다.
클래스의 static 필드 초기화 코드와 static {} 블록을 모두 모아 JVM이 자동으로 생성합니다.
자바 코드 실행 시 JVM은 Loading(.class파일 메모리로) -> Linking(검증, 준비, 링크) -> Initialization 의 과정을 거치는데 <clinit>은 Initialization에서 실행됩니다.
JVM은 <clinit> 실행 중에 해당 클래스의 모니터 락(monitor lock)을 점유하는데 이를 Class Initialization Monitor 라고 합니다.
<clinit>은 반드시 단 한번만 실행되어야 하니까, 두 스레드가 동시에 같은 클래스를 처음 사용하면 JVM이 자동으로 락을 걸어서 한 스레드만 초기화하고 다른 스레드는 대기시킵니다.
위의 로그를 보면 QExpertInquiry와 QExpertCommission이 서로가 초기화되기를 기다리는 교착상태(DeadLock) 상태인것을 확인할 수 있습니다.
3) QueryDSL APT
QueryDSL APT는 컴파일 시점에 @Entity가 붙은 클래스를 분석해 Q클래스를 자동으로 만들어줍니다.
이때 엔티티간 연관관계가 있으면 Q클래스도 상대 Q클래스를 static 필드로 참조하는 구조가 됩니다.
엔티티 코드를 확인해보니 ExpertInquiry와 ExpertCommission사이에 양방향 @OneToOne이 걸려있었습니다.
@Entity
public class ExpertInquiry extends BaseTimeEntity {
// ...
@Schema(description = "전문가 수임제안")
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "expert_commission_id")
private ExpertCommission expertCommission;
}
@Entity
public class ExpertCommission extends BaseTimeEntity {
// ...
@Schema(description = "전문가 문의")
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "expert_inquiry_id")
private ExpertInquiry expertInquiry;
}
이 양방향 참조가 APT를 통해 Q클래스의 순환 static 참조로 이루어졌고 데드락이 발생한 것입니다.
즉, 다음과 같은 상태입니다.
Thread A
- QExpertInquiry.<clinit> 시작
- QExpertInquiry init monitor 점유
- <init> 안에서 new QExpertCommission(…) 시도
- QExpertCommission init monitor 대기 ← Thread B가 점유
Thread B
- QExpertCommission.<clinit> 시작
- <init> 안에서 new QExpertInquiry(…) 시도
- QExpertInquiry init monitor 대기 ← Thread A가 점유
3. 문제 해결
이와같은 순환 참조로 인한 데드락은 세가지 방법으로 해결할 수 있습니다.
1) 양방향 연관관계 한쪽 끊기
가장 근본적인 해결책입니다. 양방향이 정말 필요한지 검토하고 한쪽에서만 참조하도록 변경합니다.
순환 참조 자체가 없어지기 때문에 Q클래스도 상대를 static 필드로 참조하는 구조가 되지 않습니다.
단, 이미 양방향 참조로 짜여진 비즈니스 로직이 있다면 수정범위가 커질 수 있습니다.
2) 앱 시작 시 단일 스레드에서 순서대로 초기화
@Component
public class QClassPreloader {
@PostConstruct
public void preload() {
// 단일 스레드에서 순서대로 강제 초기화 → 데드락 불가능
Class.forName(QExpertInquiry.class.getName(), true,
QExpertInquiry.class.getClassLoader());
Class.forName(QExpertCommission.class.getName(), true,
QExpertCommission.class.getClassLoader());
}
}
// 혹은
@Component
public class QClassPreloader {
@PostConstruct
public void preload() {
// 정적 필드 한번 참조하면 clinit 트리거됨
Object a = QExpertInquiry.expertInquiry;
Object b = QExpertCommission.expertCommission;
}
}
애플리케이션 부팅 시 메인 스레드에서 두 클래스를 순서대로 초기화하는 방법입니다.
한번만 초기화되면 되기 때문에 실제 요청을 처리할때는 문제가 발생하지 않습니다.
기존 구조를 건드리지 않아도 된다는 장점이 있지만, 나중에 비슷한 양방향 관계가 생기면 계속 Preloader에 추가해주어야 합니다.
3) static 필드 대신 직접 인스턴스 생성
// 기존
QExpertInquiry inquiry = QExpertInquiry.expertInquiry;
QExpertCommission commission = QExpertCommission.expertCommission;
// 변경
QExpertInquiry inquiry = new QExpertInquiry("inquiry");
QExpertCommission commission = new QExpertCommission("commission");
직접 인스턴스를 만들면 static 필드 접근 자체가 없으므로 <clinit>을 경유하지 않습니다. 수정 범위도 Repository로 한정되어 있어 비교적 간단합니다.
단, QueryDSL에서 관행적으로 static필드를 쓰는 방식을 벗어나는것이기 때문에 다른 팀원들과 컨벤션을 맞춰놓는것이 중요할것으로 보입니다.
진짜 앓던이가 빠진 기분입니다.
이거 찾으려고 개고생했던거 생각하면 눈물이 앞을 가릴 정도입니다.
그래도 문제를 찾고 공부했던 CS 개념들을 통해 이해하는 과정이 참 즐거웠습니다.
마지막으로 한 가지 짚고 넘어가자면, 이 문제는 QueryDSL이나 JPA만의 문제가 아닙니다.
순환 static 참조 구조가 만들어지는 상황이라면 어디서든 동일하게 발생할 수 있습니다.
