학습 동기
우테코 체스 미션을 하던 도중 한 게임에 대해 동시에 여러 이동 요청이 들어올 시에 발생할 문제점에 대해 피드백 받고 동시성 이슈에 대한 해결법을 찾다 학습.
문제점 상세 설명
체스 DB 구조
체스 미션의 기물 이동 명령은 이동할 기물의 위치와 목적지의 위치를 입력받고 해당 게임의 모든 체스기물을 불러와서 체스 보드를 만든 후에 이동할 수 있는지 도메인 검증을 거친 후 Piece테이블에 game_id와 위치로 조건절 걸고 update 쿼리를 사용하는 방식으로 구현되었다.
만약 동시에 같은 게임에 대해서 이동 명령이 들어온다면 트랜잭션의 독립성 속성에 의해 update 문을 사용하는 두 기물 데이터는 락이 이미 걸려서 뒤에 들어온 트랜잭션이 접근할 수 없지만, 동시에 들어온 두 트랜잭션 작업이 서로 다른 기물을 건드리고 있다면 한 턴에 두 기물이 동시에 움직이는 문제가 발생한다.
해결방법 1 - 트랜잭션 격리 수준(Isolation Level)을 SERIALIZABLE로 설정
나는 해당 문제를 트랜잭션의 격리 수준을 조정하며 해결했고 미션에도 이걸 반영시켰다. 더 나은 방법이 있었다는 건 추후에 알았다.
트랜잭션의 격리 수준을 SERIALIZABLE로 설정하면, 그 트랜잭션에서 접근한 모든 로우마다 다 락이 걸린다.
데이터를 수정하는 쿼리 뿐만 아니라 select문으로 읽어들이는 로우에도 락이 걸린다.
해당 해결 방법을 적용한 이후에 온 피드백
SERIALIZABLE 은 동시성이 중요한 경우 잘 사용하지 않습니다. 이 말은 즉 오늘날 서비스하는 회사는 대부분 사용하지 않을 것 같아요.
SERIALIZABLE은 격리 수준 중에 가장 높은 수준이기 때문에 성능이 가장 좋지 않다. 다른 트랜잭션이 동시에 실행되고 있다면,
락에 의해 대기시간이 발생할 확률이 높기 때문에...
예를 들면, 체스 기물을 움직이는 로직을 실행하려면 특정 체스 게임을 먼저 읽어야 하고, 그 다음 그 체스게임에 맞는 기물들을 읽어야 한다.
그러면 그 체스 게임과 체스 기물들에 대한 로우가 모두 락이 걸리는데, 기물에 대한 락은 필요한 락이라 쳐도, 체스 게임에 대한 락은 로비페이지 라든지... 다른 곳에서 충분히 요청할 수 있는데, 이런 요청들 마저도 락의 영향을 받게 된다.
우테코의 미션이 단 한명만 실행시켜 보기 때문에 해당 문제점이 드러나진 않지만, 여러 명이 이 웹에 접속한다면 이론상 이런 문제점이 있다.
해결방법 2 - `select ~ for update` 구문 사용하기
격리 수준을 그냥 REPEATABLE_READ 로 두고 필요한 로우에서만 `select ~ for update`로 락을 거는 방법이 있다.
체스 미션에서는 이동 명령이 실행된 체스게임의 기물들을 읽어올 때 `select ~ for update`로 읽어온다면, 그 기물에 대한 데이터에만 락이 걸리므로 위 해결방법에서 존재하는 문제점을 해결할 수 있다.
적용 코드
@Override
public Board findBoardByGameId(final Long gameId) {
final String sql = "select square_file, square_rank, team, piece_type "
+ "from Piece "
+ "where game_id = ? for update";
final List<Piece> pieces = jdbcTemplate.query(sql, pieceRowMapper, gameId);
final Map<Square, Piece> board = new HashMap<>();
for (Piece piece : pieces) {
board.put(piece.getSquare(), piece);
}
return new Board(board);
}
해당 방법을 알았을 때엔 이미 미션이 머지가 되어버려서 미션에 반영하지는 못했다.😢
격리 수준을 SERIALIZABLE로 두는 것 보다 훨씬 나은 방법이라고 생각한다.
해결 방법 3 - 낙관적 락
DB의 기능으로 진짜 락을 거는 것을 비관적 락이라고 한다. 낙관적 락은 DB의 물리적인 락 기능을 이용하지 않고 논리적으로 락을 구현한다.
- 락이 필요한 테이블에 version이라는 컬럼을 추가하고 들어오는 로우마다 초기 값을 1로 세팅한다.
- 로우를 수정하기 전에 수정할 로우의 version컬럼을 읽어서 조건절에 포함시켜 수정하고, 수정시에 version이 1 증가하도록 한다.
Crew라는 테이블에 name이라는 컬럼이 있고 version이라는 락을 위한 컬럼이 있다고 가정.
id name version
1 알렉스 1
1. 1번 세션에서 id가 1번의 version 1을 읽음.
2. 2번 세션에서 id가 1번의 version 1을 읽음.
3. 1번 세션에서 id가 1번이고 version이 1인 로우의 이름을 이영환으로 바꾸고 version을 1 증가시킴.(version 2)
4. 2번 세션에서 id가 1번이고 version이 1인 로우의 이름을 알락스로 바꾸고 version을 1 증가시킴. (수정 실패!)
위 방법으로 구현하면, 먼저 수정한 쪽에서 version값을 바꿔버리기 때문에 뒤에서 읽은 쪽은 동시에 같은 버전을 읽었더라도 이미 DB에서 버전이 올라가 있어서 조건절이 무시돼서 실행되지 않는다.
해당 방법은 물리적인 락을 사용하지 않기 때문에 성능이 매우 좋지만,
발생 빈도, 행위의 주체가 하나 이상인가, 경합 발생시 비즈니스 로직에 대한 영향도 정도를 같이 고려해서 채택해야 한다.
'DB' 카테고리의 다른 글
커버링 인덱스와 ICP(index condition pushdown)에 대한 고민 (0) | 2023.09.30 |
---|---|
Real MySQL 5장 트랜잭션과 잠금 정리 (0) | 2023.05.07 |
Real MySQL 4장 아키텍처 정리 (0) | 2023.05.07 |
Serializable로 동시성을 잡으려 하면 데드락을 만납니다 (0) | 2022.12.05 |