Transaction
트랜젝션은 쪼개질 수 없는 업무의 최소 단위입니다. 여기서 모두 성공하던가, 아니면 완전히 없던 일로 하든가 (All or Nothing)의 규칙을 지키는 작업 단위임을 기억해야합니다. ACID 원칙에서의 Atomic도 바로 이런 규칙을 말하는데 이런 서비스 업무의 단위를 스프링 프레임워크에서는 어떤 식으로 구현돼있는지 확인해보겠습니다.
@Transactional
Spring으로 서비스 코드를 작성할 때 항상 붙이면서도 잘 알고 사용해야만 하는 이 @Transactional이 어떤 구조와 원리로 설계되어 있는지 확인해보겠습니다.
@Transactional은 Spring AOP (Aspect Oriented Programming)을 기반으로 동작합니다. 이때 동작의 흐름은 다음과 같습니다.
- 스프링 컨테이너가 로딩될 때, @Transactional이 붙은 클래스나 메서드를 발견하면 해당 Bean의 Proxy를 만듭니다.
- 외부에서 서비스 로직을 호출하면 실제 객체가 아니라 프록시 객체가 먼저 호출됩니다.
- 프록시는 TransactionManager를 통해서 DB 커넥션을 가져오고, conn.setAutoCommit(false)를 실행하여 트랜잭션을 시작합니다.
- 이 이후에 프록시가 해당하는 타겟 객체의 메서드를 호출합니다.
- 실제 메서드가 성공적으로 끝나면 commit(), 예외가 발생하면 rollback()을 실행합니다.
- 커넥션을 반납하고 트랜젝션을 종료합니다.
이런 기본적인 흐름과 함께 실제 구현돼있는 코드를 살펴보면 아래와 같습니다.
public @interface Transactional {
@AliasFor("transactionManager")
String value() default "";
@AliasFor("value")
String transactionManager() default "";
String[] label() default {};
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
String timeoutString() default "";
boolean readOnly() default false;
Class<? extends Throwable>[] rollbackFor() default {};
String[] rollbackForClassName() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
String[] noRollbackForClassName() default {};
}
스프링에서 @Transactional에 대한 코드가 위와 같이 작성되어 있습니다. 필드를 하나씩 살펴보겠습니다.
TransactionManager
JPA는 Hibernate의 구현을 따르고 있습니다. 여기서 정의한 트랜잭션 코드를 살펴보겠습니다.
doBegin
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
HibernateTransactionObject txObject = (HibernateTransactionObject) transaction;
// 이미 쓰고 있는 ConnectionHolder (DB 연결)이 있으면 예외처리
if (txObject.hasConnectionHolder() && !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
throw new IllegalTransactionStateException(
"Pre-bound JDBC Connection found! HibernateTransactionManager does not support " +
"running within DataSourceTransactionManager if told to manage the DataSource itself. " +
"It is recommended to use a single HibernateTransactionManager for all transactions " +
"on a single DataSource, no matter whether Hibernate or JDBC access.");
}
SessionImplementor session = null;
// 새로운 하이버네이트 세션 만들기 == 영속성 컨텍스트 만들기
try {
if (!txObject.hasSessionHolder() || txObject.getSessionHolder().isSynchronizedWithTransaction()) {
Interceptor entityInterceptor = getEntityInterceptor();
Session newSession = (entityInterceptor != null ?
obtainSessionFactory().withOptions().interceptor(entityInterceptor).openSession() :
obtainSessionFactory().openSession());
if (this.sessionInitializer != null) {
this.sessionInitializer.accept(newSession);
}
if (logger.isDebugEnabled()) {
logger.debug("Opened new Session [" + newSession + "] for Hibernate transaction");
}
txObject.setSession(newSession);
}
session = txObject.getSessionHolder().getSession().unwrap(SessionImplementor.class);
// 트랜잭션 격리 수준 설정
boolean isolationLevelNeeded = (definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT);
if (isolationLevelNeeded || definition.isReadOnly()) {
if (this.prepareConnection && ConnectionReleaseMode.ON_CLOSE.equals(
session.getJdbcCoordinator().getLogicalConnection().getConnectionHandlingMode().getReleaseMode())) {
// We're allowed to change the transaction settings of the JDBC Connection.
if (logger.isDebugEnabled()) {
logger.debug("Preparing JDBC Connection of Hibernate Session [" + session + "]");
}
Connection con = session.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection();
Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con, definition);
txObject.setPreviousIsolationLevel(previousIsolationLevel);
txObject.setReadOnly(definition.isReadOnly());
txObject.connectionPrepared();
}
else {
// Not allowed to change the transaction settings of the JDBC Connection.
if (isolationLevelNeeded) {
// We should set a specific isolation level but are not allowed to...
throw new InvalidIsolationLevelException(
"HibernateTransactionManager is not allowed to support custom isolation levels: " +
"make sure that its 'prepareConnection' flag is on (the default) and that the " +
"Hibernate connection release mode is set to ON_CLOSE.");
}
if (logger.isDebugEnabled()) {
logger.debug("Not preparing JDBC Connection of Hibernate Session [" + session + "]");
}
}
}
// 읽기 전용일 경우 FlushMode를 Manual로 지정 (수동 플러시)
if (definition.isReadOnly() && txObject.isNewSession()) {
// Just set to MANUAL in case of a new Session for this transaction.
session.setHibernateFlushMode(FlushMode.MANUAL);
session.setDefaultReadOnly(true);
}
// 읽기 전용 모드가 아니면 FlushMode로 지정 (자동 플러시)
if (!definition.isReadOnly() && !txObject.isNewSession()) {
FlushMode flushMode = session.getHibernateFlushMode();
if (FlushMode.MANUAL.equals(flushMode)) {
session.setHibernateFlushMode(FlushMode.AUTO);
txObject.getSessionHolder().setPreviousFlushMode(flushMode);
}
}
// 사전 세팅이 끝나고, 트랜잭션 객체 생성
Transaction hibTx;
// 트랜잭션의 타임아웃 설정
int timeout = determineTimeout(definition);
if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {
hibTx = session.getTransaction();
hibTx.setTimeout(timeout);
hibTx.begin();
}
else {
// 생성한 새로운 트랜잭션을 시작
hibTx = session.beginTransaction();
}
// 세션 홀더(영속성 컨텍스트)에 트랜잭션 설정
txObject.getSessionHolder().setTransaction(hibTx);
// DataSource와 세션을 묶음 == 스레드 로컬에 방금 만든 세션과 커넥션을 묶음.
if (getDataSource() != null) {
final SessionImplementor sessionToUse = session;
ConnectionHolder conHolder = new ConnectionHolder(
() -> sessionToUse.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection());
if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {
conHolder.setTimeoutInSeconds(timeout);
}
if (logger.isDebugEnabled()) {
logger.debug("Exposing Hibernate transaction as JDBC [" + conHolder.getConnectionHandle() + "]");
}
TransactionSynchronizationManager.bindResource(getDataSource(), conHolder);
txObject.setConnectionHolder(conHolder);
}
// 세션 팩토리와 세션을 묶음.
if (txObject.isNewSessionHolder()) {
TransactionSynchronizationManager.bindResource(obtainSessionFactory(), txObject.getSessionHolder());
}
txObject.getSessionHolder().setSynchronizedWithTransaction(true);
}
// 위에서 트랜잭션 생성 간 예외가 터지면 세션을 닫고 CannotCreateTransactionException 예외로 처리.
catch (Throwable ex) {
if (txObject.isNewSession()) {
try {
if (session != null && session.getTransaction().getStatus() == TransactionStatus.ACTIVE) {
session.getTransaction().rollback();
}
}
catch (Throwable ex2) {
logger.debug("Could not rollback Session after failed transaction begin", ex);
}
finally {
EntityManagerFactoryUtils.closeEntityManager(session);
txObject.setSessionHolder(null);
}
}
throw new CannotCreateTransactionException("Could not open Hibernate Session for transaction", ex);
}
}
doBegin -> doCommit의 흐름으로 트랜잭션이 구성됩니다. AOP로 프록시 객체의 맨 앞과 끝을 담당하게 될 것입니다. 위의 코드는 do Begin의 코드 내용인데, 메서드의 코드 흐름은
하이버네이트 세션을 열고 -> DB 연결을 준비해서 -> 트랜잭션을 시작하고 -> 스레드에 묶어둔다.
입니다. 코드를 살펴보면 readOnly가 설정돼있을 경우 스냅샷을 만들지 않고 더티 체킹을 하지 않습니다만, 이는
session.setHibernateFlushMode(FlushMode.MANUAL);
또는
FlushMode flushMode = session.getHibernateFlushMode();
if (FlushMode.MANUAL.equals(flushMode)) {
session.setHibernateFlushMode(FlushMode.AUTO);
... }
과 같이 FlushMode에 종속적인 것을 확인할 수 있습니다. 세션(영속성 컨텍스트) 자체는 생성이 됩니다.
doCommit
트랜잭션이 시작되면 끝을 맺어줘야 합니다. 끝을 맺는 방법 중에 하나인 commit에 대한 코드입니다.
@Override
protected void doCommit(DefaultTransactionStatus status) {
HibernateTransactionObject txObject = (HibernateTransactionObject) status.getTransaction();
Transaction hibTx = txObject.getSessionHolder().getTransaction();
Assert.state(hibTx != null, "No Hibernate transaction");
if (status.isDebug()) {
logger.debug("Committing Hibernate transaction on Session [" +
txObject.getSessionHolder().getSession() + "]");
}
try {
hibTx.commit();
}
catch (org.hibernate.TransactionException ex) {
DataAccessException dae = this.exceptionTranslator.translateExceptionIfPossible(ex);
if (dae != null) {
throw dae;
}
throw new TransactionSystemException("Could not commit Hibernate transaction", ex);
}
catch (RuntimeException ex) {
// Assumably failed to flush changes to database.
throw DataAccessUtils.translateIfNecessary(ex, this.exceptionTranslator);
}
}
commit 내부에서는 Flush에 대한 로직이 존재합니다. readOnly가 true이면 처리하지 않는 부분입니다. Dirty Checking을 진행하고 INSERT, UPDATE, DELETE의 SQL 생성 및 전송을 수행합니다. SQL을 다 보낸 후 실제 몰리적인 DB 커넥션에게 connection.commit() 명령을 내립니다. 이때 DB에 명령을 내리게 되고, 이는 영구 저장됩니다.
doRollback
트랜잭션을 끝을 맺는 방법 중 하나인 롤백에 대한 정의입니다.
@Override
protected void doRollback(DefaultTransactionStatus status) {
HibernateTransactionObject txObject = (HibernateTransactionObject) status.getTransaction();
Transaction hibTx = txObject.getSessionHolder().getTransaction();
Assert.state(hibTx != null, "No Hibernate transaction");
if (status.isDebug()) {
logger.debug("Rolling back Hibernate transaction on Session [" +
txObject.getSessionHolder().getSession() + "]");
}
try {
hibTx.rollback();
}
catch (org.hibernate.TransactionException ex) {
DataAccessException dae = this.exceptionTranslator.translateExceptionIfPossible(ex);
if (dae != null) {
throw dae;
}
throw new TransactionSystemException("Could not roll back Hibernate transaction", ex);
}
catch (RuntimeException ex) {
// Shouldn't really happen, as a rollback doesn't cause a flush.
throw DataAccessUtils.translateIfNecessary(ex, this.exceptionTranslator);
}
finally {
if (!txObject.isNewSession() && !this.hibernateManagedSession) {
// Clear all pending inserts/updates/deletes in the Session.
// Necessary for pre-bound Sessions, to avoid inconsistent state.
txObject.getSessionHolder().getSession().clear();
}
}
}
롤백에서는 flush()를 호출하지 않고 지금까지 수행한 내용을 수행 취소합니다. 마지막으로 영속성 컨텍스트를 초기화합니다. DB에는 적용돼있지 않은 내용이더라도 영속성 컨텍스트에는 적용되어 있을 수 있기 때문입니다.
Propagation (전파 속성)
REQUIRED
@Transactional의 기본값입니다. 부모 트랜잭션이 있으면 합류하고, 없으면 새로 만드는 전략입니다. 기존 부모 트랜잭션이 없을 경우에는 선언하는 로직이 부모 트랜잭션이 됩니다. 부모 트랜잭션이 실패하면 자식도 롤백되고, 자식이 실패하면 부모 또한 롤백됩니다.
예시 코드
@Transactional(propagation = Propagation.REQUIRED)
public void createOrder() {
orderRepository.save(order);
stockService.decreaseStock(productId);
}
OrderService가 트랜잭션을 시작합니다. StockService가 트랜잭션이고 이 안에서 선언된 상황이므로 OrderService의 트랜잭션 안에서 자식 트랜잭션으로 StockService 트랜잭션이 합류합니다. 둘 중 하나라도 에러가 나면 주문과 재고 변경이 모두 롤백됩니다.
REQUIRES_NEW
부모 트랜잭션이 있든 없는 그냥 새로운 트랜잭션을 만드는 전략입니다. 이때 기존 트랜잭션은 보류 상태가 됩니다. 보통은 부모 트랜잭션의 성공, 실패와 관련없이 반드시 성공해야하는 작업에 사용하게 됩니다.
예시 코드
@Service
@RequiredArgsConstructor
public class PaymentService {
private final LogService logService;
private final PaymentRepository paymentRepository;
@Transactional
public void processPayment(Long orderId) {
paymentRepository.withdraw(orderId);
logService.saveLog(orderId + "의 결제 시도");
throw new RuntimeException("결제 시스템 오류 발생");
}
}
@Service
@RequiredArgsConstructor
public class LogService {
private final LogRepository logRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String message) {
logRepository.save(new Log(message));
}
}
고객이 결제를 시도했을 때 성공이든 실패든 결제 시도가 있었다는 로그를 DB에 남겨야하는 상황을 생각해볼 수 있습니다.
- processPayment()가 호출되면서 Spring이 부모 트랜잭션이 시작합니다. (DB 커넥션 1개를 점유합니다.)
- withdraw()에서 잔액 차감 쿼리가 실행됩니다.
- logService.saveLog()가 호출됩니다. 이때 트랜잭션 전파 옵션을 확인합니다. REQUIRES_NEW이므로 부모 트랜잭션을 중단(Suspend)하고 새로운 DB 커넥션을 가져와서 자식 트랜잭션을 실행합니다.
- 로그 저장 쿼리가 자식 트랜잭션에서 실행됩니다.
- save의 작업이 끝나면 자식 트랜잭션이 커밋됩니다. 커밋이 됐으므로 자식 트랜잭션이 종료되고 커넥션을 반납합니다.
- processPayment()가 다시 시작됩니다. (Resume)
이 상황에서 부모 트랜잭션이 성공하든 실패하든, 자식 트랜잭션은 이미 커밋이 되었으므로 processPayment()가 롤백되어도 LogService의 트랜잭션은 롤백되지 않습니다.
SUPPORTS
트랜잭션이 있으면 합류하고, 없으면 트랜잭션 없이 그냥 실행합니다. 있으면 좋고 없으면 말고의 전략입니다.
findProduct() 같은 조회 메서드를 예로 들 수 있습니다. OrderService 내에서 트랜잭션 안에 포함되어 메서드가 호출될 경우 영속성 컨텍스트 등의 1차 캐시 등을 사용할 수 있습니다. 반면에 그냥 브라우저에서 실행될 경우 트랜잭션 없이 가볍게 실행됩니다.
NOT_SUPPORTED
트랜잭션 없이 실행합니다. 이때 기존 트랜잭션은 보류 상태가 됩니다. 한 트랜잭션에서 DB와 관련 없는 작업이 길어질 때, DB 커넥션을 쓸데없이 점유하는 것을 막기 위해 사용합니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendEmail() {
mailSender.send(...);
}
메일 발송과 같이 DB 커넥션을 굳이 물고있지 않아도 되는데 오래 걸리는 I/O 로직의 경우에 NOT_SUPPORTED로 설정해주면 트랜잭션을 잠시 끊고 메일을 보낸 뒤에 다시 트랜잭션을 잡습니다.
MANDATORY
부모 트랜잭션을 필수화 시키는 전략입니다. 부모 트랜잭션이 없으면 예외가 터집니다. 주로 독립적으로 실행하면 안 되는 내부 로직에 적용합니다.
예시 코드
@Transactional(propagation = Propagation.MANDATORY)
public void updateStatus(Payment payment) {
payment.setStatus("COMPLETED");
}
여기서 updateStatus가 트랜잭션 없이 호출되면 에러가 TransactionRequiredException이 발생합니다.
NEVER
트랜잭션이 있으면 예외가 발생합니다. 반드시 트랜잭션 없이 실행해야합니다. 주로 개발자들의 실수를 방지하기 위해서 사용되는데, 가령 테스트 데이터 초기화나 특정 캐시 작업에서 내부적으로 스스로 트랜잭션을 관리하거나 다중 스레드를 사용하는 경우 등에서 트랜잭션 안에서 실행되면 안되는 경우 등에 사용합니다.
NESTED
부모 트랜잭션 안에 중첩 트랜잭션을 만듭니다. REQUIRES_NEW와 달리 부모 트랜잭션 안에 Savepoint를 만드는 개념입니다.
즉, NESTED는 자식이 실패해서 Savepoint (자식 트랜잭션 호출 지점)까지 롤백되면 예외 처리를 안하면 부모 전체 롤백이고, 예외를 잡으면 부모가 롤백되지 않습니다. 하지만 부모가 실패했을 때, 같이 롤백됩니다.
REQUIRES_NEW는 부모가 예외 처리를 안 하면 부모도 같이 롤백되고, 부모가 예외를 처리를 잡으면 자식만 롤백되고 부모는 롤백되지 않는 부분은 같습니다. 하지만 완전 별개의 트랜잭션이라서 자식은 롤백되지 않습니다.
*JPA에서는 기본적으로 지원하지 않아 보통은 REQUIRES_NEW를 사용헤서 로직을 구현합니다.
Isolation (격리 수준)에 따른 문제 현상
Dirty Read
아직 커밋되지 않은 데이터를 읽는 현상입니다.

