토비의 스프링 3.1, 10년차 개발자가 다시 꺼낸 이유

글쓴이: JAVA/Spring Boot 백엔드 개발 10년차. SI로 시작해 현재는 자사 서비스 백엔드를 맡고 있다.


2019년 2월의 어느 금요일 밤, 가맹점 정산 배치가 중간에 죽었다. 로그를 열어 보니 정산 내역은 절반쯤 들어가 있는데 원장 잔액은 그대로였다. 분명히 @Transactional을 붙여 둔 메서드였다. 예외가 났으면 전부 롤백되어야 했는데, 실제로는 예외 직전까지 실행된 SQL이 전부 커밋되어 있었다.

당시 팀은 4명이었고 아무도 이유를 설명하지 못했다. 그날 밤부터 사흘 동안 겪은 일을 적어 둔다. 결론만 먼저 말하면, 원인은 같은 클래스 안에서 메서드를 직접 부른 self-invocation이었다. 그리고 그 답을 준 건 검색 결과가 아니라 책장에서 먼지를 뒤집어쓰고 있던 토비의 스프링 3.1이었다. 10년차가 된 지금도 후배에게 스프링 책을 한 질만 고르라면 이 책을 고르는데, 이유의 8할은 그 사흘에 있다.

책상 위에 놓인 『토비의 스프링 3.1』 Vol.2 「스프링의 기술과 선택」 표지
토비의 스프링 3.1 Vol.2 스프링의 기술과 선택 표지

금요일 밤, 절반만 커밋된 정산 배치

문제의 코드는 대략 이런 모양이었다. 배치가 가맹점 목록을 돌면서 가맹점마다 정산을 처리하고, 가맹점 하나의 정산은 트랜잭션 하나로 묶으려는 의도였다.

@Service
public class SettlementService {

    public void settleAll(LocalDate date) {
        for (Merchant m : merchantMapper.findActive()) {
            settleMerchant(m, date);   // 같은 클래스 안에서 직접 호출
        }
    }

    @Transactional
    public void settleMerchant(Merchant m, LocalDate date) {
        settlementMapper.insertDetails(m.getId(), date);
        ledgerMapper.decreaseBalance(m.getId(), calcAmount(m, date));
    }
}

그날 한 가맹점의 수수료 데이터가 비어 있어서 calcAmount에서 NullPointerException이 났다. insertDetails는 이미 실행된 뒤였다. 트랜잭션이 걸려 있었다면 정산 내역도 함께 롤백됐어야 하는데, DB에는 정산 내역만 남고 원장은 줄지 않았다. 월요일 아침 정산팀이 금액이 안 맞는다고 연락하기 전에 막아야 했다.

첫날 밤, 어노테이션만 만지다 날린 네 시간

처음 네 시간은 어노테이션 옵션만 바꿨다. 지금 보면 부끄럽지만 순서대로 적는다.

  • rollbackFor = Exception.class 추가. 체크 예외라서 롤백이 안 된 줄 알았다. NPE는 런타임 예외라 애초에 관계없었다.
  • propagation = REQUIRES_NEW로 변경. 바깥 트랜잭션과 섞여서 그런가 싶었다. 결과는 같았다.
  • 트랜잭션 매니저 빈 이름 확인. 데이터소스가 두 개라 엉뚱한 매니저를 쓰는 줄 알았다. 설정은 멀쩡했다.

새벽 2시쯤 방향을 바꿔서 트랜잭션 로그를 켰다. 당시 MyBatis와 DataSourceTransactionManager 조합이었으니 logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG를 넣고 로컬에서 같은 데이터로 배치를 돌렸다. 정상이라면 Creating new transaction with name [...] 로그가 가맹점마다 찍혀야 했다. 그런데 한 줄도 찍히지 않았다. 트랜잭션이 롤백에 실패한 게 아니라 아예 시작되지 않았던 것이다. 이유는 여전히 몰랐지만, 적어도 어노테이션 옵션을 만질 문제가 아니라는 건 알게 됐다.

토요일, 책장에서 다시 꺼낸 6장

토요일 오전에 신입 때 사 놓고 두 번 포기한 토비의 스프링을 꺼냈다. 목차에서 트랜잭션이 나오는 곳을 찾다가 Vol. 1의 6장 AOP를 펼쳤다. 이 장은 사용자 레벨을 올리는 upgradeLevels()에서 트랜잭션 코드를 떼어 내는 과정을 한 단계씩 보여 준다. 처음에는 인터페이스로 서비스를 나누고, 그다음 다이내믹 프록시로 부가기능을 감싸고, 마지막에 스프링이 그 프록시를 대신 만들어 주는 구조로 간다.

책을 따라 직접 쳐 본 코드는 이런 모양이었다.

public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager txManager;

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object ret = method.invoke(target, args);   // 진짜 객체 호출
            txManager.commit(status);
            return ret;
        } catch (InvocationTargetException e) {
            txManager.rollback(status);
            throw e.getTargetException();
        }
    }
}

