개인 프로젝트 진행 과정 중 단축 url 클릭 API에서
을 동시에 수행하는 로직에서 @Async와 @Transactional을 같은 메서드에 적용한 것을 발견하였습니다.
@Async
@Transactional
public void logClickAndupdateDailyStats(UrlMapping urlMapping, String userAgent, String ipAddress) {
// 클릭 로그 저장
clickLogRepository.save(new ClickLog(urlMapping, userAgent, ipAddress));
// 일별 통계 업데이트
updateDailyStats(urlMapping);
}
Spring에서의 @Transactional, @Async 같은 기능은 모두 Proxy 기반으로 동작을 합니다.
즉, 원본 객체를 직접 호출하지 않고, Proxy 객체가 호출을 가로채서 부가기능을 수행합니다.
@Transactional -> 메서드 실행 전후로 트랜잭션 시작/커밋/롤백 처리
@Async -> 호출을 별도 Thread Pool에 위임
문제는 이 두 Proxy가 동시에 붙을 경우 실행 순서가 중요하다는 점입니다.
@Async
@Transactional
public void logClickAndUpdateStats(...) {
// DB 저장 로직
}
프록시 체인 순서는 보통:
결국, transaction의 보호를 전혀 받지 못하는 위험한 상태가 발생합니다.
[메인 스레드]
└── Transactional Proxy: 트랜잭션 시작
└── Async Proxy: 별도 스레드 위임
└── [별도 스레드] DB 작업 (트랜잭션 없음 ❌)
└── 메인 스레드 트랜잭션 커밋
[메인 스레드] 원본 URL 반환
└── Async Proxy: 별도 스레드 위임
└── [별도 스레드]
└── Transactional Proxy: 트랜잭션 시작
└── DB 작업 (트랜잭션 보호 ✅)
이러한 위험성을 제거하기 위해 @Async 와 @Transactional 책임을 분리하였습니다.
https://github.com/UrlOps/url_backend/commit/a4d58b3ee22d184de4d139dd6ee1906945791ef8
• ClickLogService → 비동기 실행만 담당 (@Async)
• ClickLogWriter → DB 트랜잭션만 담당 (@Transactional)
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);
}
}
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);
}
}
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);
}
}