Domain 영속화 표준(Domain 객체를 DB에 저장하고 다시 꺼내는 방법을 정한 글이다. 「영속화」는 영속성을 갖게 만든다는 뜻이다)은 트랜잭션 경계를 UseCase 구현 메서드에 두기로 하면서 이렇게 적었다.

클래스가 package-private 이어도 UseCase 인터페이스 구현 메서드는 public 이라
@Transactional 프록시가 동작하고, UseCase 에 없는 package-private 메서드에는 걸리지 않는다.

@Transactional은 메서드에 붙이는 애너테이션(@로 시작하는 표시)이고, 실제로 트랜잭션을 여는 일은 스프링이 내 객체 앞에 세워 둔 프록시가 한다. 밖에서 메서드를 부르면 프록시가 그 호출을 먼저 받아(「가로챈다」) 트랜잭션을 열고, 그다음 내 메서드를 부른다.

이 문장은 「프록시는 public 메서드만 가로챈다」를 전제로 깔고 있다. 왜 그렇게 믿었는지는 표준 글에 적혀 있지 않다. 이 글은 그 전제가 지금도 맞는지를 잰다.

뒤 절반은 「트랜잭션 경계를 가시성으로 긋는다」는 뜻이다. 가시성은 public · protected · package-private · private처럼 메서드를 누가 부를 수 있는지의 범위를 묶어 부르는 말이다. 「package-private으로 숨긴 메서드에는 트랜잭션이 안 생기니, 트랜잭션은 public인 UseCase 메서드에서만 시작된다」는 안전장치를 기대한 것이다. 트랜잭션은 「어디까지를 한 번에 성공하거나 한 번에 실패하게 묶을지」를 정하는 선이라, 그 선이 UseCase 한 곳에서만 그어져야 어디서 묶이는지를 한눈에 알 수 있다. 그게 성립하는지 쟀다.


주장 — package-private 메서드에는 @Transactional이 걸리지 않는다

@Transactional 은 public 메서드에만 걸리고 package-private 메서드에는 걸리지 않는다.

근거 종류는 1(기술 사실)이다.


반증 조건 — package-private에서 트랜잭션이 살아 있으면 반증이다

결과는 아래 세 가지 중 하나로 판정한다. isActualTransactionActive()는 스프링이 제공하는 메서드로, 지금 실행 중인 코드가 스프링이 열어 둔 트랜잭션 안에서 돌고 있는지를 true·false로 알려준다.

실험은 두 환경에서 똑같이 돌린다. Spring Boot 3.5.16과 4.1.1이다. 버전 하나에서만 재면 그 버전만의 사정일 수 있어서 둘로 쟀다.

  • refuted — 두 환경 중 하나라도 package-private 메서드에서 isActualTransactionActive()가 true다
  • supported — 두 환경 모두 public은 true, package-private은 false다
  • inconclusive — public 대조군이 false다(프록시가 아예 안 걸린 것이다)

public 메서드는 대조군이다. 표준 글도 「public이면 프록시가 동작한다」고 적었으니 public에서는 true가 나와야 정상이다. 그 public마저 false라면 가시성 이전에 프록시 자체가 안 붙은 것이라, package-private 결과를 보고 무엇도 판단할 수 없다. 그래서 inconclusive다.

protected 결과는 기록만 하고 판정에 쓰지 않는다. 표준 글의 문장이 다루는 것은 public과 package-private 둘이다.


설계 — 자기 호출을 피하고, 호출 직전 상태까지 같이 잰다

package-private 클래스 하나에 public · protected · package-private 메서드를 두고 전부 @Transactional을 달았다. 메서드 안에서 TransactionSynchronizationManager.isActualTransactionActive()를 읽는다. TransactionSynchronizationManager는 스프링이 지금 열린 트랜잭션의 상태를 스레드마다 적어 두는 클래스다.

실험 대상 클래스는 이렇게 생겼다(proofs/transactional-proxy-visibility/app에 있는 코드 그대로다).

@Service
class VisibilityTarget {                       // 클래스 자체가 package-private

    @Transactional
    public void publicMethod() {
        Marks.INSIDE.put("public", active());
    }

    @Transactional
    protected void protectedMethod() {
        Marks.INSIDE.put("protected", active());
    }

