ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [트랜잭션] @Transactional 전파 옵션, 프록시 패턴 트러블 슈팅
    백엔드/Spring 2023. 8. 7. 01:29
    반응형

    카카오 로그인 기능을 프론트엔드 크루인 황펭과 함께 연결하는데, 다른 팀원이 발생시킨 에러 로그인줄 알고 있었다가 나중에 에러 로그를 자세히 읽으면서 발견하게 되었습니다.

     

    하나의 에러에 156줄이나 되는 로그가 나왔는데, 이렇게 긴 에러 로그는 처음 보았고, 당시에 카카오 로그인 기능을 만드는데 CORS 에러가 계속 나와가지고, 이 에러의 존재를 나중에 알게 되어 고쳐나가기 시작했습니다 흑흑..

     

    중요한 로그는 아래 로그만 보면 됩니다. 읽기 전용 트랜잭션으로 열렸기 때문에 데이터 수정이 불가능하다는 것...!!

    java.sql.SQLException: Connection is read-only. Queries leading to data modification are not allowed

     

    이때부터 readOnly 에러의 늪에 빠지게 되었습니다...

     

    물론 쉬운 길은 카카오 로그인 전체 과정에 @Transactional을 붙이면 해결 됐었지만, 트랜잭션의 쓰기 작업 범위를 좁히려고 다양한 시도를 거듭했습니다.

     


     

     

     

    1. 초기 코드


    펀잇 팀의 @Transactional 컨벤션은 클래스단에 readOnly=True로 설정하는 것입니다.

    그리고 SRP를 지키기 위해서 카카오 로그인을 진행할 때, AuthService에서 loginMember를 호출하면 loginMemberMemberServicefindOrCreateMember를 호출했습니다.

    findOrCreateMember 메서드는 이미 가입된 사용자이면 찾아서 반환하고, 가입되지 않은 사용자는 같은 MemberService 클래스 내부에 있는 save 메서드를 통해 데이터베이스에 저장한 뒤에 반환해주는 로직인데요.

    결국 쓰기 연산은 save 메서드에서만 수행되기 때문에, save 메서드에만 @Transactional을 붙여주었습니다.


    [save 메서드에 private를 붙여주지 않은 이유]

    이 부분에 대해서는 고민을 많이 했었습니다.

     

    1) 아직 save 메서드가 다른 곳에서 사용하지 않는데, private로 설정해두고, findOrCreateMember@Transactional을 붙이는게 좋을까?

    2) @Transactional은 public 접근 제어자에서만 적용되는데 이걸 고려하면 save를 public으로 열어두는게 좋지 않을까?

     

    저는 언젠가 save가 쓰일 수도 있으니 살짝 오버엔지니어링으로 느껴졌지만 public으로 열어두었습니다.

     

    스프링 트랜잭션 공식 문서를 보면 @Transactional은 표준 값인 트랜잭션 프록시를 사용하면 public 메소드에서만 정의해야 작동한다고 나와있습니다.

    protect, private 접근 제어자에서도 @Transactional을 사용하면 에러가 발생하지 않지만, 트랜잭션이 적용이 되지 않는다고 하네요.

    공식 문서에 protect ,private 에서도 @Transactional을 적용할 수 있긴한데, 이거 하나 때문에 추가할 코드가 많아 보여 적용하지는 않았습니다.


    하지만 이렇게 적용해도 Connection is read-only. Queries leading to data modification are not allowed 에러는 그대로 나타났습니다.

     

    이 말은 제가 의도한 것과 다르게 자식 트랜잭션(@Transactional)이 부모 트랜잭션(@Transactional(readOnly=true))으로 계속 포함된다는 이야기입니다 😭


     

     

     

     

    2. REQUIRES_NEW를 적용해서 새로운 트랜잭션을 만들자!


    다음에는 자식 트랜잭션이 부모 트랜잭션에 포함된다면 전파 옵션을 통해 새로운 트랜잭션을 생성하면 되지 않을까?! 라는 생각이 들었습니다.

     

    그래서 테코톡 트랜잭션 준비를 하면서 공부했던 전파 옵션을 통해 REQUIRES_NEW를 적용하였습니다.

     

    어..??

    하지만 이렇게 적용해도 Connection is read-only. Queries leading to data modification are not allowed 에러는 그대로 나타났습니다.

     

     

    원래 의도는 위 사진처럼 자식 트랜잭션과 부모 트랜잭션이 따로 따로 작동하도록 코드를 작성했지만, 실제로는 아직도 아래 그림처럼 동작한다는 것이죠

     

    그러면 제 코드가 잘못 됐다는 것이니 관련 문서를 찾아보기 시작했습니다.

     

    [프록시로 동작하는 트랜잭션]

    스프링에서 @Transactional을 적용하면 트랜잭션이 스프링 AOP를 통해 실행되고, 스프링 AOP는 프록시 패턴을 사용해서 동작한다까지는 다들 알고 있을 겁니다.

    그런데 프록시 패턴으로 인해 트랜잭션을 적용할 때 주의해야할 사항이 존재합니다. 바로 내부 메서드 호출은 프록시를 거치지 않기 때문에 트랜잭션이 무시된다는 점인데요.

     

    위에 있던 코드의 트랜잭션 작동 방식을 그림으로 그려보자면 아래와 같습니다.

    findOrCreateMember 메서드가 호출된다면 위와 같은 작업이 거치게 됩니다.

    이때 save 메서드는 findOrCreateMember 메서드와 같은 클래스에 존재하기 때문에 @Transactional을 적용되도 무시된다는 것이죠

    그러면 위에서 언급한 private, protected 접근제어자에 트랜잭션을 적용해도 무시되었던 이유를 함께 찾게 되었습니다.


     

     

     

     

    3. 그러면 save가 아닌 findOrCreateMember 메서드에 트랜잭션을...


    그다음으로 수정해본 방법은 save 메서드를 private 접근 제어자로 변경한 뒤, @Transactional을 삭제하고, findOrCreateMember@Transactional을 진행하는 것입니다.

     

    저는 MemberService@Transactional(readOnly=true)가 있어도 메서드에 트랜잭션 옵션이 있으면 메서드 기준으로 적용된다는 것까지는 알고 있었습니다.

     

    그래서 의도했던 동작 방식은 아래와 같았습니다.

    부모 트랜잭션은 읽기 전용 트랜잭션으로 진행하되, 자식 트랜잭션은 따로 돌아가는 것이죠

     

    하지만 이렇게 적용해도 Connection is read-only. Queries leading to data modification are not allowed 에러는 그대로 나타났습니다 🫠🫠🫠

    아무리 해도 실제 상황은 아래 그림처럼 계속 부모 트랜잭션에 참여가 되었습니다.

     

     

    이때 전파 레벨에 대해 다시 생각해보았습니다. @Transactional의 Propagation Default 값은 REQUIRED인데, 제가 잘못된 지식을 가지고 있었습니다.

     

    - 이전까지 생각했던 동작 방식 : 다른 클래스에서 호출되면 전파 옵션이 기본값이어도 자식 트랜잭션이 부모 트랜잭션으로 참여되지 않는다.

    - 실제 동작 방식 : 전파 옵션이 REQUIRED이므로 부모 트랜잭션이 존재하면 무조건 부모 트랜잭션에 포함된다.

     

    그러면 이제 제가 원래 의도했던 방식으로 하려면 전파 옵션을 REQUIRES_NEW로 설정하면 됐었습니다.... 


     

     

     

     

    4. REQUIRES_NEW를 적용하니까 테스트 코드는 왜 실패하지...??


    이제 성공적으로 의도한대로 코드를 만들었으니, 테스트 코드를 돌리고 PR을 올릴 일만 남았습니다.

     

    그런데... 아무것도 건들지 않았던 테스트 코드가 갑자기 터져버리는 것이었습니다.

    REQUIRES_NEW만 설정했는데 도대체 왜..??? 😭

     

    테스트 내용은 기존 회원이라면 가입하지 않고, MemberRepository에서 회원을 찾습니다.

     

    다행히 이건 빠르게 캐치할 수 있었습니다.

    테스트 격리가 이루어져 있는데, REQUIRES_NEW로 인해 테스트 격리 안에 또 다른 격리가 이루어진 것이었습니다.

     

     

    위 사진처럼 일반적인 테스트 격리라면 @Transactional로 인해 테스트 메서드 전체에 격리가 적용됩니다.

    하지만 REQUIRES_NEW를 사용한다면 이미 격리된 테스트 안에 또 다른 격리가 생성되는 것이죠

     

    그래서 findOrCreateMember 메서드는 테스트 메서드에 진행된 데이터를 얻을 수 있지만, 테스트 메서드에서는 findOrCreateMember 메서드가 트랜잭션이 종료되어 진행된 데이터를 얻을 수 없습니다.

     

    테스트하는 내용이 기존 회원이라면 MemberRepository에서 회원을 반환하는 테스트인데, 다음과 같은 과정에 문제가 발생해서 테스트가 실패하게 됩니다.

     

    1) 테스트 메서드에서 MemberRepository에 회원을 저장함

    2) findOrCreateMember 메서드에서는 테스트 메서드에서 회원을 저장한 것이 보이지 않음. 그래서 회원이 존재하지 않는 것으로 생각함

    3) findOrCreateMember 메서드는 신규회원을 생성하고 반환함

    4) 테스트 메서드에서는 findOrCreateMember 메서드에서 신규 회원을 얻음

    5) 기존 회원 != 신규 회원으로 인해 테스트 실패

     

    이건 REQUIRES_NEW로 인해 테스트가 불가능하니, 테스트 할 수 있도록 해야합니다.

    테스트할 수 있는 방법은 두 가지가 있습니다.

    1) PSA (Portable Service Abstraction)

    MemberService를 interface로 만들어서 Product용 MembrerService, Test용 MemberService를 만드는 것입니다.

    하지만 문제가 되는 메서드는 하나인데, 이걸 위해 TestMemberService에서 모든 메서드를 구현해야 하는 것인지 의문이 들었습니다.

    거기다가 ProductMemberService는 MemberService의 구현체인데, 다른 서비스 코드들도 전부 interface를 만들어야 하는게 아닌가?라는 생각도 들었습니다.

    이거 하나 때문에 파생되는 비용이 매우 커져버렸습니다.

     

    2) MemberService를 상속하는 TestMemberService 만들기

    지금 테스트가 불가능한 원인은 REQUIRES_NEW 때문입니다. 트랜잭션 전파 옵션으로 인해 테스트가 불가능한 것이죠

    테스트를 가능하게 만드려면 전파 옵션을 REQUIRES_NEW에서 REQUIRED로만 변경해주면 됩니다.

    왜냐하면 프로덕트는 읽기 전용 트랜잭션 때문에 REQUIRES_NEW로 변경한 것이지, 테스트는 REQUIRED로 진행하고 있기 떄문입니다.

    그래서 저는 이 방법을 선택했습니다.

     

    이 방법의 단점은 테스트를 진행할 때, MemberService가 아닌 TestMemberService를 사용해야 한다는 점입니다.

    하지만 아직까지는 현재 상황에서 TestMemberService를 사용하는게 이득이라 다른 조치를 취하지는 않았습니다.

     

    적용이 안되서 REQUIRES_NEW로 변경하고 머지 완료


     

     

    재밌네요

    반응형

    댓글

Designed by Tistory.