1. AOP (Aspect Oriented Programming)
1) 개념 및 특징
AOP는 "관점 지향 프로그래밍"입니다. 애플리케이션 전체에 걸쳐 반복되는 부가 기능(로깅, 트랜잭션, 보안, 실행 시간 측정 등)을 '횡단 관심사(Cross-cutting Concerns)'라고 부르며, 이것을 비즈니스 로직에서 분리해 내는 프로그래밍 패러다임입니다.
2) 장점 (Pros)
- 코드 중복 제거: 반복되는 코드를 한곳에 모아 관리할 수 있습니다.
- 비즈니스 로직 집중: 핵심 로직이 깔끔해져 가독성과 유지보수성이 좋아집니다.
- 생산성 향상: 어노테이션 하나로 복잡한 기능을 적용할 수 있습니다.
3) 단점 (Cons)
- 디버깅의 어려움: 코드 흐름이 눈에 보이지 않고 숨겨져 있어(Magic), 실행 흐름을 추적하기 어렵습니다.
- 러닝 커브: Pointcut, Advice, JoinPoint 등 AOP 용어와 설정법을 익혀야 합니다.
- 성능 오버헤드: 런타임에 프록시 객체를 생성하고 호출하므로 미세한 성능 저하가 있을 수 있습니다.
4) 예시 코드 (실행 시간 측정)
@Aspect
@Component
public class ExecutionTimer {
// @LogExecutionTime 어노테이션이 붙은 메서드를 가로챔
@Around("@annotation(LogExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
// 원래 메서드 실행
Object proceed = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - start;
System.out.println(joinPoint.getSignature() + " 실행 시간 : " + executionTime + "ms");
return proceed;
}
}
2. Redisson (Redis Client)
1) 개념 및 특징
Redisson은 Java에서 사용하는 Redis 클라이언트 라이브러리입니다. 단순히 문자열(String)만 다루는 것이 아니라, Redis를 통해 분산 락, 분산 컬렉션(Map, Set, List 등)을 쉽게 사용할 수 있도록 도와줍니다. 특히 동시성 제어(분산 락) 분야의 표준처럼 사용됩니다.
2) 장점 (Pros)
- Pub/Sub 방식: 락 획득을 위해 무한 반복(Spin Lock)하지 않고, 알림을 기다리는 방식이라 Redis 부하가 매우 적습니다.
- 편리한 인터페이스: java.util.concurrent.locks.Lock 인터페이스를 구현하여 사용법이 자바 표준과 비슷합니다.
- 안전장치 (WatchDog): 락을 잡은 서버가 죽었을 때 데드락을 방지하는 자동 연장/해제 기능이 있습니다.
3) 단점 (Cons)
- 의존성: 별도의 외부 라이브러리(Redisson)를 추가해야 합니다.
- Redis 필수: Redis 서버가 죽으면 관련 기능이 모두 마비됩니다.
- 직접 사용 시 코드 복잡: 비즈니스 로직 사이에 락 획득/해제 코드가 섞여 코드가 지저분해질 수 있습니다.
4) 예시 코드 (Raw Code)
public void buyTicket() {
RLock lock = redissonClient.getLock("ticketLock");
try {
// 락 획득 시도 (10초 대기, 1초 점유)
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (available) {
// 비즈니스 로직 실행
ticketService.decrease();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 락 해제 (필수)
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
3. 비교 분석: AOP vs Redisson
이 둘은 경쟁 관계가 아니라 상호 보완 관계입니다.
| 구분 | AOP (Aspect Oriented Programming) | Redisson |
| 정체 | 방법론 (Design Pattern) | 도구 (Library/Tool) |
| 주 목적 | 코드의 깔끔함, 중복 제거, 관심사 분리 | 분산 환경에서의 데이터 공유 및 동시성 제어 |
| 적용 범위 | 애플리케이션 내부 (Java 코드 레벨) | 분산 서버 간 (Infrastructure 레벨) |
| 핵심 기술 | Proxy, Dynamic Binding | Redis, Netty, Pub/Sub |
| 비유 | 자동문 센서 (편리하게 열어줌) | 최첨단 도어락 (잠금 장치 그 자체) |
4. 결론: 둘을 같이 써야 하는 이유
"Redisson의 강력한 기능을 AOP로 감싸서 사용하라"
Redisson은 기능적으로 훌륭하지만, 위 예시 코드처럼 비즈니스 로직마다 try-catch-finally 블록을 넣으면 코드가 매우 지저분해집니다. 이때 AOP를 적용하면 Redisson의 성능과 AOP의 가독성을 모두 잡을 수 있습니다.
최종 적용 모습 (이상적인 코드)
// AOP 덕분에 비즈니스 로직에는 락 관련 코드가 하나도 없음!
@DistributedLock(key = "'ticket:'.concat(#id)")
public void buyTicket(Long id) {
ticketService.decrease(id);
}
요약
- Redisson은 엔진입니다. (분산 락을 수행하는 실제 일꾼)
- AOP는 껍데기입니다. (엔진을 쉽게 쓰게 해주는 포장지)
- 실무에서는 Redisson으로 분산 락을 구현하고, 이를 AOP로 포장해서 사용하는 것이 정석입니다.
동시성 이슈를 해결하기 위해 Redisson을 도입했지만, 비즈니스 로직마다 try-catch-finally와 락 획득/해제 코드가 섞이는 것은 유지보수에 좋지 않습니다.
이번 글에서는 Custom Annotation과 AOP를 활용하여, 어노테이션 하나로 안전하게 분산 락을 거는 전체 구현 코드를 정리합니다.
0. 의존성 추가 (build.gradle)
Redisson과 AOP를 사용하기 위해 의존성을 추가합니다.
dependencies {
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'
// Spring AOP
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
1. Custom Annotation 생성 (@DistributedLock)
개발자가 락을 걸고 싶은 메서드에 붙일 마커 어노테이션입니다.
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름 (SpEL 표현식 사용 가능)
* 예: "ticket:lock" 또는 "'ticket:'.concat(#id)"
*/
String key();
/**
* 락 획득을 위해 기다리는 시간 (기본 5초)
* 락 획득에 실패하면 false를 리턴하거나 예외를 발생시킴
*/
long waitTime() default 5L;
/**
* 락을 점유하는 시간 (기본 3초)
* 락을 획득한 후 3초가 지나면 자동으로 해제됨 (Deadlock 방지)
*/
long leaseTime() default 3L;
/**
* 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
2. SpEL Parser 구현 (Key 파싱용)
어노테이션의 key 값에 파라미터 변수(예: #id)를 바인딩하기 위한 파서입니다.
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
3. 트랜잭션 분리 클래스 (AopForTransaction)
핵심 포인트입니다. @Transactional은 프록시 기반으로 동작하므로, AOP 내부에서 트랜잭션을 시작하고 종료해야 "락 해제 전에 커밋 완료"를 보장할 수 있습니다. 이를 위해 별도의 클래스로 분리합니다.
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Component
public class AopForTransaction {
// 부모 트랜잭션 유무와 관계없이 항상 새로운 트랜잭션을 생성 (REQUIRES_NEW)
// 비즈니스 로직이 완전히 커밋된 이후에 락이 해제되도록 보장함
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
4. 메인 Aspect 구현 (DistributedLockAop)
실제 락을 획득하고 해제하는 로직을 담당하는 Aspect입니다.
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.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Method;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 1. 락 키 생성 (SpEL 파싱)
String key = CustomSpringELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
distributedLock.key()
).toString();
// 2. 락 객체 가져오기
RLock rLock = redissonClient.getLock(key);
try {
// 3. 락 획득 시도
boolean available = rLock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit());
if (!available) {
log.warn("락 획득 실패 - Key: {}", key);
return false; // 필요에 따라 예외 던지기 가능
}
// 4. 트랜잭션과 비즈니스 로직 수행
// 트랜잭션이 커밋된 후에 락을 풀어야 데이터 정합성이 깨지지 않음
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
// 5. 락 해제
try {
if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock");
}
}
}
}
5. 실전 사용 예시 (Service)
이제 비즈니스 로직에서는 지저분한 락 코드 없이 어노테이션만 붙이면 됩니다.
@Service
@RequiredArgsConstructor
public class TicketService {
private final TicketRepository ticketRepository;
/**
* 티켓 구매 로직
* ticketId를 키로 사용하여 락을 검. (예: "ticket:lock:100")
*/
@DistributedLock(key = "'ticket:lock:'.concat(#ticketId)")
public void buyTicket(Long ticketId) {
// 순수한 비즈니스 로직만 작성
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("Invalid ticket Id"));
ticket.decrease(); // 재고 감소
}
}
'Spring' 카테고리의 다른 글
| 댓글 알림 기능을 비동기(Event)로 전환 (0) | 2026.01.15 |
|---|---|
| Docker와 Kubernetes (0) | 2025.11.24 |
| [JPA] Entity Graph vs Fetch Join 차이점 및 N+1, 페이징 성능 최적화 (0) | 2025.11.20 |
| 락 선택 기준표와 실무 트러블슈팅 (Deadlock, Timeout) (1) | 2025.10.27 |
| 게시글 동시성 처리 - 비관적 락은 언제 써야 할까? (0) | 2025.10.27 |