hwasowl.log

동시성 대응 방법 - 비동기 순차처리, 비관적&낙관적 락 본문

개인프로젝트

동시성 대응 방법 - 비동기 순차처리, 비관적&낙관적 락

화 솔 2025. 5. 24. 12:30

- 좋아요 기능을 구현한다. 동시 하나에 게시글에 대한 수천, 수만 개의 좋아요 요청이 들어와도 대응할 수 있는 설계 방법이 필요하다.

테이블 구조

 

샤딩키(article_id)와 user_id 인덱스 추가

 

 


 

방법 고민

 

대규모 데이터에서 좋아요 수를 계산하기 위한 count 쿼리를 사용하면 성능 이슈가 발생하고, 게시글과 달리 일부 카운트만 보여줄 수 없다. 또한 좋아요 수에서는 전체 개수를 실시간으로 빠르게 보여줘야 한다. (좋아요를 눌러도 느리게 반영되면 어색하다.)

 

만약 조회 시점에 실시간 조회에 큰 비용이 든다면 좋아요가 생성/삭제될 때 마다 미리 좋아요 수를 갱신하는 방법이 있을 것 같다. 좋아요 테이블의 게시글 별 데이터 개수를 미리 하나의 데이터로 비정규화 해두면 된다.

 

만약 쓰기 트래픽이 비교적 크지 않고 일관성이 중요하다면 관계형 DB의 트랜잭션을 활용할 수 있을 것 같다. 좋아요 테이블의 데이터 생성/삭제와 좋아요 갱신을 하나의 트랜잭션으로 묶는 것이다.

 


 

 

가장 먼저 떠오르는건 게시글 테이블의 컬럼으로 좋아요 수를 넣는 방법이다. 게시글 테이블에 컬럼을 추가하고 좋아요가 생성/삭제될 때 마다 좋아요 수를 갱신하는 것이다. 좋아요 수와 게시글은 1:1 관계이기 때문에 게시글 테이블에 비정규화 한다고 해서 어색함은 없어 보인다.

 

하지만 위 방법에는 몇 가지 제약이 생길 수 있는데

- Record Lock

- 분산 트랜잭션

크게 이 두 가지 제약이 생길 수 있다. 개념에 대해서 하나씩 알아보자.

 

 

Record Lock

여기서 Record는 행 데이터 Lock은 프로세스나 스레드의 동시 접근을 막기 위해 제한을 거는 것이다. 즉 행 데이터에 락을 거는 것이다.

- 동일 레코드를 동시에 조회 또는 수정할 때 데이터의 무결성을 보장하고 경쟁 상태를 방지

 

직접 테스트해보기 위해 데이터베이스를 생성하고 데이터를 넣어보겠다.

테스트 데이터베이스 생성
데이터 삽입

-

 

이제 트랜잭션을 시작하고 삽입한 레코드를 업데이트해보겠다. (content='test2')

트랜잭션 시작 후 업데이트

 

 그 후 락 여부를 확인해보겠다. 아래 쿼리를 사용하면 락을 조회해볼 수 있다.

select * from performance_schema.data_locks;

performance_schema.data_locks

 

나온 결과를 보면 레코드에 수행한 쓰기 작업에 의해 id=1234 레코드에 Exclusive Lock(=X Lock)이 걸린 것을 확인할 수 있다. X락은 쓰기락으로 이게 걸리면 다른 트랜잭션에서 읽기락을 얻지 못한다. (헷갈린다면 이 글을 읽어보시는걸 추천합니다!)

 

이어서 새로운 터미널을 열고 id=1234 레코드를 조회해보겠다. (아직 트랜잭션1을 commit하지 않은 상태)

(트랜잭션1 커밋 전) 트랜잭션2에서의 1234 레코드 조회

 

아직 커밋되지 않았음으로 업데이트 쿼리를 한 test2가 아닌 test로 기존 데이터가 조회되는 걸 확인할 수 있다.

 

이제 터미널2에서 트랜잭션을 열고 id=1234 레코드를 다시 수정해보자. 이 때 아직 터미널1의 트랜잭션은 커밋되지 않은 상태이다.

