Redis 분산 lock 세션

김형준·2025년 2월 24일
0

전체 코드

ReservationController.java

import lombok.RequiredArgsConstructor;
import org.example.easytable.common.utils.AuthUtil;
import org.example.easytable.reservation.dto.request.ReservationPostReqDto;
import org.example.easytable.reservation.dto.response.ReservationCreateResDto;
import org.example.easytable.reservation.dto.response.ReservationGetResDto;
import org.example.easytable.reservation.service.ReservationService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/reservations")
@RequiredArgsConstructor
public class ReservationController {

    private final ReservationService reservationService;

    @PostMapping("/{restaurantId}")
    public ResponseEntity<ReservationCreateResDto> createReservation(
            @PathVariable Long restaurantId,
            @RequestBody ReservationPostReqDto requestDto
    ) {
        Long memberId = AuthUtil.getId();

        reservationService.createReservation(restaurantId, memberId, requestDto);

        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    @GetMapping("/{restaurantId}")
    public ResponseEntity<List<ReservationGetResDto>> getReservation(
            @PathVariable("restaurantId") Long restaurantId
    ) {

        List<ReservationGetResDto> reservation = reservationService.getReservationByRestaurant(
                restaurantId);

        return new ResponseEntity<>(reservation, HttpStatus.OK);
    }

    @GetMapping("/")
    public ResponseEntity<List<ReservationGetResDto>> getReservationByMember() {
        List<ReservationGetResDto> reservation = reservationService.getReservationByMember();

        return new ResponseEntity<>(reservation, HttpStatus.OK);
    }

    @DeleteMapping("/{reservationId}")
    public void deleteReservation(
            @PathVariable("reservationId") Long reservationId
    ) {

        reservationService.deleteReservation(reservationId);

    }
}

ReservationService.java

import lombok.RequiredArgsConstructor;
import org.example.easytable.common.aop.annotation.LockKey;
import org.example.easytable.common.aop.annotation.RedissonLock;
import org.example.easytable.common.utils.AuthUtil;
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.dto.request.ReservationCreateReqDto;
import org.example.easytable.reservation.dto.request.ReservationPostReqDto;
import org.example.easytable.reservation.dto.response.ReservationCreateResDto;
import org.example.easytable.reservation.dto.response.ReservationGetResDto;
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;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final RestaurantRepository restaurantRepository;
    private final MemberRepository memberRepository;

    @RedissonLock(prefix = "restaurant:")
    @Transactional
    public ReservationCreateResDto createReservation(@LockKey Long restaurantId, Long memberId, ReservationPostReqDto reservationPostReqDto) {

        System.out.println("Creating reservation with memberId: " + memberId);

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));

        Restaurant restaurant = restaurantRepository.findById(restaurantId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));

        restaurant.decreaseRemainingTableCount();

        Reservation newReservation = Reservation.builder()
                .member(member)
                .restaurant(restaurant)
                .reservationTime(reservationPostReqDto.reservationTime())
                .build();

        reservationRepository.save(newReservation);

        return ReservationCreateResDto.from(newReservation);
    }

    // TODO: @RedissonLock의 @LockKey 의존도를 낮춰 @LockKey를 사용하지 않고도 @RedissonLock를 적용할 수 있도록 수정할 것
    @Transactional
    public ReservationCreateResDto createReservation(ReservationCreateReqDto dto) {
        Long memberId = dto.getMemberId();
        Long restaurantId = dto.getRestaurantId();
        ReservationPostReqDto reservationPostReqDto = dto.getReservationPostReqDto();

        System.out.println("Creating reservation with memberId: " + memberId);

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));

        Restaurant restaurant = restaurantRepository.findById(restaurantId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));

        restaurant.decreaseRemainingTableCount();

        Reservation newReservation = Reservation.builder()
                .member(member)
                .restaurant(restaurant)
                .reservationTime(reservationPostReqDto.reservationTime())
                .build();

        reservationRepository.save(newReservation);

        return ReservationCreateResDto.of(newReservation, dto.getRequestId());
    }

    public List<ReservationGetResDto> getReservationByRestaurant(Long restaurantId) {

        if (!restaurantRepository.existsById(restaurantId)) {
            throw CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다");
        }

        List<Reservation> reservationList = reservationRepository.findByRestaurantId(restaurantId);

        // TODO: N+1 개선 필요 - Member, Restaurant 조회 시 발생
        return reservationList.stream()
                .map(ReservationGetResDto::from)
                .collect(Collectors.toList());
    }

    public List<ReservationGetResDto> getReservationByMember() {
        Long memberId = AuthUtil.getId();

        // TODO: N+1 개선 필요 - Member, Restaurant 조회 시 발생
        return reservationRepository.findByMemberId(memberId).stream()
                .map(ReservationGetResDto::from)
                .collect(Collectors.toList());
    }

    @Transactional
    public void deleteReservation(Long reservationId) {
        Long memberId = AuthUtil.getId();

        Reservation reservation = reservationRepository.findById(reservationId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 예약입니다"));

        if (!reservation.getMember().getId().equals(memberId)) {
            throw CustomException.of(ErrorCode.FORBIDDEN, "본인의 예약만 취소할 수 있습니다");
        }

        reservation.softDelete();
    }

}