이걸 치고 나서야 머릿속에서 그림이 맞춰졌다. 트랜잭션을 여는 건 @Transactional이 붙은 내 클래스가 아니라 그 앞에 선 프록시다. 다른 빈이 settleMerchant()를 부르면 프록시를 거치니 트랜잭션이 열린다. 하지만 settleAll() 안에서 settleMerchant()를 부르면 그건 this.settleMerchant(), 즉 프록시 뒤에 있는 진짜 객체가 자기 메서드를 직접 부르는 것이다. 프록시를 거치지 않으니 트랜잭션이 없는 게 당연했다. 금요일 밤 네 시간의 삽질이 한 문단으로 설명됐다.

일요일, 해결책 네 가지를 놓고 고른 기준

원인을 알고 나니 고칠 방법이 여러 개 보였다. 일요일에는 그중 무엇을 고를지 정리했다. 제일 먼저 해 본 건 AopContext.currentProxy()로 자기 자신의 프록시를 꺼내 부르는 방법이었는데, 바로 Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' 예외가 났다. @EnableAspectJAutoProxy(exposeProxy = true)를 켜니 동작은 했다. 그런데 월요일 코드 리뷰에서 팀장이 “서비스 코드가 AOP 내부 구조를 알아야 하는 방식”이라며 반대했고, 나도 동의했다.

방법 장점 단점 우리 선택
빈 분리 프록시 구조 그대로, 코드만 보고 이해됨 클래스가 하나 늘어남 채택
자기 주입(@Lazy self) 코드 변경이 적음 순환 참조처럼 보여 다음 사람이 헷갈림 보류
AopContext.currentProxy() 기존 구조 유지 exposeProxy 설정 필요, 스프링 AOP에 직접 의존 시도 후 철회
TransactionTemplate 트랜잭션 경계가 코드에 명시됨 선언형과 섞이면 규칙이 두 개가 됨 배치 일부에만 사용
AspectJ 모드(위빙) 내부 호출에도 적용됨 빌드·실행 설정이 무거움 제외

최종적으로는 가맹점 하나를 정산하는 책임을 별도 빈으로 뺐다. 가장 지루한 방법이었지만 책에서 본 프록시 구조와 그대로 맞아서 설명하기 쉬웠다.

@Service
@RequiredArgsConstructor
public class SettlementBatch {
    private final MerchantSettler settler;   // 프록시가 주입됨

    public void settleAll(LocalDate date) {
        for (Merchant m : merchantMapper.findActive()) {
            try {
                settler.settle(m, date);         // 프록시를 거치므로 트랜잭션 시작
            } catch (Exception e) {
                failureMapper.record(m.getId(), date, e.getMessage());
            }
        }
    }
}

@Service
public class MerchantSettler {
    @Transactional
    public void settle(Merchant m, LocalDate date) { ... }
}

실패한 가맹점은 따로 기록하고 다음 가맹점으로 넘어가도록 바꿨다. 월요일 새벽에 수동으로 데이터를 되돌린 뒤 다시 돌렸고, 문제의 가맹점 하나만 실패 목록에 남았다.

2년 뒤에야 읽힌 Vol. 2의 5장

Vol. 1의 AOP가 원리 편이라면 Vol. 2의 5장 「AOP와 LTW」는 선택 편이다. 프록시 기반 AOP의 종류와 한계, @AspectJ 스타일, 로드타임 위빙(LTW)까지 스프링이 주는 방식을 비교한다.

『토비의 스프링 3.1』 Vol.2 목차 중 5장 「AOP와 LTW」 부분 — 5.1 애스펙트 AOP, 프록시 기반 AOP, @AspectJ AOP 항목이 보인다
토비의 스프링 3.1 목차 중 5장 AOP와 LTW 부분

2019년에는 이 장을 절반쯤 읽고 덮었다. 다시 펼친 건 2021년이었다. 도메인 객체 안에서 부르는 메서드에도 트랜잭션과 로깅을 걸자는 의견이 나와서, 표의 마지막 줄인 AspectJ 위빙을 진지하게 검토했다. 5장을 읽고 로컬에서 -javaagent로 LTW를 붙여 보니 동작은 했다. 하지만 운영 서버 기동 스크립트, IDE 실행 설정, 테스트 실행 환경을 모두 바꿔야 했고, 에이전트가 빠진 환경에서는 조용히 적용이 안 되는 게 더 무서웠다. 결국 접었다. 대신 “왜 스프링이 기본값으로 프록시를 고르는지”를 팀에 설명할 수 있게 됐다. 그게 5장에서 얻은 제일 큰 소득이었다.

『토비의 스프링 3.1』 Vol.2 5장 「AOP와 LTW」 첫 페이지 — @AspectJ AOP와 로드타임 위빙을 다룬다는 도입 설명
토비의 스프링 3.1 Vol.2 5장 AOP와 LTW 도입부 페이지