(트랜잭션1 커밋 전) 트랜잭션2 타임아웃

 

보면 트랜잭션1은 즉시 업데이트 구문이 처리되었는데 트랜잭션2는 한참은 기다려도 안되더니 결국 타임아웃으로 종료되었다. Exclusive Lock에 의해 트랜잭션2는 해체될 때 까지 기다린 것이다.

 

-

 

트랜잭션2를 시작하고 이후 트랜잭션 1을 커밋해보자

트랜잭션1 커밋
트랜잭션2 업데이트

 

이후 다시 락을 조회해보면 46988에서 46990으로 변경되었다. 트랜잭션1의 Exclusive락을 해제하고 트랜잭션2가 작업을 수행하면서 락을 흭득한 것이다.

-

 

이후 트랜잭션 2를 커밋하면

트랜잭션2 커밋
락 점유 X

 

락을 해제해 락 조회가 비어있는걸 확인할 수 있다.

 

-

 

다시 설계로 돌아가보면, 게시글과 좋아요 수의 변경은 생명주기가 다르다. 즉 서로 다른 주체에 의해서 레코드 락이 잡힐 수 있는 것이다.

게시글 쓰기와 좋아요 수 쓰기는 사용자 입장에서 독립적으로 수행되는 기능이지만, 트랜잭션으로 묶어버리면 서로 영향을 끼칠 수 있게 된다.

 

따로 타임아웃을 짧게 가져가거나 요청량에 제한을 둔다고 해도 독립적인 두 기능이 서로에 의해 실패할 수 있다.

-> 작성자와 관계 없는 좋아요 수 갱신으로 작성자의 쓰기 작업이 실패하는 경우가 발생할 수 있다.

이걸 방지하기 위해 게시글과 좋아요 수의 변경을 독립적인 테이블로 분리할 수 있다.

 

-

분산 트랜잭션 문제

하지만 보통 트랜잭션은 보통 단일 데이터베이스 내에서 안정적이고 빠르게 지원하지만 분산된 시스템에서 트랜잭션을 사용하려면 분산 트랜잭션 개념이 필요하다. 하지만 분산 트랜잭션은 상대적으로 느리고 복잡하다.

 

그래서 게시글 서비스에서, 좋아요 서비스의 테이블을 관리한다면 분산 환경이 되기 때문에 트랜잭션 관리가 복잡해진다. 따라서 게시글 서비스와 좋아요 서비스의 데이터베이스에서 각각 관리한다. 그리고 MSA 환경이기에 각 서비스가 존재하는데, 게시글 서비스나 좋아요 서비스에서 묶어서 관리할 이유가 없다.

 

따라서 게시글 서비스에서 잘 사용하고 있는 게시글 샤드키를 사용해 좋아요 수 테이블도 동일하게 사용한다.

 

 


 

만약 높이 쓰기 트래픽이 들어오는 상황에 좋아요 수가 어떻게 증가/감소될 수 있을까? 단순하게 트랜잭션만 걸어두면 충분한건지, 좋아요 수를 조회하고 생성/삭제에 따라 갱신만 해주면 충분할까? 하지만 지금은 동시성 문제가 발생할 수 있다. 예시를 한번 보자

 

 

동시요청문제

 

각 트랜잭션이 정상적으로 요청을 처리했다고 판단하고 커밋했고 완료했지만, 2개의 요청이 들어왔으므로 좋아요 수는 2로 처리되어야 한다. 하지만 동시 요청으로 인해, 증가 처리가 누락될 수 있는 상황인 것이다.

-> 트랜잭션을 단순히 사용한다고 해도 동시성 문제로 인해 데이터 일관성을 여전히 깨질 수 있는 것이다.

 

이러한 문제를 해결할 수 있는 구현 방법이 필요하다.

 

우리가 앞서 예시를 통해 레코드 락에 대한 개념을 살펴보았고 락을 사용하면 동시성 문제를 해결할 수 있고, 모든 요청을 누락없이 처리할 수 있을 것 같다. 한번 아래 예시를 보자.

 

레코드락