RedissonLock.java

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RedissonLock {
    String prefix(); // 🔥 락 키의 prefix (예: "restaurant:")
}

LockKey.java

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER) // 파라미터에서만 사용 가능
public @interface LockKey {
}

RedissonLockAspect.java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.example.easytable.common.aop.annotation.LockKey;
import org.example.easytable.common.aop.annotation.RedissonLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockAspect {
    private final RedissonClient redissonClient;

    @Value("${spring.data.redis.lock.ttl:2000}")  // 락 유지 시간 (기본값 2초)
    private int ttl;

    @Value("${spring.data.redis.lock.wait:3000}") // 락 대기 시간 (기본값 3초)
    private int waitTime;

    @Around(value = "@annotation(redissonLock)")
    public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
        log.debug("🔒 Lock 점유 실행 중...");

        // 1️⃣ `@LockKey`가 붙은 파라미터 값을 가져와 락 키 생성
        String lockKey = generateLockKey(joinPoint, redissonLock);

        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();

        // 2️⃣ 트랜잭션이 실행 중인지 확인
        if (!method.isAnnotationPresent(Transactional.class) ||
                !TransactionSynchronizationManager.isActualTransactionActive()) {
            throw new IllegalStateException("🚨 트랜잭션이 적용되고 있지 않습니다.");
        }

        // 3️⃣ 락 획득
        RLock rLock = redissonClient.getLock(lockKey);

        // 4️⃣ 트랜잭션 종료 이후 락 해제 보장
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                if (rLock.isHeldByCurrentThread()) {
                    rLock.unlock();
                    log.debug("🔓 락 해제 완료");
                }
            }
        });

        try {
            // 5️⃣ 락 획득 (대기 시간 & 유지 시간 설정)
            if (rLock.tryLock(waitTime, ttl, TimeUnit.MILLISECONDS)) {
                return joinPoint.proceed(); // 6️⃣ 서비스 로직 실행
            }
            throw new RuntimeException("🚨 락 획득 실패: " + lockKey);
        } catch (InterruptedException e) {
            throw new RuntimeException("🚨 예기치 않은 오류 발생", e);
        }
    }

    /**
     * 🔑 `@LockKey`가 붙은 값을 찾아서 락 키 생성
     */
    private String generateLockKey(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] args = joinPoint.getArgs();
        Annotation[][] parameterAnnotations = method.getParameterAnnotations();

        String prefix = redissonLock.prefix(); // `@RedissonLock(prefix = "...")` 값 가져오기

        for (int i = 0; i < parameterAnnotations.length; i++) {
            for (Annotation annotation : parameterAnnotations[i]) {
                if (annotation instanceof LockKey) {
                    return prefix + args[i]; // 🔑 prefix + `@LockKey`가 붙은 값 사용
                }
            }
        }

        throw new IllegalArgumentException("🚨 `@LockKey`가 붙은 파라미터가 필요합니다.");
    }
}

RedissonConfig.java

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host:localhost}")  // ✅ 기본값 유지 (localhost)
    private String host;

    @Value("${spring.data.redis.port:6379}")  // ✅ 기본값 유지 (6379)
    private int port;

    @Value("${spring.data.redis.password:}")  // ✅ 기본값 유지 (비밀번호 없을 경우 빈 문자열)
    private String password;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress(String.format("redis://%s:%d", host, port));

        // 🔥 비밀번호가 설정된 경우에만 인증 적용 (기본값 유지)
        if (password != null && !password.trim().isEmpty()) {
            config.useSingleServer().setPassword(password);
        }

        return Redisson.create(config);
    }
}

