[Spring] MSA 아키텍처에서 Redis를 활용한 분산락 적용을 Spring AOP를 활용해 재사용성 높게 적용하는 방법

가오리·2024년 2월 17일
1

BackEnd

목록 보기
1/14
post-thumbnail

왜 분산락인가?

MSA 환경에서는 서비스가 여러 인스턴스로 수평 확장되어, 동일 데이터(예: 주문 처리, 재고 차감 등)에 동시 접근 시 race condition이 발생할 수 있습니다.

전통적 DB 락은 성능 부하와 데드락 리스크가 크고, 클러스터 환경 간 락 공유가 제한적입니다.

Redis 분산락은 대기 시간·만료 설정이 유연하고, 클러스터·Sentinel·Single 모드 모두 지원해 확장성·고가용성을 만족합니다.

Spring AOP로 락 획득·해제 로직을 애노테이션 한 줄로 추상화하면, 비즈니스 코드와 완전 분리된 재사용 가능한 분산락 모듈을 만들 수 있습니다.

1. 환경 및 라이브러리 준비

build.gradle 예시:

dependencies {
    // AOP
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    // Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    // Redisson (분산락 클라이언트)
    implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
}

2. Redis·Redisson 설정

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}") private String host;
    @Value("${spring.redis.port}") private int port;
    @Value("${spring.redis.password:}") private String password;

    @Bean
    public RedissonClient redissonClient() {
        Config cfg = new Config();
        // Single 서버 모드
        cfg.useSingleServer()
           .setAddress("redis://" + host + ":" + port)
           .setPassword(password.isEmpty() ? null : password);
        // └ Cluster나 Sentinel 모드 샘플도 아래처럼 추가 가능
        // cfg.useClusterServers()…;  cfg.useSentinelServers()…;
        return Redisson.create(cfg);
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory factory = new LettuceConnectionFactory(host, port);
        if (!password.isEmpty()) factory.setPassword(password);
        return factory;
    }
}

Tip: 운영 환경별(Cluster/Sentinel) 설정 예시는 [Redisson 공식 문서]를 참고하세요.

3. @DistributedLock 애노테이션

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    /**
     * SpEL 표현식으로 락 키를 생성합니다.
     * ex) "order:#{#orderId}"
     */
    String key();

    /** 락 획득 최대 대기 시간 (default: 5초) */
    long waitTime() default 5;

    /** 락 만료 시간 (default: 10초) */
    long leaseTime() default 10;

    /** 시간 단위 (default: SECONDS) */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}
  1. 분산락 AOP 구현
@Aspect
@Component
public class DistributedLockAspect {

    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();

    public DistributedLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Around("@annotation(lockAnno)")
    public Object around(ProceedingJoinPoint pjp, DistributedLock lockAnno) throws Throwable {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        Method method = sig.getMethod();

        // 1) SpEL로 락 키 파싱
        String lockKey = parseKey(lockAnno.key(), method, pjp.getArgs());
        RLock lock = redissonClient.getLock(lockKey);

        // 2) 락 획득 시도
        boolean acquired = lock.tryLock(lockAnno.waitTime(), lockAnno.leaseTime(), lockAnno.timeUnit());
        if (!acquired) {
            throw new LockAcquisitionException("Lock 획득 실패: " + lockKey);
        }

        try {
            // 3) 비즈니스 로직 실행
            return pjp.proceed();
        } finally {
            // 4) 항상 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private String parseKey(String spelKey, Method method, Object[] args) {
        EvaluationContext ctx = new StandardEvaluationContext();
        String[] params = nameDiscoverer.getParameterNames(method);
        if (params != null) {
            for (int i = 0; i < params.length; i++) {
                ctx.setVariable(params[i], args[i]);
            }
        }
        return parser.parseExpression(spelKey).getValue(ctx, String.class);
    }
}
  • parseKey(): 메서드 인자를 SpEL 변수(#argName)로 바인딩해, survey:#{#dto.id} 같은 동적 키를 지원합니다.

  • tryLock(wait, lease, unit): 대기 시간(waitTime) 동안 락을 시도하고, 성공 시 leaseTime 후 자동 해제됩니다.

  • finally에서 unlock()을 호출해, 예외가 나도 락이 풀리도록 보장합니다.

5. 실제 적용 예시


@Service
public class SurveyService {

    // surveyDto.id별로 동시성 제어
    @DistributedLock(key = "survey:#{#dto.surveyId}", waitTime = 3, leaseTime = 5)
    public void submitSurvey(SurveyDto dto) {
        // 1) 중복 응답 검사
        // 2) DB에 저장
        // 3) 후속 알림 처리
    }
}

key에 SpEL 표현식을 넣어, DTO 속성이나 메서드 파라미터를 바로 락 키로 사용합니다.

waitTime·leaseTime·timeUnit 속성으로, 서비스별 성격에 맞게 획득 대기·만료 타이밍을 미세 조정할 수 있습니다.

6. 운영환경에서 고려할 점

모니터링 & 알람

획득 실패율, 대기 시간 통계(Micrometer → Prometheus) → 임계치 이상 시 알람

장애 복구

애플리케이션 강제 종료 시 자동 해제를 보장하려면 leaseTime을 꼭 설정하고, Redis 만료 정책도 검토

트랜잭션과 순서

락 해제(unlock)와 DB 커밋 타이밍이 잘 맞아야, 트랜잭션 롤백 시에도 락이 풀리도록 설계

테스트 전략

Embedded Redis를 이용한 통합 테스트로, 멀티 스레드 환경에서 분산락 시나리오 검증

Cluster/Sentinel 지원

운영 Redis 구성에 따라 Redisson 설정(Cluster/Sentinel) 예시 코드를 추가해 두면 실무 적용이 편리

결론

Spring AOP + Redis(Redisson) 분산락 모듈을 애노테이션 한 줄로 추상화하면,

  • 비즈니스 코드와 완전 분리

  • 다양한 락 키·타이밍 조절 가능

  • MSA 환경의 동시성 문제를 깔끔하게 해결

profile
가오리의 개발 이야기

0개의 댓글