@Async, @Transactional 동시 사용의 위험성 및 해결 과정

gminnimk·2025년 9월 11일
0

문제 해결

목록 보기
11/18

1. 문제 상황

개인 프로젝트 진행 과정 중 단축 url 클릭 API에서

  • 원본 URL 반환 (즉시 처리)
  • 클릭 로그 및 통계 저장 (비동기 처리)

을 동시에 수행하는 로직에서 @Async와 @Transactional을 같은 메서드에 적용한 것을 발견하였습니다.

@Async
@Transactional
public void logClickAndupdateDailyStats(UrlMapping urlMapping, String userAgent, String ipAddress) {
    // 클릭 로그 저장
    clickLogRepository.save(new ClickLog(urlMapping, userAgent, ipAddress));
    
    // 일별 통계 업데이트
    updateDailyStats(urlMapping);
}


2. @Async 와 @Transactional 동시 사용 위험성

Spring에서의 @Transactional, @Async 같은 기능은 모두 Proxy 기반으로 동작을 합니다.

즉, 원본 객체를 직접 호출하지 않고, Proxy 객체가 호출을 가로채서 부가기능을 수행합니다.

  • @Transactional -> 메서드 실행 전후로 트랜잭션 시작/커밋/롤백 처리

  • @Async -> 호출을 별도 Thread Pool에 위임

문제는 이 두 Proxy가 동시에 붙을 경우 실행 순서가 중요하다는 점입니다.


동작 흐름 (잠못된 경우)

@Async
@Transactional
public void logClickAndUpdateStats(...) {
    // DB 저장 로직
}

프록시 체인 순서는 보통:

  1. 트랜잭션 프록시 -> 메인 쓰레드에서 트랜잭션 시작
  2. 비동기 프록시 -> 별도 쓰레드로 작업 위임
  3. 메인 쓰레드에서는 이미 메서드 호출이 끝났다고 판단 -> 트랜잭션 즉시 커밋/종료
  4. 별도 쓰레드에서는 이미 트랜잭션이 닫혀 있어서 DB 작업이 트랜잭션 없이 실행됨

결국, transaction의 보호를 전혀 받지 못하는 위험한 상태가 발생합니다.

  • @Async + @Transactional을 같은 메서드에 붙이면, 메인 스레드에서 트랜잭션이 열리고 바로 닫히면서 별도 스레드에서는 트랜잭션 컨텍스트가 없는 상태로 DB 작업을 시도하게 됩니다.
    • 이 경우 JPA/Hibernate가 “이미 종료된 트랜잭션” 때문에 예외를 던질 수 있고, 예외를 잡지 않으면 저장이 실패할 수 있습니다.
    • 또는 트랜잭션이 전혀 적용되지 않은 “autocommit 모드”로 동작해서 의도치 않게 저장은 되지만 원자성/일관성 보장이 안 되는 상황이 발생할 수도 있습니다.

다이어그램 비교

동시 사용

[메인 스레드] 
   └── Transactional Proxy: 트랜잭션 시작
          └── Async Proxy: 별도 스레드 위임
                 └── [별도 스레드] DB 작업 (트랜잭션 없음 ❌)
   └── 메인 스레드 트랜잭션 커밋

책임 분리

[메인 스레드] 원본 URL 반환
   └── Async Proxy: 별도 스레드 위임
          └── [별도 스레드]
                 └── Transactional Proxy: 트랜잭션 시작
                        └── DB 작업 (트랜잭션 보호 ✅)



3. 해결 방안

이러한 위험성을 제거하기 위해 @Async 와 @Transactional 책임을 분리하였습니다.

https://github.com/UrlOps/url_backend/commit/a4d58b3ee22d184de4d139dd6ee1906945791ef8

• ClickLogService → 비동기 실행만 담당 (@Async)
• ClickLogWriter → DB 트랜잭션만 담당 (@Transactional)