실행 흐름

  1. ReservationController에서 예약 추가를 위해 서비스 계층을 호출
  2. 호출되는 createReservation에서는 @Transactional로 트랜잭션 실행 중이며 @RedissonLock에 “restaurant:” 문자열을 전달
  3. RedissonLockAspect에서는 @RedissonLock이 붙은 메서드를 탐지함
  4. ProceedingJoinPoint로 createReservation를 가져와 generateLockKey()에서 @LockKey가 매개변수에 사용되었는지 검사
    1. 사용되었다면 “restaurant:” 접두사와 @LockKey값을 합쳐 반환
    2. 사용되지 않았다면 에러 반환
  5. 현재 ProceedingJoinPoint에서 트랜잭션이 실행 중인지 확인
    1. 트랜잭션 실행 여부는 @Transactional 사용 여부, TransactionSynchronizationManager.isActualTransactionActive() 참 여부로 결정
  6. 검증이 완료되었다면 RedissonClient를 통해 RLock 객체를 생성
    1. redis에서 lockKey 키 값이 존재하는지 확인하고, 없다면 lock 획득, 있다면 대기하는 방식
  7. TransactionSynchronizationManager.registerSynchronization()를 통해 트랜잭션이 commit 또는 rollback으로 인해 종료된 시점에 lock이 해제되도록 설정
    1. 트랜잭션이 DB에 완전히 반영되기 전 lock이 풀려 예상치 못한 동작이 발생하는 것을 방지하기 위함
  8. RLock.tryLock()으로 waitTime 시간 동안 lock 획득을 시도함
    1. 획득한 lock은 ttl만큼의 시간 제한을 가짐
    2. waitTime 시간 동안 lock 획득 실패 시 에러 반환
  9. lock 획득 이후 ProceedingJoinPoint(여기서는 createReservation 메서드)를 수행
  10. createReservation 메서드 수행 종료 시 @Transactional AOP에서 commit/rollback을 처리함
  11. 트랜잭션 종료 이후 lock이 해제됨

분산 lock 사용 이유

전체적인 비교

비관적 lock은 DB 상의 데이터에 대한 배타적 lock을 거는 방식이므로 데이터 정합성을 가장 확실하게 보장해주지만, 트래픽이 몰릴 경우 응답 시간이 길어지기 쉽다.

낙관적 lock은 주로 lock을 거는 대신 테이블에 version 또는 timestamp 필드를 붙여 commit 시 이를 검증하도록 구현하므로 Deadlock 및 응답 속도 이슈로부터 자유롭다.

하지만 여러 스레드의 update 결과 간 충돌이 발생하는 경우 버전이 일치하지 않는 쪽이 버려지게 되므로 데이터 정합성 문제가 발생하기 쉽다.

식당 예약의 경우 데이터 정합성이 확실히 지켜질 필요가 있는 서비스라고 판단되어 사용하지 않는다.

Synchronized를 이용해 스레드 간 실행 순서를 맞추는 방식은 다중 서버 환경에서 동시성을 보장하지 못한다.

Spin lock 사용 시 lock 획득을 지속적으로 시도하므로 lock 해제에 대해 즉각적으로 반응할 수 있다.

하지만 lock 획득을 시도하는 만큼 스레드가 CPU 자원을 소모하게 되고, Redis에 부하가 커지는 문제가 발생한다.

따라서 다중 인스턴스 환경에서도 문제없이 작동하며, CPU 및 Redis에 부담을 덜 줄 수 있으면서도 동시성을 확실히 보장하는 분산 락을 도입하였다.

다만 분산 lock도 만능은 아니며 구현이 복잡하고 Redis-서버 간 네트워크 상황에 따라 지연 시간이 길어질 수 있다.

VS 비관적 락

현재 배포 환경에서는 여러 ECS들이 하나의 MYSQL을 공유 중이다.

이러한 환경에서 DB 상에서 비관적 락을 적용하면 실질적으로 분산 락과 비슷하다고 느껴질 수 있다.

하지만 비관적 락은 모든 락 요청 및 DB 작업 요청을 MYSQL에서 모두 처리해야 하므로 DB의 부담이 커진다.

따라서 lock 관리는 Redis에서, DB 작업 요청은 MYSQL에서 처리하도록 분리해 각각의 부담을 줄일 수 있다.

Redisson

