본문 바로가기
팀 프로젝트/최종 프로젝트

AOP(트랜잭션)와 try-catch

by pon9 2025. 3. 27.

개요

2025.03.08 - [팀 프로젝트/최종 프로젝트] - 트러블슈팅: 크롤링 시 트랜잭션 범위가 너무 길다

 

트러블슈팅: 크롤링 시 트랜잭션 범위가 너무 길다

현재 문제점크롤링을 진행하는 메서드 전체에 트랜잭션이 잡혀있다.크롤링 해올 페이지가 적은 상황에선 문제가 안 되지만, 여러 페이지를 한 트랜잭션에 포함시킬 때 문제가 될 수 있다.중간

roqkfchqh.tistory.com

이 문제를 해결할 때, 한 클래스 내에서 트랜잭션 같이 사용하면 정상적으로 작동하지 않는다는 걸 어렴풋이 알고는 있었지만,

왠진 모르겠지만 그냥 되길래(??) 잠시 묻어두고 넘어갔었다.

 

그러다 지금 AOP를 공부하다가 깨달았다.

Spring의 트랜잭션은 AOP 기반이다. 즉, 프록시를 통해 메서드가 호출될 때만 트랜잭션이 적용된다.

 

그런데 클래스 내부 메서드 호출은 프록시를 타지 않는다.

외부에서 리플렉션을 통해 메서드를 호출할 때만 AOP advice를 발동시키고,

내부 메서드 호출은 그냥 평범한 java 메서드 호출과 같다.

여기서 propessPage()는 내부 메서드 호출일 뿐이고, 프록시가 아니니 새로운 트랜잭션이 생기는 구조가 아니다.

근데 내 크롤러에서 REQUIRES_NEW가 "작동하는 것 처럼" 보였던 이유는 무엇일까?

 

 

실험

@Service
@RequiredArgsConstructor
public class TransactionService {

    private final UserRepository userRepository;
    private final OtherComponent otherComponent;

    @Transactional
    public void outer(String name){
        TransactionLogger.logTransactionInfo("\uD83D\uDFE2 outer 시작");
        TransactionLogger.logTransactionLifecycle("outer 트랜잭션");
        try {
            for(int i = 0; i < 10; i++){
                otherComponent.inner(name + i);
                inner(name + i);
                if(i == 5) throw new RuntimeException("exception");
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner(String name){
        TransactionLogger.logTransactionInfo("\uD83D\uDD35 inner 시작");
        TransactionLogger.logTransactionLifecycle("inner 트랜잭션");
        userRepository.save(new User(name));
    }
}

위의 상황을 재현한 service계층이다.

outer에서 i가 5일때, 그러니까 크롤러로 치면 6페이지에서 에러가 발생하도록 만들었다.

@Component
@RequiredArgsConstructor
public class OtherComponent {

    private final UserRepository userRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner(String name){
        TransactionLogger.logTransactionInfo("\uD83D\uDD34 component 시작");
        TransactionLogger.logTransactionLifecycle("component 트랜잭션");
        userRepository.save(new User(name));
    }
}

외부 컴포넌트도 하나 더 만들어 inner()와 동일하게 동작하게끔 했다.

@Slf4j
public class TransactionLogger {

    public static void logTransactionInfo(String label) {
        log.info("========== {} ==========", label);
        log.info("트랜잭션 활성화 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
        log.info("현재 트랜잭션 이름: {}", TransactionSynchronizationManager.getCurrentTransactionName());
        log.info("현재 스레드 ID: {}", Thread.currentThread().getId());
        log.info("=================================");
    }

    public static void logTransactionLifecycle(String label) {
        log.info("=== 트랜잭션 리스너 등록됨: {} ===", label);

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                log.info("커밋됨 → {}", label);
            }

            @Override
            public void afterCompletion(int status) {
                switch (status) {
                    case STATUS_COMMITTED:
                        log.info("커밋 완료 → {}", label);
                        break;
                    case STATUS_ROLLED_BACK:
                        log.info("롤백됨 → {}", label);
                        break;
                    default:
                        log.warn("이건뭐냐({}) → {}", status, label);
                }
            }
        });
    }
}

 

TransactionSynchronization으로 새로운 트랜잭션이 실제로 생기는지 로그를 남겼다.

이상하다. 분명 에러가 났는데 200OK가 발생하며,

ㅗ는 의도가 아니다

저장까지 의도한 대로 되는데,

outer와 component는 서로 다른 트랜잭션이 활성화되었지만, inner는 같은 트랜잭션을 사용하고 있다.

근데 어떻게 예외가 발생했는데 롤백되지 않고 저장된걸까?

이상함을 느껴 try-catch문을 빼보니,

500에러와 함께 othercomponent에서 처리된 것들만 저장된다!

 

 

try-catch의 함정..

Spring AOP 트랜잭션은 예외가 외부로 전파되어야 rollback된다.

catch로 예외를 삼켜서 트랜잭션이 정상적으로 끝났구나! 하고 커밋해버린거다.

 

즉, REQUIRES_NEW가 실제로 분리되어 있지 않았지만, catch문 때문에 예외가 발생하지 않았고 rollback조건도 안 맞아서

결국 모든 데이터가 정상적으로 저장된 것처럼 보였던 거다.

이쯤되니 하나 더 궁금한게 생겼다. Checked 예외인 IOException은 어떨까?

놀랍게도 try-catch로 감싸지 않았는데도 롤백되지 않았다.

Transactional은 Unchecked 예외만 롤백 대상으로 보는거다..!

@Transactional(rollbackFor = IOException.class)

이렇게 rollback 조건을 명시해주면 Checked Exception도 롤백되게 할 수 있다.

 

 

Clustered Index

이미 눈치 채신 분들도 있겠지만, 또 이상한 점을 발견할 수 있었다.

예외를 강제로 발생시켜 트랜잭션을 롤백시켰더니, DB에 데이터는 안 들어갔는데 id는 비어있는 번호로 넘어가있었다.

125, 126(삭제됨), 127, 128(삭제됨) 이런식이다.

왜그런가 했더니 이건 MySQL(InnoDB)의 AUTO_INCREMENT가 insert 시점에 sequence를 먼저 증가시키고 rollback돼도 되돌리지 않기 때문이었다.

 

MySQL에서 PK는 Clustered Index이고, 이는 데이터 자체가 정렬된 상태로 저장된다.

만약 rollback될 때 마다 sequence를 되돌리면 동시성 문제, 락 병목, B+tree 재정렬 등의 문제가 생기기 때문에 차라리 ID를 비워도 성능을 택하는 구조인 듯 했다.

어떻게보면 이것도 일종의 프록시같았다. ID라는 껍데기를 먼저 배정(B+Tree에 순서대로 선점) 해두고, 실제 데이터는 나중에 채워지는 구조로 처리되니 말이다

 

 

회고

처음엔 그저 크롤러 하나 짜는 일이었고, 그냥 트랜잭션을 붙이면 다 해결될 줄 알았다.

그런데 AOP의 동작 방식, 예외 처리의 영향, Checked 예외와 Unchecked 예외 등등 하나씩 알게 되면서

"작동하는 코드"와 "의도대로 동작하는 코드"의 차이를 확실히 깨달았다.

 

코드 한 줄 한 줄 의도를 담아서 짜내려가야한다!

'팀 프로젝트 > 최종 프로젝트' 카테고리의 다른 글

LazyInitializationException  (0) 2025.03.25
트러블슈팅: 인증과 인가  (0) 2025.03.19
Spring Security와 JWT  (0) 2025.03.18