[TIL] 최종 프로젝트 (3) - 동시성 제어와 락의 이해

J쭈디·2025년 2월 13일
0

Sparta_프로젝트

목록 보기
14/35

요새 며칠 계속 문서화 작업만 하니까 정말 너무 너무 너무! 개발이 하고 싶다. 그래서 개발 관련 공부를 문서화 하는 걸 하기로 했다. 저번에는 리다이렉트에 대한 개념을 공부하면서 정리를 했었는데, 이번에는 아예 동시성 제어에 대한 공부를 하면서 정리를 해보기로 했다.

1. 동시성(Concurrency)이란 무엇인가?

동시성은 하나의 CPU(멀티코어CPU포함)에서 각각 실행되고 있는 Task(process의 하위 단위)들이 빠른 속도로 번갈아가면서 실행되면서 마치 동시에 실행되는 것처럼 보이는 것을 말한다. 이는 단일 CPU에서도 발생할 수 있고, 멀티스레드 환경에서는 병렬성과 혼합되어 실행되기도 한다.

동시성에 대한 이야기를 들었을 때 누군가 마치 닌자의 분신술마냥 실체는 여러 개가 아닌데 여러개로 보이는 거 아니냐는 말도 했엇다.

중앙에 있는 본체(?)가 하나의 CPU인데 분신술을 사용하는 이런 느낌..?
조금 다르긴 하지만, 이해하긴 좋은 비유일지도 모르겠다.

요컨데 동시성은 작업을 사용자가 눈치채지 못할 정도로 작은 단위로 쪼개서 1실행 2실행 3실행을 반복하는 것이라고 생각할 수 있다.

1이 아주 조금 실행 -> 2가 아주 조금 실행 > 3이 아주 조금 실행 > 다시 1이 아주 조금 실행 > 2가 조금 실행 ...
이러한 형태로 진행이 되는 것이기 때문에, 사람이 보기엔 1,2,3이 동시에 실행되는 것처럼 보이는 것이다.

이와 반대의 개념에는 병렬성이 있는데, 병렬성은 여러 CPU에서 실제로 동시에 여러 개의 작업이 처리된다는 점이 동시성과의 큰 차이점이라고 볼 수 있다.

닌자로 예를 들면 병렬성은 이 사진마냥 여러명의 닌자들이 동시에 일하는 거라고 보면 되겠다. 뭐 CPU가 이렇게 한번에 많이 돌아가면 뭐 슈퍼컴퓨터인가 이런 생각도 들지만 말이다? ㅋㅋ

멀티스레드 환경에 대한 추가설명

  • 멀티스레드 환경에서는 병렬성과 동시성이 혼합될 수도 있다.
  • 멀티코어CPU에서는 멀티스레드가 실행될 수 있는데 현재의 컴퓨터는 대부분 멀티스레드 환경을 지원하고 있다. 지금 이 게시글이 작성되고 있는 내 노트북만 해도 현재 인텔 i7에 쿼드코어로 4개의 물리적 코어를 가진 CPU가 내장되어 있다.

내가 만약 무슨 코어인지 모르면 컴퓨터 시스템을 켜서 정보칸을 켜보면 된다.

이처럼 컴퓨터 정보가 나오는데 여기서 나온 CPU이름을 검색하면 코어가 몇 개인지 알려준다.

사실 내 컴퓨터 버전을 보면 알 만한 사람들은 알겠지만 2017년에 생산된 제품이다.
나와 함께 늙어간다 마이컴퓨터호텔

이거 일단 보는 방법은 생각보다 매우 간단하다
코어 i7 - 7700
뒷자리 번호가 7로 시작하면 7세대를 의미하는데 7세대가 17년도 생산 제품이다.
아마 올해에 인텔에서 생산예정되어 있는 라인은 15세대일 것이다.

그리고 앞에 있는 i7도 코어 갯수와 관련이 있는 걸로 안다. 숫자가 높을 수록 코어가 크다는 의미로 알고 있다. -> 이 부분은 GPT한테 과거의 유물 소리를 들었다. 현재는 등급 차는 있지만 세대 별로 같은 숫자여도 코어 수는 다르다는 이야기였다.

