본문 바로가기
💻 개발로그 (Tech Log)/Spring & Java & 웹개발 실무노트

데드락(Deadlock)이란? 실무에서 겪은 디버깅 사례와 ShedLock 적용기

by 찡로그 2025. 8. 31.
반응형

데드락이란?

데드락(Deadlock)은 두 개 이상의 프로세스나 스레드가 서로의 자원을 기다리며 무한정 대기하는 상황을 말한다. 마치 좁은 다리에서 두 차가 서로 마주보고 서있어 어느 쪽도 움직일 수 없는 상황과 비슷하다.

간단한 데드락 예시

일상생활로 비유하면 이런 상황이다:

철수: "영희야, 네 펜 좀 빌려줘. 내 지우개 줄게."
영희: "철수야, 네 지우개 좀 빌려줘. 내 펜 줄게."

결과: 둘 다 상대방이 먼저 주기를 기다리며 영원히 대기...

프로그래밍에서의 데드락 예시

// 스레드 A
synchronized(lock1) {
    Thread.sleep(100);
    synchronized(lock2) {  // lock2를 기다림
        // 작업 수행
    }
}

// 스레드 B (동시 실행)
synchronized(lock2) {
    Thread.sleep(100);
    synchronized(lock1) {  // lock1을 기다림
        // 작업 수행
    }
}

결과: 스레드 A는 lock2를 기다리고, 스레드 B는 lock1을 기다리며 서로 영원히 대기하게 된다.