Redis 및 Valkey를 Java 상에서 쉽게 사용할 수 있도록 구현된 라이브러리

분산 lock 매커니즘을 직접 구현해야 하는 Spring Data Redis와 달리 분산 lock에 대한 인터페이스도 제공한다.

RLock

Redis에 대한 재진입 가능한 분산 lock 기능을 제공하는 인터페이스

lock 획득을시도하는 경우 pub/sub 채널을 통해 다른 모든 RLock 인스턴스에 알림을 전송한다.

tryLock()

대기 시간동안 비동기 방식의 락 획득을 시도하는 메서드

lua script를 이용해 Redis lock 획득을 원자적으로 시도함 → lock 존재 여부 확인과 락 획득 작업 간 동기화가 지켜진다.

RLock rlock = redissonClient.getLock("myLock");
rLock.tryLock(6000, 5000, TimeUnit.MILLISECONDS); // lock 획득 성공 시

Redis 상에서는 아래와 같이 저장됨
Key: "myLock"
Field: [스레드 고유 식별자]
Value: [해당 스레드가 lock을 획득한 횟수(default 0)]

lock 획득 시 수행되는 Lua script는 아래와 같다.

  • 키에 대한 존재 여부 확인(exists) 실패 시 새로운 키를 생성하고 위와 같이 Field 값을 정의한다.
  • 현재 스레드가 이미 락을 보유하고 있는 상황이라면(hexists) value 값을 1 증가시킨다.
  • lock 획득에 성공 시 TTL을 설정해 일정 시간이 지나면 자동으로 락을 해제한다.(pexpire)

lock 획득 실패 시 Redis의 Pub/Sub 매커니즘을 활용해 락 해제 이벤트가 전달될 때 까지 스레드를 대기시킨다.

대기 시간(위의 경우 6000ms)동안 이벤트가 전달되었지만 다른 스레드가 기회를 얻어 락 획득에 실패하면 내부적으로 대기 시간 값을 감소시키며 락 획득을 재시도한다.

  • 재시도 시 Pub/Sub 매커니즘을 이용하는 방식을 따른다.

ReadWriteLock

read 작업의 경우 여러 스레드들이 동시에 lock을 걸 수 있지만, write 작업의 경우에는 RLock과 동일하게 하나의 스레드만 lock을 걸 수 있는 분산 lock

read/write 작업을 분리해 더 빠른 조회가 가능하면서도 데이터 정합성이 유지되지만, 지속적인 read 수행 시 write lock을 획득하지 못하는 starvation 현상이 발생할 수 있음

현재 식당 예약 서비스의 경우 배포를 가정했을 때 read/write 중 write가 더 많이 발생할 가능성이 높다.

이러한 경우 ReadWriteLock의 장점보다 read/write 요청 구분 시 발생하는 오버헤드가 더 크게 다가올 것이라고 판단했고, 따라서 RLock을 사용했다.

ProceedingJoinPoint

AspectJ AOP 대상이 되는 메서드에 대한 정보를 제공하는 인터페이스

AOP 내에서 proceed()를 통해 어느 시점에 실행될 것인지, 어떤 방식으로 실행시킬 것인지 정할 수 있다.

try {
		// 5️⃣ 락 획득 (대기 시간 & 유지 시간 설정)
	  if (rLock.tryLock(waitTime, ttl, TimeUnit.MILLISECONDS)) {
		    return joinPoint.proceed(); // 6️⃣ 서비스 로직 실행
		}
    throw new RuntimeException("🚨 락 획득 실패: " + lockKey);
} catch (InterruptedException e) {
    throw new RuntimeException("🚨 예기치 않은 오류 발생", e);
}

위 코드에서는 lock 획득에 성공한 경우에만 대상 메서드(여기서는 createReservation())를 실행하도록 설정했다.

또한 ProceedingJoinPoint를 통해 메서드의 각 정보들에 접근할 수 있다.

// joinPoint의 메서드 시그니처에 접근
// 메서드 시그니처는 메서드명, 매개변수 타입&개수 및 순서로 구분되는 조합
MethodSignature signature = (MethodSignature) joinPoint.getSignature();

// 시그니처로부터 실제 호출된 메서드 객체를 추출
Method method = signature.getMethod();

// joinPoint의 매개변수에 접근
Object[] args = joinPoint.getArgs();

// 실제 메서드에서 사용된 어노테이션들에 접근
Annotation[][] parameterAnnotations = method.getParameterAnnotations();

