[좋아요 동시성 문제] 데드락 해결하기1 - 원인 분석

트러블슈팅
2025. 4. 18. 01:18
목차
  1. 배경
  2. 문제: 데드락 발생
  3. 데드락이란?
  4. 원인 분석
  5. 1. 트랜잭션 교착 상태 감지 내역 확인
  6. 2. Repeatable Read
  7. 3. 코드 분석
  8. 코드대로 SQL 실행해보기
728x90

배경

Mysql 8.0, Spring Boot 3, java 17 사용

학교 게시판에 좋아요 기능이 있는데, 트래픽이 많아질 경우 좋아요 동시성 문제를 생각해 보게 되었다.

유저 3개를 만들고 포스트맨의 collection을 활용해서 테스트를 해보았다.

결과는 둘다 200 OK가 떳다.

이는 포스트맨의 방식이 Delay가 아무리 줄어도 동기 방식이기 때문에 병렬로 동시에 보내는 요청이 아니고 순차적인 요청이었기 때문이었다.

 

그래서 원시적인 방법으로 curl로 테스트 해보기로 했다.

터미널에 각 유저에 대한 요청 3개를 동시에 보냈다.

curl --location --request POST 'http://localhost:9090/api/board/like?board_id=1' \
--header 'Authorization: Bearer ${TOKEN_VALUE1}'
& curl --location --request POST 'http://localhost:9090/api/board/like?board_id=1' \
--header 'Authorization: Bearer ${TOKEN_VALUE2}'
& curl --location --request POST 'http://localhost:9090/api/board/like?board_id=1' \
--header 'Authorization: Bearer ${TOKEN_VALUE3}'

문제: 데드락 발생

  • 에러 코드 1213: MySQL에서 발생하는 Deadlock
  • SQLState 40001: 트랜잭션 충돌로 인해 롤백이 필요하다는 의미

데드락이란?

두 개 이상의 트랜잭션이 서로가 필요한 자원을 점유한 채 기다리는 상황을 말한다. 여기서 DB가 한 트랜잭션을 강제로 롤백시켜서 해결했다.

원인 분석

  1. 스토리 엔진 상태 살피기
  2. MySQL은 기본적으로 Repeatable Read 격리 수준을 사용한다
  3. JPA 에서 제공하는 CRUD를 사용한다.

 

1. 트랜잭션 교착 상태 감지 내역 확인

다음 명령어로 트랜잭션 교착 상태 감지 내역을 확인 할 수 있다.

SHOW ENGINE INNODB STATUS;

HOLDS THE LOCK(S)

  • 트랜잭션 2406이 board 테이블의 PRIMARY 키 인덱스에서 레코드 락 (S 락)을 들고 있음.
  • heap no 2 → board_id = 1인 레코드.
  • 이건 레코드 자체에는 락이 걸렸지만, 인덱스의 GAP에는 락이 걸리지 않은 상태라는 뜻. 즉, 정확한 레코드를 대상으로만 락이 걸린 것.

LOCK WAIT

  • 지금 트랜잭션 2406이 또 다른 락을 기다리고 있음.
  • 즉, 이 트랜잭션도 다른 트랜잭션이 들고 있는 락 때문에 기다리는 중인데,
  • 서로 상대방의 락을 기다리는 상태 → 이것이 전형적인 데드락 상황

WAITING FOR THIS LOCK TO BE GRANTED

  • 지금은 X(exclusive) 락을 요청하고 있지만 대기 중
  • 같은 레코드 (heap no 2) 에 대해 X 락을 걸고 싶어함

여기서 의문점 하나

select하는 쿼리는 모두 순수 select인데, 대체 어디서 S-lock이 걸리는거지?
답은 Like 레코드를 insert 할 때 board 테이블에 S-lock이 걸린다. 자세한 내용을 "3. 코드 분석"에 나온다.

2. Repeatable Read

A트랜잭션에 대해 B트랜잭션은 A트랜잭션이 commit 된 데이터만 읽을 수 있다.

이 격리 수준에서는 트랜잭션 id로 구분해서 데이터를 읽게 된다.