락을 걸어서 업데이트를 기다리고 트랜잭션2는 커밋이 되면 받아서 잘 처리했다. 결과를 보면 좋아요 수도 2로 잘 증가된 걸 확인할 수 있다. 하지만 여기에는 문제가 있다.

점유문제

 

보면 레코드락에 의해 처리가 잠시 지연되었다. 이렇게 리소스를 점유하고 있는 블로킹 작업은 장애가 발생할 여지가 있게 된다.

 

그리고 트래픽이 많다면 동시성 문제는 불가피하게 발생할 수 있다. 여러 개의 요청이 1개의 좋아요 수 레코드를 수정해야 되기 때문이다. 따라서 동일한 데이터를 수정하기 때문에, 동시성 문제를 제어하기 위한 방법은 당연히 필요하다.

 

 



동시 쓰기 요청이 여러개 들어왔을 때, 동시성 문제를 제어하며 데이터 유실 또는 장애 없이 처리할 수 있는 방법은

- 비관적 락

- 낙관적 락

- 비동기 순차 처리

위 세 가지 방법이 있다. 차례대로 알아보자

 


비관적 락(Pessimistic Lock)

데이터 접근 시에 항상 충돌이 발생할 가능성이 있다고 가정한다.

데이터를 보호하기 위해 항상 락을 걸어 다른 트랜잭션 접근을 방지한다.

- 다른 트랜잭션은 락이 해제되기까지 대기

- 락을 오래 점유하고 있으면, 성능 저하 또는 deadlock 등으로 인한 장애 문제

 

즉 이전 게시글에서 다뤘던 레코드 락을 의미한다. 이를 구현하는 두 가지 방법에 대해서 알아보자

 

방법1.

1. transaction start;
2. insert into article_like values({article_like_id}, {article_id}, {user_id},
{created_at});
• 좋아요 데이터 삽입
3. update article_like_count set like_count = like_count + 1 where article_id =
{article_id};
• 좋아요 수 데이터 갱신
• Pessimistic Lock 점유
4. commit;
• Pessimistic Lock 해제

데이터베이스에 저장된 데이터 기준으로 업데이트 문을 수행한다.

 

방법2.

1. transaction start;
2. insert into article_like values({article_like_id}, {article_id}, {user_id}, {created_at});
• 좋아요 데이터 삽입
3. select * from article_like_count where article_id = {article_id} for update;
• for update 구문으로 데이터 조회
• 조회된 데이터에 대해서 Pessimistic Lock 점유(이 시점부터 다른 Lock은 점유될 수 없다.)
• 애플리케이션에서 JPA를 사용하는 경우, 객체(엔티티)로 조회할 수 있다.
4. update article_like_count set like_count = {updated_like_count} where article_id = {article_id};
• 좋아요 수 데이터 갱신
• 조회된 데이터를 기반으로 새로운 좋아요 수를 만들어준다. (조회 시점부터 Lock을 점유하고 있기 때문에 가능)
• Client(애플리케이션)에서 JPA를 사용하는 경우, 엔티티로 위 과정을 수행할 수 있다.
5. commit;
• Pessimistic Lock 해제
for updat

 

 

3번을 보면 for update 구문으로 조회 결과에 대해 락을 점유하겠다고 명시한다.

이 후 트랜잭션에 조회된 데이터 기준으로 업데이트 문을 수행한다.

 

방법 1(update 구문) vs 방법 2(select for update + update 구문)

 

<락 점유>

방법 1 : UPDATE 문 수행하는 시점에 락을 점유한다.

=> 락 점유하는 시간이 상대적으로 짧다.

 

방법 2 : 데이터 조회 시점부터 락을 점유한다.

=> 락 점유하는 시간이 상대적으로 길다.

=> 데이터를 조회한 뒤 중간 과정을 수행해야 하기 때문에, 락 해제가 지연될 수 있다.

 

<애플리케이션 개발>

방법 1:

=> 데이터베이스의 현재 저장된 데이터 기준으로 증감 처리하기 때문에 SQL문을 직접 전송한다.

 

방법 2:

=> JPA를 사용하는 경우, 엔티티를 이용하여 조금 더 객체지향스럽게(?) 개발할 수 있다

 