이를 통해 createReservation()의 매개변수에서 원하는 어노테이션이 적용되었는지를 검사할 수 있다.

for (int i = 0; i < parameterAnnotations.length; i++) {
    for (Annotation annotation : parameterAnnotations[i]) {
        if (annotation instanceof LockKey) {...} // joinPoint에 @LockKey가 사용되었는지 검증
		}
}

트랜잭션과 lock 범위

try-finally 구조로 lock 해제 시 메서드 실행이 끝나는 지점에서 바로 해제된다.

반면 TransactionSynchronizationManager.registerSynchronizationafterCompletion()로 lock 해제 시 메서드 종료 이후 트랜잭션이 끝나는 시점에 맞춰 lock이 해제된다.

@Transactional 사용 시 AOP 프록시 내부에서는 아래와 같은 순서로 트랜잭션 흐름이 이루어진다.

  1. 트랜잭션 시작
  2. 비즈니스 로직(메서드) 수행
  3. 메서드 종료
  4. 트랜잭션 처리
    1. 이 때 commit, rollback 처리가 이루어짐
  5. 트랜잭션 종료

이 흐름에서 try-finally 구조는 3번 완료 시점에 lock을 해제하며, afterCompletion()은 4번 완료 시점에 lock을 해제한다.

tryfinally의문제점.drawio.png

큰 차이는 아니라고 생각될 수도 있다.

하지만 이로 인해 트랜잭션이 commit 되기 전에 다른 스레드가 lock을 잡아 작업할 수 있게 되며, 이로 인해 동시성 문제가 발생할 수 있다.

따라서 afterCompletion()을 적용해 동시성 문제를 예방할 수 있다.

TransactionSynchronizationManager

DB 뿐 아니라 애플리케이션 레벨에서도 서비스 로직에 대해 트랜잭션을 관리해야 하는 일들이 생기기 마련인데, 이러한 트랜잭션에서 요구되는 Connection Resource 동기화를 개발자의 별도 구현 없이 알아서 수행해주는 추상 클래스이다.

또한 TransactionSynchronizationManager는 내부적으로 ThreadLocal을 사용해 스레드 별 트랜잭션 리소스 관리, DB Connection과 스레드 간 바인딩, 현재 트랜잭션의 상태 제공 및 트랜잭션의 단계별로 수행될 콜백을 등록할 수 있도록 한다.

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
		@Override
    public void afterCompletion(int status) {
		    if (rLock.isHeldByCurrentThread()) {
		        rLock.unlock();
            log.debug("🔓 락 해제 완료");
        }
		}
});

AOP에서는 위에서 소개한 트랜잭션-락 범위 문제를 해결하기 위해 TransactionSynchronization 익명 클래스를 정의하고 이를 TransactionSynchronizationManager의 동기화 콜백으로 등록하였다.

TransactionSynchronization.afterCompletion()은 트랜잭션이 완전히 종료된 경우(commit/rollback)에만 실행되는 메서드로, 이를 통해 트랜잭션 종료 시 lock이 해제되도록 보장받을 수 있다.

self-invocation

같은 클래스 내에서 한 메서드가 다른 메서드를 호출하는 것을 말한다.

단순 Java 코드 내에서는 문제될 게 없지만, Spring 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가 적용되어야 하는 메서드는 별도의 클래스로 추출하고, 기존 클래스에서 이를 호출하는 식으로 구현하면 자기 호출을 피할 수 있다.

또한 책임의 분리도 이루어지므로 유지보수성이 올라가는 건 덤이다.

혹은 객체 내부에서 자기 자신에 대한 프록시 객체를 주입받아 이를 호출하도록 하는 self injection을 통해서도 해결할 수 있다.

마지막으로 공식 문서에서도 권장하지 않지만, 클래스 내부 로직을 AOP와 연결해 해결할 수도 있다.

코드가 AOP와 완전히 결합되고, 클래스 내부에서도 AOP를 사용한다는 것을 알아야 하기에 AOP의 주요 특징인 책임 분리가 약해진다는 문제가 있으므로 권장되지 않는다.

참고

Redisson

https://redisson.org/docs/data-and-services/locks-and-synchronizers/#lock

TransactionalSynchroizationManager

https://bimmm.tistory.com/51

Self-Invocation

https://docs.spring.io/spring-framework/reference/core/aop/proxying.html

0개의 댓글

관련 채용 정보