트랜잭션 2에서 아직 트랜잭션이 종료되지 않은 상태에서 동일한 select 문을 실행하면 본인 트랜잭션 id(10)보다 작은 id 값만 읽으니까 undo 영역에 있는 Busan을 읽게 된다.

  • 테이블에 자신보다 이후에 실행된 트랜잭션(본인 트랜잭션 id보다 높은거)의 데이터가 존재하면 undo log를 참고해서 데이터 조회
  • 레코드를 추가하는 건 막지 않아서 팬텀리드가 발생한다고 하는데, MySQL은 MVCC덕분에 일반적인 조회에서 팬덤리드는 발생하지 않음
  • MVCC관련 포스팅

하지만 Phantom Read가 절대로 발생하지 않는다고 할 순 없다.

💡 Phantom Read가 발생하는 조건

lock이 사용되는 경우 phantom Read가 발생하게 된다.
lock을 사용하면 undo log를 읽는 것이 아니라 테이블에서 조회하게 된다. 아래와 같이 쓰기 잠금(for update) / 읽기 잠금(for share)을 사용한 경우가 해당 된다.


일발적인 RDBMS


Gap Lock이 존재하지 않음.
id = 50인 레코드만 잠금이걸린 상태이고, 사용자 A의 요청은 잠금 없이 즉시 실행
➡️ 이때 사용자 B가 동일한 쓰기 잠금 쿼리로 다시 한번 데이터를 조회하면, 이번에는 2건의 데이터가 조회된다.
이게 Phantom Read

MySQL에는 Gap Lock이 존재한다.
MySQL에는 갭 락이 존재하기 때문에 위의 상황에서 문제가 발생하지 않는다.
사용자 B가 SELECT FOR UPDATE로 데이터를 조회한 경우에 MySQL은 id가 50인 레코드에는 레코드 락, id가 50보다 큰 범위에는 갭 락으로 넥스트 키 락을 건다. 따라서 사용자 A가 id가 51인 member를 INSERT 시도한다면, B의 트랜잭션이 종료(커밋 또는 롤백)될 때 까지 기다리다가, 대기를 지나치게 오래 하면 락 타임아웃이 발생하게 된다.

select for update 잠금기반이니 → 테이블 데이터를 읽음


MySQL 기준으로 정리된 내용이다. (mysql의 기본 트랜잭션 단위: repeatable read) → gap lock 적용

  • SELECT FOR UPDATE(갭락) 이후 SELECT: 갭락 때문에 팬텀리드 X
  • SELECT FOR UPDATE (갭락)이후 SELECT FOR UPDATE(갭락): 갭락 때문에 팬텀리드 X
  • SELECT(mvcc) 이후 SELECT(mvcc): MVCC 때문에 팬텀리드 X
  • SELECT(mvcc) 이후 SELECT FOR UPDATE(현재 테이블에 갭락걸고 테이블 데이터 읽음): 팬텀 리드 O

 

3. 코드 분석

테이블과 로직은 아래와 같다.

Like 테이블

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "likes")
public class Like {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Like_Id")
private Long id;
@ManyToOne
@JoinColumn(name = "Member_Id", nullable = false)
private Member member;
@ManyToOne
@JoinColumn(name = "Board_Id", nullable = false)
private Board board;
}

 

Board 테이블

@Entity
@AllArgsConstructor
@Builder
@NoArgsConstructor
@Getter
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Board_Id")
private Long Id;
@JoinColumn(name = "Member_Id", nullable = false)
@ManyToOne
private Member member;
private String title;
@ColumnDefault("0")
private int likes;
private LocalDateTime createAt;
private LocalDateTime updateAt;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
public void updateBoard(String content, LocalDateTime updateAt) {
this.content = content;
this.updateAt = updateAt;
}
public void updateLike(int like) {
this.likes = like;
}
}

 

좋아요 추가 로직

@Transactional
public void saveLike(Long id, Member member) {
//이미 좋아요가 반영되었다면 예외
Board findBoard = getBoardById(id);
Like findLike = likeRepository.findByMemberAndBoard(member, findBoard);
if (findLike != null) {
throw new BusinessException(CustomErrorCode.EXIST_LIKE);
}
likeRepository.save(Like.builder().board(findBoard).member(member).build());
int likeCnt = findBoard.getLikes();
findBoard.updateLike(++likeCnt);
}
  • getBoardById(): 락이 없는 순수 select
  • likeRepository.findByMemberAndBoard(): 락이 없는 순수 select
  • likeRepository.save(): 외래키가 있는 테이블을 insert 하는 쿼리 동작 
    • 해당 board_id, member_id인 레코드가 잠겨버린다.
  • findBoard.updateLike(): 더티체킹을 통해 update쿼리 날림

 