낙관적 락(Optimistic Lock)

데이터 접근 시 항상 충돌이 발생할 가능성이 없다고 가정한다.

데이터의 변경 여부를 확인해서 충돌을 처리한다. 즉 다른 트랜잭션에 의해 데이터가 수정되었는지 확인한다.

- 수정된 내역이 있다면 후처리(롤백, 재처리)

 

여기서 데이터 변경 여부는 version 컬럼으로 변경 여부를 추적한다.

충돌은 어떻게 확인할까?

충돌은 어떻게 확인할 수 있을까?

1. 각 트랜잭션에서 version을 함께 조회한다.

2. 레코드를 업데이트 한다.

- 이 때, WHERE 조건에 조회된 version을 넣고, version은 증가시킨다.

3. 충돌을 확인한다.

- 데이터 변경이 성공 했다면, 충돌은 없었다.

- 데이터 변경이 실패 했다면, 충돌이 있었다.

- 다른 트랜잭션에서 version을 이미 증가 시켰음을 의미하므로, 충돌이 생긴 것이다.

 

낙관적 락 예시

예시를 보면 트랜잭션1에서 업데이트를 수행하고 버전을 2로증가시켰다.

하지만 트랜잭션2에서 버전1에 대한 업데이트를 수행하려고 하자 실패시키고 롤백했다.

 

하지만 위 과정에서 락을 명시적으로 잡는 과정이 있었을까?

아니요

 

트랜잭션 2는 UPDATE 문 수행에 실패함으로써 충돌을 감지했고, 즉시 rollback을 수행했다.

이 과정에서 트랜잭션은 즉시 종료되고, 락을 명시적으로 잡는 과정은 없었던 것이다.

 

그리고 요청 자체가 실패하였기 때문에, 사용자는 실패 응답을 받게 될 것이다. 요청은 실패하였지만 사용자는 실패한 상황을 인지할 수 있고, 데이터 일관성이 깨지는 상황도 발생하지 않았다.

 

하지만 데이터베이스에서 따로 후처리를 해주지 않기에, 클라이언트에서 추가 작업을 해서 후처리 로직을 구현해야 한다.

 


비동기 순차 처리

모든 상황을 실시간으로 처리하고 즉시 응답해줄 필요는 없다는 관점

- 요청을 대기열에 저장해두고, 이후에 비동기로 순차적으로 처리할 수도 있다.

- 게시글마다 1개의 스레드에서 순차적으로 처리하면, 동시성 문제도 사라진다.

- 락으로 인한 지연이나 실패 케이스가 최소화된다. 즉시 처리되지 않기 때문에 사용자 입장에서는 지연될 수 있다.

 

하지만 큰 비용이 든다.

- 비동기 처리를 위한 시스템 구축 비용

- 실시간으로 결과 응답이 안되기 때문에 클라이언트 측 추가 처리 필요

  -> 이미 처리된 것처럼 보이게 하고, 실패 시에 알림을 준다든지?

- 서비스 정책으로 납득이 되어야 한다.

- 데이터의 일관성 관리를 위한 비용

  -> 대기열에서 중복/누락 없이 반드시 1회 실행 보장되기 위한 시스템 구축이 필요하다.

 

 

-

 

결론

비동기 순차처리는 좋아요 처리에는 좋지 않은 선택이라고 생각한다.

좋아요 쓰기 트래픽이 고려할만큼 크지 않을 뿐더러 시스템을 따로 만들어야 하기에 비용이 크다.

 

비관적락, 낙관적락 둘 중에 하나를 선택하면 되는데,

 

- 비관적 락은 결국 락을 명시적으로 잡아야하지만, 게시글 단위로 좋아요가 처리되기 때문에, 좋아요 쓰기 트래픽에서 단일 레코드에 대한 잠깐의 락은 문제되지 않을 수 있다.

- 낙관적 락은 락을 잡지 않기 때문에 지연은 낮을 수 있지만, 애플리케이션에서 충돌 감지 시에 추가적인 처리가 필요하다.

 

특정 기능에 대한 서비스 중요도에 따라 방법을 선택하면 될 것 같다.