[식구하자_MSA] SNS 게시글 좋아요 기능: 퍼사드 패턴에서 AOP로 전환한 동시성 제어 방식 개선

이민우·2024년 8월 22일
6

🍀 식구하자_MSA

목록 보기
21/21

📌 배경


이전 포스팅에서 저는 Redis의 Redisson을 사용해 SNS 마이크로서비스에서 좋아요 기능의 동시성 문제를 해결하기 위해 분산락을 구현했습니다. 이 과정에서 퍼사드 패턴을 도입하여 락과 트랜잭션의 범위를 일치시키는 방법을 사용했습니다. 퍼사드 패턴은 여러 메서드에서 공통적으로 필요한 락 관리 로직을 캡슐화하여 복잡성을 줄이고, 락의 범위를 트랜잭션 범위보다 크게하는데 효과적이었습니다.

그러나 퍼사드 패턴을 사용하여 락을 관리하는 방식은 본질적으로 트랜잭션 범위 외부에서 락을 적용하고 해제하는 구조였습니다. 이 방식은 락의 범위를 트랜잭션 범위보다 크게 하는데 성공했지만, 서비스 코드에 락 관리 로직이 명시적으로 중복되고, 유지보수에 불편함이 따랐습니다. 또한, 퍼사드 패턴은 엄격한 의미의 퍼사드라기보다는 단순히 기능을 묶어 관리하는 서비스 패턴에 가까웠습니다......

이전에 적용했던 방식이 적합하지 않다고 판단하여, 더 효율적이고 유지보수가 용이한 구조로 리팩터링을 진행하려 합니다. AOP(Aspect-Oriented Programming)를 도입하여 락을 관리하는 방식으로 전환하려 합니다. AOP는 트랜잭션이 시작되기 전에 자동으로 락을 걸고, 트랜잭션이 종료된 후에 락을 해제할 수 있도록 투명한 동작을 제공합니다. 이를 통해 서비스 코드에서 중복을 제거하고, 트랜잭션과 락의 범위를 일관되게 유지할 수 있어 더 효율적이고 유지보수성이 높은 구조를 만들 수 있습니다.

이 포스팅에서는 기존의 퍼사드 패턴에서 AOP로 전환하는 과정을 다루며, 왜 AOP가 더 적합한 방법인지에 대한 구체적인 설명과 코드 구현 방식을 소개하겠습니다!!!

이 글에서는 Redisson을 이용해 분산락을 적용하는 과정을 자세히 다루지 않으므로, 과정이 궁금하신 분들은 아래 이전 포스팅을 참고 부탁드리겠습니다!!

👉 [이전 포스팅]

🤔 그래서, AOP가 뭐라고?

  • AOP(Aspect-Oriented Programming)는 소프트웨어 개발에서 관심사의 분리(Separation of Concerns)를 통해 중복 코드를 줄이고, 애플리케이션의 핵심 로직과 부가적인 기능을 분리하여 유지보수를 쉽게 하는 프로그래밍 패러다임입니다. 간단히 말해, AOP는 코드의 특정 부분에 공통적으로 적용되어야 하는 기능(예: 로그 기록, 권한 검사, 트랜잭션 관리 등)을 관점(Aspect)이라는 개념으로 모듈화하여 필요할 때 자동으로 적용되게 하는 것입니다.

AOP를 도입하는 이유는 바로 아래와 같은 문제를 해결하기 위함입니다. AOP는 락을 걸고 해제하는 로직을 트랜잭션의 시작과 종료 시점에 자동으로 적용할 수 있어 코드의 중복을 줄이고, 명시적인 락 관리 로직을 제거할 수 있습니다. 또한, 락과 트랜잭션의 범위를 일관되게 유지할 수 있어 안정성유지보수성이 높아집니다.

따라서, 퍼사드 패턴 대신 AOP를 사용함으로써 동시성 제어를 더욱 효과적이고 간결하게 구현할 수 있습니다!

🔎 AOP로 전환

☑️ 기존 코드

퍼사드 패턴을 사용한 기존의 락 관리 방식에서는 트랜잭션과 락의 경계를 맞추기 위해 락을 서비스 메서드 외부에서 관리했습니다. 이로 인해 코드 중복과 가독성 문제가 발생했습니다. 아래는 기존 퍼사드 패턴을 사용한 코드입니다.

public class SnsPostServiceFacade {
    private final SnsPostService snsPostService;
    private final RedissonClient redissonClient;