기존 로직

  • ClickLogService
package be.url_backend.feature.log;

import be.url_backend.feature.log.repository.ClickLogRepository;
import be.url_backend.feature.stats.DailyStats;
import be.url_backend.feature.url.UrlMapping;
import be.url_backend.feature.stats.repository.DailyStatsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
@Service
@RequiredArgsConstructor
public class ClickLogService {

    private final ClickLogRepository clickLogRepository;
    private final DailyStatsRepository dailyStatsRepository;

    @Async
    @Transactional
    public void logClickAndupdateDailyStats(UrlMapping urlMapping, String userAgent, String ipAddress) {
        ClickLog clickLog = new ClickLog(urlMapping, userAgent, ipAddress);
        clickLogRepository.save(clickLog);

        updateDailyStats(urlMapping);
    }

    private void updateDailyStats(UrlMapping urlMapping) {
        LocalDate today = LocalDate.now();
        DailyStats dailyStats = dailyStatsRepository.findByUrlMappingAndDate(urlMapping, today)
                .orElse(new DailyStats(urlMapping, today));

        dailyStats.incrementClickCount();
        dailyStatsRepository.save(dailyStats);
    }
} 



개선 이후

  • ClickLogService (비동기 실행만 담당)
package be.url_backend.feature.log;

import be.url_backend.feature.url.UrlMapping;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ClickLogService {

    private final ClickLogWriter clickLogWriter;

    @Async
    public void logClickAndupdateDailyStats(UrlMapping urlMapping, String userAgent, String ipAddress) {
        clickLogWriter.saveLogAndStats(urlMapping, userAgent, ipAddress);
    }
} 

  • ClickLogWriter (DB 트랜잭션 관리만 담당)
package be.url_backend.feature.log;

import be.url_backend.feature.log.repository.ClickLogRepository;
import be.url_backend.feature.stats.DailyStats;
import be.url_backend.feature.stats.repository.DailyStatsRepository;
import be.url_backend.feature.url.UrlMapping;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

@Component
@RequiredArgsConstructor
public class ClickLogWriter {

    private final ClickLogRepository clickLogRepository;
    private final DailyStatsRepository dailyStatsRepository;

    @Transactional
    public void saveLogAndStats(UrlMapping urlMapping, String userAgent, String ipAddress) {
        ClickLog clickLog = new ClickLog(urlMapping, userAgent, ipAddress);
        clickLogRepository.save(clickLog);

        updateDailyStats(urlMapping);
    }

    private void updateDailyStats(UrlMapping urlMapping) {
        LocalDate today = LocalDate.now();
        DailyStats dailyStats = dailyStatsRepository.findByUrlMappingAndDate(urlMapping, today)
                .orElse(new DailyStats(urlMapping, today));

        dailyStats.incrementClickCount();
        dailyStatsRepository.save(dailyStats);
    }
} 


4. 핵심 정리

  1. @Async와 @Transactional은 모두 Proxy 기반 AOP로 동작한다.
  2. 같은 메서드에 적용 시 -> 트랜잭션 프록시가 먼저 실행되어 메인 쓰레드에 묶인다.
  3. 이후 별도 스레드에서 DB 작업은 트랜잭션 보호를 받지 못하는 상태가 된다.
  4. 해결책은 책임 분리:
    • @Async는 비동기 실행만
    • @Transactional은 별도 서비스/컴포넌트에서 DB 트랜잭션만

5. 배운 점

  • Spring 프록시 기반 AOP의 동작 순서와 셀프 호출 문제에 대해 깊게 이해할 수 있었습니다.
  • 트랜잭션은 쓰레드 단위로 관리된다는 사실을 다시 한번 확인할 수 있었습니다.
  • 단순히 어노테이션을 붙이는 것만으로는 안전하지 않고, 책임을 명확히 분리하는 설계가 중요하다는 것을 느낄 수 있었습니다.

0개의 댓글