
동시성 프로그래밍에서 경쟁 상태 (Race Over)를 해결하는 방안을 공부중이다.
(동시성이 뭐고, 경쟁상태가 뭔지는 요기로!)
동시성을 공부하던 도중, 흥미로운 사실을 하나 알아냈다.
import http from 'k6/http';
export let options = {
vus: 500, // 동시에 50명의 가상 유저가 요청
iterations: 500, // 각 유저 1회 요청
// duration: '5s', // 5초 동안 실행
};
export default function () {
const url = 'http://localhost:8080/api/post/like';
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const res = http.post(url, params);
// console.log(res.status);
}
// Service단에 synchronized를 적용한 상태
public synchronized ResponseEntity<?> likePost() {
// 그냥 테이블에 데이터 없으면 넣어주는 코드
// 딱히 이 실험에 영향이 가지 않는다.
if ( postRepository.count() == 0 ) initData();
try {
Post post = postRepository.findById(1L)
.orElseThrow(() -> new NullPointerException("Post not found"));
post.setLikes(post.getLikes() + 1);
postRepository.save(post);
return ResponseEntity.ok().body("좋아요: " + post.getLikes());
} catch (Exception e) {
log.error("Error liking post: {}", e.getMessage());
return ResponseEntity.status(500).body("좋아요 실패." + e.getMessage());
}
}
현재 코드는 서비스에 synchronized를 적용한 상태다.
유저는 간단하게 500명이 있다고 가정하고, k6로 500개의 요청을 보내면...
"경쟁상태"에 의해 500개보다 좋아요👍 의 수가 적어야 된다고 생각했다.
근데...
이거 왜 500개가 그대로 찍히지? 😱😱
ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅠㅠㅠㅠㅠㅠㅠ
자세하게 알아보자.
원인은 @Transactional 이 친구다!
트랜잭션 (Transaction)
데이터베이스의 상태를 변화시키기 해서 수행하는 작업의 단위
@Transactional
해당 메소드 내의 모든 데이터베이스 접근 작업이 하나의 트랜잭션으로 묶임
트랜잭션을 붙이게 되면,
그 메소드 내에서 실행되는 모든 쿼리가 성공해야만 커밋(저장)되고,
하나라도 실패하면 롤백(취소) 된다.
public synchronized ResponseEntity<?> likePost() {
// 그냥 테이블에 데이터 없으면 넣어주는 코드
// 딱히 이 실험에 영향이 가지 않는다.
if ( postRepository.count() == 0 ) initData();
try {
Post post = postRepository.findById(1L)
.orElseThrow(() -> new NullPointerException("Post not found"));
post.setLikes(post.getLikes() + 1);
postRepository.save(post);
return ResponseEntity.ok().body("좋아요: " + post.getLikes());
} catch (Exception e) {
log.error("Error liking post: {}", e.getMessage());
return ResponseEntity.status(500).body("좋아요 실패." + e.getMessage());
}
}
지금 이 메소드에 @Transactional 적용되어 있지 않다.
그렇기에 postRepository.save(post) 가 실행이 되면, 바로 DB에 커밋을 하게 된다.
커밋을 한 후, synchronized에 의해 락이 걸려 있던 likePost() 메소드가 풀리면서, 정상적으로 다음 스레드가 likePost()를 다시 실행한다.
즉, 커밋 시점이 synchronized의 락이 풀리는 시점보다 빨라서 "경쟁 상태"가 발생하지 않는다.

그럼 @Transactional을 적용하면...?
정상적으로(?) 경쟁상태가 됐다.
@Transactional을 적용시키기 되면,
1. Spring의 AOP 프록시가 메서드 호출을 가로챔
2. AOP가 트랜잭션을 미리 시작
→ 여기서 DB 커넥션 + 트랜잭션 바인딩됨
3. 진짜 메서드 진입 (synchronized 발동)
→ 다른 스레드는 여기서 기다림 (Lock)
4. 메서드 본문 실행
5. 메서드 종료
→ Lock 해제 → 다른 스레드가 접근 가능
6. AOP가 트랜잭션을 커밋 or 롤백
5번에서 메서드가 종료되고, 그와 동시에 Lock도 풀려버린다.
이렇게 되면, AOP가 Commit이나 RollBack을 하기 전에 자원을 가져가게 되는 순간!
경쟁상태가 되어 버린다...

간단하다. Controller에 synchronized를 적용하면 된다.
@PostMapping("/like")
public synchronized ResponseEntity<?> likePost() {
return postService.likePost();
}
AOP가 트랜잭션을 실행하는 시점은 Service 계층이다.
그 전에, Controller 계층에서 synchronized로 Lock을 하게 되면, 경쟁상태가 발생하지 않는다.
정상적으로 500개의 좋아요가 저장되어 있다.
많은 개발자들이 "DB 락을 사용해!" 라고 말한다.
왜 그럴까...?
synchronized의 가장 큰 단점은 "다중 환경"이다.

서버의 트래픽 분산을 위해,
위와 같은 구조처럼 MSA 혹은 분산서버로 서버를 여러대를 사용하는 경우가 많다.
A서버에서 likePost()의 메소드를 Lock을 했다고 가정하자.
그럼 B서버가 그걸 어케 아는가...?
출처: 요기
당연히 A(Server 1)는 B(Server 2)의 스레드에 대한 동시성을 제어할 수는 없다.
그렇기에 실무에서는 잘 사용하지 않는다고 한다.
그래서! DB 레벨에서 Lock을 걸어버린다고 한다.
다음엔 DB 락 공부해야지~