데드락 발생 조건 (Coffman's Conditions)

데드락이 발생하려면 다음 4가지 조건이 모두 만족되어야 한다:

  1. Mutual Exclusion (상호 배제): 한 번에 하나의 프로세스만 자원을 사용할 수 있다
    • 예: 데이터베이스 행에 대한 배타적 락, 파일 쓰기 권한
  2. Hold and Wait (점유와 대기): 프로세스가 자원을 점유한 상태로 다른 자원을 기다린다
    • 예: A 테이블 락을 가진 채로 B 테이블 락을 기다림
  3. No Preemption (비선점): 다른 프로세스가 사용 중인 자원을 강제로 빼앗을 수 없다
    • 예: 트랜잭션이 진행 중일 때 락을 강제로 해제할 수 없음
  4. Circular Wait (순환 대기): 프로세스들이 원형으로 서로의 자원을 기다린다
    • 예: A→B→A 또는 A→B→C→A 형태의 대기 관계

이 4가지 조건 중 하나라도 깨뜨리면 데드락을 예방할 수 있다!

실무에서 겪은 데드락 사례

사례 1: Database Transaction Deadlock

마이크로서비스 환경에서 주문 처리 시스템을 개발하던 중, 다음과 같은 데드락이 발생했다:

// Transaction A
@Transactional
public void updateInventory(Long productId, int quantity) {
    // 1. product 테이블 락 획득
    productRepository.findByIdForUpdate(productId);
    
    // 2. inventory 테이블 락 시도
    inventoryRepository.updateQuantity(productId, quantity);
}

// Transaction B (동시 실행)
@Transactional
public void updateProductPrice(Long productId, BigDecimal price) {
    // 1. inventory 테이블 락 획득
    inventoryRepository.findByProductIdForUpdate(productId);
    
    // 2. product 테이블 락 시도
    productRepository.updatePrice(productId, price);
}

문제점: 두 트랜잭션이 서로 다른 순서로 테이블에 락을 걸어 데드락이 발생했다.

해결책: 항상 동일한 순서로 락을 획득하도록 수정했다.

@Transactional
public void updateInventoryAndProduct(Long productId, int quantity, BigDecimal price) {
    // 항상 product 테이블 -> inventory 테이블 순서로 락 획득
    productRepository.findByIdForUpdate(productId);
    inventoryRepository.findByProductIdForUpdate(productId);
    
    // 실제 업데이트 수행
    if (quantity != 0) {
        inventoryRepository.updateQuantity(productId, quantity);
    }
    if (price != null) {
        productRepository.updatePrice(productId, price);
    }
}

사례 2: 분산환경에서의 스케줄러 중복 실행 문제

우리 서비스가 성장하면서 서버를 2대로 확장했는데, 예상치 못한 문제가 발생했다.

기존에 단일 서버에서 잘 동작하던 스케줄 작업들이 두 서버에서 동시에 실행되기 시작한 것이다!

// 문제가 있던 기존 코드 (단일 서버에서는 문제없었음)
@Component
public class DataSyncScheduler {
    
    @Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
    public void syncCustomerData() {
        // 😱 서버 A, B에서 동시에 실행됨!
        // 결과: 외부 API 중복 호출, 데이터 중복 처리
        customerDataService.syncFromExternalApi();
    }
}

실제 발생한 문제들:

  • 외부 API 호출량이 2배로 증가 (비용 증가 💸)
  • 같은 데이터를 두 번 처리하여 중복 데이터 생성
  • 배치 작업 시간이 예측 불가능해짐
  • 외부 API Rate Limit 초과로 에러 발생

이건 엄밀히 말하면 전통적인 데드락은 아니지만, 분산환경에서의 동시성 문제로 볼 수 있다.

여러 인스턴스가 같은 자원(외부 API, 데이터베이스)에 동시 접근하면서 예상치 못한 충돌이 발생한 것이다.

ShedLock을 활용한 분산 락 해결책

"왜 ShedLock이 필요한가?"

단일 서버에서는 @Scheduled만으로도 충분했지만, **분산환경(서버 2대 이상)**에서는 다음과 같은 문제가 발생한다:

서버 A: @Scheduled 작업 실행 중...
서버 B: @Scheduled 작업 실행 중... (동시에!)
결과: 같은 작업이 2번 실행됨 😱

ShedLock은 이런 상황에서 **"분산 락"**을 제공하여 한 번에 하나의 인스턴스에서만 스케줄 작업이 실행되도록 보장한다.

ShedLock 동작 원리

1. 서버 A가 스케줄 작업 시작 시도
   → shedlock 테이블에 락 기록 삽입 (성공)
   → 작업 실행

2. 서버 B가 동일한 작업 시작 시도 (거의 동시에)
   → shedlock 테이블에 락 기록 삽입 시도 (실패 - 이미 존재)
   → 작업 실행하지 않고 대기

3. 서버 A 작업 완료 또는 시간 초과
   → 락 해제
   → 다음 스케줄 시간에 다시 경쟁

1. 의존성 추가

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.42.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.42.0</version>
</dependency>

2. ShedLock 테이블 생성

CREATE TABLE shedlock (
    name VARCHAR(64) NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    locked_by VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

3. ShedLock 설정

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime() // DB 시간을 사용하여 여러 인스턴스 간 시간 동기화
                .build()
        );
    }
}

4. @SchedulerLock 어노테이션 적용

@Component
public class DataSyncScheduler {
    
    @Scheduled(cron = "0 0 2 * * ?")
    @SchedulerLock(
        name = "syncCustomerData",
        lockAtMostFor = "30m", // 최대 30분간 락 유지
        lockAtLeastFor = "5m"   // 최소 5분간 락 유지
    )
    public void syncCustomerData() {
        log.info("고객 데이터 동기화 시작");
        customerDataService.syncFromExternalApi();
        log.info("고객 데이터 동기화 완료");
    }
    
    @Scheduled(fixedDelay = 300000) // 5분마다
    @SchedulerLock(
        name = "processOrderQueue",
        lockAtMostFor = "10m",
        lockAtLeastFor = "1m"
    )
    public void processOrderQueue() {
        orderQueueService.processNextBatch();
    }
}

ShedLock 적용 후 개선사항

1. 중복 실행 완전 방지

// Before: 서버 2대에서 각각 실행 (총 2번)
// After: 전체 클러스터에서 1번만 실행 ✅

