[main-project] 동시성 문제

박채은·2023년 3월 24일

Project

목록 보기
20/21

동시성

여러 사람이 동시에 한 게시글의 좋아요를 누른다면 어떻게 되는가?
만약 한 사람이 같은 아이디로 동시접속 창을 두개 띄워놓고 동시에 좋아요를 눌렀을 때 어떻게 되는가?

동시성을 해결하기 위해서는 크게 2가지 방법이 있을 것이다.

  • 애플리케이션 단에 Lock을 걸기
  • DB에 Lock을 걸기

동시 요청 보내기

curl <내용> & curl <내용>

1. 같은 아이디로 동일한 게시글에 좋아요를 동시에 눌렀을 때 어떻게 되는가?

같은 JWT 키(같은 아이디)로, 같은 게시글에 좋아요를 요청하면 하나의 요청은 성공하고 다른 하나의 요청은 실패하면서 데드락이 발생한다.

Hibernate: select prfpost0_.id as id1_3_0_, prfpost0_.created_at as created_2_3_0_, prfpost0_.modified_at as modified3_3_0_, prfpost0_.category as category4_3_0_, prfpost0_.content as content5_3_0_, prfpost0_.image_key as image_ke6_3_0_, prfpost0_.like_count as like_cou7_3_0_, prfpost0_.member_id as member_10_3_0_, prfpost0_.tags as tags8_3_0_, prfpost0_.title as title9_3_0_ from prf_post prfpost0_ where prfpost0_.id=?
Hibernate: select prfpost0_.id as id1_3_0_, prfpost0_.created_at as created_2_3_0_, prfpost0_.modified_at as modified3_3_0_, prfpost0_.category as category4_3_0_, prfpost0_.content as content5_3_0_, prfpost0_.image_key as image_ke6_3_0_, prfpost0_.like_count as like_cou7_3_0_, prfpost0_.member_id as member_10_3_0_, prfpost0_.tags as tags8_3_0_, prfpost0_.title as title9_3_0_ from prf_post prfpost0_ where prfpost0_.id=?

Hibernate: select prfpostlik0_.id as id1_5_, prfpostlik0_.created_at as created_2_5_, prfpostlik0_.modified_at as modified3_5_, prfpostlik0_.member_id as member_i4_5_, prfpostlik0_.prf_post_id as prf_post5_5_ from prf_post_like prfpostlik0_ where prfpostlik0_.member_id=? and prfpostlik0_.prf_post_id=?
Hibernate: select prfpostlik0_.id as id1_5_, prfpostlik0_.created_at as created_2_5_, prfpostlik0_.modified_at as modified3_5_, prfpostlik0_.member_id as member_i4_5_, prfpostlik0_.prf_post_id as prf_post5_5_ from prf_post_like prfpostlik0_ where prfpostlik0_.member_id=? and prfpostlik0_.prf_post_id=?

Hibernate: insert into prf_post_like (created_at, modified_at, member_id, prf_post_id) values (?, ?, ?, ?)
Hibernate: insert into prf_post_like (created_at, modified_at, member_id, prf_post_id) values (?, ?, ?, ?)

Hibernate: update prf_post set modified_at=?, like_count=? where id=?
Hibernate: update prf_post set modified_at=?, like_count=? where id=?

