반응형

동시성 이슈 발생

설명

  • 한 이벤트에 한 유저가 2번까지만 투표할 수 있는 시스템이 있다고 가정.
  • 동시에 여러 요청을 할 경우 데이터 정합성이 맞지 않는 동시성 이슈가 발생할 수 있음.
  • 이와 관련된 이슈 발생 케이스를 설명.
  • 아래 예시는 JPA와 MySQL을 사용.

VoteReq

@AllArgsConstructor
@NoArgsConstructor
@Setter
@Getter
@Table(indexes = {
    @Index(columnList = "eventId"),
    @Index(columnList = "userId")
})
@Entity
public class VoteReq {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long voteReqId;
    private Long eventId;
    private Long userId;
    private Long count;
}

VoteReqRepository

@Repository
public interface VoteReqRepository extends JpaRepository<VoteReq, Long> {
    VoteReq findByEventIdAndUserId(Long eventId, Long userId);
}

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final VoteReqRepository voteReqRepository;

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        VoteReq voteReq = voteReqRepository.findByEventIdAndUserId(eventId, userId);

        if (voteReq == null) {
            voteReqRepository.save(new VoteReq(null, eventId, userId, 1L));
            return;
        }

        if (voteReq.getCount() >= MAX_VOTE_COUNT_PER_USER) {
            throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
        }

        voteReq.setCount(voteReq.getCount() + 1);
    }
}

VoteServiceTest

@SpringBootTest
public class VoteServiceTest {
    @Autowired
    private VoteService voteService;

    @Test
    public void testRequestVote() throws InterruptedException {
        int threadSize = 5;
        ExecutorService service = Executors.newFixedThreadPool(threadSize);

        IntStream.range(0, threadSize).forEach((i) -> {
            service.execute(() -> {
                voteService.requestVote(1L, 1L);
            });
        });

        service.shutdown();
        service.awaitTermination(1, TimeUnit.HOURS);
    }
}

결과

  • 데이터 결과를 확인했을 때 5개의 스레드가 동시에 투표를 요청하여 5개의 투표가 이루어진 것으로 확인된다.
  • 한 사용자는 2번의 투표만 이루어져야 하므로 count가 2인 데이터 하나만 존재해야한다.
mysql> select * from VoteReq;
+-----------+-------+---------+--------+
| voteReqId | count | eventId | userId |
+-----------+-------+---------+--------+
|         1 |     1 |       1 |      1 |
|         2 |     1 |       1 |      1 |
|         3 |     1 |       1 |      1 |
|         4 |     1 |       1 |      1 |
|         5 |     1 |       1 |      1 |
+-----------+-------+---------+--------+

MySQL의 User-Level Lock을 활용

설명

  • 위 이슈를 해결하기 위해서는 동일 요청에 대해서는 원자적으로 동작하도록 처리할 수 있어야한다.
  • 실 운영환경이라면 여러대의 서버로 서비스를 하게 되기 때문에 다중 서버에서도 동일 요청에 대해 원자적으로 동작할 수 있도록 처리해야한다.
  • Distributed Lock을 사용하여 위 고민들을 해결할 수 있다.
  • MySQL의 User-Level Lock 기능을 활용하여 위 문제를 해결하는 방법을 설명한다.

LockTemplate

@RequiredArgsConstructor
@Component
public class LockTemplate {
    private final EntityManager entityManager;

    @Transactional
    public void execute(String key, int timeoutSeconds, Runnable runnable) {
        execute(key, timeoutSeconds, () -> {
            runnable.run();
            return null;
        });
    }

    @Transactional
    public <T> T execute(String key, int timeoutSeconds, Supplier<T> supplier) {
        BigInteger result = getLock(key, timeoutSeconds);

        try {
            return supplier.get();
        } finally {
            if (result.intValue() == 1) {
                releaseLock(key);
            }
        }
    }

    private BigInteger getLock(String key, int timeoutSeconds) {
        return (BigInteger) entityManager
            .createNativeQuery("SELECT GET_LOCK(:key, :timeout)")
            .setParameter("key", key)
            .setParameter("timeout", timeoutSeconds)
            .getSingleResult();
    }

    private BigInteger releaseLock(String key) {
        return (BigInteger) entityManager
            .createNativeQuery("SELECT RELEASE_LOCK(:key)")
            .setParameter("key", key)
            .getSingleResult();
    }
}

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final VoteReqRepository voteReqRepository;
    private final LockTemplate lockTemplate; // 의존성 추가

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        String key = String.format("%d:%d", eventId, userId);

        // lockTemplate.execute()로 감싸서 실행
        lockTemplate.execute(key, 10, () -> {
            VoteReq voteReq = voteReqRepository.findByEventIdAndUserId(eventId, userId);

            if (voteReq == null) {
                voteReqRepository.save(new VoteReq(null, eventId, userId, 1L));
                return;
            }

            if (voteReq.getCount() >= MAX_VOTE_COUNT_PER_USER) {
                throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
            }

            voteReq.setCount(voteReq.getCount() + 1);
        });
    }
}

결과

  • 의도한대로 count는 2인 데이터 하나만 저장되고, 나머지 요청은 OVER_MAX_VOTE_COUNT_PER_USER 오류를 발생시킨다.
mysql> mysql> select * from VoteReq;
+-----------+-------+---------+--------+
| voteReqId | count | eventId | userId |
+-----------+-------+---------+--------+
|         1 |     2 |       1 |      1 |
+-----------+-------+---------+--------+

MySQL의 Row-Level Lock을 활용