A 트랜잭션이 작업 중인 데이터를 B가 읽어버리는 것입니다. 만약 A 트랜잭션이 롤백된다면, B는 존재하지도 않는 더러운 데티를 가지고 로직을 수행하게 됩니다.
Non-Repeatable Read
같은 데이터를 다시 읽었는데 값이 바뀌어 있는 현상입니다.

하나의 트랜잭션 내에서 똑같은 조회 쿼리를 두 번 날렸는데, 그 사이에 다른 트랜잭션이 데이터를 수정하거나 삭제하고 커밋해버려서 두 결과가 다르게 나오는 상황입니다.
Phantom Read
없던 데이터가 갑자기 튀어나오는 현상입니다.

하나의 트랜잭션 내에서 범위 조회(Range Query)를 반복할 때 발생합니다. 그 사이에 다른 트랜잭션이 새로운 데이터를 삽입하고 커밋해서 첫 번째 조회 때는 없던 행이 두 번째 조회 때 유령(Phantom)처럼 나타나는 상황입니다.
Isolation (격리 수준) Options
DEFAULT
DB의 기본 설정을 따릅니다. MySQL은 Repeatable Read가 기본 설정입니다.
READ_UNCOMMITTED (Level 0)
커밋되지 않은 데이터도 읽을 수 있습니다. 성능은 높아질 수 있지만 Dirty Read가 발생할 수 있습니다.
READ_COMMITTED (Level 1)
커밋된 데이터만 읽습니다. Dirty Read를 방지할 수 있습니다. 하지만 Non-Repeatable Read는 방지하지 못합니다.
REPEATABLE_READ (Level 2)
Dirty Read와 Non-Repeatable Read를 방지합니다. Phantom Read는 방지하지 못합니다.
DSERIALIZABLE (Level 3)
Dirty Read, Non-Repeatable Read, Phantom Read를 모두 방지할 수 있습니다. 옵션 중 성능이 가장 떨어져서, 거의 직렬화 급의 성능을 보입니다.
readOnly
기본 값은 false입니다. true로 설정 시에는 JPA가 스냅샷을 만들지 않고 Dirty checking을 수행하지 않습니다.
rollbackFor, noRollbackFor
스프링의 기본 롤백 정책은 아래와 같습니다.
- Unchecked Exception (RuntimeException)
- NullPointerException, IndexOutOfBoundsException...
- 시스템이 망가진 상황으로 판단해서, 자동 롤백 처리합니다.
- Checked Exception (Exception)
- 잔액부족Exception, 로그인실패Exception
- 예상 가능한 비즈니스 상황의 예외 처리로, 자동 커밋 처리합니다.
여기서 throw new Exception("Custom exception"); 으로 예외를 던지면 롤백처리 되겠다고 생각이 들지만, 이는 Checked Exception이므로 데이터를 DB에 커밋 처리합니다. @Transactional에는 옵션으로 이 커밋 처리를 커스터마이징해서 롤백 처리로 변경할 수 있습니다.
@Transactional(rollbackFor = Exception.class)
위와 같이 Exception이 터져도 무조건 롤백하게 옵션을 켤 수 있습니다.
@Transactional(noRollbackFor = Exception.class)
이렇게 설정하면 에러가 나도 롤백하지 않습니다.
timeout
지정된 시간 내에 트랜잭션이 완료되지 않으면 예외를 던지고 롤백합니다.
이런 스프링에서 제공하는 @Transactional을 바탕으로 로직의 Retry 정책을 정의해보겠습니다.
@Transactional(readOnly = false)
public void sendToAllMembers(Long templateId, Map<String, String> variables) {
List<Object[]> result = memberRepository.findMinMaxId();
if (result.isEmpty() || result.get(0)[0] == null) {
return;
}
Object[] row = result.getFirst();
long minId = (Long) row[0];
long maxId = (Long) row[1];
int partitionSize = 20000;
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (long start = minId; start <= maxId; start += partitionSize) {
long end = Math.min(start + partitionSize - 1, maxId);
final long currentStart = start;
final long currentEnd = end;
futures.add(CompletableFuture.runAsync(() -> {
LocalDateTime now = LocalDateTime.now().minusSeconds(1);
memberNotificationJdbcRepository.partitionInsert(templateId, variables, currentStart, currentEnd); // DB 작성
List<NetworkMessageDto> chunkData =
memberNotificationJdbcRepository.findNetworkMessageDtoByRange(templateId, currentStart, currentEnd, now); // 실시간 메시지 객체 생성
if (!chunkData.isEmpty()) {
eventPublisher.publishEvent(new NotificationSavedEvent(chunkData)); // 실시간 메시지 전송 이벤트 발송
}
}, executorService));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
기존 코드입니다. 알림 전송의 로직인데, 여기서 Retry 정책이 적용이 않았습니다. 지금은 Exception이 발생하면 데이터 정합성에 대한 문제가 발생할 수 있습니다.
가령 CompletableFuture로 실행한 스레드들은 메인 스레드의 @Transactional을 물려받지 못합니다. 즉 10개의 작업이 각각 별도의 DB 커넥션과 별도의 트랜잭션을 맺고 각자가 커밋을 합니다. 이때 10개 중 1개의 트랜잭션이 실패하면 CompletableFuture.allOf(...).join()에서 모든 작업이 끝났을 때 CompletionException을 리턴합니다. 이것은 메인 메서드에서 예외가 터진 것으로, 메인 스레드에서 처리됩니다. 메인 메서드는 예외를 잡아놓지 않았으므로 롤백됩니다. 그런데 롤백 되는 것은 메인 스레드가 한 일 뿐이므로 메인 스레드는 DB에 아무것도 안 썼어서, 롤백돼도 복구하는 것이 없습니다. 사실상 의미가 없는 롤백입니다. 1개의 실패는 유실된 상태로 끝나기 때문입니다.
futures.add(CompletableFuture.runAsync(() -> {
processPartitionSend(templateId, variables, currentStart, currentEnd);
}, executorService));
private void processPartitionSend(Long templateId, Map<String, String> variables, long start, long end) {
int maxRetries = 3;
long retryDelayMs = 1000;
Exception lastException = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
LocalDateTime now = LocalDateTime.now().minusSeconds(1);
// DB Insert
memberNotificationJdbcRepository.partitionInsert(templateId, variables, start, end);
// 실시간 메시지 객체 생성
List<NetworkMessageDto> chunkData =
memberNotificationJdbcRepository.findNetworkMessageDtoByRange(templateId, start, end, now);
// SSE Send 이벤트 발행
if (!chunkData.isEmpty()) {
eventPublisher.publishEvent(new NotificationSavedEvent(chunkData));
}
return;
} catch (Exception e) {
lastException = e;
log.warn("[Batch Insert Failed] Range: {}-{}, Attempt: {}/{}", start, end, attempt, maxRetries, e);
if (attempt < maxRetries) {
try {
Thread.sleep(retryDelayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Thread interrupted during retry backoff", ie);
}
}
}
}
log.error("[FATAL] All retries failed for Range: {}-{}", start, end);
throw new RuntimeException("Partition failed after " + maxRetries + " retries", lastException);
}
이렇게 반복문으로 retry를 정의하고, 실패시에 maxRetries보다 작으면 한번 더 메서드를 시도합니다. 여기서 InterruptedException은 외부 인터럽트가 발생해서 중단하게되면 터지는 예외입니다. Sleep 시간 동안 인터럽트가 발생하면 Thread.currentThread().interrupt()는 외부에서 서버가 종료되는 등의 인터럽트 상태로 스레드의 상태를 지정해 놓는 코드이고, RuntimeException을 던져서 CompletableFuture가 실패로 끝납니다. 즉, 재시도 대기 중에 취소 요청이 들어오면 즉시 그만두기 위해서 존재하는 예외처리 코드입니다.
'Backend > Spring' 카테고리의 다른 글
| [Spring] Spring에서의 Tomcat과 비교한 Netty의 구조 (0) | 2026.02.11 |
|---|---|
| [Spring] In memory에서 관리하는 SSE Emitter 메모리 최적화 (0) | 2026.01.22 |