반응형
동시성 이슈 발생
설명
- 한 이벤트에 한 유저가 2번까지만 투표할 수 있는 시스템이 있다고 가정.
- 동시에 여러 요청을 할 경우 데이터 정합성이 맞지 않는 동시성 이슈가 발생할 수 있음.
- 이와 관련된 이슈 발생 케이스를 설명.
- 아래 예시는 JPA와 MySQL을 사용.
- JPA 참고 : https://sg-choi.tistory.com/295
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 |