[쇼핑몰 프로젝트] 분산환경에서의 동시성 문제

대원·2025년 2월 22일

쇼핑몰

목록 보기
11/13
post-thumbnail

동시성에 대해서

배경

토이프로젝트를 만들면서 ‘동시성’에 대한 고민을 지금까지는 크게 하지 않았습니다. ‘동시성’과 관련된 키워드와 연관된 예제 또는 강의가 특정 도메인(쿠폰 또는 재고처리 등)과 강결합되어 있는 경우가 많아서 해당 도메인을 프로젝트에 추가해야만 동시성을 다룰 수 있을 것만 같은 생각이 마음 한 켠에는 자리하고 있었습니다. 생각해보면 ‘동시성’이라는 개념 자체를 명확히 정의하지 못하다보니 안에서 어떤 오개념이 생겨서 해당 개념을 특정 개념 또는 도메인으로만 풀어낼 수 있다고 생각한 것 같습니다.

그래서 이번 글을 통해서 동시성과 동시성 문제를 명확히 이해하고 동시성 문제를 해결하는 여러가지 방법들에 대해서 정리해보고자 합니다.

또 많은 실무가 분산환경에 놓여있는 만큼, 분산환경(다수의 WAS를 띄운 상태)에서 성능을 최대한 보장하면서 '동시성 문제'를 어떻게 해결할 수 있을 지를 탐구하고 저의 토이프로젝트에 적용해보는 시간을 갖도록 하겠습니다.


동시성

컴퓨터 사이언스에서 ‘동시성’이란 무엇을 의미하는 개념일까요?

동시성이란 여러 작업이 동시에 실행되는 것처럼 보이는 논리적 개념으로서 CPU는 실제적으로 한 번에 하나의 연산(작업)을 처리할 수 있지만, 그 연산속도가 굉장히 빠르기 때문에 마치 동시에 처리하는 것처럼 느껴지는 성질 따위를 의미합니다.

프로세스는 작업을 수행하기 위해 CPU를 점유합니다. CPU는 기본적으로 굉장히 비싸고 한정적인 자원이기때문에 이를 효율적으로 활용하기 위해서 OS는 컨텍스트 스위칭을 통해 여러 프로세스(스레드)를 실행할 수 있습니다. 사용자가 봤을때 컨텍스트 스위칭을 통해 마치 여러 작업이 동시에 실행되는 것처럼 보인다고 해서 ‘동시성’이라고 합니다.

동시성 문제

그렇다면 흔히 말하는 ‘동시성 문제’란 무엇을 의미할까요?

동시성 문제란 하나의 자원에 대해서 서로 다른 프로세스(또는 쓰레드 따위)가 접근할때 겪게되는 일련의 문제상황을 의미합니다. 이 문제상황이라는 것은 ‘경쟁 조건(Race Condition)’, ‘데이터 불일치’, ‘데드락’ 등으로 나눠집니다.

경쟁 조건(Race Condition): 두 개 이상의 스레드가 공유 데이터를 함께 읽고 쓰는 과정에서 순서나 타이밍에 따라 예기치 않은 결과가 발생
데이터 불일치: 재고가 음수가 되거나 중복 주문이 생성되는 등, 실제 비즈니스 로직과 어긋나는 결과가 발생
데드락(Deadlock): 서로 자원을 점유한 스레드들이 한정된 자원을 얻기 위해 무한정 대기하는 상태

위와 같은 동시성 문제를 어떻게 해결할 수 있을까요?

자바언어 관점(언어레벨)

자바에서는 동시성과 관련하여 ’synchronized’라는 예약어를 제공합니다.
해당 예약어를 메서드와 함께 작성하게 되면 해당 메서드가 작성된 인스턴스는 락이 걸리게 됩니다. 이는 Java 언어 자체의 내부 매커니즘으로 동작하며 매우 간편하게 이를 통해 안정성과 신뢰성을 얻을 수 있습니다.