<WARN & ERROR>
2023-03-24 16:39:48.913  WARN 1780 --- [nio-8080-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
2023-03-24 16:39:48.913 ERROR 1780 --- [nio-8080-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction

Deadlock found when trying to get lock; try restarting transaction
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement

2. 다른 아이디로 동일한 게시글에 좋아요를 동시에 눌렀을 때 어떻게 되는가?

member_id가 7, 8인 사용자가 동일한 게시글에 좋아요를 누르면 id가 8인 사용자만 처리가 되고 7인 사용자는 처리되지 않으며 DeadLock이 발생한다.

❓ 원래는 Deadlock이 발생하면 안 되고, 데이터의 정합성이 깨질지언정 둘 다 처리가 되어야한다.
하지만 왜 Deadlock이 발생하는가?!


Lock

우선 왜 DeadLock이 발생하는가?에 대한 이해를 하려면 Lock에 대한 이해가 필요할 것 같다.

Exclusive lock (X락, 배타적 잠금)

  • 쓰기 잠김(Write lock) 이라고도 불린다.
  • 어떤 트랜잭션에서 INSERT, UPDATE, DELETE로 데이터를 수정하려고 할 때 트랜잭션이 완료될 때까지 테이블 혹은 행에 Lock을 부여한다.
  • X락이 걸려있으면, S락을 걸 수 없다.(배타적 잠금)
    • 즉, 수정하거나 삭제하고 있는 Row는 읽기, 수정, 삭제가 전부 불가능하다.
  • X락이 걸려있으면, 다른 트랜잭션이 X락을 걸 수 없다.

Shared lock (S락, 공유 잠금)

  • 읽기 잠금(Read lock)이라고도 불린다.
  • S락이 걸려있을 때, 다른 S락도 걸 수 있다.
    • 즉, 하나의 Row를 여러 트랜잭션이 동시에 읽을 수 있다는 것
  • S락이 걸려있을 때, X락은 걸 수 없다.
    • 즉, 다른 트랜잭션이 읽고 있는 Row를 수정하거나 삭제할 수 없다.
  • MySQL InnoDB는 SELECT시 S락을 걸지 않고 조회한다.
    • S락을 얻기 위해서는 SELECT ~ LOCK IN SHARE MODE 를 사용하면 된다.

[참고]
Exclusive lock과 Shared lock
Lock의 종류
Select 쿼리는 S락이 아니다.

DeadLock

두 개 이상의 트랜잭션들이 동시에 진행될때 각각 자신의 데이터에 대하여 락을 획득하고 상대방 데이터에 대하여 락을 요청하면 무한 대기 상태에 빠질 수 있는 현상이다.

예를 들어 T1에서 A에 대해 락을 걸고 T2에서 B에 대해 락을 걸었다고 하자. 그리고 나서 T1에서 B에 대해 락을 걸고 T2가 A에 대해 락을 건다면 T1과 T2는 서로 A, B에 대한 락을 유지하며 무한루프에 빠지게 됩니다.

일반적으로 DBMS에서는 데드락 탐지(Deadlock detection) 기능을 제공하기 때문에 데드락이 발생하면 DBMS가 T1 혹은 T2 중 하나를 강제로 중지시켜, 한 트랜잭션은 정상적으로 실행되며 중지된 트랜잭션에서 변경한 데이터는 원래 상태로 되돌려 놓는다. (실제 데드락 상황이 아닐지라도 락에 대한 대기시간이 설정된 시간을 초과하면 이것도 데드락으로 처리된다)

또한, 동시에 같은 Row를 update 하려 하면 Deadlock이 발생한다. 둘 이상의 쓰레드가 서로의 작업이 끝나기를 계속 기다리는 것이다.

DeadLock 예시

<Query A>
INSERT INTO my_table (pk, name) VALUES(1, 'a'), (2,'b') ON DUPLICATE KEY UPDATE name=VALUES(name);
<Query B>
INSERT INTO my_table (pk, name) VALUES(2, 'a'), (1,'b') ON DUPLICATE KEY UPDATE name=VALUES(name);
  • A에서 pk=1인 row insert, pk=1 lock 획득
  • B에서 pk=2인 row insert, pk=2 lock 획득
  • A에서 pk=2인 row update 시도 -> B에서 먼저 lock을 가져갔기때문에 대기
  • B에서 pk=1인 row upsert 시도 -> A에서 먼저 lock을 가져갔기때문에 대기
    => 서로가 서로의 lock을 가져갔기 때문에 데드락 발생

DeadLock 예시

트랜잭션

  • 메서드를 하나의 작업으로 만들어주며, 해당 메서드를 끝까지 완료해서 성공했다면 커밋을 실패하게 된다면 롤백을 해준다.
  • 트랜잭션의 격리 수준(Isolation level)
  • @Transactional은 데이터베이스의 동일한 행을 동시에 수정하지 못하게 Lock을 걸어준다.
  • @Transactional만 설정해두면, 두 개의 쓰레드가 있을 때 각각의 쓰레드는 2개의 트랜잭션을 만들고 두 트랜잭션은 독립적으로 실행된다. 이때 2개의 트랜잭션이 동일한 DB의 동일한 행에 접근하게 된다면 하나의 트랜잭션은 성공하고 다른 하나의 트랜잭션은 실패하게 될 것이다.

synchronized

  • method 와 블럭을 통해 설정 가능하며 해당 구역에 쓰레드 접근시 Lock을 획득하여 구간을 빠져 나올때 까지 다른 쓰레드 접근을 막는 매커니즘이다.
  • 즉 메서드가 한 번에 하나의 스레드만 실행되도록 Lock을 건다.

좋아요 동시 요청

위에서 좋아요 동시 요청을 보내면, 데드락이 발생하는데 그 이유는 동시에 같은 Row를 update 하기 때문이다.

교착상태(Deadlock)가 발생한다. 즉 둘 이상의 프로세스가 서로 작업이 끝나길 기다린다는 것이다. 멀티프로그래밍이 가능한 시스템에서 일어날 수 있는 현상이다. 뫼부우스의 띠 처럼 계속 한다는 것.

✔️ synchronized와 @Transactional가 모두 없는 상태

T1: |---add 좋아요--->
T2: |---add 좋아요--->

=> Deadlock 발생

✔️ synchronized만 있는 경우

T1: |---add 좋아요---> T2: |---add 좋아요--->

✔️ @Transactional만 있는 경우

원래는 다음과 같이, 여러 트랜잭션이 독립적으로 실행된다.
T1: begin Transaction ---> method ---> commit Transaction
T2: begin Transaction ---> method ---> commit Transaction

하지만 동시에 두 사용자(아이디가 다름)가 같은 게시글에 좋아요 요청을 하는 경우에는 문제가 발생한다.

// LikeController
@PostMapping("/prf-posts/{prf-post-id}")
public ResponseEntity postPrfPostLike(@AuthenticationPrincipal Member member, @PathVariable("prf-post-id") long prfPostId) {
        PrfPost prfPost = prfPostService.findverifiedPrfPost(prfPostId);

        PrfPostLike prfPostLike = likeService.addPrfPostLike(member, prfPost);

        return new ResponseEntity(new PrfPostLikeDto.Response(prfPost.getLikeCount()), HttpStatus.CREATED);
}

// LikeService
@Transactional
public PrfPostLike addPrfPostLike(Member member, PrfPost prfPost){
        checkExistPrfPostLike(member, prfPost); // 이미 좋아요한 경우

        PrfPostLike prfPostLike = new PrfPostLike(member, prfPost);
        prfPost.likeCountUp();

        return prfPostLikeRepository.save(prfPostLike);
}
Hibernate: select prfpost0_.id as id1_3_0_, prfpost0_.created_at as created_2_3_0_, prfpost0_.modified_at as modified3_3_0_, prfpost0_.category as category4_3_0_, prfpost0_.content as content5_3_0_, prfpost0_.image_key as image_ke6_3_0_, prfpost0_.like_count as like_cou7_3_0_, prfpost0_.member_id as member_10_3_0_, prfpost0_.tags as tags8_3_0_, prfpost0_.title as title9_3_0_ from prf_post prfpost0_ where prfpost0_.id=?
Hibernate: select prfpost0_.id as id1_3_0_, prfpost0_.created_at as created_2_3_0_, prfpost0_.modified_at as modified3_3_0_, prfpost0_.category as category4_3_0_, prfpost0_.content as content5_3_0_, prfpost0_.image_key as image_ke6_3_0_, prfpost0_.like_count as like_cou7_3_0_, prfpost0_.member_id as member_10_3_0_, prfpost0_.tags as tags8_3_0_, prfpost0_.title as title9_3_0_ from prf_post prfpost0_ where prfpost0_.id=?

Hibernate: select prfpostlik0_.id as id1_5_, prfpostlik0_.created_at as created_2_5_, prfpostlik0_.modified_at as modified3_5_, prfpostlik0_.member_id as member_i4_5_, prfpostlik0_.prf_post_id as prf_post5_5_ from prf_post_like prfpostlik0_ where prfpostlik0_.member_id=? and prfpostlik0_.prf_post_id=?
Hibernate: select prfpostlik0_.id as id1_5_, prfpostlik0_.created_at as created_2_5_, prfpostlik0_.modified_at as modified3_5_, prfpostlik0_.member_id as member_i4_5_, prfpostlik0_.prf_post_id as prf_post5_5_ from prf_post_like prfpostlik0_ where prfpostlik0_.member_id=? and prfpostlik0_.prf_post_id=?

Hibernate: insert into prf_post_like (created_at, modified_at, member_id, prf_post_id) values (?, ?, ?, ?)
Hibernate: insert into prf_post_like (created_at, modified_at, member_id, prf_post_id) values (?, ?, ?, ?)

Hibernate: update prf_post set modified_at=?, like_count=? where id=?
Hibernate: update prf_post set modified_at=?, like_count=? where id=?

<WARN & ERROR>
2023-03-24 16:39:48.913  WARN 1780 --- [nio-8080-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1213, SQLState: 40001
2023-03-24 16:39:48.913 ERROR 1780 --- [nio-8080-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : Deadlock found when trying to get lock; try restarting transaction

=> Deadlock 발생
=> T1은 성공, T2는 실패

이때 두 트랜잭션이 레코드를 수정하려고 하면, 서로의 락에 의해 데드락이 발생한다. 먼저 UPDATE를 시도한 트랜잭션이 타임아웃되어서 실패하고, 다른 트랜잭션이 성공한다. 결과적으로 하나의 수정만 성공한다.

https://karbachinsky.medium.com/deadlock-found-when-trying-to-get-lock-try-restarting-transaction-54a3ed118068
=> 참고해서 다시 정리하기


해결 방법

1. 애플리케이션 단에 Lock 걸기

1-1. synchronized 추가

  • Controller 단에 synchronized를 설정해뒀더니 동일한 게시글에 대한 서로 다른 사용자의 좋아요 요청이 모두 잘 처리되었다.

1-2. @Transactional 추가

  • Service 단에 @Transactional를 추가했다.
    • 동시성 문제와 상관없이 하나의 트랜잭션으로 묶어야한다고 생각했기 때문에
  • synchronized 없이 @Transactional만 추가한다면, Deadlock이 발생한다.

🚨 주의) synchronized와 @Transactional를 같이 사용하면, 동시성 이슈는 해결되지 않는다.
synchronized이 @Transactional보다 먼저 선행되어야 한다.

stackoverflow
stackoverflow를 풀어서 잘 설명해주신 블로그

문제는 해결됐지만 애플리케이션 단에 Lock을 건 것이 좋은 방법은 아닌 것 같다.
지금처럼 요청이 동시에 2 개가 들어온 것이 아니라 동시에 요청이 100만건이 들어오게 된다면 어떻게 될까?
애플리케이션 단에 말고, 데이터베이스 단에 Lock을 걸어보고 성능 차이를 확인해보자.


[읽어볼 자료들]
Transaction Dead Lock
@Transactional은 만능이 아닙니다.

https://tourspace.tistory.com/54
https://backtony.github.io/java/2021-12-24-java-41/

https://zzang9ha.tistory.com/443

https://idea-sketch.tistory.com/45
https://velog.io/@jkijki12/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%B4%EB%B3%B4%EA%B8%B0

https://mangkyu.tistory.com/30
https://sigridjin.medium.com/weekly-java-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%9E%AC%EA%B3%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%ED%95%99%EC%8A%B5%ED%95%98%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-9daa85155f66
https://sabarada.tistory.com/187
https://blog.tekenlight.com/2019/02/21/database-deadlock-mysql.html
https://velog.io/@rnjsrntkd95/Mysql%EC%9D%98-%EC%9E%A0%EA%B8%88Lock%EA%B3%BC-%EB%8D%B0%EB%93%9C%EB%9D%BDDeadLock-%EB%B0%9C%EC%83%9D

궁금한 점

  • 테스트를 해봤을 때, @Transactional를 안 붙이고 synchronized만 붙여도 잘 됐는데 @Transactional를 붙이는 게 좋을까 안 붙이는 게 좋을까?
  • synchronized를 Controller 단에 붙였는데 Service에 붙이는 게 좋을까?
  • 같은 테이블에 insert를 하면, 같은 행에 lock이 걸릴까? 아니면 모두 다른 행에 lock이 걸릴까?
    • 여러 개의 insert 작업이 동시에 발생하면, 각각의 작업이 다른 레코드를 조작하기 때문에 락 충돌이 발생하지 않을 가능성이 높습니다. 따라서, 하나의 테이블에 여러 개의 insert 작업이 동시에 발생하더라도, 데드락이 발생하는 것은 매우 드물 것입니다.
    • 하지만, 예외적인 경우에는 동시에 여러 개의 insert 작업이 하나의 레코드를 조작하려고 할 수 있습니다. 예를 들어, 하나의 테이블에서 유일한 값을 갖는 필드를 조작할 경우, 여러 개의 insert 작업이 같은 값을 삽입하려고 시도할 수 있습니다. 이러한 경우, 데드락이 발생할 수 있습니다.

0개의 댓글