Spring AOP를 활용해 Lock AOP를 구현하던 중, AOP가 제대로 적용되고 있지 않다는 사실을 알게 되었다.
@Service
@Slf4j
@RequiredArgsConstructor
public class ReservationService {
private final ReservationRepository reservationRepository;
private final RestaurantRepository restaurantRepository;
private final MemberRepository memberRepository;
@Transactional
public ReservationCreateResDto save(Long restaurantId, LocalDateTime reservationTime, int guestCount, Long memberId) {
Member member = memberRepository.findById(memberId.intValue())
.orElseThrow(() -> new RuntimeException("유저 조회 실패"));
Reservation createdReservation = Reservation.builder()
.member(member) //member 코드 추가시에 member 지정 가능
.reservationTime(reservationTime)
.status(ReservationStatus.CONFIRMED)
.guestCount(guestCount)
.isDeleted(false)
.build();
saveReservationWithLock(restaurantId, createdReservation, guestCount);
return new ReservationCreateResDto(
createdReservation.getId(), createdReservation.getMember().getId(),
createdReservation.getRestaurant().getId(), createdReservation.getReservationTime(),
createdReservation.getStatus());
}
public Restaurant findRestaurantById(Long restaurantId) {
return restaurantRepository.findById(restaurantId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));
}
@RedissonLock(key = "'lock:restaurant:' + #restaurantId")
@Transactional
public void saveReservationWithLock(Long restaurantId, Reservation reservation, int guestCount) {
Restaurant foundRestaurant = findRestaurantById(restaurantId);
reservation.setRestaurant(foundRestaurant);
reservationRepository.save(reservation);
foundRestaurant.changeValidSeatCount(-guestCount);
restaurantRepository.save(foundRestaurant);
}
}
saveReservationWithLock()은 외부에서 save() 호출 시 같이 호출이 되는 구조이다.
여기서 자기 자신의 메서드를 호출하는 것이 문제가 되는데, 이로 인해 saveReservationWithLock()에 적용된 AOP가 제대로 동작하지 않게 된다.
이는 Spring AOP가 proxy에 기반하기 때문인데, 아래 코드를 예시로 설명해보겠다.
public class Main {
public static void main(String[] args) {
ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new RetryAdvice());
Pojo pojo = (Pojo) factory.getProxy();
// this is a method call on the proxy!
pojo.foo();
}
}
Main.main()을 호출 시 프록시가 addInterface(), addAdvice() 수행 후 실제 Pojo 클래스의 메서드 foo()를 호출한다.
이렇게만 수행되면 문제가 없겠지만, 만약 SimplePojo 내부 메서드에서 자신의 메서드(예: bar())를 호출하게 되면 프록시를 거치지 않는다.
그러한 방식(self-invocation)으로 호출된 메서드들은 위 ProxyFactory가 수행하는 부가 기능들의 영향을 받지 않는다.
결국 AOP도 마찬가지로 self-invocation된 메서드들에는 작동하지 않는 것이다.
가장 좋은 방법은 자기 호출(self-invocation)을 피하는 것이다.
AOP가 적용되어야 하는 메서드는 별도의 클래스로 추출하고, 기존 클래스에서 이를 호출하는 식으로 구현하면 자기 호출을 피할 수 있다.
또한 책임의 분리도 이루어지므로 유지보수성이 올라가는 건 덤이다.
위에서 문제가 되었던 서비스 코드는 아래와 같이 클래스를 분리할 수 있다.
// 분리된 클래스
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.example.easytable.common.aop.annotation.RedissonLock;
import org.example.easytable.exception.CustomException;
import org.example.easytable.exception.ErrorCode;
import org.example.easytable.member.entity.Member;
import org.example.easytable.member.repository.MemberRepository;
import org.example.easytable.reservation.entity.Reservation;
import org.example.easytable.reservation.repository.ReservationRepository;
import org.example.easytable.restaurant.entity.Restaurant;
import org.example.easytable.restaurant.repository.RestaurantRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// ReservationService의 AOP Self-invocation 문제 해결을 위한 클래스
@Service
@RequiredArgsConstructor
public class ReservationLockingService {
private final EntityManager entityManager;
private final MemberRepository memberRepository;
private final ReservationRepository reservationRepository;
private final RestaurantRepository restaurantRepository;
@RedissonLock(key = "'lock:restaurant:' + #p0")
@Transactional
public void saveReservationWithLock(Long restaurantId, Long memberId, Reservation reservation, int guestCount) {
Member foundMember = memberRepository.findById(memberId.intValue())
.orElseThrow(() -> new RuntimeException("유저 조회 실패"));
Restaurant foundRestaurant = restaurantRepository.findById(restaurantId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));
if (foundRestaurant.isReservationAvailable(guestCount)) {
reservation.setRestaurant(foundRestaurant);
reservation.setMember(foundMember);
reservationRepository.save(reservation);
foundRestaurant.changeValidSeatCount(-guestCount);
restaurantRepository.save(foundRestaurant);
}
entityManager.flush();
}
}
// 기존 클래스
@Service
@RequiredArgsConstructor
public class ReservationService {
private final ReservationRepository reservationRepository;
private final RestaurantRepository restaurantRepository;
private final ReservationLockingService lockingService;
@Transactional
public ReservationCreateResDto save(Long restaurantId, LocalDateTime reservationTime, int guestCount, Long memberId) {
Reservation createdReservation = Reservation.builder()
.reservationTime(reservationTime)
.status(ReservationStatus.CONFIRMED)
.guestCount(guestCount)
.isDeleted(false)
.build();
lockingService.saveReservationWithLock(restaurantId, memberId, createdReservation, guestCount);
return new ReservationCreateResDto(
createdReservation.getId(), createdReservation.getMember().getId(),
createdReservation.getRestaurant().getId(), createdReservation.getReservationTime(),
createdReservation.getStatus());
}
}
혹은 객체 내부에서 자기 자신에 대한 프록시 객체를 주입받아 이를 호출하도록 하는 self injection을 통해서도 해결할 수 있다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class MyService {
// 자기 자신의 프록시를 주입받음 (Spring 컨테이너가 생성한 프록시 객체)
@Autowired
private MyService self;
public void process() {
System.out.println("process() 시작");
// 내부 메서드 호출 시 'this' 대신 self를 사용하여 프록시 체인을 거치게 함.
self.performTransactionalTask();
}
// AOP 어드바이스(예: 트랜잭션)가 적용되어야 하는 메서드
@Transactional
public void performTransactionalTask() {
System.out.println("performTransactionalTask() 호출 - 트랜잭션 어드바이스 적용됨");
// 실제 비즈니스 로직 처리
}
}
마지막으로 공식 문서에서도 권장하지 않지만, 클래스 내부 로직을 AOP와 연결해 해결할 수도 있다.
public class SimplePojo implements Pojo {
public void foo() {
// This works, but it should be avoided if possible.
((Pojo) AopContext.currentProxy()).bar();
}
public void bar() {
// some logic...
}
}
public class Main {
public static void main(String[] args) {
ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new RetryAdvice());
factory.setExposeProxy(true);
Pojo pojo = (Pojo) factory.getProxy();
// this is a method call on the proxy!
pojo.foo();
}
}
코드가 AOP와 완전히 결합되고, 클래스 내부에서도 AOP를 사용한다는 것을 알아야 하기에 AOP의 주요 특징인 책임 분리가 약해진다.
참고
https://docs.spring.io/spring-framework/reference/core/aop/proxying.html