[Spring] QueryDSL 특정 API 타임아웃 - JPA 양방향 @OneToOne으로 인한 클래스 초기화 데드락, Class Initialization Monitor

2026. 5. 11. 21:06·TroubleShooting

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 참조 구조가 만들어지는 상황이라면 어디서든 동일하게 발생할 수 있습니다.

 

'TroubleShooting' 카테고리의 다른 글

[Infra] AWS EC2 scheduled reboot, docker container 갑자기 모두 내려갔을때  (0) 2026.04.07
[FCM] FCM 백그라운드 알림 에러, 안드로이드에서 백그라운드 알림이 가지 않을때  (5) 2025.07.10
[Jenkins] Built-In Node 오프라인 에러, Disk space is below threshold of 1.00GiB  (0) 2025.04.02
[Infra] Docker Certbot 인증서 발급 에러, Connection refused status: 400 에러, docker compose certbot 에러  (2) 2025.01.17
[Spring] Failed to load driver class org.mariadb.jdbc.Driver 에러  (0) 2025.01.13
'TroubleShooting' 카테고리의 다른 글
  • [Infra] AWS EC2 scheduled reboot, docker container 갑자기 모두 내려갔을때
  • [FCM] FCM 백그라운드 알림 에러, 안드로이드에서 백그라운드 알림이 가지 않을때
  • [Jenkins] Built-In Node 오프라인 에러, Disk space is below threshold of 1.00GiB
  • [Infra] Docker Certbot 인증서 발급 에러, Connection refused status: 400 에러, docker compose certbot 에러
단군왕건영
단군왕건영
널리 세상을 이롭게 하고 싶은 개발자
  • 단군왕건영
    홍익인간 개발자
    단군왕건영
  • 전체
    오늘
    어제
    • 분류 전체보기 (90) N
      • TroubleShooting (16)
      • Backend (13)
        • Java (2)
        • Spring (9)
        • JPA (2)
      • DB (1)
      • Algorithm (7)
        • 백준 (4)
      • Infra (3)
      • CS (40)
        • 컴퓨터구조 (25)
        • 네트워크 (12)
        • 운영체제 (3)
      • Git (3)
      • Mac (2)
      • 회고 (3) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    백준
    컴퓨터구조
    Jenkins
    spring
    MariaDB
    컴퓨터 구조
    네트워크
    docker
    java
    springboot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
단군왕건영
[Spring] QueryDSL 특정 API 타임아웃 - JPA 양방향 @OneToOne으로 인한 클래스 초기화 데드락, Class Initialization Monitor
상단으로

티스토리툴바