지금도 신입이 올 때마다 반복되는 함정

프록시 구조를 알고 나면 비슷한 문제가 한 가족처럼 보인다. 지금 팀에서 신입이 오면 아래 목록을 먼저 보여 준다.

  • 같은 클래스 내부 호출. 이번 장애의 원인이다. @Cacheable, @Async도 똑같이 무시된다. @Async 쪽 사례는 @Async 잘못 쓰면 트랜잭션·예외가 새어나간다에 따로 적었다.
  • private 메서드에 붙인 @Transactional. 프록시가 가로챌 수 없으니 붙여도 아무 일도 일어나지 않는다.
  • 체크 예외는 기본적으로 롤백하지 않는다. 첫날 밤 내가 엉뚱하게 의심했던 부분이지만, 실제로 이것 때문에 생기는 장애도 있다.
  • final 클래스와 final 메서드. Spring Boot 2.0부터 기본값이 클래스 기반(CGLIB) 프록시라 상속할 수 없는 대상에는 부가기능이 붙지 않는다.

트랜잭션 쪽 함정은 @Transactional 자주 틀리는 5가지 함정에 더 자세히 정리했다. 그리고 2019년 이후로는 정산처럼 돈이 걸린 코드에 롤백 테스트를 꼭 붙인다. 중간에 예외를 일부러 내고 아무것도 남지 않았는지 확인하는 테스트다.

@Test
void 원장_차감_중_예외가_나면_정산_내역도_남지_않는다() {
    given(feePolicy.rateOf(any())).willReturn(null);   // 계산 중 예외 유도

    assertThatThrownBy(() -> settler.settle(merchant, DAY))
            .isInstanceOf(NullPointerException.class);

    assertThat(settlementMapper.countBy(merchant.getId(), DAY)).isZero();
}

작년에도 우리 팀 2년차가 똑같은 self-invocation 문제로 반나절을 썼다. 설명하는 대신 이 책의 6장을 빌려줬더니, 다음 날 본인 입으로 프록시 구조를 설명했다. 이 테스트도 그 친구가 처음 작성했다.

낡은 책이 아직 유효한 이유

솔직히 이 책의 예제 환경은 낡았다. XML 설정이 많이 나오고, 스프링 3.1은 지원이 끝난 지 오래다. Spring Boot 3.x와 Java 21을 쓰는 지금 예제를 그대로 따라 치는 건 권하지 않는다.

그런데 6장이 다루는 핵심인 다이내믹 프록시, 포인트컷과 어드바이스, 프록시의 한계는 지금 스프링에서도 그대로다. Spring Framework 공식 AOP 문서를 열어 봐도 개념 체계가 이 책과 거의 같다. 도구는 바뀌었지만 원리는 14년째 그대로다. 2019년의 장애도, 작년 2년차의 반나절도 결국 같은 원리에서 나왔다.

다시 읽는다면 이렇게 읽겠다

신입 때 1장부터 순서대로 완독하려다 두 번 포기했다. 두 권을 합치면 1,600페이지가 넘는 분량이라 정면으로 부딪치는 건 무리였다. 다시 처음부터 읽는다면 이렇게 하겠다.

  • Vol. 1의 1장과 6장을 먼저 읽는다. 1장은 중복을 상속으로 풀었다가 인터페이스와 조합으로 넘어가는 이야기인데, 나중에 결제 모듈에서 똑같은 실수를 하고 나서야 제대로 읽혔다. 그 경험은 인터페이스와 상속, 중복 코드를 없애는 원리에 적었다.
  • 6장 예제는 눈으로 읽지 말고 직접 친다. TransactionHandler를 손으로 만들어 본 것과 읽기만 한 것의 차이가 컸다.
  • 나머지는 실무에서 그 주제에 부딪칠 때 찾아 읽는다. 이 책은 소설보다 레퍼런스에 가깝다.
  • 트랜잭션이 이상하면 옵션보다 로그부터 켠다. 트랜잭션이 “실패했는지”와 “시작도 안 했는지”를 먼저 구분했다면 금요일 밤 네 시간을 아꼈을 것이다.

Spring Boot로 개발을 시작해서 스프링이 “그냥 되는 것”으로 느껴지는 사람일수록 이 책의 효과가 크다. 부트가 감춰 놓은 부분이 바로 이 책이 설명하는 부분이기 때문이다. 개정판이 없으니 중고로 구해도 내용 차이는 없다. 다만 어떤 판본이든 Vol. 1의 6장과 Vol. 2의 5장만큼은 건너뛰지 말았으면 한다. 내 경험으로는 그 두 장이 두꺼운 책값의 대부분을 한다.

“토비의 스프링 3.1, 10년차 개발자가 다시 꺼낸 이유”에 대한 1개의 생각

댓글 남기기