서론
기술 면접을 진행하다보면 가장 많이 들어오는 질문 중 하나는 데이터베이스 입니다. 그 중에 단골 질문을 꼽자면 디비 트랜잭션에 대한 질문과 데이터베이스를 기반으로 동시성을 제어하는 질문입니다. 해당 질문을 가장 자주 물어보는 이유는 트랜잭션은 백엔드를 개발하는데 가장 작은 단위이기도 하고 트랜잭션에 대한 이해없이 개발을 했다가 운영 중에 데이터의 무결성이 깨지거나 동시성을 제어하지 못하는 경우들을 자주 접하기 때문입니다.
하지만 백엔드 엔지니어를 준비하면서 보통 트랜잭션의 특성과 Isolation Level의 개념, 각 Isolation Level에 일어날 수 있는 문제들만 충분하다는 생각에 단기적으로 외우고 끝내는 경우가 많습니다. 저도 마찬가지로 그렇게 외우고 있다가 최근에 T사 면접과 스타트업 면접에서 디비에서 어떤 원리로 트랜잭션을 제어하는지 물어보고 각 트랜잭션 레벨에서 사용되는 락이 어떤 형태로 사용되는지 질문이 들어왔을 때 제대로 답변하지 못하는 저의 모습을 보여 다시 한번 정리해보려고 합니다.
Transaction 이란
만약 여러분들이 티켓을 예매하는 백엔드 애플리케이션을 개발한다고 가정하고 아래의 요구사항을 기반으로 예매 기능을 구현해봅시다.
- 결제 내역 추가: 티켓을 결제했을 때 결제된 금액과 결제 수단에 대한 정보를 저장.
- 남은 티켓 감소: 행사장의 전체 티켓의 남은 수량을 감소.
- 티켓 발급: 티켓 소유 유저 아이디, 티켓 자리 번호, 어떤 행사의 티켓인지에 대한 정보를 담은 티켓 발급.
- 포인트 적립: 티켓을 구매했을 때 구매한 금액의 일부를 포인트로 적립.
// Step 1: 결제 내역 추가
Payment payment = Payment.createPayment(userId, paymentRequest.getAmount(), paymentRequest.getPaymentMethod());
paymentRepository.save(payment);
// Step 2: 남은 티켓 감소
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("Event not found"));
event.decrementRemainingTickets();
eventRepository.save(event);
// Step 3: 티켓 발급
Ticket ticket = Ticket.issueTicket(userId, eventId, seatNumber);
ticketRepository.save(ticket);
// Step 4: 포인트 적립
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
user.addPoints((int) (paymentRequest.getAmount() * 0.05)); // 5% 적립
userRepository.save(user);
위의 로직을 보면 예매라는 작업 하나에 Payment, Event, Ticket, UserPoint의 데이터가 변경되는 것을 알 수 있습니다. 만약 위의 코드가 동작하는 과정속에서 결제 정보와 남은 티켓 감소는 성공했지만 중간에 티켓 발급이 실패하면 어떻게 해야될까요? 바로 감소했던 티켓수를 롤백하고 저장했던 결제 정보도 같이 되돌려야 될 것입니다. 이처럼 하나의 데이터 변경 작업으로 묶여서 처리해야 되는 단위를 트랜잭션이라고 설명할 수 있습니다.
코드로 구현했을 때
@Transactional
public void bookTicket(Long userId, Long eventId, String seatNumber, PaymentRequest paymentRequest) {
// Step 1: 결제 내역 추가
Payment payment = Payment.createPayment(userId, paymentRequest.getAmount(), paymentRequest.getPaymentMethod());
paymentRepository.save(payment);
// Step 2: 남은 티켓 감소
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("Event not found"));
event.decrementRemainingTickets();
eventRepository.save(event);
// Step 3: 티켓 발급
Ticket ticket = Ticket.issueTicket(userId, eventId, seatNumber);
ticketRepository.save(ticket);
// Step 4: 포인트 적립
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
user.addPoints((int) (paymentRequest.getAmount() * 0.05)); // 5% 적립
userRepository.save(user);
}
Transaction의 특징
트랜잭션의 특징을 이해하는데 가장 중요한 개념은 ACID 입니다. 각 철자는 원자성(Atomicity), 일관성(Consistency), 신뢰성(Reliability), 격리(Isolation) 그리고 영속성(Durability)을 의미합니다. 위의 설명했던 예시로 각 특성을 이해 해봅시다.
원자성(Atomicity)
트랜잭션에 속한 각각의 실행 명령(데이터를 읽기, 쓰기, 업데이트 또는 삭제하기 위함)을 하나의 단위로 취급하는 것을 원자성으로 이야기 하고 있습니다. 위의 예매를 예시로 설명하자면 트랜잭션의 원자성으로 인해 아래의 SQL로 인해 처리되는 데이터들이 함께 적용되거나 함께 롤백된다고 이해하시면 됩니다.
// 결제 정보 저장
INSERT INTO payment (user_id, amount, payment_method) VALUES (?, ?, ?);
// 티켓 개수 감소
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
// 티켓 생성
INSERT INTO ticket (user_id, event_id, seat_number) VALUES (?, ?, ?);
// 유저 포인트 적립
SELECT * FROM user_points WHERE id = ?;
UPDATE user_points SET points = ? WHERE id = ?;
일관성(Consistency)
트랜잭션에서의 일관성은 데이터를 처리하기 전과 후의 데이터에서 모순이 없다고 설명하고 있습니다. 쉽게 설명하자면 테이블에 정의된 제약 조건에 맞춰 일관된 형태로 적용해야 된다는 것을 의미합니다. 예를 들어 아래와 같이 존재하지 않는 필드에 데이터를 적용하려고 하거나 티켓을 생성하는데 seat_number를 필수적으로 지정하지 않는다면 트랜잭션을 중단해야 된다는 것을 의미합니다. 추가적으로 해당 특성은 테이블에 대한 제약 사항들을 명확하게 잘 정의했을 때 잘 활용할 수 있습니다.
// 티켓 생성(테이블에 없는 컬럼에 데이터를 삽입)
INSERT INTO ticket (user_id, event_id, seat_number, ticket_name) VALUES (?, ?, ?, ?);
// 티켓 생성(seat_number가 not null인 상황에 데이터를 넣지 않음)
INSERT INTO ticket (user_id, event_id) VALUES (?, ?);
독립성(Isolation)
트랜잭션의 레벨이 바로 이 특성을 기반으로 만든 개념이라고 볼 수 있습니다. 서로 다른 트랜잭션의 작업들이 서로에게 영향이 가지 않고 독립적으로 동작할 수 있도록 보장하는 것을 의미합니다. 예시로 설명드리자면 아래의 쿼리들이 서로 다른 트랜잭션에서 실행되었을 때 적절한 수준의 트랜잭션 레벨을 지정하여 독립성을 보장해주도록 하는 것이라고 이야기 할 수 있습니다.
독립성이 지켜진 사례
// A 트랜잭션의 티켓 개수 감소 100 -> 99
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
// B 트랜잭션의 티켓 개수 감소 99 -> 98
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
독립성이 지켜지지 않은 사례
// A 트랜잭션의 티켓 개수 감소 100 -> 99
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
// B 트랜잭션의 티켓 개수 감소 100 -> 99
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
영속성(Durability)
성공적으로 처리된 트랜잭션은 영구적으로 적용된다는 의미입니다. 예시로 예매 트랜잭션이 성공적으로 처리가 된 뒤에 디비에 시스템 장애가 생기거나 데이터베이스가 죽더라도 이 데이터들은 영구적으로 물리 저장소에 저장되어 보존되어야 한다는 의미입니다. 예시를 들어 티켓예매 트랜잭션이 성공적으로 처리된 뒤에 디비를 강제 종료하고 다시 키더라도 트랜잭션 성공적으로 처리된 휘의 데이터로 보존되어야 한다는 설명할 수 있습니다.
// A 트랜잭션의 티켓 개수 감소 커밋 완료 100 -> 99
SELECT * FROM event WHERE id = ?;
UPDATE event SET remaining_tickets = ? WHERE id = ?;
------------ 디비 장애 후 재실행 ------------------
// 티켓수 99개
SELECT * FROM event WHERE id = ?;
Transaction의 격리 수준
트랜잭션의 격리 수준은 비즈니스 로직을 구현하는 데에 있어서 매우 중요한 개념입니다. 그래서 그런지 면접 단골 질문으로 자주 나오는 편이고 심도있게 물어보는 편입니다. 트랜잭션 격리 수준은 트랜잭션의 ACID 개념 중 하나인 I(Isolation)를 제어하기 위해 나왔으며 격리 수준에 따라 디비의 성능과 데이터의 정합성이 높아지기 때문에 잘 알아두면 둘수록 실무와 면접에 많은 도움이 되실 겁니다.
READ UNCOMMITTED[LEVEL 0]
아무런 제약이 걸리지 않은 상태로 다른 트랜잭션에서 커밋이 적용되지 않은 데이터를 조회가 가능하기 때문에 영속화 되지 않은 데이터를 읽는다는 의미인 Dirty Read 같은 문제가 발생합니다. 아래의 예시를 통해 Dirty Read가 어떤 문제인지 정확하게 알아봅시다.
Dirty Read
아래의 그림을 보면 트랜잭션 A가 티켓 구매를 처리하기 위해 events 테이블의 남은 티켓 개수를 감소 시키는 것을 볼 수 있습니다. 여기서 트랜잭션 A가 Read Uncommited의 격리수준인 상황에 새로운 트랜잭션이 난입하게 되면 어떤 결과가 나오게 될까요?
트랜잭션 B는 아직 커밋되지 않은 A가 수정한 티켓수를 그대로 바라보게 되고 98개로 감소시키는 것을 볼 수 있습니다. 여기서 생기는 문제는 트랜잭션 A가 문제가 발생하여 롤백되어 티켓개수가 100 개로 돌아왔는데 트랜잭션 B는 커밋되지 않은 티켓 개수를 보고 그대로 98개로 감소시키는 것을 확인할 수 있습니다. 이처럼 다른 트랜잭션에서 처리중인 데이터를 다른 트랜잭션이 조회하게 되는 문제를 Dirty Read라고 합니다.
READ COMMITED[LEVEL 1]
트랜잭션이 종료된 즉 커밋된 데이터만 조회하도록 제어하는 격리 수준입니다. 해당 격리 수준은 READ UNCOMMITED와 다르게 커밋된 데이터만 조회할 수 있도록 공유락을 걸어 제어하기 때문에 Dirty Read의 문제는 발생하지 않습니다. 어떤 원리로 Dirty Read가 발생하지 않는지 설명하도록 하겠습니다.
위의 그림처럼 트랜잭션의 A가 실행될 때 변경하고자 하는 레코드에 공유 락을 걸고 아직 커밋되지 않은 변경 사항은 버퍼 캐시에 저장하여 데이터 수정 작업을 진행합니다. 그리고 진행한 작업들을 트랜잭션 로그에 저장하여 기록합니다.
그래서 트랜잭션 B가 남은 티켓을 조회했을 때에는 아직 커밋되지 않은 정보인 100개가 조회되어 Dirty Read가 발생하지 않는 것입니다.버퍼캐시의 데이터 변경사항은 트랜잭션 A의 롤백 여부에 따라 달라집니다. 만약 롤백이 되었다면 아래의 그림처럼 실제 테이블에 적용하지 않고 롤백이 되었다는 로그를 트랜잭션 로그에 저장합니다. 만약 롤백되지 않고 커밋되었다면 변경 사항을 디스크에 기록하여 영속화 시킵니다. 이 과정을 플러시라고 합니다.
공유 락이란?
공유락은 읽기락이라고도 불리며 영어로는 Shared Lock(쉐어드 락)이라고 불립니다. 다른 트랜잭션들이 현재 점유하고 있는 레코드를 볼 수는 있지만 수정/삭제는 할 수 없도록 막는 락입니다. 그래서 여러 사용자가 동시에 같은 레코드를 접근하더라도 데이터 읽기에 대한 일관성을 보장됩니다.
Unrepeatable read
하지만 Read commited 수준의 격리 레벨은 Unrepreatable Read를 막아주지는 않습니다. 영어를 해석하자면 반복할 수 없는 읽기라는 뜻인데 하나의 트랜잭션에서 조회를 반복해서 요청했을 때 같은 레코드가 조회된다는 것을 보장할 수 없다는 것을 의미합니다. 분명 공유락은 다른 트랜잭션의 쓰기를 제어한다고 했는데 어떤 원인으로 인해 Unrepeatable Read 발생하는지 아래의 그림을 통해 알아봅시다.
위의 그림을 보면 Read Commited의 격리 레벨은 조회가 끝난 뒤 바로 공유락을 해제되는 것을 볼 수 있습니다. Read Commited 격리 레벨은 읽기하는 순간 공유락을 걸고 읽기가 끝난다면 락을 해제합니다. 그래서 락이 풀린 순간 트랜잭션 B가 독점락을 걸어 수정 사항을 커밋할 수 있었고 트랜잭션 A는 다시 조회했을 때에는 99가 조회되는 문제가 발생합니다.
Repeatable Read[Level 2]
Repeatable Read의 격리 레벨은 Mysql InnoDB의 기본 격리 레벨로 채택 될 만큼 안정적인 격리 레벨 입니다. Read Commited와 다르게 하나의 트랜잭션에서 반복 읽기를 하더라도 데이터 일관성을 보장해줍니다. 어떤 원리를 통해 Unrepeatable Read를 방지하는지 아래의 그림을 통해 알아봅시다.
트랜잭션 A에서 조회가 끝나더라도 공유락을 유지하는 것을 볼 수 있습니다. 그 뒤에 트랜잭션 B가 레코드를 수정하기 위해 독점락을 요청했지만 현재 공유락이 점유 중이기 때문에 트랜잭션 B에게 대기하라고 요청하는 것을 볼 수 있습니다. 그래서 트랜잭션 B는 트랜잭션 A가 공유락을 해제할 때까지 대기하고 트랜잭션 A는 몇번을 조회하더라도 같은 레코드를 조회하는 것을 확인할 수 있습니다.
독점 락은 공유락이 전부 끝날 때까지 대기하기 때문에 서로 다른 트랜잭션들이 반복해서 조회하더라도 Unrepeatable Read가 발생하지 않는 것을 확인할 수 있습니다. 보다시피 락이 점유되고 대기하는 시간들이 있다보니 Read Commited에 비해 성능이 떨어질 수 있습니다.그외에도 Repeatable Read에서 발생할 수 있는 문제가 있는데 바로 Panturm Read 입니다.
Panturm Read
위의 그림처럼 봤을 때 락을 걸었을 때 다른 트랜잭션이 새로운 row 추가하고 현재 트랜잭션이 다시 조회했을 때 새로운 Row가 생기는 것을 팬텀리드라고 합니다. 이런 문제는 실무에서도 문제가 발생할 수 있는데 예시로 계좌 내역을 조회하여 출금을 하려고 할 때 그사이에 통신사에서 금액을 빼가서 출금 금액이 부족함에도 출금이 되는 경우나 통계를 내기위해 집계를 하는데 집계 데이터가 맞지 않아 일관성이 깨지는 것이 그런 케이스입니다.
Serializable
Serializable은 Repeatable Read와 마찬가지로 읽기 락(Shared Lock)과 쓰기 락(Exclusive Lock, X Lock)을 씁니다. 하지만 해당 락으로는 팬텀리드를 제어할 수 없기에 추가적으로 쓰는 락이 범위 락(Range Lock)입니다. 범위 락은 특정 조건에 의해 검색을 할 때 해당 검색 범위에 해당하는 레코드들을 완전히 제어하는 락입니다. 그리고 추가적으로 잠재적으로 추가될 수 있는 레코드까지 제어하기 때문에 다른 트랜잭션에서 행을 추가, 삭제가 불가능합니다.
Serializable 단점
하지만 실무에서는 Serializable Level까지는 왠만하면 잘 활용하지 않습니다. Isolation level 중 데이터의 무결성을 이처럼 완벽하게 지켜주는 격리 수준은 없는데 왜 잘 활용하지 않을까요? 바로 데이터의 병목으로 인해 그렇습니다. 위의 그림을 보면 트랜잭션이 처리되는 동안 다른 트랜잭션은 해당 트랜잭션이 종료 될 때까지 대기하고 있는 상태인 것을 볼 수 있습니다.
예를들어 데이터를 처리하기 위해 트랜잭션 하나당 처리하는데 1초씩 걸린다고 하고 초당 1000개의 요청이 들어온다고 가정해봤을 때 하나의 트랜잭션이 처리되는 동안 1초씩 기다리게 되고 결국 마지막 트랜잭션은 16분 40초가 지나고 나서야 처리되게 됩니다. 이런 데이터의 병목으로 인해 왠만하면 사용하지 않고 다른 전략들을 채택하게 되는데 그건 다음 글을 통해 공유하도록 하겠습니다.
마지막으로
이번 글은 트랜잭션과 각 트랜잭션을 제어하는 격리 레벨에 알아봤습니다. 하지만 해당 개념만으로는 Lost Update나 Write Skew와 같은 동시성 이슈들을 제어하고 관리하기는 쉽지 않습니다. 그리고 디비의 격리 수준을 높일수록 Latency는 더 길어지기 때문에 단순히 Isolation Level을 높이는 것이 아니라 다른 전략을 통해 동시성을 제어하는 케이스도 많습니다. 그래서 다음 주제에는 시스템에 발생할 . 수 있는 동시성 문제들을 디비나 다른 인프라를 활용하여 동시성 이슈를 보다 더 효율적이고 안정적으로 제어하는 방법에 대해 알아보도록 하겠습니다. 긴 글을 읽어주셔서 감사합니다.
'데이터베이스' 카테고리의 다른 글
SQL 처리 과정과 최적화 (0) | 2022.11.17 |
---|