여기서 중요한게 likeRepository.save()이다.

FK가 있는 테이블에 Insert 쿼리에서 락이 어떤식으로 진행되는지 살펴보자.

 

코드대로 SQL 실행해보기

Lock의 흐름을 보기 위해 수동 트랜잭션으로 진행한다.

전체 플로우: t1: 좋아요 추가 → t2: 좋아요 추가 → t1: 게시글에 좋아요수 update → t2: 게시글에 좋아요 수 update

2개의 세션으로 진행(터미널 2개키고 진행)

 

아래 SQL대로 진행했다.

start transaction; #auto commit 끄기
insert into likes (board_id, member_id)
values(1, 1);
update board
set likes = likes + 1
where board_id = 1;

 

 

트랜잭션의 잠금 획득 및 요청 정보는 performance_schema의 data_locks 테이블에서 확인할 수 있다.

 

SELECT ENGINE_TRANSACTION_ID AS Trx_Id,
OBJECT_NAME AS `Table`,
INDEX_NAME AS `Index`,
LOCK_DATA AS Data,
LOCK_MODE AS Mode,
LOCK_STATUS AS Status,
LOCK_TYPE AS Type
FROM performance_schema.data_locks;

 

1. t1(2457) 좋아요 추가 -> t2(2458) 좋아요 추가

사진을 보면  서로 다른 트랜잭션 id가 각각 s-lock을 획득한걸 볼 수 있다.

 

 

2. t1(2457) 게시글 좋아요 수 업데이트

t1게시글 좋아요 업데이트를 위해 xlock 획득해야함 → 대기중

여기서 t2에서 동일한 게시글의 좋아요 수를 업데이트하면?

 

3. t2(2458) 게시글 좋아요 수 업데이트

t1에서 먼저 xlock을 요청했고, 결국 t1이 xlock을 획득함. t2의 트랜잭션은 모두 롤백된걸 볼 수 있음

 

 

아직도 의문인점 S-Lock은 어디서 걸리는 걸까

외래키 제약 조건 때문에 걸린다고 한다.

좋아요를 insert할 때, 좋아요 테이블에 board_id, member_id  fk가 존재한다.
외래키 제약 조건을 확인하기 위해서 해당 fk레코드에 s-lock을 걸어버리는 것이다.

- likes에 row를 넣기 전에 board.id = 1 과 member.id = 1 이 실제 존재하는지 검증
- 검증 중인 그 row에 S(공유) 락을 걸어서, 트랜잭션이 끝날 때까지 해당 row가 삭제되지 않도록 보호

 

요약하자면 

  • likeRepository.save(): trasaction 2457이 board_id = 1 에 s lock을 걸음
  • likeRepository.save(): trasaction 2458이 board_id = 1 에 s lock을 걸음
  • insert할때 외래키가 있는 테이블에 s lock을 걸고 수행하게 된다. (insert하는 도중에 fk레코드가 변하면 안되므로 → 데이터 정합성)
  • board.updateLike(): trasaction 2457이 board_id = 1 에 X lock 걸기 시도
    • trasaction 2458이 s-lock을 걸고 있음 → s-lock 풀기까지 대기
  • board.updateLike(): trasaction 2458이 board_id = 1 에 X lock 걸기 시도
    • trasaction 2457이 s-lock을 걸고 있음 → s-lock 풀기까지 대기
  • 서로 원하는 레코드(board_id = 1)에 대해 읽기 락으로 가지고 있고, 상대방의 읽기 락이 풀리기를 기다리고 있음 → DeadLock

 

여기서 또 드는 의문

board_id 레코드가 s-lock이 걸려있는데 x-lock을 사용할 수 있나?

InnoDB 동일한 트랜잭션에서 s-lock을 걸었다가 x-lock으로 승격할 수 있다.
중요한 건 동일한 트랜잭션이므로 승격이 가능한 것이다.

 