또한 Spring에서 사용하게 되면, 스프링에 등록된 Bean을 싱글톤으로 생성하기때문에 특정 클래스의 인스턴스 메서드에 ‘synchronized’ 예약어를 이용하면 자동적으로 해당 메서드를 실행하게되면 클래스 레벨 자체에 락이 걸리게끔 설계할 수 있습니다.

장점

  • 자바 언어 차원에서 제공하므로 구현이 단순하고 편리하다.
  • 스프링 싱글톤 빈과 함께 사용할 때, 특정 메서드에 대해 자연스럽게 락을 걸 수 있다.

그러나 synchronized의 문제는 없을까요?

단점

이전의 글들에서 언급하였듯 서버를 단일 인스턴스로만 띄워서 운영하는 경우는 극히 드물다고 알고 있습니다. 자바의 Synchronized는 기본적으로 JVM 위에서 동작합니다. 따라서 synchronized를 통해 락을 획득한다고 해도 그 범위는 단일 인스턴스를 넘어설 수 없습니다.

예를 들어 A라는 쇼핑몰에 아이폰의 신규버전이 입고되어 사용자가 아이폰 신규버전을 주문한다고 가정해봅시다.

해당 상품의 재고가 한정적인 관계로 경쟁이 치열하기 때문에 동일한 사용자가 태블릿, 스마트폰, PC에서 각각 로그인하여 주문을 생성한다고 해봅시다.

아래와 같이 하나의 WAS 서버를 운영하는 경우 그 자체로 문제가 없습니다. 어플리케이션 단에서 주문을 처리할때 해당 클래스에 락을 걸기 때문에 동시성 문제를 적절히 해결할 수 있습니다.

아래와 같은 상황에선 어떨까요?

동일한 사용자의 태블릿 PC, 노트북에서의 요청이 각각 다른 WAS 인스턴스 서버로 들어왔다고 해봅시다. 전술하였듯 synchronized는 JVM에 기반해 동작하므로 WAS를 넘어설 수 없습니다.
따라서 동일한 사용자의 동일한 요청이 2번 생성되게 됩니다.

이에 따라 동일한 요청이지만 DB에 각각 다른 주문으로 생성이 될 것이고 , 주문과 연관된 재고처리로직 및 결제로직처리에도 큰 영향을 주게되어 데이터 무결성 및 정합성에 큰 오류가 생기게 됩니다.

그렇다면 언어레벨이 아니라 DB 관점에서 Lock을 제어할 수는 없을까요?

DB 관점

Database 관점에서 Lock을 거는 것은 크게 2가지 큰 축이 있습니다.
하나는 비관적 락이고 다른 하나는 비관적 락입니다.

비관적 락

비관적 락은 실제로 DB에 X-Lock(배타 락)을 걸어서 동시성을 제어합니다.
해당 락은 트랜잭션 단위로 수행되며, 트랜잭션이 종료되기 전까지 다른 트랜잭션은 해당 데이터를 수정할 수 없습니다.

비관락은 JPA를 사용하는 경우 @Query를 작성한 Repository에 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 추가해서 간편하게 이용할 수 있습니다.

  • 장점
    • 구현이 간편하다.
    • 실제 물리적 Lock을 DB에 걸기 때문에 충돌이 발생하지 않는다.
  • 단점
    • DB Connection 및 Disk I/O가 발생한다.
    • 동일 자원에 접근하는 트랜잭션이 많으면 대기 시간이 증가하고, 락을 길게 점유하면 성능 저하를 초래할 수 있다.

낙관적 락

낙관적 락은 DB에 실제로 락을 걸지 않고, 데이터 수정이 발생하게 되면 수정 전후의 데이터를 비교하여 확인하는 기법입니다. 비관적 락과 달리, 실제로 물리적 락을 수행하지 않고 상태값을 가지고 있다가 변경 시에 확인을 해주는 것이기 때문에 기본적으로 어플리케이션에서 조작합니다.