1. 동시성이 필요한 이유

동시성이 필요한 이유에는 여러가지가 있는데 대표적으로 웹 페이지에 동시에 여러 사람이 접속할 때 데이터의 일관성과 무결성을 유지하려면 동시성이 필요하다.

조회 수 같은빠르게 업데이트 되는 정보가 있을 때 충돌이 생기지 않게 할 때 필요하다.
그리고 동시성 제어를 해주는 것이 동기화를 하는 것보다 성능이 더 빨라질 수 있고, 여러 기능을 확장하는 데에 도움을 줄 수 있기 때문이다.

2. 동시성의 문제사항들

2-1. Race Condition

  • 여러 개의 스레드가 동일 자원에 동시에 접근하여 예기치 못한 상황이 발생하는 것
  • 조회 수 증가 로직이 동시에 여러 스레드에서 실행되면 결과적으로 몇 번의 조회수가 증가되었는지 손실되어 알 수 없게 된다.

실제로 레이스 컨디션 상태에 대해서는 내가 겪은 일이 있는데, 내가 떡볶이집 아르바이트를 했을 때 몇 초 이내에 배달주문이 동시에 들어오면 동시성 문제가 발생했다.
만약 그 상태에서 실수로 내가 배달 주문 수락을 연속으로 누르면 제대로 눌렀다고 해도 배달영수증이 안나오는 대참사가 발생하곤 했다.
당시에는 이게 뭔지 정확한 개념이 없어서 몰랐는데, 아무래도 이건 배달주문 프로그램에서 발생된 동시성 제어의 문제였던 것 같다.

2-2. Dirty Read

  • 한 트랜잭션이 아직 커밋되지 않은 데이터를 읽는 경우를 말한다.
  • 1이 데이터를 업데이트 하고 있지만 커밋하기 전에 B가 그걸 읽어버리는 것이다.

쉽게 말하면 아직 수정중인 인터넷 신문이 있는데 그걸 기자가 완전 공개하기 전에 사람들이 읽어버리는 문제라고 볼 수 있겠다.

2-3. Lost Update

  • 두 개 이상의 트랜잭션이 동시에 같은 데이터를 수정할 때 둘 중 하나가 손실되는 경우
  • 동시에 두 개의 조회수가 증가하는 요청이 들어왔을 때 한 개만 반영이 되고 나머지 하나는 누락되는 경우

이 부분은 아무래도 깃허브를 사용해본 개발자나, 예비 개발자라면 알 것이라 생각한다. 코드를 작성하다 보면 어쩔 수 없이 동시에 같은 부분을 수정해야 하는 일이 빈번하기 때문에 컨플릭트가 발생하곤 하는데 이 때 걸핏 잘못 하다간 업데이트 중에 코드가 없어지는 대참사가 발생하는 것이다. 물론 깃허브는 이를 방지하기 위해 전 커밋으로 돌릴 수 있게 하는 등, 대비책을 마련해두었지만 그마저도 커밋을 제대로 안 하게 되면 이런 문제가 생기는 것이다.

2-4. Phantom Read

  • 한 트랜잭션이 동일한 쿼리를 여러 번 실행할 때, 다른 트랜잭션이 데이터를 추가하거나 삭제하여 결과 집합이 달라지는 현상을 말한다.

이건 말 그대로 유령처럼 읽혔다가 사라지고 변경되는 조회 결과를 뜻하는 용어인 것 같다.

3. 동시성 문제의 해결법

  • Lock 활용하기
  • Lock을 사용하면 공유자원에 대해 동시접근을 제한하기 때문에 동시성 문제가 발생하지 않게 된다.
  • OCC(Optimistic Concurrency Controll) == 낙관적 동시성 제어하기

3-1. 비관적 락(pessimistic Lock)

  • 트랜잭션이 자원을 선점 후, 다른 트랜잭션이 접근 못하게 막는 방법
  • 충돌이 많이 날 것을 가정하고 사용하는 Lock
  • 장점 : 데이터 정합성 보장
  • 단점 : 성능 저하 가능성 (선점 시간만큼 대기시간 증가)