    /**
     * 게시글 좋아요 증가 메서드 락을 걸기 위한 메서드
     * redis의 분산락을 통한 동시성 제어
     * 실제락을 거는 시점은 퍼사드 패턴으로 분리하여
     * @Transactional 바깥에서 락을 걸어줌
     * @param : Long id(게시글 번호),Integer memberNo
     */
    public void updateSnsLikesCountLock(Long snsPostId, Integer memberNo) {
        final String lockName = "likes:lock";
        final RLock lock = redissonClient.getLock(lockName);

        try {
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS))
                return;
            snsPostService.updateSnsLikesCount(snsPostId, memberNo);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}
@Service
@RequiredArgsConstructor
public class SnsPostServiceFacade {
    private final SnsPostService snsPostService;
    private final RedissonClient redissonClient;

    /**
     * 게시글 좋아요 증가 메서드 락을 걸기 위한 메서드
     * redis의 분산락을 통한 동시성 제어
     * 실제락을 거는 시점은 퍼사드 패턴으로 분리하여
     * @Transactional 바깥에서 락을 걸어줌
     * @param : Long id(게시글 번호),Integer memberNo
     */
    public void updateSnsLikesCountLock(Long snsPostId, Integer memberNo) {
        final String lockName = "likes:lock";
        final RLock lock = redissonClient.getLock(lockName);

        try {
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS))
                return;
            snsPostService.updateSnsLikesCount(snsPostId, memberNo);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}

✅ AOP 도입: @DistributedLock 어노테이션 활용

AOP를 통해 동시성 제어를 개선하기 위해, @DistributedLock 어노테이션을 도입했습니다. 이 어노테이션을 사용하면, 서비스 메서드 내에서 명시적으로 락을 걸고 해제할 필요 없이, 트랜잭션의 시작과 종료 시점에 락을 관리할 수 있습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    /**
     * 락의 이름
     */
    String key();

    /**
     * 락의 시간 단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락을 기다리는 시간 (default - 5s)
     * 락 획득을 위해 waitTime 만큼 대기한다
     */
    long waitTime() default 5L;

    /**
     * 락 임대 시간 (default - 3s)
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
     */
    long leaseTime() default 3L;
}
@Slf4j
public class DistributedLockAop {
    private static final String LOCK_PREFIX = "likes:lock:";

    private final RedissonClient redissonClient;

