배경
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가 한 트랜잭션을 강제로 롤백시켜서 해결했다.
원인 분석
- 스토리 엔진 상태 살피기
- MySQL은 기본적으로 Repeatable Read 격리 수준을 사용한다
- 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) 좋아요 추가

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

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

아직도 의문인점 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 쿼리가 나갈 때 생긴다.
- 이건 쿼리 실행 시점에만 락이 걸린다.
- 즉, update 쿼리가 실행되는 순간에만 X 락이 생기고,
- 실행이 끝나면 트랜잭션 커밋 전까지는 락이 유지됩니다.
- 비영속성 상태이기 때문에 영속 컨텍스트와 동기화 안 됨
- 함수형 update는 엔티티 객체를 수정하는 것과 다르다
- 1차 캐시 (영속성 컨텍스트) 를 무시하고 직접 DB를 업데이트함.
- 이후 같은 엔티티를 다시 조회하면 Stale 데이터가 나올 수 있음
그래서 보통 @Modifying 쿼리 쓰면 아래처럼 영속성 컨텍스트 초기화도 같이 사용
@Modifying(clearAutomatically = true, flushAutomatically = true)
관련 포스팅
ref
'트러블슈팅' 카테고리의 다른 글
배경
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가 한 트랜잭션을 강제로 롤백시켜서 해결했다.
원인 분석
- 스토리 엔진 상태 살피기
- MySQL은 기본적으로 Repeatable Read 격리 수준을 사용한다
- 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()
: 락이 없는 순수 selectlikeRepository.findByMemberAndBoard()
: 락이 없는 순수 selectlikeRepository.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) 좋아요 추가

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

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

아직도 의문인점 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 쿼리가 나갈 때 생긴다.
- 이건 쿼리 실행 시점에만 락이 걸린다.
- 즉, update 쿼리가 실행되는 순간에만 X 락이 생기고,
- 실행이 끝나면 트랜잭션 커밋 전까지는 락이 유지됩니다.
- 비영속성 상태이기 때문에 영속 컨텍스트와 동기화 안 됨
- 함수형 update는 엔티티 객체를 수정하는 것과 다르다
- 1차 캐시 (영속성 컨텍스트) 를 무시하고 직접 DB를 업데이트함.
- 이후 같은 엔티티를 다시 조회하면 Stale 데이터가 나올 수 있음
그래서 보통 @Modifying 쿼리 쓰면 아래처럼 영속성 컨텍스트 초기화도 같이 사용
@Modifying(clearAutomatically = true, flushAutomatically = true)
관련 포스팅
ref