요새 며칠 계속 문서화 작업만 하니까 정말 너무 너무 너무! 개발이 하고 싶다. 그래서 개발 관련 공부를 문서화 하는 걸 하기로 했다. 저번에는 리다이렉트에 대한 개념을 공부하면서 정리를 했었는데, 이번에는 아예 동시성 제어에 대한 공부를 하면서 정리를 해보기로 했다.
동시성은 하나의 CPU(멀티코어CPU포함)에서 각각 실행되고 있는 Task(process의 하위 단위)들이 빠른 속도로 번갈아가면서 실행되면서 마치 동시에 실행되는 것처럼 보이는 것을 말한다. 이는 단일 CPU에서도 발생할 수 있고, 멀티스레드 환경에서는 병렬성과 혼합되어 실행되기도 한다.
동시성에 대한 이야기를 들었을 때 누군가 마치 닌자의 분신술마냥 실체는 여러 개가 아닌데 여러개로 보이는 거 아니냐는 말도 했엇다.
중앙에 있는 본체(?)가 하나의 CPU인데 분신술을 사용하는 이런 느낌..?
조금 다르긴 하지만, 이해하긴 좋은 비유일지도 모르겠다.
요컨데 동시성은 작업을 사용자가 눈치채지 못할 정도로 작은 단위로 쪼개서 1실행 2실행 3실행을 반복하는 것이라고 생각할 수 있다.
1이 아주 조금 실행 -> 2가 아주 조금 실행 > 3이 아주 조금 실행 > 다시 1이 아주 조금 실행 > 2가 조금 실행 ...
이러한 형태로 진행이 되는 것이기 때문에, 사람이 보기엔 1,2,3이 동시에 실행되는 것처럼 보이는 것이다.
이와 반대의 개념에는 병렬성이 있는데, 병렬성은 여러 CPU에서 실제로 동시에 여러 개의 작업이 처리된다는 점이 동시성과의 큰 차이점이라고 볼 수 있다.
닌자로 예를 들면 병렬성은 이 사진마냥 여러명의 닌자들이 동시에 일하는 거라고 보면 되겠다. 뭐 CPU가 이렇게 한번에 많이 돌아가면 뭐 슈퍼컴퓨터인가 이런 생각도 들지만 말이다? ㅋㅋ
멀티스레드 환경에 대한 추가설명
내가 만약 무슨 코어인지 모르면 컴퓨터 시스템을 켜서 정보칸을 켜보면 된다.
이처럼 컴퓨터 정보가 나오는데 여기서 나온 CPU이름을 검색하면 코어가 몇 개인지 알려준다.
사실 내 컴퓨터 버전을 보면 알 만한 사람들은 알겠지만 2017년에 생산된 제품이다.
나와 함께 늙어간다 마이컴퓨터호텔
이거 일단 보는 방법은 생각보다 매우 간단하다
코어 i7 - 7700
뒷자리 번호가 7로 시작하면 7세대를 의미하는데 7세대가 17년도 생산 제품이다.
아마 올해에 인텔에서 생산예정되어 있는 라인은 15세대일 것이다.
그리고 앞에 있는 i7도 코어 갯수와 관련이 있는 걸로 안다. 숫자가 높을 수록 코어가 크다는 의미로 알고 있다. -> 이 부분은 GPT한테 과거의 유물 소리를 들었다. 현재는 등급 차는 있지만 세대 별로 같은 숫자여도 코어 수는 다르다는 이야기였다.
동시성이 필요한 이유에는 여러가지가 있는데 대표적으로 웹 페이지에 동시에 여러 사람이 접속할 때 데이터의 일관성과 무결성을 유지하려면 동시성이 필요하다.
조회 수 같은빠르게 업데이트 되는 정보가 있을 때 충돌이 생기지 않게 할 때 필요하다.
그리고 동시성 제어를 해주는 것이 동기화를 하는 것보다 성능이 더 빨라질 수 있고, 여러 기능을 확장하는 데에 도움을 줄 수 있기 때문이다.
실제로 레이스 컨디션 상태에 대해서는 내가 겪은 일이 있는데, 내가 떡볶이집 아르바이트를 했을 때 몇 초 이내에 배달주문이 동시에 들어오면 동시성 문제가 발생했다.
만약 그 상태에서 실수로 내가 배달 주문 수락을 연속으로 누르면 제대로 눌렀다고 해도 배달영수증이 안나오는 대참사가 발생하곤 했다.
당시에는 이게 뭔지 정확한 개념이 없어서 몰랐는데, 아무래도 이건 배달주문 프로그램에서 발생된 동시성 제어의 문제였던 것 같다.
쉽게 말하면 아직 수정중인 인터넷 신문이 있는데 그걸 기자가 완전 공개하기 전에 사람들이 읽어버리는 문제라고 볼 수 있겠다.
이 부분은 아무래도 깃허브를 사용해본 개발자나, 예비 개발자라면 알 것이라 생각한다. 코드를 작성하다 보면 어쩔 수 없이 동시에 같은 부분을 수정해야 하는 일이 빈번하기 때문에 컨플릭트가 발생하곤 하는데 이 때 걸핏 잘못 하다간 업데이트 중에 코드가 없어지는 대참사가 발생하는 것이다. 물론 깃허브는 이를 방지하기 위해 전 커밋으로 돌릴 수 있게 하는 등, 대비책을 마련해두었지만 그마저도 커밋을 제대로 안 하게 되면 이런 문제가 생기는 것이다.
이건 말 그대로 유령처럼 읽혔다가 사라지고 변경되는 조회 결과를 뜻하는 용어인 것 같다.
@Transactional
public void updateViewCount(Long pageId) {
Page page = pageRepository.findByIdWithLock(pageId); // SELECT ... FOR UPDATE
page.incrementViewCount();
pageRepository.save(page);
}
@Entity
public class Page {
@Id @GeneratedValue
private Long id;
private int viewCount;
@Version
private int version; // 버전 필드를 활용한 낙관적 락
public void incrementViewCount() {
this.viewCount++;
}
}
위처럼 @Version을 사용하면 데이터 변경 시 구 버전과 비교하여 예외를 발생시킨다.
하지만 이래도 충돌이 발생하면 재시도 로직을 추가할 필요가 있다.
<OCC와 낙관적 락에 대하여>
위에서는 OCC를 사용한다 했는데 왜 갑자기 낙관적 락이란 말이 나오냐 하면, 사실 낙관적 락은 @Version을 사용하는 방식에만 한정하는 의미이기 때문이다. OCC라 함은 전체적인 동시성 제어 기법을 의미하는 용어라서 조금 더 넓은 스펙트럼을 가진 용어다.
OCC와 낙관적 락의 공통된 특징은 일단 낙관적(긍정적)으로 생각하면서 충돌이 없을 걸 가정 하고 작업 하는 방식이라는 점이다.
이제 이러한 동시성 문제 해결법을 내가 프로젝트에 적용해야한다는 건데... 생각보다 이해가 바로 될 거 같진 않다. 심지어 정보처리기사 공부하면서 다 가볍게 공부했던 개념인데도 이걸 코드로 소화하려니 시간이 하루이틀은 걸릴 거 같다.
SELECT view_count FROM pages WHERE id = ? FOR UPDATE;
이러한 형태로 특정 id의 view_count에 락을 거는 FOR UPDATE를 사용할 수 있다. FOR UPDATE 문이 SQL에서 사용되면 하나의 트랜잭션이 끝날 때까지 다른 트랜잭션이 대기해야 한다.
이 경우에는 트래픽이 많아지면 성능 저하 가능성이 높아진다.
@Transactional
public void updateViewCount(Long pageId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
page.incrementViewCount();
pageRepository.save(page);
}
이 경우에는 별도로 @Vesion이 사용된 필드를 사용하여 데이터 충돌 시 에 예외를 발생하게끔 해야한다.
그리고 그럼에도 충돌이 발생한다면 재시도하는 로직을 다시 추가해야 하는 문제가 생긴다.
만약 단일 어플리케이션 서버라면 Synchronized 라는 키워드를 사용하여 동기화를 할 수 있기 때문에 이걸로도 해결이 될 수 있다.
public synchronized void incrementViewCount(Long pageId) {
Page page = pageRepository.findById(pageId).get();
page.incrementViewCount();
pageRepository.save(page);
}
Synchronized는 이런 식으로 메서드 자체에 넣어버리는 예약어의 일종이다.
이는 다중 서버 환경에서는 효과가 없다.
마지막으로 Redis를 활용한 일괄 업데이트 방식이다.
조회 수 요청을 Redis에 저장하고, 설정한 일정 간격마다 DB에 반영해주는 방식이다.
public void incrementViewCount(Long pageId) {
redisTemplate.opsForValue().increment("page:view:" + pageId);
}
@Scheduled(fixedRate = 60000) // 1분마다 DB 업데이트
public void syncViewCountToDatabase() {
List<Long> pageIds = redisTemplate.keys("page:view:*")
.stream()
.map(key -> Long.valueOf(key.replace("page:view:", "")))
.collect(Collectors.toList());
for (Long pageId : pageIds) {
int viewCount = redisTemplate.opsForValue().get("page:view:" + pageId);
pageRepository.updateViewCount(pageId, viewCount);
redisTemplate.delete("page:view:" + pageId);
}
}
Redis에서 조회수를 캐싱하고 설정된 간격만큼 DB에서 조회수를 업데이트 해 주는 방식이다.
물론 단점을 보완하는 방법도 존재한다.
<출처>
https://velog.io/@wlsrhkd4023/동시성Concurrency과-병렬성Parallelism-차이
https://fierycoding.tistory.com/93
https://seamless.tistory.com/42
https://dololak.tistory.com/446
https://binux.tistory.com/169