2. 비용 절감

  • 외부 API 호출량 50% 감소
  • 데이터베이스 부하 감소
  • 서버 리소스 효율적 사용

3. 데이터 정합성 보장

더 이상 중복 데이터나 중복 처리로 인한 비즈니스 로직 오류가 발생하지 않았다.

4. 운영 모니터링 개선

ShedLock 테이블을 통해 어느 서버에서 언제 작업이 실행되었는지 명확하게 추적할 수 있게 되었다.

-- 실시간 락 상태 확인
SELECT 
    name as '작업명',
    locked_by as '실행서버',
    locked_at as '시작시간',
    lock_until as '락만료시간',
    CASE 
        WHEN lock_until > NOW() THEN '🔒 실행중'
        ELSE '완료'
    END as '상태'
FROM shedlock
ORDER BY locked_at DESC
LIMIT 10;

실제 운영 결과:

작업명              실행서버          시작시간              상태
syncCustomerData   server-01      2024-03-15 02:00:01    완료
processOrders      server-02      2024-03-15 01:55:12    완료

이제 2대의 서버가 있어도 스케줄 작업은 정확히 1번만 실행되고, 어느 서버에서 실행되었는지도 명확하게 알 수 있다!

주의사항과 베스트 프랙티스

1. lockAtMostFor 설정

@SchedulerLock(
    name = "longRunningTask",
    lockAtMostFor = "60m" // 작업이 예상보다 오래 걸릴 경우를 대비
)

lockAtMostFor는 작업이 비정상적으로 오래 걸리거나 인스턴스가 죽었을 때 락이 영원히 유지되는 것을 방지한다.

2. lockAtLeastFor 설정

@SchedulerLock(
    name = "quickTask",
    lockAtLeastFor = "30s" // 너무 빠른 재실행 방지
)

lockAtLeastFor는 작업이 빨리 끝나더라도 최소 시간 동안은 락을 유지하여 클러스터 환경에서의 동시성 문제를 방지한다.

3. 유니크한 락 이름 사용

// 좋은 예
@SchedulerLock(name = "syncCustomerData_v2")

// 나쁜 예 - 너무 일반적
@SchedulerLock(name = "sync")

데드락 디버깅 팁

1. 데이터베이스 레벨에서 확인

-- MySQL에서 데드락 정보 확인
SHOW ENGINE INNODB STATUS;

-- PostgreSQL에서 락 정보 확인
SELECT * FROM pg_locks WHERE NOT granted;

2. 애플리케이션 로그 분석

@Slf4j
@Component
public class DeadlockDetector {
    
    @EventListener
    public void handleDeadlock(DeadlockDetectedEvent event) {
        log.error("데드락 감지됨: {}", event.getDetails());
        // 알림 발송 또는 모니터링 시스템에 전송
    }
}

3. JVM 레벨에서 스레드 덤프 분석

# 스레드 덤프 생성
jstack <pid> > threaddump.txt

# 데드락 패턴 검색
grep -A 10 -B 10 "deadlock" threaddump.txt

마무리

데드락은 멀티스레드와 분산환경에서 피할 수 없는 문제 중 하나다. 특히 서버를 여러 대 운영하는 요즘 시대에는 더욱 중요한 이슈가 되었다.

전통적인 데드락 문제는 올바른 락 순서와 설계로 해결할 수 있고, 분산환경에서의 스케줄러 중복 실행 문제는 ShedLock 같은 도구로 깔끔하게 해결할 수 있다는 것을 경험했다.

핵심 포인트 정리:

  • 단일 서버: @Scheduled만으로도 충분
  • 분산환경 (2대 이상): ShedLock 필수!
  • 간단한 어노테이션 하나로 복잡한 분산 락 로직 해결
  • 운영 모니터링까지 덤으로 얻을 수 있음

다음에 또 다른 동시성 이슈를 만나면 이번 경험을 바탕으로 더 빠르게 해결할 수 있을 것 같다.

역시 삽질은 성장의 지름길이다! 

 

728x90
반응형