이 글에서는 좋아요 기능에 대한 동시성 처리 개발에 대해서 다루겠습니다.
글의 목차는 다음과 같습니다.
1) 프로젝트에 좋아요 기능에 대한 동시성 처리를 개발한 이유
2) Pessimistic Lock vs Optimistic Lock vs Named Lock
3) Named Lock으로 좋아요 기능 개발하기
4) 테스트 코드로 테스트하기
1) 프로젝트에 좋아요 기능에 대한 동시성 처리를 개발한 이유
- 제가 개발하는 interviewPrep 프로젝트에는 사용자가 다른 사용자의 답안에 좋아요를 누를 수 있습니다.
이 때, 해당 답안에는 좋아요 수가 기록됩니다.
그런데 여러 명의 사용자가 동시에 좋아요를 누를 때, 동시성 이슈로 인해
좋아요 수에 대한 부정합이 발생할 수 있다고 판단했습니다.
따라서, 프로젝트의 좋아요 기능에 대한 동시성 처리가 필요하다고 판단했습니다.
2) Pessimistic Lock vs Optimistic Lock vs Named Lock
- 동시성 처리에 대해 세 가지 락킹 기법을 고려했습니다.
그것은 Pessimistic Lock, Optimistic Lock, Named Lock입니다.
각각의 특징을 설명하고, 장점과 단점을 비교해보겠습니다.
(1) Pessimistic Lock
- Pessmistic Lock은 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 거는 방법입니다.
이것은 데이터베이스가 제공하는 락 기능을 사용합니다.
(2) Optimistic Lock
- Optimistic Lock은 트랜잭션은 대부분 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법입니다.
이것은 데이터베이스가 제공하는 락 기능이 아니라 JPA가 제공하는 버전 관리 기능을 사용합니다.
(3) Named Lock
- Named Lock은 GET_LOCK() 함수를 이용해 임의의 문자열에 대해 잠금을 설정할 수 있습니다.
이 잠금은 단순히 사용자가 지정한 임의의 문자열(String)에 대해 획득하고 반납(해제)하는 잠급입니다.
Named Lock은 주로 분산 락을 구현할 때 사용합니다.
- 각각의 장점과 단점은 다음과 같습니다.
| 장점 | 단점 | |
| Pessimistic Lock | 1) 충돌이 빈번하게 발생한다면, Optimistic Lock 보다 성능이 좋음 2) Lock을 통해 업데이트를 제어하기에 데이터 정합성이 보장됨 |
별도의 Lock으로 인한 성능 감소 존재 |
| Optimistic Lock | 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능상 이점이 있음 |
업데이트 실패 시 재시도 로직을 개발자가 직접 작성해야 함 |
| Named Lock | 1) Pessimistic Lock은 time-out을 구현하기 힘들지만, Named Lock은 손쉽게 구현할 수 있음 2) 데이터 삽입 시 정합성을 보장함 |
트랜잭션 해제 시, 락 해제, 세션 관리를 직접 해줘야 함 |
- 결론적으로 저는 동시성 제어를 위해 Named Lock을 선택했습니다.
그 이유는 다음과 같습니다.
(1) 답안에 대한 좋아요는 수백, 수천 명이 동시에 클릭할 확률은 낮으므로,
정합성이 동시성보다 좀 더 고려되는 편이 좋다고 생각했습니다.
(2) 정합성에 유리한 Pessimistic Lock과 Named Lock 중에서는 서비스 특성에 맞게 time-out을 튜닝하기 편리한
Named Lock을 선택하는 편이 좋다고 판단했습니다.
3) Named Lock으로 좋아요 기능 개발하기
(1) 서버 - 컨트롤러 구현
- 서버에서는 좋아요 생성 시 요청을 수신할 컨트롤러가 필요합니다.
컨트롤러의 create 메소드는 요청 수신 시 서비스의 createHeart 메소드를 호출하도록 하였습니다.
@RestController
@RequestMapping("/api/v1/hearts")
@CrossOrigin(origins = "*")
@Log4j2
public class HeartController {
private final HeartService heartService;
public HeartController(HeartService heartService) {
this.heartService = heartService;
}
@PostMapping()
public ResponseEntity<Void> create(@RequestBody HeartRequest heartRequest) throws InterruptedException {
heartService.createHeart(heartRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
(2) 서버 - 서비스 구현
@Service
public class HeartService {
private final HeartRepository heartRepository;
private final AnswerLockRepository answerLockRepository;
private final AnswerRepository answerRepository;
private final MemberRepository memberRepository;
public HeartService(HeartRepository heartRepository, AnswerLockRepository answerLockRepository, AnswerRepository answerRepository, MemberRepository memberRepository) {
this.heartRepository = heartRepository;
this.answerLockRepository = answerLockRepository;
this.answerRepository = answerRepository;
this.memberRepository = memberRepository;
}
@Transactional
public void createHeart(HeartRequest heartRequest) {
Long memberId = JwtUtil.getMemberId();
Long answerId = heartRequest.getAnswerId();
Answer answer = answerRepository.findById(answerId).orElseThrow(() -> new CommonException(NOT_FOUND_ANSWER));
Member member = memberRepository.findById(memberId).orElseThrow(() -> new CommonException(NOT_FOUND_MEMBER));
if (checkHeartExists(answerId, memberId)) {
throw new CommonException(EXIST_HEART_HISTORY);
}
increaseAnswerHeartCntWithNamedLock(answerId);
Heart heart = Heart.builder()
.answer(answer)
.member(member)
.build();
heartRepository.save(heart);
}
public boolean checkHeartExists(Long answerId, Long memberId) {
Optional<Heart> heart = heartRepository.findByAnswerIdAndMemberId(answerId, memberId);
return heart.isPresent();
}
public void increaseAnswerHeartCntWithNamedLock(Long answerId) {
try {
answerLockRepository.getLock(answerId.toString());
increaseAnswerHeartCnt(answerId);
} finally {
answerLockRepository.releaseLock(answerId.toString());
}
}
public void increaseAnswerHeartCnt(Long answerId) {
Answer answer = findAnswer(answerId);
answer.increase();
}
public Answer findAnswer(Long answerId) {
return answerRepository.findById(answerId).orElseThrow(() -> new CommonException(NOT_FOUND_ANSWER));
}
}
- 코드를 나눠서 살펴보겠습니다.
(2-1) createHeart 메소드
public void createHeart(HeartRequest heartRequest) {
Long memberId = JwtUtil.getMemberId();
Long answerId = heartRequest.getAnswerId();
Answer answer = answerRepository.findById(answerId).orElseThrow(() -> new CommonException(NOT_FOUND_ANSWER));
Member member = memberRepository.findById(memberId).orElseThrow(() -> new CommonException(NOT_FOUND_MEMBER));
if (checkHeartExists(answerId, memberId)) {
throw new CommonException(EXIST_HEART_HISTORY);
}
increaseAnswerHeartCntWithNamedLock(answerId);
Heart heart = Heart.builder()
.answer(answer)
.member(member)
.build();
heartRepository.save(heart);
}
- 새 좋아요를 만드는 메소드입니다.
한 사용자가 같은 답안에 대해 중복으로 좋아요를 누를 수 없으므로,
memberId와 answerId를 활용해서 checkHeartExists 메소드를 통해 좋아요 중복 검사를 해줍니다.
그 후에 중복 검사를 통과하면, increaseAnswerHeartCntWithNamedLock 메소드를 통해
Answer 엔티티에 저장된 좋아요 수를 증가시켜줍니다.
- 마지막으로 Heart 엔티티를 생성한 후에, DB에 저장해줍니다.
(2-2) checkHeartExists 메소드
public boolean checkHeartExists(Long answerId, Long memberId) {
Optional<Heart> heart = heartRepository.findByAnswerIdAndMemberId(answerId, memberId);
return heart.isPresent();
}
- 좋아요 중복 검사를 위한 메소드입니다.
Java8의 Optional을 활용하였고, heart가 존재하는 경우,
즉, heart.isPresent()인 경우 true를 아닌 경우 false를 반환하도록 하였습니다.
(2-3) increaseAnswerHeartCntWithNamedLock 메소드
@Transactional
public void increaseAnswerHeartCntWithNamedLock(Long answerId) {
try {
answerLockRepository.getLock(answerId.toString());
increaseAnswerHeartCnt(answerId);
} finally {
answerLockRepository.releaseLock(answerId.toString());
}
}
- NamedLock을 적용해서 Answer 엔티티의 좋아요 숫자를 증가시키는 메소드입니다.
@Transctional을 통해 Lock의 생성과 해제가 같은 트랜잭션에서 발생하도록 하였습니다.
- NamedLock은 개발자가 명시적으로 getLock을 통해 락을 걸고,
relaseLock을 통해서 락을 해제해야 한다는 특징을 갖고 있습니다.
또한, 락이 반드시 해제되어야 하므로 finally 구문을 통해 락이 해제되도록 하였습니다.
(2-4) increaseAnswerHeartCnt 메소드
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void increaseAnswerHeartCnt(Long answerId) {
Answer answer = findAnswer(answerId);
answer.increase();
}
- 트랜잭션 전파레벨을 적용했습니다.
트랜잭션 전파레벨을 적용한 이유는, 부모 트랜잭션과 독립적인 자식 트랜잭션을 실행하고,
자식 트랜잭션이 롤백되더라도, 부모 트랜잭션은 롤백되지 않도록 만들기 위함입니다.
- @Transactional(propagation = Propagation.REQUIRES_NEW)를 통해
부모 트랜잭션과 별도의 자식 트랜잭션이 실행되도록 하였습니다.
- findAnswer 메소드를 통해 answerId에 해당하는 Answer 엔티티를 DB에서 찾은 후에,
해당 엔티티의 좋아요 수를 1만큼 증가 시킵니다.
(2-5) findAnswer 메소드
public Answer findAnswer(Long answerId) {
return answerRepository.findById(answerId).orElseThrow(() -> new CommonException(NOT_FOUND_ANSWER));
}
- answerId에 해당하는 Answer 엔티티를 DB에서 찾은 후 반환합니다.
(3) 서버 - 리포지토리 구현
public interface AnswerLockRepository extends JpaRepository<Answer, Long> {
@Query(value = "select get_lock(:key, 1)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
- AnswerLockRepository 인터페이스에 getLock, releaseLock 메소드를 구현하였습니다.
getLock 메소드의 경우 1초의 timeout을 갖도록 구현하였습니다.
4) 테스트 코드로 테스트하기
- 마지막으로 테스트 코드로 테스트한 부분을 설명드리겠습니다.
@SpringBootTest
public class HeartServiceConcurrencyTest {
@MockBean
AnswerRepository answerRepository;
@MockBean
HeartRepository heartRepository;
@MockBean
AnswerLockRepository answerLockRepository;
@MockBean
MemberRepository memberRepository;
HeartService heartService;
@MockBean
Question question;
@MockBean
Member member;
Answer answer;
@BeforeEach
void setUp() {
heartService = new HeartService(heartRepository, answerLockRepository, answerRepository, memberRepository);
answer = Answer.builder()
.id(1L)
.question(question)
.member(member)
.heartCnt(0)
.build();
answerRepository.save(answer);
given(answerRepository.findById(1L)).willReturn(Optional.ofNullable(answer));
}
@Test
@DisplayName("동시에 100개의 요청")
public void increaseNamedLockTest() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
heartService.increaseAnswerHeartCntWithNamedLock(answer.getId());
} finally {
latch.countDown();
}
});
}
latch.await();
assertEquals(100, answerRepository.findById(answer.getId()).orElseThrow().getHeartCnt());
}
}
(1) increaseNamedLockTest
@Test
@DisplayName("동시에 100개의 요청")
public void increaseNamedLockTest() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
heartService.increaseAnswerHeartCntWithNamedLock(answer.getId());
} finally {
latch.countDown();
}
});
}
latch.await();
assertEquals(100, answerRepository.findById(answer.getId()).orElseThrow().getHeartCnt());
}
- ExecutorService를 활용해 32개의 쓰레드를 가진, 쓰레드 풀을 생성한 후에, 100개의 동시 요청을 합니다.
ExecutorService 내부에는 블로킹 큐가 있고, 요청들은 사용 가능한 스레드가 있는 경우는 바로 처리 되고,
없는 경우에는 블로킹 큐에 대기했다가 처리됩니다.

결과적으로 answer 엔티티의 heartCnt값이 100이 됨으로써, 테스트가 통과함을 확인할 수 있습니다.
참고
김영한 자바 ORM 표준 JPA 프로그래밍
재고시스템으로 알아보는 동시성이슈 해결방법 대시보드 - 인프런 | 강의 (inflearn.com)
OptimisticLock / Pessimistic Lock / Named Lock :: 쿵짝 (tistory.com)
[Spring] @Transactional의 전파 레벨에 대해 알아보자 (tistory.com)
'interviewPrep 프로젝트' 카테고리의 다른 글
| 단위 테스트 및 인수 테스트 작성 (0) | 2023.09.30 |
|---|---|
| 로그인 시 JWT Access Token, Refresh Token 적용하기(with Redis) - 1부 (0) | 2023.09.12 |
| SSE 댓글 알림 기능 개발(with Redis) (0) | 2023.09.11 |
| Spring에서 Mysql Replication으로 Master/Slave 이중화하기 (0) | 2023.09.09 |
| Spring Security를 활용한 Oauth2 적용 (0) | 2022.11.02 |