일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- DDL
- jwt
- select_type
- 필드 주입
- redis
- static
- 조합
- 인덱스
- StringBuilder
- AOP
- 생성자 주입
- equals
- DI
- 재정의
- Spring
- VUE
- Test
- 열 속성
- hashcode
- MSA
- cache
- SQL
- java
- KEVISS
- 테스트 코드
- jpa
- stream
- 바이너리 카운팅
- docker
- lambda
- Today
- Total
백엔드 개발자 블로그
동시성 제어 본문
동시성 문제를 해결하기 위해 synchronized, Locking(비관적 락), Redis 분산락을 시도한 과정을 작성해봅니다.
문제 상황
모임 인원에 제한이 있는 그룹에 member가 참여하는 코드입니다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ParticipateService {
private final MemberFindService memberFindService;
private final GroupFindService groupFindService;
@Transactional
public void participate(Long groupId, Long memberId) {
Group group = groupFindService.findGroup(groupId);
Member member = memberFindService.findMember(memberId);
group.participate(member);
}
}
CountDownLatch, ExecutorService를 통해 별도의 스레드에서 참여로직이 동시에 처리되도록 테스트 코드를 작성했습니다.
- CountDownLatch : 다른 쓰레드에서 작업이 완료될 때까지 기다릴 수 있게 해주는 클래스입니다.
- ExcutorService : 병렬 작업시 여러 작업을 효율적으로 처리하기 위해 제공되는 라이브러리입니다. ThreadPool을 구성하는 데 사용했습니다.
@Sql(value = "classpath:init.sql", executionPhase = BEFORE_TEST_METHOD)
@Sql(value = "classpath:truncate.sql", executionPhase = AFTER_TEST_METHOD)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@RequiredArgsConstructor
@SpringBootTest
class ParticipateServiceConcurrencyTest {
private final ParticipateService participateService;
private final GroupRepository groupRepository;
private final MemberRepository memberRepository;
private Member host;
@BeforeEach
void setUp() {
this.host = memberRepository.save(MOMO.toMember());
}
@DisplayName("모임 참여 동시 요청이 올 경우에도 정원을 넘어선 인원이 모임에 참여할 수 없다")
@Test
void participateConcurrencyTest() throws InterruptedException {
int capacity = 3;
int numOfParticipants = 50;
long groupId = groupRepository.save(
MOMO_STUDY.builder()
.capacity(capacity)
.toGroup(host)
).getId();
List<Long> participantIds = new ArrayList<>();
for (int i = 0; i < numOfParticipants; i++) {
Member savedMember = memberRepository.save(
new Member(UserId.momo("user" + i),
Password.encrypt("User123!", new SHA256Encoder()),
UserName.from("user" + i)));
participantIds.add(savedMember.getId());
}
CountDownLatch latch = new CountDownLatch(numOfParticipants);
ExecutorService executor = Executors.newFixedThreadPool(numOfParticipants);
for (Long participantId : participantIds) {
executor.submit(() -> {
try {
participateService.participate(groupId, participantId);
} finally {
latch.countDown();
}
});
}
executor.shutdown();
latch.await();
long actual = participateService.findParticipants(groupId).size();
assertThat(actual).isEqualTo(capacity);
}
}
테스트 결과, 3명의 인원 제한이 있는 모임인데 11명의 참여자가 발생하게 되었습니다. 즉, 동시성 문제가 발생했습니다.
해결 과정 1 - synchronized
자바에서 하나의 스레드만이 임계 구역(Critical Section)에 접근하도록 지원해주는 synchronized를 통해 동시성 문제를 해결을 시도했습니다.
코드
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ParticipateService {
private final MemberFindService memberFindService;
private final GroupFindService groupFindService;
@Transactional
public synchronized void participate(Long groupId, Long memberId) { // synchronized 추가
Group group = groupFindService.findGroup(groupId);
Member member = memberFindService.findMember(memberId);
group.participate(member);
}
}
문제점
단일 서버에서는 1개의 요청씩 동작하지만, N개의 서버를 운영하는 분산 환경에서는 N개의 요청씩 동작하게 되어 동시 접근을 제어하지 못하게 됩니다.
해결 과정 2 - 비관적 락 vs 낙관적 락
비관적 락을 선택하여 문제 해결을 시도했습니다. 동시 요청이 발생할 경우, 잦은 충돌로 인한 낙관적 락의 오류 처리 비용으로 인해 성능에 더 영향을 줄 것이라 판단해 비관적 락을 택하였습니다.
낙관적 락 | 비관적 락 | |
장점 | 트랜젝션을 필요로 하지 않고, 별도의 lock을 사용하지 않으므로 성능적으로 좋다. | 동시성 문제가 빈번하게 일어난다면 rollback의 횟수를 줄일 수 있기 때문에 성능적으로 좋다. |
단점 | 동시성 문제가 빈번하게 일어나면 계속 rollback 처리를 해주어야 하며, 업데이트가 실패했을 때 재시도 로직도 개발자가 직접 작성해야 한다. | 모든 트랜젝션에 lock을 사용하기 때문에, lock이 필요하지 않은 상황이더라도 무조건 lock을 걸어서 성능상 문제가 될 수 있다. 특히 read 작업이 많이 일어나는 경우 단점이 될 수 있다. 또한, 선착순 이벤트같이 많은 트래픽이 몰리는 상황이나 여러 테이블에 lock을 걸면서 서로 자원이 필요한 경우, 데드락이 발생할 수 있고 이는 비관적 락으로 해결할 수 없는 부분이다. |
코드
비관적 락을 적용한 코드는 아래와 같습니다.
public interface GroupSearchRepository extends Repository<Group, Long>, GroupSearchRepositoryCustom {
@Lock(value = LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용
@Query("select g from Group g where g.id = :id")
Optional<Group> findByIdForUpdate(@Param("id") Long id);
...
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class GroupFindService {
private final GroupSearchRepository groupSearchRepository;
private final ParticipantRepository participantRepository;
...
public Group findByIdForUpdate(Long id) {
return groupSearchRepository.findByIdForUpdate(id)
.orElseThrow(() -> new GroupException(NOT_EXIST));
}
...
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ParticipateService {
private final MemberFindService memberFindService;
private final GroupFindService groupFindService;
@Transactional
public void participate(Long groupId, Long memberId) {
// findByIdForUpdate -> SELECT FOR UPDATE를 통한 x-lock 획득
Group group = groupFindService.findByIdForUpdate(groupId);
Member member = memberFindService.findMember(memberId);
// group 객체는 capacity와 참여자의 정보를 얻기 위한 그래프 탐색을 하기 위해 획득
// group 테이블에는 변경 X, Participants 테이블에만 새로운 참가자 데이터가 추가됨
group.participate(member);
}
}
위의 로직을 살펴보면 participate() 메서드가 실행됨과 동시에 SELECT FOR UPDATE를 통해 Group정보를 얻으며 해당 데이터에 x-lock을 걸며 다른 읽기, 쓰기 요청에 대한 모든 접근을 제한하였습니다.
그 결과 테스트를 실행하여도 정상적으로 통과하는 것을 확인할 수 있었습니다.
문제점
1. 모임 테이블에는 변화가 없는데 참여자 생성에 대한 Critical Section을 만들고자 Select For Update 쿼리로 Group엔티티에 락을 걸게된 어색한 상황이 만들어졌습니다.
2. 데이터베이스에 x-lock을 거는 것은 경합이 심한 경우, 데이터베이스 커넥션 부족으로 다른 데이터베이스 요청 처리에도 영향을 주는 문제가 발생할 수도 있습니다.
예를 들면, 만약 1000명의 사용자가 하나의 모임에 참여 요청을 동시에 진행할 경우, 데이터베이스와의 커넥션을 맺고 트랜잭션을 실행한 이후에 1000개의 트랜잭션이 경합하게 됩니다. 경합이 끝날 때까지 커넥션을 유지하게 되고, 서비스의 커넥션 부족 문제로 이어질 수 있습니다.
해결 과정 3 - Redis 분산락
마지막으로 분산락을 선택하여 문제해결을 시도했습니다.
분산락은 데이터베이스 row에 Lock을 거는 것이 아닌 로직을 수행하는 부분(Critical Section)에 외부 저장소를 사용해 락을 획득하고 수행하는 방식입니다. 즉, 실제 데이터를 가져오는 데이터베이스가 아닌 별도의 저장소에서 락을 획득하기 위한 경합을 하게 됩니다. 변동이 없는 Group 엔티티에 락을 거는 행위와 x-lock획득을 위한 경합으로 인해 데이터베이스의 커넥션을 오래 유지하는 문제를 없앨 수 있다고 판단하여 선택했습니다.
사용 기술 선정
분산락은 MySQL, Redis, Zookeeper 등을 통해 구현할 수 있고, 아래의 이유로 Redis를 택했습니다.
1. 사전에 글로벌 캐시를 구축했기 때문에 추가적인 인프라 구축이 필요가 없습니다.
2. 빠른 처리 속도, 낮은 부하율
MySQL 서버도 구축되어 있었으나, Redis의 처리 속도가 더 빠르고 락을 위한 MySQL의 부하를 발생시키지 않기 위해 이와 같은 선택을 하였습니다.
3. Kafka 사용 안하는 상황이어었습니다.
Zookeeper는 Kafka에 사용하는 분산 코디네이션입니다.
Lettuce vs Redisson
Redis를 통한 분산락 구현은 대표적으로 Lettuce와 Redisson이라는 RedisClient 중 하나를 사용합니다.
Lettuce | Redisson | |
Lock 방식 | Spin Lock 방식 (락을 획득 때까지 지속적으로 락 획득 요청을합니다.) | pub/sub 방식 (락을 얻을 수 있다고 알림을 줍니다.) |
락에 대한 타임 아웃 설정 가능 여부 | 불가능 | 가능 |
Redisson이 Spin Lock보다 서버 부하가 적고, 락에 타임 아웃을 설정할 수 있기 때문에 Redisson을 선택했습니다.
분산락 AOP 개발
분산락이 적용되는 로직마다 코드를 추가하면 코드의 중복이 생겨 유지보수가 어려워지므로 AOP를 통해 어노테이션 기반 분산락을 구현했습니다.
동작과정은 아래와 같습니다.
- Lock에 사용될 키 값을 생성한다.
- redissonClient를 통해 RLock 인스턴스를 가져온다.
- RLock.tryLock() 메서드를 통해 최대 waitTime 시간만큼 락의 획득을 기다린다.
- 락의 획득이 가능하다면 leaseTime을 타임아웃으로 설정하며 락을 획득한다.
- 새로운 트랜잭션을 생성하며 로직을 수행한다.
위와같이 동작하기 위해서 아래와 같은 작업이 필요합니다.
의존성 추가
Redisson을 사용하기 위해 의존성을 추가해줍니다.
dependencies {
...
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
}
DistributionLock
분산락 어노테이션을 만들어줍니다.
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 DistributionLock {
String key();
long waitTime() default 5L;
long leaseTime() default 2L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
락에 사용될 키 값과 Redisson에서 락을 획득하기 위해 사용하는 tryLock() 메서드의 파라미터 값들을 변수로 두어 락에 관한 설정을 쉽게 할 수 있게 하였습니다.
- waitTime : 락을 획득하기까지 기다리는 최대 시간 (시간이 지날 때까지 락을 획득하지 못하면 false를 반환)
- leaseTime : 락의 타임아웃을 설정 (설정 시간만큼 지나면 락이 만료되어 스스로 해제)
- timeUnit : 락 설정 값에 사용될 시간 타입
DistributionLockKeyGenerator
Lock에 사용될 키를 생성하는 클래스입니다.
키 값은 전달받은 메서드의 이름과 ExpressionParser를 통해 파싱한 값을 통해 생성합니다.
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class DistributionLockKeyGenerator {
public static Object generate(String methodName, 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 methodName + "-" + parser.parseExpression(key).getValue(context, Object.class);
}
}
DistributionLockAop
앞서 생성한 @DistributionLock 어노테이션을 통해 수행할 AOP 설정 클래스 입니다.
import java.lang.reflect.Method;
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 com.woowacourse.momo.global.exception.exception.GlobalErrorCode;
import com.woowacourse.momo.global.exception.exception.MomoException;
@Component
@Aspect
@RequiredArgsConstructor
@Slf4j
public class DistributionLockAop {
private static final String LOCK_PREFIX = "LOCK: ";
private final RedissonClient redissonClient;
private final TransactionGeneratorAop transactionGeneratorAop;
@Around("@annotation(com.woowacourse.momo.support.distributionlock.DistributionLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
DistributionLock distributionLock = method.getAnnotation(DistributionLock.class);
String key = LOCK_PREFIX + DistributionLockKeyGenerator.generate(methodSignature.getName(),
methodSignature.getParameterNames(), joinPoint.getArgs(), distributionLock.key());
RLock lock = redissonClient.getLock(key);
try {
if (!lock.tryLock(distributionLock.waitTime(), distributionLock.leaseTime(), distributionLock.timeUnit())) {
throw new MomoException(GlobalErrorCode.LOCK_ACQUISITION_FAILED_ERROR);
}
log.info("lock - " + key);
return transactionGeneratorAop.proceed(joinPoint);
} catch (InterruptedException e) {
log.error(e.getMessage());
throw new MomoException(GlobalErrorCode.LOCK_INTERRUPTED_ERROR);
} finally {
log.info("unlock - " + key);
lock.unlock();
}
}
}
TransactionGeneratorAop
새로운 트랜잭션을 만들어 로직을 수행하는 AOP 클래스입니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)를 통해 새로운 트랜잭션을 만들어 로직을 수행합니다.
@Component
public class TransactionGeneratorAop {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
해당 AOP를 통해 새로운 트랜잭션을 생성하지 않더라도 분산락 테스트는 정상적으로 통과합니다. 하지만 이럴 경우 분산락의 반납이 진행된 이후에 트랜잭션의 커밋이 발생하기에 희박한 가능성으로 예상하지 못한 동작으로 이어질 수 있습니다. 이해를 위해 그림으로 살펴보겠습니다.
분산락을 획득하고 반납이 트랜잭션 안에서 처리되면 위와 같이 이전에 동락한 트랜잭션(1번 트랜잭션)의 커밋이 되지 않아 락을 획득한 트랜잭션(2번 트랜잭션)에서 업데이트 된 데이터가 아닌 언두로그의 데이터를 읽어와 정합성이 깨지는 상황이 발생할 수 있습니다. 이러한 문제는 락을 획득하고 반납하는 로직 내에서 트랜잭션의 커밋까지 이루어지도록 변경하면 됩니다.
위에서 지정한 TransactionGeneratorAop를 통해 분산락을 통해 동작하는 로직을 새로운 트랜잭잭션 내에서 수행하도록 변경하면 락을 반납하기 이전에 트랜잭션의 커밋이 이루어져 아래와 같이 동작하게 됩니다.
테스트
비즈니스 로직
이제 앞서 생성한 분산락 어노테이션을 비즈니스 로직에 적용하고 테스트해보겠습니다. 기존 비즈니스 로직과 다르게 내부 로직에서 새로운 트랜잭션을 만들기에 @Transacitonal을 제거하였습니다. @Transactional을 그대로 붙여둬도 되지만 불필요하게 더 많은 데이터베이스 커넥션을 사용하여 커넥션 고갈 문제가 발생할 수 있습니다.
@RequiredArgsConstructor
@Service
public class ParticipateService {
private final MemberFindService memberFindService;
private final GroupFindService groupFindService;
@DistributionLock(key = "#groupId")
public void participate(Long groupId, Long memberId) {
Group group = groupFindService.findGroup(groupId);
Member member = memberFindService.findMember(memberId);
group.participate(member);
}
}
이제 앞서 생성한 분산락 어노테이션을 비즈니스 로직에 적용하고 테스트해보겠습니다. 기존 비즈니스 로직과 다르게 내부 로직에서 새로운 트랜잭션을 만들기에 @Transacitonal을 제거하였습니다. @Transactional을 그대로 붙여둬도 되지만 불필요하게 더 많은 데이터베이스 커넥션을 사용하여 커넥션 고갈 문제가 발생할 수 있습니다.
결과 확인
위의 로직을 적용 후 테스트를 진행해본 결과는 아래와 같이 성공적으로 통과하는 것을 확인할 수 있습니다. 또한 로그를 확인해보면 Lock의 획득과 Lock의 반납이 순차적으로 진행되는 것을 확인할 수 있습니다.
문제점 - 트랜잭션을 새로 분리하며 실패하는 테스트
트랜잭션을 분리하며 기존에 성공하던 관련 로직들이 실패하는 문제가 발생했습니다. 먼저 실패하는 테스트 중 한개를 살펴보겠습니다.
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@RequiredArgsConstructor
@SpringBootTest
class ParticipateServiceTest {
...
private Group group;
private Member host;
private Member participant;
@BeforeEach
void setUp() {
this.host = memberRepository.save(MOMO.toMember());
this.group = groupRepository.save(MOMO_STUDY.toGroup(host));
this.participant = memberRepository.save(DUDU.toMember());
}
@DisplayName("모임에 참여한다")
@Test
void participate() {
long groupId = group.getId();
long participantId = participant.getId();
participateService.participate(groupId, participantId);
List<MemberResponse> participants = participateService.findParticipants(groupId);
assertThat(participants).hasSize(2);
}
...
}
기존에 정상적으로 통과하던 테스트가 “존재하지 않는 모임입니다.” 라는 예외와 함께 실패하게 됐습니다.
Propagation.REQUIRES_NEW를 통해 새로운 트랜잭션을 만들어 수행하다보니 트랜잭션 격리에 의해 커밋되지 않은 부모 트랜잭션의 데이터를 읽지 못해 모임을 찾지 못하는 문제가 발생한 것입니다.
이를 해결하기 위해 테스트 격리 방법을 @Transactional을 통해 롤백하는 방식이 아닌 @Sql을 통해 테스트의 실행 전에 데이터베이스의 테이블을 초기화는 방식으로 변경하며 문제를 해결했습니다.
@Sql(value = "classpath:init.sql", executionPhase = BEFORE_TEST_METHOD)
@Sql(value = "classpath:truncate.sql", executionPhase = AFTER_TEST_METHOD)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@RequiredArgsConstructor
@SpringBootTest
class ParticipateServiceTest {
...
@DisplayName("모임에 참여한다")
@Test
void participate() {
long groupId = group.getId();
long participantId = participant.getId();
participateService.participate(groupId, participantId);
List<MemberResponse> participants = participateService.findParticipants(groupId);
assertThat(participants).hasSize(2);
}
...
}
📚Reference
- 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
- Redisson Repo
- [JPA] 락을 이용한 재고 관리
- SpringBoot 재고 감소 동시성 제어 - Redisson 사용 (+ AOP 적용)
- Redis를 활용하여 동시성 문제 해결하기
- [Spring & Java] 🚀 재고시스템으로 알아보는 동시성이슈 해결방법
- 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)
- Redis를 통한 분산락(Distribution Lock)으로 동시성 문제 제어하기
- MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리 | 우아한형제들 기술블로그