데이터를 저장할 때 단순하게 파일에 저장해도 될 것이다. 하지만 우리는 데이터베이스에 저장한다. 왜 일까?
여러 이유가 있지만 가장 대표적인 이유로 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문이다.
트랜잭션 개념
데이터베이스에 트랜잭션은 하나의 작업을 안전하게 처리하도록 보장하는 것을 뜻한다. 하지만 안전하게 처리하려면 고려해야할 점이 많다.
예를 들어 5000원의 계좌이체를 하는 작업이 있다고 가정하자.
- A의 잔고 5000원 감소
- B의 잔고 5000원 증가
이 두 가지 작업이 합쳐져 하나의 작업처럼 동작해야 한다. 만약 A쪽에서는 성공했는데 B쪽에서 실패한다면 A의 5000원만 사라지는 심각한 문제가 발생하는 것이다.
데이터베이스가 제공하는 트랜잭션 기능을 사용하면 두 가지 작업이 함께 성공해야 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있다. 커밋과 롤백이다.
- 커밋 : 모든 작업이 성공해 데이터베이스에 정상 반영됨.
- 롤백 : 작업 중 하나라도 실패해 거래 이전으로 되돌리는 것.
트랜잭션 ACID
트랜잭션은 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 한다.
복습 겸 다시 간단히 하면,
- 원자성 : 트랜잭션 내 실행한 작업들은 마치 하나의 작업인 것 처럼 모두 성공하거나 모두 실패해야 한다.
- 일관성 : 모든 트랜잭션은 일관성 있는 상태를 유지해야 한다. 예를 들면 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
- 격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 동시에 같은 데이터에 접근해 수정할 수 있다면 문제가 발생할 수 있다.
- 동시성과 관련한 성능 이슈로 인해 트랜잭션 격리 수준을 선택할 수 있다.
- 지속성 : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해 성공한 트랜잭션 내용을 복구해야 한다.
트랜잭션 격리 수준
위의 격리성에서 트랜잭션 간 격리성을 완벽하게 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다. 이렇게 하면 동시 처리 성능이 매우 나빠진다.
이러한 문제때문에 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의한다.
- READ UNCOMMITED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE (직렬화 가능)
아래로 갈수록 격리성을 완벽하게 보장하는 형태이다. 성능은 당연히 느려진다.
아래서 설명할 내용들은 일반적으로 많이 사용 (PostgreSQL, SQL Server, 오라클 기본 값) 하는 READ COMMITTED 트랜잭션 격리 수준을 기준으로 한다.
데이터베이스의 연결 구조와 DB 세션
트랜잭션의 더 자세한 이해를 위해 데이터베이스 서버 연결 구조와 DB 세션에 대해 알아본다.
데이터베이스 연결 구조
- 사용자는 WAS나 DB 접근 툴 같은 클라이언트를 사용해 데이터베이스 서버에 접근할 수 있다.
- 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺는다.
- 커넥션을 맺을 때 데이터베이스 서버는 서버 내부에 세션을 만든다. 그러면 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해 실행된다.
- 즉 개발자가 클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행하는 것이다.
- 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 종료 후 새로운 트랜잭션을 다시 시작할 수 있다.
- 사용자가 커넥션을 닫거나, 또는 DBA(DB관리자)가 세션을 강제로 종료하면 세션은 종료된다.
- 커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개가 만들어진다.
- 세션의 생성시점? 커넥션 풀 setting 시점인지, 요청에 의해 커넥션을 가져다 쓸 때 생성되는지? (질문 답변 기다리기)
트랜잭션 동작 이해
아래 설명하는 내용은 트랜잭션 개념에 대한 이해를 돕기 위한 것이고, 구체적인 실제 구현 방식은 데이터베이스마다 다르다.
트랜잭션 기본 동작
- 데이터 변경 쿼리를 실행하고 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고, 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback을 호출한다.
- 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다.
- 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고, 다른 세션에게는 변경 데이터가 보이지 않는다.
- 등록, 수정, 삭제 모두 변경이므로 같은 원리로 동작한다.
예시를 보자.
- 세션1과 세션2 둘 다 가운데 있는 기본 테이블을 조회(select) 하면 해당 데이터가 그대로 조회된다.
여기서 세션1에서 데이터를 추가하게 된다면?
- 세션1은 트랜잭션을 시작하고 신규회원1, 2를 추가했다. 아직 커밋은 하지 않은 상태이다.
- 이 데이터들은 임시상태로 저장된다.
- 세션1은 select 쿼리를 실행하면 커밋 전임에도 본인이 입력한 신규 회원 1,2 데이터도 함께 조회된다.
- 세션2는 select 쿼리를 실행하면 신규 회원들을 조회할 수 없다. 커밋 전이기 때문이다.
만약 커밋하지 않은 데이터를 다른 곳에서 조회할 수 있다면?
- 세션2는 데이터를 조회했을 때 신규회원 1,2가 보일 것이다. 따라서 신규회원들이 있다고 가정하고 그 신규회원에 새로운 로직을 수행할 수 있다.
- 이 때 세션1이 롤백을 수행하면?
- 신규 회원 1,2 데이터가 사라지고 이는 데이터 정합성에 큰 문제가 발생한다.
따라서 커밋 전의 데이터는 다른 세션에서 보이지 않는 것이다. 물론 격리 수준을 설정해 보이게 할 수는 있다. (단, 더티 리드 발생)
- 세션1이 데이터를 추가한 후 commit을 호출한 상황이다.
- commit의 호출로 새로운 데이터가 실제 데이터베이스에 반영된다.
- 데이터의 상태가 임시 -> 완료로 변경된다.
- 이제 다른 세션에서도 회원 테이블을 조회하면 신규 회원을 확인할 수 있다.
- 세션1이 신규 데이터를 추가한 뒤 rollback을 호출한 상황이다.
- 세션1이 데이터베이스에 반영했던 것들이 적용되지 않는다.
- 수정하거나 삭제한 데이터도 rollback을 호출하면 모두 트랜잭션을 시작하기 전 상태로 복구된다.
자동 커밋, 수동 커밋
트랜잭션을 사용하려면 자동 커밋과 수동 커밋을 이해해야 한다.
자동 커밋으로 설정하면 각각의 쿼리 실행 직후 자동으로 커밋을 호출한다. 따라서 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있다.
하지만 쿼리를 하나하나 실행할 때 마다 자동으로 커밋이 되기 때문에 트랜잭션의 기능을 제대로 쓸 수 없게 된다.
자동 커밋 설정
1
2
3
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1', 10000);
insert into member(member_id, money) values ('data2', 20000);
수동 커밋 설정
1
2
3
4
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3', 40000);
insert into member(member_id, money) values ('data4', 50000);
commit; //수동 커밋
보통 자동 커밋 모드가 기본으로 설정된 경우가 많다.
그래서 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현할 수 있다.
참고로 수동 커밋 모드나 자동 커밋 모드는 한 번 설정하면 해당 세션에서는 계속 유지된다. 중간에 변경하는 것도 가능하다.
트랜잭션 동작 예제로 직접 확인하기
H2 데이터베이스를 사용한다. 서로 다른 세션ID를 입력해 같은 DB에 각각 다른 세션에서 접근하도록 세팅했다.
위와 같은 상태로 초기 데이터를 세팅한다.
- memberA의 돈을 memberB에게 2000원 계좌이체하는 트랜잭션을 실행한다.
- 아래 처럼 2번의 update 쿼리가 실행되어야 한다.
1
2
3
set autocommit false;
update member set money = 10000 - 2000 where member_id = 'memberA';
update member set money = 10000 + 2000 where member_id = 'memberB';
계좌이체 성공 상황
위의 쿼리를 실행한 세션 1 (왼쪽) 에서는 데이터의 변경이 이루어졌고 (임시), 세션 2에서는 커밋되지 않은 데이터이기 때문에 변경이 보이지 않는다.
여기서 commit 명령어를 세션 1에서 입력하면 세션 2에서도 데이터가 보이게 된다.
계좌이체 실패 상황
다시 초기 데이터 세팅 (10000원씩) 으로 돌아간다.
1
2
3
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생
위와 같이 한 쿼리에서 예외가 발생하는 상황을 가정한다.
1
2
3
Column "MEMBER_IDDD" not found; SQL statement:
update member set money=10000 + 2000 where member_iddd = 'memberB'
[42122-200] 42S22/42122
위와 같은 오류가 발생한다.
위와 같은 상황인 것이다. 이런 상황에서 강제 커밋을 하게 되면?
A에서는 돈이 빠졌지만, B의 잔고는 올라가지 않는 상황이 벌어지는 것이다.
이렇게 중간에 문제가 발생했을 때 롤백을 호출해 트랜잭션의 시작 시점으로 데이터를 원상복구 해야한다.
세션 1에서 rollback 명령어를 입력하면 당연히 세션2에서는 DB가 원래대로 보인다. 물론 롤백 전에도 커밋을 하지 않았기 때문에 같은 결과가 나온다.
세션 1에서의 상황을 보자.
세션1도 임시로 변경된 데이터가 아닌, 원래대로 돌아온 것을 확인할 수 있다.
이런 종류의 작업은 반드시 수동 커밋, 롤백을 이용할 수 있도록 해야함을 알 수 있었다. 이래서 보통 이렇게 자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션을 시작한다고 표현한다.
DB 락
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안, 아직 커밋 하지 않았는데 세션 2에서 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생한다.
트랜잭션의 원자성이 깨지는 것이다. 여기에 더해 세션 1이 중간에 롤백을 하게 되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.
이러한 문제들을 방지하려면, 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없도록 막아야 한다. 이러한 개념이 DB의 락(LOCK) 이다.
락(LOCK)의 동작 방식
- 세션 1은 MemberA의 금액을 500원으로 변경하고 싶고, 세션 2는 1000원으로 변경하고 싶은 상황이다.
조금이라도 빨리 접근하는 세션이 우선순위를 가진다. 세션 1이 우선순위를 가졌다고 가정한다.
- 세션1이 트랜잭션을 시작한다.
- 세션1은 MemberA의 money를 500으로 변경하려고 시도한다. 이 때 해당 로우(행)의 락을 먼저 획득해야 한다.
- 락이 남아있으므로 세션 1은 락을 획득한다.
- 세션 1은 락을 획득했으므로 해당 로우에 update sql을 수행한다.
- 세션 2도 트랜잭션을 시작한다.
- 해당 로우의 락을 획득해야 변경이 가능한데, 락이 없다. 락이 돌아올 때 까지 대기한다.
- 참고로 세션2가 무한정 대기하는 것은 아니다. 락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다.
- 세션1이 작업을 마치고 커밋하게 되면, 커밋으로 트랜잭션이 종료되었으므로 락을 반납한다.
- 락을 기다리던 세션2가 락을 획득하고 작업을 수행한다.
- 세션 2는 업데이트를 마치고, 커밋을 수행하면 마찬가지로 트랜잭션이 종료되었으므로 락을 반납한다.
결과적으로는 락을 통해 같은 로우에 동시에 변경이 수행될 수 없고, 결과만 보면 이후에 세션1이 변경한 로우에 마지막으로 접근해 변경을 한 세션2의 결과가 남게 된다.
직접 확인해보기
테이블이 이렇게 세팅되어있고, 세션1과 2에서 접근해본다.
1
2
set autocommit false;
update member set money=500 where member_id = 'memberA';
세션 1에서는 위와같은 쿼리를,
1
2
SET LOCK_TIMEOUT 60000; set autocommit false;
update member set money=1000 where member_id = 'memberA';
세션 2에서는 위와같은 쿼리를 실행시킨다. 60초안에 세션2에서 락을 획득하지 못하면 예외가 발생한다.
위와 같이 각각의 세션에서 쿼리를 입력하고 실행시킨 후 양쪽에서 조회해보면 위와 같은 결과가 나온다.
60초가 지나기 전 세션 1에서 커밋을 수행한다면?
세션2의 쿼리가 수행된 결과인 위와 같은 결과가 나오는 것이다.
물론 세션 2에서는 아직 커밋을 하지 않았기때문에 세션 1에서는 money = 500의 결과가 나올 것이고, 위의 세션 2의 결과는 임시 결과인 것이다.
DB락 - 조회의 경우
일반적인 조회는 락을 사용하지 않는다. 데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않고 데이터를 조회할 수 있다.
예를 들면 세션 1에서 락을 획득하고 데이터를 변경하고 있어도, 세션 2에서 데이터를 조회할 수 있다.
데이터를 조회할 때도 락을 획득하고 싶은 경우가 있을 수 있다.
- select for update 구문을 사용하면 된다.
- 이러면 세션 1이 조회 시점에 락을 가져가면 다른 세션에서 해당 데이터를 변경할 수 없다.
- 이 경우도 트랜잭션을 커밋하면 락을 반납하는 방식이다.
조회 시점에 락이 필요한 경우
- 트랜잭션 종료 시점까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 때.
- 예를 들면 애플리케이션 로직에서 memberA의 금액을 조회한 후 이 금액 정보로 애플리케이션에서 어떠한 계산을 수행하는 경우가 있다고 하자.
- 그런데 이러한 계산이 돈과 관련된 매우 중요한 계산이어서 계산을 완료할 때 까지 memberA의 금액을 다른 곳에서 변경하면 안되는 상황인 것이다.
- 즉 단순 조회 -> 애플리케이션 로직에서의 값 변경인 상황인데 이 때는 조회만 했기 때문에 락 획득이 없어, 다른 세션에서 변경이 가능한 상황인 것이다.
- 계산중인데, 중간에 값을 다른 쪽에서 수정하면 혼란이 생길 수 있는 것이다.
사용
세션 1에서 아래와 같은 쿼리를 실행했다고 해보자.
1
2
set autocommit false;
select * from member where member_id='memberA' for update;
- select for update 구문을 사용하면 조회를 하면서 동시에 선택한 로우의 락을 획득한다.
- 물론 락이 없다면 락을 획득할 때 까지 대기한다.
1
2
set autocommit false;
update member set money=500 where member_id = 'memberA';
세션 2에서 위와 같이 데이터를 변경하는 쿼리를 실행한다고 하면?
- 세션 1의 작업은 조회이지만, 세션 1이 이미 memberA 로우의 락을 획득했기 때문에 세션 2는 락을 획득할때까지 대기해야한다.
- 이후 세션1이 커밋을 수행하면 세션 2가 락을 획득하고 데이터를 변경한다.
트랜잭션과 락 정리
- 트랜잭션과 락은 데이터베이스마다 실제 동작하는 방식이 다르기 때문에, 해당 데이터베이스 메뉴얼을 확인해보고, 의도한대로 동작하는 지 테스트한 이후 사용해야 한다.