설명

  • MySQL의 Row-Level Lock 기능을 활용하여 위 문제를 해결하는 방법을 설명한다.
  • 이미 DB에 존재하는 데이터를 수정하는 경우에 사용할 수 있는 방법이다.

데이터 추가

INSERT INTO VoteReq (eventId, userId, count) VALUES (1, 1, 0);

VoteReqRepository

@Repository
public interface VoteReqRepository extends JpaRepository<VoteReq, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ~ FOR UPDATE
    VoteReq findByEventIdAndUserId(Long eventId, Long userId);
}

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final VoteReqRepository voteReqRepository;

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        VoteReq voteReq = voteReqRepository.findByEventIdAndUserId(eventId, userId);

        if (voteReq.getCount() >= MAX_VOTE_COUNT_PER_USER) {
            throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
        }

        voteReq.setCount(voteReq.getCount() + 1);
    }
}

결과

  • 의도한대로 count는 2인 데이터 하나만 저장되고, 나머지 요청은 OVER_MAX_VOTE_COUNT_PER_USER 오류를 발생시킨다.
mysql> mysql> select * from VoteReq;
+-----------+-------+---------+--------+
| voteReqId | count | eventId | userId |
+-----------+-------+---------+--------+
|         1 |     2 |       1 |      1 |
+-----------+-------+---------+--------+

MySQL의 원자적 쿼리 활용

설명

  • MySQL에서 한 쿼리는 원자적으로 동작한다는 특성을 갖고 있다.
  • 이러한 특징을 활용하여 동시성 이슈를 해결하는 방법을 설명한다.
  • Row-Level Lock처럼 이미 DB에 존재하는 데이터를 수정하는 경우에 사용할 수 있는 방법이다.

데이터 추가

INSERT INTO VoteReq (eventId, userId, count) VALUES (1, 1, 0);

VoteReqRepository

@Repository
public interface VoteReqRepository extends JpaRepository<VoteReq, Long> {
    // 원자적 쿼리
    @Modifying
    @Query("update VoteReq r set r.count = r.count + 1 where r.eventId = :eventId and r.userId = :userId and r.count < :maxCount")
    int increaseCount(@Param("eventId") Long eventId, @Param("userId") Long userId, @Param("maxCount") Long maxCount);
}

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final VoteReqRepository voteReqRepository;

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        int updateCount = voteReqRepository.increaseCount(eventId, userId, (long) MAX_VOTE_COUNT_PER_USER);

        if (updateCount == 0) {
            throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
        }
    }
}

결과

  • 의도한대로 count는 2인 데이터 하나만 저장되고, 나머지 요청은 OVER_MAX_VOTE_COUNT_PER_USER 오류를 발생시킨다.
mysql> mysql> select * from VoteReq;
+-----------+-------+---------+--------+
| voteReqId | count | eventId | userId |
+-----------+-------+---------+--------+
|         1 |     2 |       1 |      1 |
+-----------+-------+---------+--------+

Redis의 원자적 함수 활용

설명

  • Redis의 원자적 함수(아래 예제에서는 increase())를 활용한 방법을 설명한다.
  • spring-redis 설정 참고

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final StringRedisTemplate redisTemplate;

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        String key = String.format("%d:%d", eventId, userId);

        if (redisTemplate.opsForValue().increment(key) > MAX_VOTE_COUNT_PER_USER) {
            throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
        }
        
        // 비동기적으로 Redis 데이터를 MySQL에 적재
    }
}

Redisson을 활용

설명

  • Redisson 라이브러리를 활용한 방법을 설명한다.
  • Redis를 통해 Lock을 획득하고 해제하는 방식으로 동시성을 해결하는 방식이다.
  • Redisson은 Redis 기반이기 때문에 spring-redis 설정을 미리 해야한다.
  • spring-redis 설정 참고

Dependencies

dependencies {
    ...    
    implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
}

VoteService

@RequiredArgsConstructor
@Service
public class VoteService {
    private static final int MAX_VOTE_COUNT_PER_USER = 2;

    private final VoteReqRepository voteReqRepository;
    private final RedissonClient redissonClient; // 의존성 추가

    @Transactional
    public void requestVote(Long eventId, Long userId) {
        String key = String.format("%d:%d", eventId, userId);
        RLock lock = redissonClient.getLock(key);
        lock.lock(10, TimeUnit.SECONDS); // 락 획득

        try {
            VoteReq voteReq = voteReqRepository.findByEventIdAndUserId(eventId, userId);

            if (voteReq == null) {
                voteReqRepository.save(new VoteReq(null, eventId, userId, 1L));
                return;
            }

            if (voteReq.getCount() >= MAX_VOTE_COUNT_PER_USER) {
                throw new IllegalStateException("OVER_MAX_VOTE_COUNT_PER_USER");
            }

            voteReq.setCount(voteReq.getCount() + 1);
        } finally {
            lock.unlock(); // 락 해제
        }
    }
}

결과

  • 의도한대로 count는 2인 데이터 하나만 저장되고, 나머지 요청은 OVER_MAX_VOTE_COUNT_PER_USER 오류를 발생시킨다.
mysql> mysql> select * from VoteReq;
+-----------+-------+---------+--------+
| voteReqId | count | eventId | userId |
+-----------+-------+---------+--------+
|         1 |     2 |       1 |      1 |
+-----------+-------+---------+--------+

참고

반응형

'Development > Spring' 카테고리의 다른 글

[Spring] WebSocket  (0) 2020.12.27
[Spring] Dependency Injection  (0) 2020.12.27
[Spring] Transactional  (0) 2020.12.27
[Spring] AOP  (0) 2020.12.27
[Spring] Cookie & Session  (0) 2020.12.27

+ Recent posts