스프링과 JPA를 이용할 경우, 비관적락과 유사하게 @Lock 어노테이션을 추가하고, Entity에 버전필드인 @Version 어노테이션을 추가해서 기능을 사용할 수 있습니다.

아래와 같이 간단히 재고를 다루는 Entity가 있다고 하면 다음과 같이 작성할 수 있습니다.

@Entity
public class Stock {

	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private Long productId;

	private Long quantity;

	@Version
	private Long version;

		...

}
public interface StockRepository extends JpaRepository<Stock, Long> {

	@Lock(LockModeType.OPTIMISTIC)
	@Query("select s from Stock s where s.id = :id")
	Stock findByIdWithOptimisticLock(Long id);
}

동일한 자원을 동시에 수정하려고 하면, JPA는 OptimisticLockingFailureException 예외를 발생시키기 때문에 개발자가 적절하게 예외를 Catch하여 Retry 로직등을 구현해줘야 합니다.

낙관적 락의 장단점은 다음과 같습니다.

장점

  • 비관락에 비해서 실제 물리 락을 걸지 않기 때문에 상대적으로 성능이 좋다.
  • 구현이 간편하다.

단점

  • 동일 자원에 대해서 충돌이 잦을 경우 예외가 많이 발생할 수 있다.

Database Lock의 한계점

Database를 통한 동시성 제어는 분산 시스템에서도 적용을 할 수 있습니다만 여러가지 한계를 지니고 있습니다.
RDB의 스케일 아웃 자체의 어려움과 WAS가 일반적으로 스케일 아웃해서 사용하는 특성을 고려한다면, 아래와 같은 제한사항이 있습니다.

  • 낙관적 락
    • 버전필드를 이용해서 충돌방지를 사용한다고 해도, 노드의 수(WAS)가 많아질수록 버전 충돌 관리가 복잡하다.
  • 비관적 락
    • 실제 물리적 락 메커니즘에 의존적이기 때문에 성능 저하 포인트가 될 가능성이 높다.

그렇다면 분산환경에서 DB Lock의 한계를 돌파할 수 있으며 동시성 문제를 적절히 해결할 수 있는 것은 무엇이 있을까요?

Redis를 통한 분산락 구현

위와 같은 분산환경에서 Redis 기반 분산락을 적용할 수 있습니다.
Redis의 분산락은 크게 2가지가 있습니다.

  • Lettuce
    • SETNX 명령어를 활용하여 분산락 구현
    • Spin Lock 방식
      • Retry로직을 개발자가 직접 구현
  • Redisson
    • RLock 객체를 통해 손쉽게 분산락을 획득 및 해제할 수 있으며 재시도 로직 등 다양한 기능이 내장되어 있음.

저의 경우 라이브러리 내에서 자체적으로 Retry 로직을 제공하고 Lock Interface 등을 지원하는 Redisson을 이용하였습니다.


현재 토이프로젝트에서 적용할 수 있는 포인트

현재 저의 토이프로젝트는 분산환경을 염두에 두었습니다.

동시성문제에서 유발할 수 있는 문제 중 ‘데이터 불일치’ 부분이 있었습니다.

이커미스 서비스에서 주문 / 결제 요청을 상정해보면, 사용자 A가 동일한 주문,결제 요청이 똑같이 n번 생성된다면 어떻게 될까요?

사용자 입장에선 한 번 요청했는데, 동일한 주문, 결제가 생성된다면 서비스 입장에서는 신뢰성이 하락할 수 밖에 없습니다.

즉, 주문과 결제 요청에 대해서 ‘동시성 문제’를 Redis 분산락을 통해 해결할 수 있습니다.

그렇다면 Key는 무엇을 설정해줘야할까요?

