Post

[ICTicket] - Redis 분산 락을 걸때 발생한 Transaction 문제

문제 상황


좌석 선택 기능에 분산 락을 적용한 후 테스트를 진행했는데, 좌석 상태가 DB에 정상적으로 업데이트되지 않는 문제가 발생했다. 원인을 확인해본 결과, 트랜잭션이 제대로 적용되지 않은 것을 발견했다.

왜 트랜잭션이 안걸렸을까?


  • 아래 그림의 outerMethod는 분산 락을 획득하고, 해제하는 역할을 하고, innerMethod는 좌석 선택 로직이다.
  • SeatService 안에 outerMethod는 @Transactional이 없고 innerMethod는 @Transactional이 있다. 이때, outerMethod를 통해서 innerMethod를 실행하게 되면 어떻게 될까?
  • innerMethod가 실행될 때 @Transactional이 적용되어서 트랜잭션이 시작되고, innerMethod가 종료될 때 트랜잭션이 커밋될 것으로 예상했지만, 그렇지 않았다.

  • 그 이유는 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출하기 때문이다. 스프링에서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체를 호출해야 한다.

  • 아래 그림처럼 API가 호출되었을 때, Proxy를 거쳐서 transaction begin이 실행되고, 타깃 객체의 메서드가 실행되고 다시 transaction commit이 실행되면서 트랜잭션이 끝난다.

  • 하지만 아래 그림처럼 @Transactional이 없는 outerMethod를 실행하면 Proxy를 통하지 않고, 바로 타깃 객체의 메서드를 실행해버리기 때문에, innerMethod에 @Transactional이 있더라도 트랜잭션이 적용되지 않는 것이다.

❌ 해결방법 1 - .save() (실패)


  • 첫 번째 적용해본 방법은 가장 간단하게 .save()를 명시적으로 작성해주어 세이브하는 방법을 썼다. 테스트 해봤을 때 업데이트가 적용되어서 해결된 것으로 생각했었지만, 사실상 트랜잭션을 사용하지 않는 방법이다 보니 여러개의 좌석을 예매할 때 문제가 발생했다.
  • 아래 그림처럼 여러개의 좌석 중에 3번 하나만 좌석 예매를 실패하더라도 전부 실패하게 되어야 하는데, 트랜잭션이 적용되지 않아서 roll back이 되지 않기 때문에 DB에는 해당 유저가 1,2번 좌석을 점유해버린 것으로 나타나게 되었다.

🟡 해결방법 2 - 클래스 분리 (실패)


  • 다음으로 시도해본 방법은 innerMethod와 outerMethod를 다른 서비스 클래스로 분리하는 방법이었다.
  • 아래 코드처럼 SeatService에 innerMethod, LockHandler에 outerMethod를 두어 분리하게 되면, outerMethod에서 innerMethod를 호출할때, seatService를 호출하면서 Proxy를 통하기 때문에 Transaction이 적용된다.
  • Transaction, 동시성 제어 모두 정상적으로 작동했지만, controller에 LockHandler 객체를 하나 더 불러와야 하는 등의 코드의 일관성, 유지보수성을 해치기 때문에 다른 방법을 찾기로 했다.

🟢 해결방법 3 - AOP, 클래스 분리 (성공)


  • 마지막 해결방법으로 AOP로 락을 관리하고, outerMethod joinpoint를 전달하여 innerMethod를 호출하도록 코드를 작성했다.
  • 좌석 선택 API가 실행되면 AOP가 실행되고, Lock을 획득하게 된다. 이제 Transactional을 적용한 outerMethod가 실행되면서 프록시를 통해 Transaction begin이 실행된다. innerMethod 로직을 수행하고 다시 Transaction commit 되면서 Transaction이 끝나고, 락 해제 해주면서 동시성제어까지 잘 작동하게 되었다.