    @Around("@annotation(com.example.plantsnsservice.common.aspect.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        // 메서드 시그니처와 DistributedLock 애노테이션 정보 추출
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        // 락 키 생성 및 Redisson 락 객체 획득
        String key = LOCK_PREFIX + distributedLock.key();
        RLock lock = redissonClient.getLock(key);
        try {
            // 지정된 시간 동안 락 획득 시도
            boolean isLockAcquired = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!isLockAcquired) {
                // 락 획득 실패 시 null 반환
                return null;
            }

            // 락 획득 성공 시 원래 메서드 실행
            return joinPoint.proceed();
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            // 현재 스레드가 락을 보유하고 있다면 락 해제
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

다음은 @DistributedLock 어노테이션 선언 시 수행되는 aop 클래스입니다.

@DistributedLock 어노테이션의 파라미터 값을 가져와 분산락 획득 시도 그리고 어노테이션이 선언된 메서드를 실행합니다.

  1. 락의 이름으로 RLock 인스턴스를 가져온다.
  2. 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다.
  3. DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
  4. 종료 시 무조건 락을 해제한다.

AopForTransaction.java

/**
 * AOP에서 트랜잭션 분리를 위한 클래스
 */
@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

여기서 ProceedingJoinPoint는 스프링 AOP(Aspect-Oriented Programming)에서 사용되는 인터페이스로, 특정 지점(joint point)에서 메서드 호출을 가로채거나 처리하기 위한 정보를 제공합니다

@DistributedLock 어노테이션이 선언된 메서드는 Propagation.REQUIRES_NEW 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정했습니다. 이는 분산 락과 트랜잭션 관리를 더욱 효과적으로 하기 위함입니다.

락 해제 시점의 중요성

분산 환경에서 가장 중요한 점은 트랜잭션 커밋 이후에 락이 해제되도록 처리하는 것입니다. 이는 동시성 환경에서 데이터의 정합성을 보장하기 위해 필수적입니다. 이 원칙의 중요성을 쿠폰 발급 예제를 통해 살펴보겠습니다.

쿠폰 발급 예제

쿠폰은 선착순으로 하루에 100개만 발급 가능하며, 한 사람당 한 번만 받을 수 있다고 가정해봅시다. 여러 사용자가 동시에 쿠폰 발급을 요청할 때, 락의 해제 시점이 트랜잭션 커밋 시점보다 빠르면 다음과 같은 문제가 발생할 수 있습니다:

  1. LEE와 KIM이 동시에 쿠폰 발급 요청
  2. LEE가 락을 획득하고 쿠폰 발급 처리 (100 → 99)
  3. LEE의 트랜잭션이 커밋되기 전에 락 해제
  4. KIM이 락을 획득하고 쿠폰 수량 확인 (여전히 100으로 보임)
  5. KIM도 쿠폰 발급 처리 (100 → 99)
  6. 결과적으로 LEE와 KIM 두 명에게 쿠폰이 발급되었지만, 실제 차감된 수량은 1개뿐

결국 이렇게 되버리면 2명의 사용자가 쿠폰을 발급했지만, 실제로 데이터베이스의 반영되는 감소 카운트는 1입니다. 이렇게 되면 데이터 정합성의 문제가 발생하므로 아래 사진 처럼 락의 범위를 트랜잭션의 범위보다 크게 설정해주는 것입니다

트랜잭션 전파 속성: REQUIRES_NEW의 역할

여기서 잠깐, 앞서 언급한 Propagation.REQUIRES_NEW에 대해 간단히 알아보겠습니다. 이 옵션은 왜 중요하고, 어떤 역할을 할까요?

Propagation.REQUIRES_NEW VS REQUIRED

[트랜잭션 전파 수준이란?]

스프링 프레임워크에서 제공하는 @Transactional 어노테이션을 통해 트랜잭션 관리를 선언적으로 처리할 수 있습니다. 이 때 중요한 개념 중 하나가 트랜잭션 전파 속성(Transaction Propagation)입니다. 트랜잭션 전파 속성은 트랜잭션이 진행 중인 상황에서 추가적인 트랜잭션이 발생할 때 어떻게 동작할지를 결정합니다.

[트랜잭션의 종류: 물리 트랜잭션과 논리 트랜잭션]

트랜잭션은 일반적으로 물리 트랜잭션논리 트랜잭션으로 구분됩니다.

  • 물리 트랜잭션은 실제 데이터베이스와 연결된 트랜잭션으로, 하나의 커넥션을 사용하여 데이터를 커밋하거나 롤백하는 트랜잭션입니다.
  • 논리 트랜잭션은 스프링에서 트랜잭션 매니저가 관리하는 트랜잭션을 의미합니다. 물리 트랜잭션을 감싸는 형태로 동작하며, 스프링의 내부 처리 과정에서 중요한 역할을 합니다.

아래 그림은 외부 트랜잭션과 내부 트랜잭션이 1개의 물리 트랜잭션을 사용하는 경우입니다.

트랜잭션이 중첩되거나 여러 개의 트랜잭션이 발생할 경우, 스프링은 논리 트랜잭션의 개념을 통해 물리 트랜잭션의 복잡성을 관리합니다. 기본적으로 논리 트랜잭션이 모두 성공해야 물리 트랜잭션이 커밋되고, 하나라도 실패하면 물리 트랜잭션은 롤백됩니다.

주요 전파 속성: REQUIRED와 REQUIRES_NEW

스프링에서 제공하는 7가지 트랜잭션 전파 속성 중 REQUIREDREQUIRES_NEW는 가장 핵심적인 두 가지입니다. 이 둘을 제대로 이해하면 다른 전파 속성도 쉽게 적용할 수 있습니다.

1. REQUIRED

REQUIRED는 기본 전파 속성으로, 이미 트랜잭션이 진행 중이라면 그 트랜잭션에 참여하고, 그렇지 않다면 새로운 트랜잭션을 시작합니다. 내부 트랜잭션은 외부 트랜잭션의 연장선에서 실행되므로, 별도의 물리 트랜잭션을 생성하지 않습니다. 즉, 하나의 커넥션을 공유하여 트랜잭션이 효율적으로 관리됩니다. 다만 내부 트랜잭션에서 롤백이 발생하면 외부 트랜잭션도 함께 롤백됩니다.

이 과정에서 스프링의 트랜잭션 매니저는 내부 트랜잭션을 논리 트랜잭션으로 관리합니다. 논리 트랜잭션이란 물리적으로 하나의 트랜잭션 안에서 서로 독립적으로 처리되는 트랜잭션을 의미합니다. 따라서 내부 트랜잭션이 커밋을 호출하더라도 실제 커밋은 외부 트랜잭션이 성공적으로 완료될 때 최종적으로 이루어집니다.

롤백 처리도 유사한 방식으로 작동합니다. 내부 트랜잭션에서 롤백이 발생하면 즉시 물리 트랜잭션이 롤백되지 않고, 논리 트랜잭션들이 모두 검토된 후에 하나라도 실패한 경우 물리 트랜잭션 전체가 롤백됩니다. 요약하면, 모든 논리 트랜잭션이 성공해야 물리 트랜잭션도 성공하고, 하나라도 실패하면 전체 물리 트랜잭션이 롤백된다는 것입니다.

2. REQUIRES_NEW

반면 REQUIRES_NEW는 현재 진행 중인 트랜잭션이 있더라도 새로운 물리 트랜잭션을 생성합니다. 즉, 기존 트랜잭션과는 별도로 새로운 커넥션을 사용하여 독립적인 트랜잭션을 시작합니다. 이 방식에서는 내부 트랜잭션과 외부 트랜잭션이 각각 독립적으로 커밋 또는 롤백되기 때문에, 내부 트랜잭션이 실패하더라도 외부 트랜잭션에 영향을 미치지 않습니다.

그러나 REQUIRES_NEW는 각 트랜잭션이 별도의 데이터베이스 커넥션을 사용하기 때문에, 여러 커넥션이 필요할 수 있어 리소스 관리에 주의해야 합니다. 만약 많은 트랜잭션이 동시에 실행되면 데이터베이스 커넥션 풀이 고갈될 수 있으므로 신중하게 사용해야 합니다!!

테스트 해보기


그럼 지금까지 적용한 AOP를 이용한 분산락이 잘 작동하는지 테스트를 진행보도록 하겠습니다!

테스트 시나리오는 sns 게시글 좋아요 예제에서 @DistributedLock 어노테이션을 사용하여 동시성 제어를 적용한 경우와 그렇지 않은 경우로 나눠서 비교해보겠습니다

테스트 코드는 아래와 같습니다!!


@SpringBootTest
class SnsPostServiceLikesMethodTest {
    @Autowired
    SnsPostRepository snsPostRepository;
    @Autowired
    SnsPostService snsPostService;

    @Test
    void updateSnsLikesCount() throws InterruptedException, IOException {
        //given
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        Set<String> hashTags = new HashSet<>();
        List<MultipartFile> files = new ArrayList<>();
        hashTags.add("나무");
        SnsPostRequestDto snsPostRequestDto=SnsPostRequestDto.builder()
                .id(1L)
                .snsPostTitle("sns 게시글 테스트")
                .snsPostContent("테스트")
                .snsLikesCount(0)
                .snsViewsCount(1)
                .hashTags(hashTags)
                .build();

        snsPostService.createPost(snsPostRequestDto, files);
        //when
        for (int i = 0; i<threadCount; i++) {
            Integer memberNo = i;
            executorService.submit(() -> {
                try{
                    snsPostService.updateSnsLikesCount(1L,memberNo);
                }
                finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        Thread.sleep(10000);
        Optional<SnsPost> byId = snsPostRepository.findById(1L);
        assertThat(byId.get().getSnsLikesCount()).isEqualTo(1000);
    }
}
  1. @DistributedLock(key = "#snsPostId") 어노테이션을 적용한 경우
    • 1000개의 스레드가 동시에 updateSnsLikesCount() 메서드를 호출
    • 각 스레드가 별도의 트랜잭션으로 실행되며, AOP를 통해 분산 락이 자동으로 관리
    • 최종적으로 게시글의 좋아요 수가 1000으로 증가했음을 확인!

  1. @DistributedLock(key = "#snsPostId") 어노테이션을 제거한 경우
    • 1000개의 스레드가 동시에 updateSnsLikesCount() 메서드를 호출
    • 각 스레드가 별도의 트랜잭션으로 실행되지만, 분산 락이 적용되지 않음..
    • 최종적으로 게시글의 좋아요 수가 38로 실패하는것을 확인할 수 있습니다

마무리


이번 포스팅에서는 퍼사드 패턴을 사용했던 기존의 락 관리 방식의 문제점을 살펴보고, AOP를 도입하여 이를 개선하는 방법에 대해 알아봤습니다. AOP를 통해 트랜잭션의 시작과 종료 시점에 자동으로 락을 관리할 수 있게 되어, 코드의 중복을 줄이고 가독성과 유지보수성을 향상시킬 수 있었습니다.

또한 락 해제 시점의 중요성을 sns 게시글 좋아요 예제를 통해 살펴보았고, 트랜잭션 전파 속성 중 REQUIRES_NEW 옵션이 왜 필요한지도 알아봤습니다

오늘도 읽어주셔서 감사합니다 :)

참고


https://helloworld.kurly.com/blog/distributed-redisson-lock/
https://mangkyu.tistory.com/269

profile
백엔드 공부중입니다!

0개의 댓글

관련 채용 정보