단순하게 PK값으로 설정하면 되지 않느냐라는 의문을 가질 수 있지만, 현재 저의 경우 Auto-Increment방식으로 PK를 생성해주기에 이것을 Key로 사용하면 Redis 분산락을 사용하는 의미가 없다고 판단하였습니다.
또한 snowflake 방식을 이용할수 있지만, 이미 Auto-Increment 방식으로 모든 PK를 생성해주기에 분산락때문에 Key 생성방식을 바꾸는 것은 지나치게 과하다고 생각하였습니다.

Idempotency Key(멱등 키 방식)

주문과 결제 요청에 대해서 한 번 생각해 봅시다.
사용자가 보내온 주문,결제 요청은 (동일한 요청이라면) 여러번 전송되더라도 단 한번만 실행되도록 서비스가 보장해줘야합니다.
멱등 키(Idempotency Key)는 특정 요청이 여러 번 전송되더라도 단 한 번만 실행되도록 보장하는 메커니즘입니다. 이는 네트워크 환경에서 중복 요청이 발생할 수 있는 경우 특히 중요합니다.

사용자가 동일한 요청을 여러 번 보내는 경우, 예를 들어 결제 요청이 중복되면 의도하지 않은 중복 결제가 발생할 수 있습니다. 이를 방지하기 위해 요청마다 고유한 멱등 키를 생성하고, 해당 키를 기반으로 Redis에 저장하여 중복 요청을 필터링할 수 있습니다.

이 ‘멱등키’는 클라이언트가 UUID 또는 고유한 requestId를 생성해서 요청에 보낼 수 있습니다.
(이 멱등키를 무엇으로 어떻게 설정할지는 협업의 관점에서는 프론트와 백엔드가 협의를 해야겠지만, 혼자 백엔드 API를 작성하고 있기 때문에, 일단 String 값으로 보내준다고 설정하였고 해당 요청은 반드시 ‘멱등성’을 보장한다고 가정하였습니다.)

저의 경우 해당 멱등키를 Request Header에 포함해서 보낸다고 설정하였습니다.

주문요청을 예를 들어서 코드로 예시를 보여주면 다음과 같습니다.

OrderController의 주문을 생성하는 API Endpoint는 다음과 같습니다.

@PreAuthorize("hasRole('BUYER')")
@PostMapping("/order")
public ApiResponse<List<OrderProductCreateResponseDto>> createOrder(@RequestHeader("idempotency-key") String idempotenceKey, @RequestBody  OrderCreateRequestDto orderCreateRequestDto) {
    return ApiResponse.ok(orderUsecase.createDirectOrder(idempotenceKey, orderCreateRequestDto));

}

Application Layer의 OrderUsecase에서 아래와 같이 커스텀하게 작성한 @DistributedLock을 지정해주고 SPEL 문법을 사용하였습니다.

@Transactional
@DistributedLock(key = "#idempotenceKey")
public List<OrderProductCreateResponseDto> createDirectOrder(final String idempotenceKey, final OrderCreateRequestDto orderCreateDto) {

    validateRequest(orderCreateDto, RequestType.ORDER_CREATE);
    return orderCreateService.createOrderWithOutCart(orderCreateDto);

}

@DistributedLock

해당 락의 대기시간 및 잠금시간은 임의로 설정해줬지만, Lock의 대상에 따라서 지정시에 커스텀하게 설정할 수 있도록 하였습니다.

package shoppingmall.domainredis.common.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)

public @interface DistributedLock {
    String key();   // key 이름
    long waitTime() default 3L;  // 디폴트 대기시간
    long leaseTime() default 5L; // 디폴트 잠금시간
    TimeUnit timeUnit() default TimeUnit.SECONDS; // 디폴트 시간단위
}

또한 해당 어노테이션을 아래의 AOP Class를 정의하여 Lock 설정 및 해제를 진행해주었으며 Parser Class를 정의하여 편리하게 작성할 수 있도록 진행하였습니다.

package shoppingmall.domainredis.common.aop;

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.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import shoppingmall.domainredis.common.annotation.DistributedLock;
import shoppingmall.domainredis.common.util.DistributedLockKeyparser;