    @Transactional
    void packagePrivateMethod() {              // 접근 제어자 없음 = package-private
        Marks.INSIDE.put("package", active());
    }

    private String active() {
        return String.valueOf(TransactionSynchronizationManager.isActualTransactionActive());
    }
}
  • @Service — 이 클래스를 스프링 빈으로 만들라는 표시다. 내가 new를 부르지 않아도 프로그램이 뜰 때 스프링이 이 객체를 만들고, @Transactional을 보고 프록시로 감싼다
  • 세 메서드는 접근 제어자만 다르고 하는 일은 같다. 메서드 안에서 「지금 트랜잭션 안인가」를 읽어 Marks.INSIDE라는 Map에 적는다
  • Marks.INSIDE는 결과를 밖으로 나르는 정적 Map이다. 실험이 끝나면 이 값을 꺼내 한 줄로 찍는다
  • active()는 isActualTransactionActive()의 true·false를 문자열로 바꿀 뿐이다

호출은 같은 패키지의 다른 빈이 한다. 같은 빈 안에서 부르면 프록시를 안 거쳐 무조건 false가 나오는데, 그건 가시성 때문이 아니다. 이걸 자기 호출이라고 한다 — 다른 빈이 받아 든 것은 프록시라서 그 호출은 프록시를 먼저 지나지만, 이미 원래 객체 안으로 들어온 뒤 this로 부르는 호출은 프록시를 지날 일이 없다. 이걸 틀리면 실험이 통째로 무의미해진다. 그래서 부르는 쪽을 따로 두었다.

@Service
class VisibilityCaller {

    private final VisibilityTarget target;     // 스프링이 넣어 주는 것은 프록시다

    VisibilityCaller(VisibilityTarget target) {
        this.target = target;
    }

    void callAll() {
        Marks.INSIDE.put("before_public", active());
        target.publicMethod();
        Marks.INSIDE.put("before_protected", active());
        target.protectedMethod();
        Marks.INSIDE.put("before_package", active());
        target.packagePrivateMethod();
    }
    // active() 는 VisibilityTarget 의 것과 같다
}
  • 생성자로 VisibilityTarget을 받는다. 이 생성자는 내가 부르지 않는다. 스프링이 프로그램을 띄울 때 VisibilityCaller를 만들면서 생성자의 매개변수를 보고 맞는 빈을 찾아 넣는다. 이걸 의존성 주입이라고 한다. 이때 넣어 주는 것은 원본이 아니라 프록시다
  • callAll()은 세 메서드를 차례로 부른다. 같은 패키지라 protected와 package-private 메서드도 부를 수 있다
  • 부르기 직전마다 호출자 쪽에서 같은 값을 읽어 before_*로 적는다. 이 대조군이 왜 필요했는지는 아래 「예상과 달랐던 점」에 있다

프록시 방식(AopUtils.isCglibProxy)도 기록했다. 위 코드에서는 줄였지만 VisibilityCaller에 proxyKind()라는 메서드가 하나 더 있고, 그 안에서 주입받은 target을 이 메서드에 넘긴다 — 이 메서드는 스프링이 만든 프록시가 CGLIB 방식인지를 true·false로 확인해 주는 AOP 유틸리티다. 스프링이 프록시를 만드는 방법은 CGLIB 말고 JDK 동적 프록시도 있고, 둘은 만드는 방식이 다르다. 그래서 가시성 지원이 프록시 방식에 달려 있을 수 있다고 보고 같이 기록했다(JDK 동적 프록시로는 재지 않았다).

DB도 붙였다. 트랜잭션을 실제로 열고 닫는 트랜잭션 매니저는 결국 DB에 「트랜잭션을 시작하라」고 요청하는 쪽이라, DB가 없으면 @Transactional이 할 일이 없다. 이 컴퓨터에서 다른 프로젝트가 쓰는 로컬 PostgreSQL 17 컨테이너에 붙었기 때문에, 그 안의 데이터를 건드리지 않도록 실험 전용 데이터베이스를 만들고 끝나면 지웠다.


결과 — 셋 다 트랜잭션 안이었다

PROBE boot=3.5.16 proxy=cglib public=true protected=true package=true
                  outside_before_public=false outside_before_protected=false outside_before_package=false
PROBE boot=4.1.1  (같음)