X-lock은 더티체크로 인해 update 쿼리가 나갈 때 생긴다.

  1. 이건 쿼리 실행 시점에만 락이 걸린다.
    • 즉, update 쿼리가 실행되는 순간에만 X 락이 생기고,
    • 실행이 끝나면 트랜잭션 커밋 전까지는 락이 유지됩니다.
  2. 비영속성 상태이기 때문에 영속 컨텍스트와 동기화 안 됨
    • 함수형 update는 엔티티 객체를 수정하는 것과 다르다
    • 1차 캐시 (영속성 컨텍스트) 를 무시하고 직접 DB를 업데이트함.
    • 이후 같은 엔티티를 다시 조회하면 Stale 데이터가 나올 수 있음

그래서 보통 @Modifying 쿼리 쓰면 아래처럼 영속성 컨텍스트 초기화도 같이 사용

@Modifying(clearAutomatically = true, flushAutomatically = true)

관련 포스팅

  • 데드락 문제해결

ref

  • SQL로 트랜잭션 교착 상태 확인하는 법 참고
  • 트랜잭션 격리 수준과 MySQL 스토리 엔진 락
  • 트랜잭션 격리 수준 참고
728x90

'트러블슈팅' 카테고리의 다른 글

[좋아요 동시성 문제] 데드락 해결하기2 - 문제 해결  (0) 2025.04.20
[You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'TODO, primary key (study_id)) engine=InnoDB' at line 1]  (0) 2024.11.22
ERROR: failed to solve: ubuntu:latest: failed to resolve source metadata for docker.io/library/ubuntu:latest: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH, out: ``  (0) 2024.10.25
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: ~: connect: permission denied  (0) 2024.10.17
git pull이 안되는 문제: rebase로 해결하기  (0) 2024.10.16
  1. 배경
  2. 문제: 데드락 발생
  3. 데드락이란?
  4. 원인 분석
  5. 1. 트랜잭션 교착 상태 감지 내역 확인
  6. 2. Repeatable Read
  7. 3. 코드 분석
  8. 코드대로 SQL 실행해보기
'트러블슈팅' 카테고리의 다른 글
  • [좋아요 동시성 문제] 데드락 해결하기2 - 문제 해결
  • [You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'TODO, primary key (study_id)) engine=InnoDB' at line 1]
  • ERROR: failed to solve: ubuntu:latest: failed to resolve source metadata for docker.io/library/ubuntu:latest: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH, out: ``
  • permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: ~: connect: permission denied
hapBday
hapBday
hapBday
개발자로 성장하기 위한 기록들
hapBday
전체
오늘
어제
  • 분류 전체보기 (199)
    • CS (12)
      • 컴퓨터네트워크 (11)
      • 운영체제 (0)
      • 분산 시스템 (0)
      • 데이터베이스 (1)
    • Spring (45)
      • Spring 핵심 원리 (13)
      • Spring MVC (15)
      • Spring DB (12)
      • Spring Security (4)
    • JPA (14)
    • 알고리즘 (30)
      • 프로그래머스 (6)
      • 백준 (20)
    • Design Pattern (0)
    • 언어 (5)
      • JAVA (5)
    • ASAC 웹 풀스택 (38)
      • Spring Boot (21)
      • React (0)
      • DevOps (8)
    • 트러블슈팅 (14)
    • DevOps (5)
      • Docker (5)
    • ETC (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록
  • github

공지사항

인기 글

태그

  • JPA
  • jwt
  • Session
  • cookie
  • spring boot
  • 3-layerd 아키텍쳐 패턴
  • 백준
  • docker workflow
  • CSRF
  • Spring
  • docker best practices
  • docker
  • spring security
  • s-lock
  • currency control
  • aws lambda
  • 인프런
  • Java
  • 김영한
  • 프로그래머스
  • 트랜잭션
  • 구현
  • x-lock
  • CORS
  • basicerrorcontroller
  • MVC
  • 오블완
  • multi-stage
  • S3
  • 티스토리챌린지

최근 댓글

최근 글

hELLO · Designed By 정상우.v4.3.0
hapBday
[좋아요 동시성 문제] 데드락 해결하기1 - 원인 분석
상단으로

티스토리툴바

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.