"동시에 2명이 좋아요를 눌렀는데 왜 하나만 저장될까?"
"동시에 눌러도 DB에 2개가 저장 되야 하는거 아니야?"
좋아요 기능을 구현하고 테스트 하던 중 2명의 유저가 동시에 좋아요를 누르는 상황일때, 좋아요 갯수가 한개만 저장되는 문제가 발생했습니다.
"왜 이런 문제가 발생할까요?"
해결방법을 이야기하기 전에 동시성 문제에 대해 알아보도록 하겠습니다.
(이번 편은 여러 해결 방법 중 update 쿼리문으로 해결하는 방법만 이야기 해보겠습니다.)
동시성 문제
동시성 문제는 여러 개의 프로그램이 동시에 같은 데이터를 변경하려 할 때 발생하는 문제를 의미합니다.
동시에 같은 데이터를 변경하는게 어떤 문제점을 가져올 수 있을까요? 우리 실생활에 적용될 만한 상황 중 은행으로 예시를 들어보겠습니다.
상황 예시
XX은행을 사용하는 A,B,C 유저가 있습니다. B,C유저는 동시에 A유저에게 계좌이체를 하게 됩니다.
(단, A유저의 계좌에는 0원이 있다고 가정하고, B와 C유저는 A유저에게 10,000원을 이체한다고 가정합니다.)
(1) B유저와 C유저가 동시에 A유저에게 계좌 이체를 합니다.
(2) B유자와 C유저가 계좌를 이체하려는 시점에 조회된 A유저의 계좌엔 0원입니다.
(3) A유저의 휴대폰에 돈이 입금되었다는 알림이 왔고, 확인을 해보니 10,000원이 입금되어 있습니다.
(4) A유저는 B와C 유저에게 내 계좌에 20,000원이 아닌 10,000원 밖에 들어오지 않았다고 말합니다.
(5) B와 C유저는 A유저에게 각각 이체했던 내용을 보여줍니다.
"어떻게 이런 일이 발생할 수 있을까요?"
프로그래밍적 관점으로 접근을 해보겠습니다. 위의 상황 예시는 다음과 같은 로직의 흐름을 가집니다.
1. Thread-B, Thread-C가 동시에 DB에서 A유저의 계좌 잔고를 조회하여 0원이라는 값을 읽습니다.
SELECT 잔액 FROM ACCOUNT WHERE 계좌 주인 = 'A';
2. Thread-B와 Thread-C는 0원이라는 값에 10,000원을 더해서 DB로 UPDATE Query를 날려줍니다.
UPDATE ACCOUNT SET 잔액 = 10000 WHERE 계좌 주인 = 'A';
3. DB 입장에선 동일한 UPDATE 쿼리문이 오게되고, 최종적으로 DB의 A계좌에는 10,000원이 저장되게 됩니다.
DB에는 20,000원이 저장되어야 하는데 10,000원이 저장되는 문제가 발생하게 됩니다.
"왜 10,000원만 저장이 될까요?"
문제의 근본적인 원인은 Thread-B, Thread-C가 동시에 DB에서 A유저의 계좌 잔고를 조회할때, 0원이라고 읽었기 때문에 발생하는 문제입니다.즉, B와 C가 동시에 (0 + 10,000) 을 계산하고 같은 UPDATE를 실행하면서 최종 값이 10,000원이 저장 됩니다.
"MySQL은 왜 이런 문제를 해결하지 못하게 하는 것일까요?"
먼저, MYSQL의 작동 방식에 대해서 알아보도록 하겠습니다.
MySQL의 InnoDB엔진은 기본적으로 트랜잭션 격리 수준을 READ COMMITED로 해놓습니다.
트랜잭션 격리 수준은 동시에 실행되는 트랜잭션 간의 데이터 접근을 어떻게 제한할 것인지 결정하는 기준을 말합니다. READ COMMITED는 트랜잭션 격리의 여러가지 기준 중 하나를 의미합니다.
READ COMMITTED는 커밋된 데이터만 읽을 수 있도록 보장하는 트랜잭션 격리 수준을 의미합니다.
즉, 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽을 수 없도록 제한하는 것입니다.
상황 예시는 다음과 같습니다.
A유저의 계좌에 B유저가 10,000원을 이체하는 상황입니다.
프로그래밍적 관점으로 봤을때 순서는 다음과 같습니다.
SELECT 문으로 A유저의 계좌 조회 → 조회된 A유저 계좌에 10,000원 추가 → UPDATE 쿼리로 A유저 계좌의 값 수정
이러한 과정은 하나의 트랜잭션으로 묶여 있습니다.
만약, B유저의 트랜잭션이 시작되었는데 똑같은 방법으로 C유저의트랜잭션이 시작되면 어떻게 될까요? READ COMMITED라는 격리 수준은 어떻게 처리를 해줄까요?
앞서 보았던 READ COMMITED는 "다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽을 수 없도록 제한한다"고 했습니다.
이말은 곧, B의 트랜잭션이 완전히 끝나고 DB에 COMMIT이 될때, C의 트랜잭션에서 A유저의 계좌를 읽어 값을 가져올 수 있다는 것입니다.
계좌를 조회하고, 업데이트 하는 로직을 하나의 트랜잭션이라고 가정을 하고, 간단하게 두가지 상황을 정리하면 다음과 같습니다.
1. B트랜잭션이 아직 끝나지 않은 상태에서 C트랜잭션이 시작된 상황
[B의 트랜잭션] A계좌 조회(SELECT) ✅ → [C의 트랜잭션] A계좌 조회 ❌ → [B의 트랜잭션] A계좌 업데이트후 COMMIT
2. B트랜잭션이 끝난(COMMIT) 상태에서 C트랜잭션이 시작된 상황
[B의 트랜잭션] A계좌 조회(SELECT) ✅ → [B의 트랜잭션] A계좌 업데이트후 COMMIT → [C의 트랜잭션] A계좌 조회 ✅
"왜 READ COMMITED 격리 수준은 두명이 동시에 이체를 했음에도 10,000원밖에 저장하지 못할까요?"
두명이 이체를 한 상황은 B유저가 이체를 하는 B 트랜잭션과 C유저가 이체를 하는 C 트랜잭션이 조금의 오차도 없이 완전히 똑같은 시점에 실행이 되는 상황입니다. 즉, READ COMMITED의 격리 수준이 의미가 없어지게 됩니다.
완전히 동일한 시점에 SELECT문으로 값을 조회하니 같은 값을 조회하게 되는 것 입니다.
만약, 조금이라도 C트랜잭션이 늦게 시작되었다면, B트랜잭션이 커밋되기 전까진 C트랜잭션이 A유저의 계좌를 읽지 못할 것입니다.
해결 방법
지금까지 계좌 이체를 예시로 들어서 설명을 했는데, 기존에 문제였던 좋아요 기능을 기준으로 설명하도록 하겠습니다.
동시성 문제를 해결하는 방식으로는 Lock을 사용하는 방식, DB 트랜잭션 격리 수준을 올려 사용하는 방식, Update 쿼리를 사용하는 방식 중 Update 쿼리를 사용하는 방식에대해서 설명하도록 하겠습니다.
좋아요 기능에 대한 동시성 문제가 발생했던 예시 코드는 다음과 같습니다.
(⚠️ 도메인과 엔티티를 분리한 코드 구조를 갖기 때문에, 기존에 보던 코드와 다를 수 있습니다.)
@Transactional
public void likePost(LikePostRequestDto dto) {
Post post = getPost(dto.postId());
User user = userService.getUser(dto.userId());
post.like(user);
likeRepository.like(post, user);
}
코드 흐름은 다음과 같습니다.
(1) 게시물 조회
(2) 좋아요를 누른 유저 조회
(3) 게시물 도메인에 좋아요 갯수 업데이트
(4) 게시물 도메인을 게시물 엔티티로 변환하여 좋아요 갯수 DB에 최종 업데이트
이 코드가 동시성 문제가 발생되는 핵심은 각 스레드단에서 좋아요 갯수를 업데이트 해주기 때문입니다.
각 스레드에 존재하는 데이터는 그 값이 DB값과 동기화되었다는 보장이 없습니다.
서버의 스레드가 DB와 동기화가 되지 않는 상황의 흐름은 다음과 같습니다.
Thread-A에서 DB에 A게시물에 좋아요 갯수가 0이라는 값을 읽음 → 다른 스레드에 의해 DB에서 A게시물에 좋아요 갯수가 1로갱신이 됨 → Thread-A에는 여전히 A게시물의 좋아요 갯수가 0개라는 값으로 알고 있음
그렇다면, 다음과 같은 생각을 할 수 있습니다.
"서버단의 Thread로 갱신을 할때, 동기화가 안되는 문제가 있다면 Thread에 데이터를 가져와서 로직을 처리하는게 아니라 직접 DB단에서 UPDATE 쿼리로 업데이트 해주면 되는거 아니야?"
UPDATE쿼리를 사용해서 동시성 문제를 해결해주는 방식은 위에 나온 질문과 같습니다.
서버단의 Thread에서 DB의 데이터를 가져와서 처리하는 것이 아닌, 쿼리를 직접 날려서 DB단에서 직접 처리하도록 하는 것입니다.
쉽게말해, 애플리케이션에서 값을 가져와 연산 후 업데이트하는 것이 아니라,
DB에서 직접 현재 값을 읽고 그 위에서 연산을 수행하여 최신 데이터를 반영한다는 의미입니다.
"DB단에서 처리하면 어떤일이 발생할까요?"
DB단에서 처리를 해주기때문에 늘 최신화된 데이터를 업데이트하게 됩니다.
참고로, DB에서 update쿼리문을 수행할때, 대상이 되는 행(row)에 대해서 행잠금(rowLock)을 걸어줍니다.
쉽게말해, Update 쿼리문이 두개가 있을때, 하나의 Update문이 진행 중이라면 다른 Update문은 먼저 실행된 Update문이 실행이 완료될 때까지 기다려야 합니다.
"UPDATE를 DB단에서 처리하면 최신화된 데이터를 업데이트 하는 건 알겠는데, 결국 DB에도 동시에 UPDATE쿼리를 실행하면 동시성 문제는 해결이 안되는거 아닌가? UPDATE 자체를 동시에 하니깐?"
MySQL InnoDB 엔진에서는 트랜잭션이 실행될 때, 내부적으로 순서를 결정하는 매커니즘이 존재하기 때문에,
물리적으로 완전히 동일한 시점에 실행되는 것은 불가능합니다.
"뭔 소리지? 동시에 Update쿼리를 실행한다는데, MySQL InnoDB의 내부적인 메커니즘이 무슨 상관이지?"
이 부분은 좀더 CS적으로 접근해보겠습니다.
멀티 스레드 방식에서 사실 완전히 동시에 실행된다는 것은 존재할 수 없습니다.
우리가 "두 개의 트랜잭션이 동시에 실행된다"라고 표현하지만, 이는 실제로 우리 눈에 그렇게 보일 뿐, CPU에서 실제로는 순차적으로 실행된다는 것입니다.
다시 말해, 스레드를 계속해서 컨텍스트 스위칭을 통해 작업을 순차적으로 작업을 처리하는 것이 사람 눈에 보기에는 동시에 실행되는 것처럼 보이는 것입니다.
즉, "완전히 동일한 시점"이라는 것은 사실 논리적인 개념일 뿐, 실제로는 하나씩 순차적으로 실행됩니다.
"그럼 동시 실행은 불가능한건가?"
멀티 스레드 환경에서 실행되는 작업들은 컨텍스트 스위칭을 통해 순차적으로 실행되기 때문에 완전히 동일한 시점에 실행되는 것은 물리적으로 불가능 하지만, 멀티 코어 환경에서는 가능합니다. 각 코어가 동시에 작업을 처리할 수 있습니다. 이를 병렬실행이라고 합니다.
결론은 스레드는 애초에 동시에 실행되지 않는다. 우리 눈에만 동시 실행되는 것처럼 보일 뿐이다. 그래서 MySQL InnoDB에도 순차적으로 처리가 된다라는 점입니다.
이제 Query의 관점에서 바라보도록 하겠습니다.
동시성 문제가 발생하는 쿼리는 다음과 같습니다.
(게시물에 좋아요가 0개라는 상황을 가정합니다.)
UPDATE PostEntity p SET p.likeCount = 1;
애플리케이션에서 값을 가져와 연산 후 업데이트를 하는 방식을 의미합니다.
동시성 문제를 예방하기 위해 DB단에서 처리하는 쿼리는 다음과 같습니다.
UPDATE PostEntity p SET p.likeCount = p.likeCount + 1
DB에서 직접 현재 값을 읽고 그 위에서 연산을 수행하여 최신 데이터를 반영하는 방식입니다.
해결한 코드는 다음과 같습니다.
@Modifying
@Query(value = "UPDATE PostEntity p "
+"SET p.likeCount = p.likeCount + :likeCount,"
+"p.modDate = now() "
+"WHERE p.id = :postId")
void updatePostLikeCount(Long postId, Integer likeCount);
정리
사실 멀티 스레드 환경에선 동시에 라는 개념이 존재하지 않습니다. 그저 순차처리하는 CPU가 컨텍스트 스위칭을 통해서 작업을 너무 빠르게 처리하다보니, 우리 눈에만 동시에 처리되는 것처럼 보일 뿐입니다.
UPDATE 쿼리를 활용한 동시성 해결 방식은, 애플리케이션에서 값을 가져와 연산 후 업데이트하는 것이 아니라,
DB에서 직접 현재 값을 읽고 그 위에서 연산을 수행하여 최신 데이터를 반영하는 방식입니다.
'JPA' 카테고리의 다른 글
find 메서드와 1차 캐시의 관계 (0) | 2025.02.04 |
---|---|
IDENTITY 식별자 생성 전략과 persist()의 관계 (0) | 2025.02.03 |
save()와 SELECT의 관계 (0) | 2025.01.26 |
JPA와 DB는 어떻게 동기화가 되는걸까? (0) | 2025.01.26 |
🙆🏻♂️ 너, Fetch Join 만능이야? (0) | 2024.09.24 |