이 줄은 실험 프로그램이 끝날 때 PROBE라는 머리말을 붙여 찍은 것이다. 실험 스크립트가 이 출력을 그대로 result.md 파일(proofs/transactional-proxy-visibility/result.md)에 남기는데, 여기서는 이름=값 묶음 가운데 판정과 관계없는 몇 개(자바 버전 · DB 이름 등)를 뺐다.

  • boot — 그 환경의 Spring Boot 버전이다
  • proxy=cglib — 생성자로 주입받은 target이 CGLIB 프록시였다는 뜻이다
  • public · protected · package — 각 메서드 안에서 읽은 「트랜잭션 안인가」다
  • outside_before_* — 각 메서드를 부르기 직전에 호출자 쪽에서 읽은 같은 값이다. 왜 넣었는지는 다음 섹션에서 설명한다

판정은 refuted다. 두 Boot 버전 모두 CGLIB 프록시였고 package-private 메서드에서도 트랜잭션이 살아 있었다.


예상과 달랐던 점 — 착시를 먼저 의심하고 대조군을 더 넣었다

첫 결과가 주장과 정반대라 앞 호출의 트랜잭션이 남아 다음 메서드가 저절로 true로 보이는 착시를 의심했다. 트랜잭션 상태는 스레드마다 적히고, 세 메서드는 한 스레드에서 차례로 불린다. 그러니 만약 public 메서드의 트랜잭션이 끝나고도 지워지지 않고 남아 있다면, 뒤에 불린 package-private 메서드는 자기 프록시가 없어도 그 남은 트랜잭션 안에서 true를 읽게 된다. 그래서 호출자 쪽에서 호출 직전에 같은 값을 읽는 대조군(outside_before_*)을 넣고 다시 돌렸다.

호출 직전(호출자 쪽)   false
메서드 안             true

셋 다 호출 직전은 false다. 트랜잭션은 그 호출에서 새로 시작된 것이고, 프록시가 실제로 package-private 메서드를 가로챈다.

왜 걸리는지는 재지 않았다. 스프링의 어느 버전에서 무엇이 바뀌었는지는 측정한 것이 아니라 추측이라 적지 않는다. 측정된 것은 동작뿐이다.


이 결과가 표준에 남기는 것 — 경계를 가시성에 기대던 문장이 무너진다

규칙(트랜잭션 경계는 UseCase 구현 메서드에 둔다)은 이 실험이 판정하지 않는다. 깨진 것은 「UseCase에 없는 package-private 메서드에는 안 걸린다」는 안전장치 설명이다. 지금은 내부 보조 메서드에 @Transactional을 실수로 달아도 그대로 동작한다. 경계가 의도와 다른 자리에 생길 수 있다는 뜻이다. 예를 들어 트랜잭션이 없는 다른 빈이 UseCase를 거치지 않고 그 보조 메서드를 바로 부르면, 거기서 트랜잭션이 따로 열리고 그 메서드가 끝날 때 따로 커밋된다(@Transactional의 기본 전파 설정 REQUIRED는 열린 트랜잭션이 없으면 새로 연다). 코드를 읽는 사람은 「package-private이니 트랜잭션이 없다」고 믿고 지나간다.

규칙은 바로 고치지 않는다. 실험 한 번이 틀렸을 수도 있어서다. 대신 표준 글의 그 원칙 밑에 「이 문장은 실험으로 반증됐다」는 한 줄을 단다. 마크다운에서 >로 시작하는 인용 줄이고, > 반증: <요약> — [증명](이 글 주소) 모양이다. 그리고 GitHub 이슈를 하나 연다. 이슈에 붙이는 kind: 모순은 「실험이 틀렸는지 규칙이 틀렸는지 따져야 하는 이슈」라는 분류다. 거기서 경계를 무엇으로 강제할지를 정한다. 가시성이 아니라면 ArchUnit 같은 검사가 후보다.


정리

  • public·protected·package-private 셋 다 트랜잭션 안이었다. 두 Boot 버전 모두 CGLIB
  • 호출 직전 상태는 전부 false였다. 앞 트랜잭션이 샌 착시가 아니다
  • 가시성으로 트랜잭션 경계를 그을 수 없다
  • 재지 않은 것 — 그렇게 동작하는 이유(스프링 안의 어느 코드가 package-private 메서드까지 가로채게 하는지), JDK 동적 프록시일 때, 자기 호출일 때

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기