@Transactional
public void updateViewCount(Long pageId) {
    Page page = pageRepository.findByIdWithLock(pageId); // SELECT ... FOR UPDATE
    page.incrementViewCount();
    pageRepository.save(page);
}

3-2. 낙관적 락(Optimistic Lock)

  • 데이터를 읽은 후 변경이 발생하면 이 변경에 대한 업데이트를 허용하지 않는 방법
  • 충돌이 적게 날 것이라고 가정하고 사용하는 Lock
  • 장점 : 락을 사용하지 않기 때문에 성능상 대기가 적어 빠르다.
  • 단점 : 그럼에도 충돌이 발생하면 다시 시도해야한다.
@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와 낙관적 락의 공통된 특징은 일단 낙관적(긍정적)으로 생각하면서 충돌이 없을 걸 가정 하고 작업 하는 방식이라는 점이다.

4. 조회수 카운팅과 동시성 문제

이제 이러한 동시성 문제 해결법을 내가 프로젝트에 적용해야한다는 건데... 생각보다 이해가 바로 될 거 같진 않다. 심지어 정보처리기사 공부하면서 다 가볍게 공부했던 개념인데도 이걸 코드로 소화하려니 시간이 하루이틀은 걸릴 거 같다.

4-1. DB 레벨에서 비관적 락으로 해결하기

SELECT view_count FROM pages WHERE id = ? FOR UPDATE;

이러한 형태로 특정 id의 view_count에 락을 거는 FOR UPDATE를 사용할 수 있다. FOR UPDATE 문이 SQL에서 사용되면 하나의 트랜잭션이 끝날 때까지 다른 트랜잭션이 대기해야 한다.

이 경우에는 트래픽이 많아지면 성능 저하 가능성이 높아진다.

4-2. DB 레벨에서 낙관적 락으로 해결하기

@Transactional
public void updateViewCount(Long pageId) {
    Page page = pageRepository.findById(pageId)
            .orElseThrow(() -> new IllegalArgumentException("Page not found"));
    page.incrementViewCount();
    pageRepository.save(page);
}

이 경우에는 별도로 @Vesion이 사용된 필드를 사용하여 데이터 충돌 시 에 예외를 발생하게끔 해야한다.
그리고 그럼에도 충돌이 발생한다면 재시도하는 로직을 다시 추가해야 하는 문제가 생긴다.

4-3. 애플리케이션 레벨에서 Synchronized 키워드로 해결하기

만약 단일 어플리케이션 서버라면 Synchronized 라는 키워드를 사용하여 동기화를 할 수 있기 때문에 이걸로도 해결이 될 수 있다.

public synchronized void incrementViewCount(Long pageId) {
    Page page = pageRepository.findById(pageId).get();
    page.incrementViewCount();
    pageRepository.save(page);
}

Synchronized는 이런 식으로 메서드 자체에 넣어버리는 예약어의 일종이다.
이는 다중 서버 환경에서는 효과가 없다.

4-4. Redis를 활용해 해결하기

마지막으로 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에서 조회수를 업데이트 해 주는 방식이다.

  • 장점 : DB 부하감소, 그로 인한 성능 향상 가능
  • 단점 : 실시간 데이터 반영이 어렵고, 애플리케이션 복잡도가 증가할 수 있음

물론 단점을 보완하는 방법도 존재한다.

  • AOF(Append-Only File) 기능을 활성화하면 Redis 장애 시에도 조회수 데이터를 보존하게 할 수가 있다. (appendonly yes 설정 등으로 데이터 실시간 저장 후, 장애 발생시 복구를 용이하게 한다.)
  • Kafka 같은 메시지 큐를 활용하면 실시간 반영도 가능하다.

결론. 동시성 제어에 정답은 없다.

  • 트래픽이 낮은 환경이면 낙관적 락으로도 해결이 가능
  • 데이터들의 정합성(시간이 지나도 값이 일치하는가?)이 높아야 할 때는 비관적 락으로 해결가능
  • 트래픽이 높은 환경이면 Redis 기반으로 일괄 업데이트 하는 방법을 써서 동시성을 제어한다.

<출처>
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

profile
언제 어느 위치에 있더라도 그 자리의 최선을 다 하는 사람이 되고 싶습니다.

0개의 댓글