백만 개 row를 수정하다가 트랜잭션을 롤백한다면?

    요새 Hussein Nasser이라는 유쾌한 개발자 아조시의 Fundamentals of Database Engineering이라는 강의를 듣고 있는데, (엄청 재밌다!) 인덱스/트랜잭션 파트에서 꽤 재미있는 질문을 던지는 아티클을 읽었다. 아래는 아티클과 비슷한 내용을 담고 있는 이 아조시의 유튜브 비디오.

     

    아조시 사는 데 어딘지 모르겠지만... 좋아 보이시네요

     

    요약하자면, 엄청 많은 개수의 row를 수정하는 트랜잭션을 돌리다가 에러가 나서 해당 변경사항을 롤백하려면 시간이 많이 걸릴 텐데, 과연 DB는 어떻게 이 작업을 수행할까? 하는 질문이었다.

    1. 바로(Eagerly) 롤백하기
    2. Lazy하게 롤백하기

    DBMS마다 접근 방식이 다른데, 우선 해당 강의에서 주로 예시로 들고 있는 PostgreSQL에서는 후자의 방식을 사용한다. ‘아니, 롤백이 바로 안 되면 ACID의 원자성(Atomacity)이 보장 안 되는 거 아니야?’ 하는 생각이 들었는데, 아티클에서는 이에 대해서 약간 궁금증만 돋워 놓고 설명은 좀 알듯말듯 하게만 해 해준다. 대답은 여러분이 생각해 보세요 하는 식인데… 그래서 써 보는 글!

     

     

    DBMS는 어떻게 변경된 데이터를 관리할까?

    기본적으로 MySQL은 트랜잭션에서 기존 row에 대한 업데이트가 생겼을 때 실제 테이블에는 업데이트된 값만을 남긴다. 그런데 MySQL의 기본 트랜잭션 격리 수준은 REPEATABLE READ이다. 한 트랜잭션에서 도중에 특정 row를 변경해도 다른 트랜잭션에서는 처음 트랜잭션을 시작할 때의 값을 읽을 수 있다는 뜻이다. 그런데 테이블에는 새로 업데이트된 값만 들어있다면, 어떻게 REPEATABLE READ가 가능한 것일까? 이 때 사용되는 게 Undo log다. 그래서 기존 값이 업데이트되었을 때, 기존의 변경 이전 값들은 undo log(undo segment라고도 한다)라고 불리는 별도의 자료구조에 저장되고, 다른 트랜잭션은 undo log에 있는 기존 값들을 읽게 된다. MySQL이라고는 했지만, MySQL뿐만 아니라 오라클 등 다른 DBMS에서도 사용하고 있는 방식이다.

     

    이와 비교해 PostgreSQL은 전혀 다른 접근법을 사용한다. Postgres에서는 기존 데이터가 수정되어도 기존 데이터와 새로운 데이터를 모두 테이블에 남겨둔다. 그리고 각 데이터마다 시작한 commit의 버전을 명시해서 버전 관리를 한다. (이건 상당히 디테일을 많이 퉁친 설명인데, 보다 자세하게 알고 싶다면 이 글을 참고하면 된다) 그리고 나중에 한꺼번에 오래된 데이터들을 처리해주는 VACUUM이라는 작업을 수행하는데, 일종의 Garbage Collecting과 비슷한 작업이라고 볼 수 있다.

     

     

    undo log는 롤백이 조금 더 힘들다

    사실 변경사항을 모두 테이블에 남겨두고 Vacuum을 쓰는 건 Postgres만의 독특한 방식인데, 이건 꽤 여러가지 장단점을 가지고 있다. 장점 중 하나는 바로 Hussein이 말한 것처럼 엄청 많은 레코드를 수정했다가 롤백해야 할 때에 드러난다. MySQL은 이미 Heap에서 이전 데이터를 지우고 새로운 데이터로 교체한 상태다. 이전 데이터들에 대한 정보는 undo log에 쌓여 있는데, undo log를 열어서 변경사항을 읽은 다음 다시 heap으로 해당 내용을 롤백시키는 것은 꽤나 비용이 많이 드는 일임에 분명하다. 만약 트랜잭션이 간단한 한 번의 업데이트라면 좀 낫겠지만, 업데이트 과정도 복잡했다면 비용은 더 증가할 수밖에 없다.

     

    그리고 undo log를 사용하는 방식에서 이 과정은 롤백이 결정된 이후에 바로 수행되어야 한다. 트랜잭션의 원자성이 보장되어야 하기 때문이다. 그래서 위 동영상 8분 지점에서 말하듯이 MS SQL Server에서 엄청 긴 트랜잭션을 돌리다가 오류가 나서 DB를 재시작했더니, Restoring… 이라는 메시지와 함께 아무것도 할 수 없는 상황이 발생하는 것이다. undo log 방식에서는 힙에 있는 데이터 버전은 1개 뿐이기 때문이다.

     

    이와 비교해 Postgres의 방식은 이런 상황에서 꽤 이점을 가진다. 이전 버전의 데이터도 테이블(힙)에 그대로 남아있기 때문에, 결국 ‘트랜잭션이 실패했기 때문에 그 이전 버전의 데이터가 최신이다’라는 정보(이는 별도의 테이블을 통해 관리된다)만 있으면 추가적인 데이터 변경 작업이 필요 없다.

     

     

    PostgreSQL은 DB가 자꾸 커진다

    그럼 PostgreSQL의 방식에는 문제가 없을까? 조금만 생각해 봐도 이전 버전의 데이터를 힙에 모두 가지고 있는 건 비효율을 불러오기 딱 좋다. 결국 테이블의 크기가 커진다는 건 I/O 시에 읽어야 할 페이지의 크기가 커진다는 걸 의미하고, 결국 이는 성능 저하로 이어질 수밖에 없다. 자, 이럴 때 어떻게 해야 할까? 위에서 잠깐 언급했던 Vacuum이라는 가비지 콜렉팅 작업을 해서 필요없는 row들을 정리해 줘야 한다. 그런데 이 vacuum도 두 가지 종류가 있다. 그냥 일반적인 vacuum은 필요없는 데이터 공간을 재사용할 수 있도록 정리할 뿐, 해당 저장 공간을 다시 OS한테 반환하지 않는다.

     

    이게 무슨 소리냐면, 현재 사용중인 데이터 크기가 1GB고, 가비지로 쌓인 이전 데이터 크기가 1GB여서 총 힙에 2GB의 데이터가 쌓여 있다면, vacuum은 1GB짜리 가비지 공간을 DB가 새로운 데이터를 저장할 때 재사용할 수 있게 비워줄 뿐, DB가 차지하고 있는 힙의 크기는 여전히 2GB라는 것이다. 여전히 I/O 시에도 같은 크기의 저장공간에 접근해야 한다. page를 읽었는데 거기에 있는 데이터들이 다 이미 가비지 콜렉팅 된 데이터일 수도 있다. 그래서 vacuum 처리를 해도 데이터 누적으로 인한 성능 저하는 계속된다.

     

    그래서 당연히 정리한 데이터 공간을 다시 OS에 반환하고 깨끗이 비우는 Full Vacuum이라는 기능도 있다. 하지만 Full Vacuum을 처리하는 동안에는 DB에 락이 걸리게 되고, 그때그때 롤백 처리를 하는 undo log 방식에 비해 한꺼번에 GC를 처리하는 Full Vacuum의 경우 더 작업의 크기가 클 가능성이 높고, 시간도 더 오래 걸릴 가능성이 크다.

     

    대신 일반적인 Vacuum은 DB에 락을 걸지 않고도 다른 DB 엑세스 작업과 병렬적으로 처리할 수 있다. 조금만 생각해 보면 당연한 이치다. 이전 데이터를 참조하고 있는 오래된 트랜잭션을 제외하고는 대부분의 경우 최신 데이터를 참조하고 있을 것이고, 전체 힙 공간을 줄이지 않으면서 오래된 데이터들만 정리하는 건 다른 작업과 함께 진행해도 큰 문제가 없다. 이런 점을 생각해 보면, 1) 데이터 공간 크기를 일정 정도 더 먹는 것에 의한 비효율을 허용 범위 내에서 미리 계산하고, 2) 주기적으로 vacuum을 돌리면서 전체 힙 크기를 일정 수준으로 유지하고, 3) 유저의 사용이 적은 시간대(혹은 점검 시간)에 Full Vacuum을 돌리는 방향으로 관리한다면 데이터 변경으로 인한 down time을 최소화하면서 꽤 효율적으로 DB를 관리할 수 있겠다는 생각이 들었다.

     

     

    논란거리: 트랜잭션은 롤백보다 커밋되는 게 더 많은데...

    하지만 위에서 말한 상황은 이상적인 이론상으로나 가능한 이야기고, 이 주제에 대해서 여러 의견들을 찾아 보면 PostgreSQL 사용자들도 꽤 많은 문제에 부딪히는 모양이다. 우선 Full Vacuum이 도대체 얼마나 걸릴지 모른다는 것. 하루 웬종일 걸린다는 글도 심심치 않게 찾아볼 수 있다. 그리고 또 하나는, 일종의 예외 상황이라고 볼 수 있는 롤백 시의 효율성이 높은 vacuum 방식의 장점보다, 데이터 변경이 잦아도 힙 크기에 변화가 없는 undo log 방식의 장점이 더 큰 것 아니냐? 하는 의견이다. 실제로 2018년에 PostgreSQL의 주요 커미터 중 한 명인 Robert Haas가 undo log와 유사한 방식을 시험해보고 있다는 글을 올렸을 때에 한 말도, ‘롤백되는 트랜잭션보다는 커밋되는 트랜잭션이 더 많다'는 이야기였다. 이에 대해서 HackerNews 같은 여러 커뮤니티에서도 undo log 방식이 나은 거 같아서 환영한다는 반응이 많았는데, 아직 vacuum 방식으로 돌아가고 있는 걸 보면 여러가지 이유로 실현되지는 못한 모양이다.

     


     

    공부하다 보니 트랜잭션 간의 데이터 버전을 어떻게 DBMS가 격리하는지부터 시작해서 트랜잭션의 동작 과정에 대해서도 이해가 더 깊어지는 지점이 있었다. PostgreSQL은 실제로 접할 일이 많이 없었는데, DBMS마다 세부적인 구현방식이 다르다는 게 신기하기도 하고, 정답은 없는 문제라서 여러 갑론을박이 벌어지는 것도 따라가다 보니 흥미로웠다. 결국 어떤 기술이든 쓰는 상황이나 목적에 따라 트레이드오프를 주고 받으면서 최선의 선택을 하는 것!

     

    참고 자료

    댓글