@Component
@Aspect
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
    private final RedissonClient redissonClient;
    private final DistributedLockKeyparser lockKeyParser;


    @Around("@annotation(distributedLock)")
    public Object arroundLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {

        String spelKey = distributedLock.key();
        String lockKey = lockKeyParser.parseKey(spelKey, joinPoint);


        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        try {
            locked = lock.tryLock(
                    distributedLock.waitTime(),
                    distributedLock.leaseTime(),
                    distributedLock.timeUnit()
            );

            if (!locked) {
                throw new IllegalStateException("해당 Key에 대해서 이미 락이 점유 중입니다. 동일한 요청에 대해서는 유효할 수 없습니다. 다시 확인해주세요." + lockKey);
            }

            return joinPoint.proceed();
        } catch (InterruptedException e) {
            log.error("Locking Error", e);
            throw new IllegalStateException("Locking Error", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                try {
                    lock.unlock();
                } catch (Exception e) {
                    log.info("Redisson Lock Already UnLock {}, {} ",
                            lockKey, e.getMessage()
                    );
                }
            }

        }


    }
}



package shoppingmall.domainredis.common.util;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;


@Component
public class DistributedLockKeyparser {
    private final ExpressionParser parser = new SpelExpressionParser();

    public String parseKey(String spelKey, ProceedingJoinPoint proceedingJoinPoint) {
        MethodSignature signature = (MethodSignature)proceedingJoinPoint.getSignature();
        String[] parameterNames = signature.getParameterNames();
        Object[] args = proceedingJoinPoint.getArgs();

        StandardEvaluationContext context = new StandardEvaluationContext();
        for(int i=0; i< parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(spelKey).getValue(context, String.class);


    }
}

그렇다면 Redis기반 분산락의 장단점은 무엇이 있을까요?

Redis 기반 분산 락의 장·단점

  • 장점

    • 싱글 스레드로 동작하기 때문에 Race Condition 발생하지 않음
    • 일반 RDB 락보다 빠른 메모리 기반 처리가 가능하여 상대적으로 ‘빠른 실패’를 처리할 수 있음.
      • 메모리에서 Lock 유무를 Key로 빠르게 확인하고 결과를 빠르게 보내줄 수 있다.
      • 이는 DB 기반 Lock의 디스크 I/O작업에 비해서 속도가 빠르다.
    • 필요한 로직만 선택적으로 락을 적용할 수 있으므로, 성능 최적화가 유연
  • 단점

    • Redis 단일 노드 장애 시 락이 제대로 해제되지 않거나 동기화가 어긋날 위험(SPOF)
    • 여러 Redis 노드가 있는 클러스터 환경에서는 추가적인 처리(예: RedLock, 주키퍼 등 다른 분산 코디네이션 시스템)가 필요할 수 있음
    • TTL(만료 시간) 설정이 중요하며, 작업이 예상보다 오래 걸리면 만료로 인해 락이 풀려버리는 문제가 생길 수 있음

마무리

동시성 문제는 규모가 작은 토이프로젝트에서는 다소 느슨하게 다룰 수 있지만, 서버 인스턴스가 여러 대가 되거나 트래픽이 급격히 늘어날 때 더욱 더 중요하다고 생각합니다.

단순 자바 언어의 synchronized부터, DB 락, 그리고 Redis 기반 분산락까지 단계별로 살펴보았는데, 결국 상황에 맞는 전략을 선택하는 것이 핵심이라고 생각합니다.

단일 환경에서 굳이 WAS를 한 대 띄우는데 Redis 분산락을 이용할 필요는 없을 것이고, 다중 환경에서 언어레벨로 동시성 문제를 해결하고자 하는 방법 역시 적절하지 못할 것 입니다.

따라서 결국 기술의 특성과 장단점을 잘 이해하고 상황에 따라 전략을 잘 설계하는 것이 가장 중요한 것 같습니다.

참고
컬리 기술블로그

profile
고민하고 공부하는 사람

0개의 댓글