개발놀이터

@Transactional과 PostgreSQL은 어울리지 않는다. 본문

CS 지식/데이터베이스

@Transactional과 PostgreSQL은 어울리지 않는다.

마늘냄새폴폴 2024. 6. 24. 21:25

제목이 꽤나 자극적이지만 아무리 생각해도 이거만큼 좋은 제목은 떠오르지 않았습니다. 제가 오늘 포스팅을 하게된 계기가 되는 포스팅도 저자분이 "@Transactional의 해로움" 이라고 할 정도이니 말 다한 것이죠.

 

우선 원래의 글을 올려드릴테니 자세한 내용은 아래의 링크를 참고해주세요!

 

https://channel.io/ko/blog/bad-transactional?fbclid=IwZXh0bgNhZW0CMTEAAR0Atxir0WzSFcBMLmVjDXNLrUoiUl_qX93JPUPoo6EIRqP3irVoc22JEM4_aem_Gt65sDAFShcRmzIYKpla4w

 

@Transactional의 해로움

들어가며: 23.12.31 Database outage 새해를 하루 앞둔 12월 31일 자정을 얼마 지나지 않아, 채널톡 서비스에 약 15분 간 지속된 서비스 장애가 발생했습니다. 00:37 ~ 00:52 시간대에 메인 데이터베이스로 사

channel.io

 

 

데이터베이스 MVCC

데이터베이스에는 MVCC라는 것이 존재합니다. 아마 데이터베이스의 격리수준을 조금 깊이있게 공부하신 분들이라면 한번정도는 들어보셨을 법한 이 내용은 동시성 관리 (Concurrency Control) 과 관련있는 내용입니다. 

 

우선 네가지나 되는 격리수준을 다 보진 않고 Repeatable 격리수준에 대해서 잠깐 짚어보고 넘어가도록 하겠습니다. 

 

데이터베이스별 MVCC

데이터베이스는 동일한 쿼리를 재실행 했을 때 같은 결과가 나오는 것을 보장해야합니다. 이것이 Repeatable Read 격리수준의 알파이자 오메가이죠. 

 

같은 결과를 보장하기위해 활용하는 방법론이 바로 MVCC이고 RDBMS에서는 이 MVCC를 각기 다른 방법으로 구현하고 있습니다. 

 

우리는 RDBMS에서 오라클 진영인 Oracle과 MySQL, Postgres 진영인 PostgreSQL의 MVCC에 대해서 알아보려고 합니다. 

 

오라클 진영에선 MVCC를 Undo 테이블로 관리하고 있습니다. 데이터들을 이 Undo 테이블에 저장하고 있음으로써 쿼리를 재실행 했을 때 이 테이블을 사용자에게 보여줌으로써 같은 결과를 보장해줍니다. 

 

Postgres진영은 조금 복잡한 방식의 MVCC를 구현하고 있습니다. 

 

Postgres진영에선 xmin, xmax라는 것을 이용해서 MVCC를 구현하는데요, 내용이 많지만 쉽게 설명해서 xmin에는 가장 최근의 트랜잭션 아이디 (이하 tid) 를 적어넣고 xmax에는 가장 오래된 tid를 적어 넣어서 언제 갱신된 레코드인지 식별할 수 있게 하는 것입니다. 

 

이렇게 식별하기위한 하나하나의 레코드를 튜플 (tuple) 이라고 하고 결론적으로 Postgres에선 이 튜플을 이용해 MVCC를 구현했다고 볼 수 있죠. 

 

하지만 이 튜플이 점점 쌓이게 되면 데이터베이스의 메모리를 많이 차지하기 때문에 이를 지워주는 vacuum이라는 엔진을 이용해서 튜플을 정리해주죠. GC와 비슷하다고 보시면 됩니다. 

 

 

MultiXact

기본적으로 데이터베이스 락에는 두가지 종류의 락이 있는데 한번 락을 획득하면 해당 트랜잭션이 락을 놓아줄 때까지 다른 트랜잭션이 락을 획득할 수 없는 exclusive lock이 있고, 이것과 완전 반대되는 모든 트랜잭션이 락을 획득할 수 있는 shared lock이 있습니다. 

 

보통 shared lock은 read level lock으로서 조회에서 주로 사용되고 exclusive lock은 UPDATE, DELETE, INSERT에서 주로 사용됩니다. 

 

이번 포스팅에선 두가지 락이 모두 중요하게 언급되지만 MultiXact를 이해하기 위해선 shared lock을 조금 더 집중해서 볼 필요가 있습니다. 

 

shared lock은 어떤 트랜잭션도 락을 획득할 수 있기 때문에 MVCC에서 xmax에 적어야하는 tid가 애매해집니다. 계속 tid를 업데이트할 수도 없는 노릇이고 그렇다고 업데이트해서 오버라이트 해버리면 이전 트랜잭션이 락을 획득했다는 정보가 없어지니 난감해지죠. 

 

그래서 Postgres에선 Map<String, List<String>> 이런 자료구조를 이용해서 xmax에 tid를 넣어 관리하고 있습니다. 이를 그림으로 표현하면 다음과 같죠. 

 

그리고 이 tid를 관리하는 자료구조에 있는 tid가 바로 MultiXactId입니다. 이 자료구조에 접근하기 위해 사용되는 것이 MultiXact라고 이해하셔도 무방합니다. 

 

원글에서 저자분은 장애가 발생하고 AWS RDS의 메트릭을 보니 MultiXactMemberId, MultiXactMemberBuffer라는 두가지 메트릭이 평소보다 수백배 높았고 이를 통해 MultiXact의 존재를 인지했다고 하셨습니다. 

 

하지만 MultiXact는 shared lock에서 tid를 관리할 때 사용되는 것이지만 저자분은 shared lock을 그 어느곳에서도 사용하고 있지 않아 당황했다고 하시더군요. 

 

저자분은 Postgres에 대한 다양한 문서를 읽고 나서 MultiXact는 shared lock 뿐만 아니라 exclusive lock을 획득하고 나서 update문을 날릴 때에도 MultiXact를 사용한다고 알게 되었습니다. 

 

즉, SELECT FOR UPDATE 를 통해 exclusive lock을 획득하고 그 후 UPDATE를 날리면 MultiXact가 발동되고 MultiXact가 많이 사용되면 사용될수록 vacuum이 정상적으로 작동되지 않을 때 cache miss를 일으켜 데이터베이스 성능이 떨어진다는 것이 본문의 내용이었습니다. 

 

저자분의 애플리케이션 장애상황은 00:30 분에 진행되는 배치잡으로 인해 long transaction이 발생하였고 이로인해 vacuum이 정상적으로 작동할 수 없게 되고 이것이 장애를 발생시킨 원인이라고 하였습니다. 

 

 

nested transaction

저자분의 말씀처럼 SELECT FOR UPDATE로 lock을 획득하고 UPDATE를 날리게 되는 상황을 다음과 같이 정의했습니다. 

 

스프링에선 A라는 트랜잭션이 선언된 로직이 B라는 트랜잭션이 선언된 로직을 호출할 때 물리 트랜잭션과 논리 트랜잭션으로 트랜잭션을 나누고 전파하여 각각의 로직에 대한 롤백 정책을 펼칩니다. 

 

해당 내용에 대한 내용은 아래의 내용에 자세히 담겨있습니다!

 

https://coding-review.tistory.com/484

 

데이터베이스 트랜잭션 심화

약 2년전 시작했던 프로젝트인 온라인 쇼핑몰 프로젝트는 제가 취직을 함으로써 종료되었고 더이상 건드리지 않았습니다. 하지만 사이드프로젝트로서 나중에 이직할때 도움이 되고자 코드 리

coding-review.tistory.com

 

이렇게 트랜잭션이 전파될 때 데이터베이스 내부에선 save point를 만들어서 롤백이 일어나면 전체 트랜잭션을 롤백하는것이 아니라 부분부분만 롤백해서 롤백에 대한 리소스를 최소화합니다. 

 

이렇게 save point가 생성되면 exclusive lock을 획득하게 되고 이 상황에서 UPDATE 쿼리가 발생한다면 MultiXact가 많이 발생하게 되고 cache hit의 비율이 낮아지게 되면 disk에서 직접 I/O를 유발하게 되고 이는 잠재적으로 성능의 저하로 이어집니다. 

 

또한, 저자분은 MultiXact가 전역적으로 관리되는 global lock이기 때문에 전혀 관련 없는 테이블에 접근하는 쿼리도 다같이 느려질 수 있다고 판단하셨습니다. 

 

즉, 이런 패턴은 Postgres에서 독이 되는 패턴이라고 할 수 있습니다. 

 

ItemService.java
@Transactional
public void select() {
	memberService.update();
}

MemberService.java
@Transactional
public void update() {
	// business logic
}

 

이런 nested transaction이 많아지면 많아질수록 MultiXact가 많이 발생하고 그렇게 되면 1분 남짓되는 long transaction에도 큰 영향을 끼칠 수 있습니다. 

 

왜냐하면 long transaction의 상황에선 vacuum이 정상적으로 작동하기 어렵고 vacuum에 의해 삭제되는 MultiXact가 많아지면 성능저하를 일으킬 수 있기 때문입니다. 

 

 

마치며

이런 패턴이 데이터베이스 구조상 악영향을 미칠 것이라고 생각도 못했습니다. 왜냐하면 nested transaction은 꽤나 빈번하게 발생하는 것인데말이죠... 

 

때문에, 저자는 Postgres에선 TransactionProvider에서 nested 속성을 false로 주어 해결할 수 있다고 하였습니다. 

 

제가 자주 사용하는 MySQL진영에선 어떻게 대처하는지 궁금해져서 조금 알아보니 MySQL은 Undo방식으로 MVCC를 구현하기 때문에 xmax에 많은 tid가 적히고 이를 관리하기위한 자료구조가 딱히 없어 문제되지 않는다고 하더군요. 

 

또한 MySQL에선 이때문인진 모르겠지만 기본적으로 nested 속성이 false로 설정되어있다고 하네요. 

 

그렇기 때문에 Postgres의 이 문제가 꽤나 치명적으로 다가옵니다. @Transactional은 만능이 아니라는 것을 다시한번 깨닫게 되면서 이번 포스팅 마무리짓도록 하겠습니다. 

 

 

출처

https://channel.io/ko/blog/bad-transactional?fbclid=IwZXh0bgNhZW0CMTEAAR0Atxir0WzSFcBMLmVjDXNLrUoiUl_qX93JPUPoo6EIRqP3irVoc22JEM4_aem_Gt65sDAFShcRmzIYKpla4w

 

@Transactional의 해로움

들어가며: 23.12.31 Database outage 새해를 하루 앞둔 12월 31일 자정을 얼마 지나지 않아, 채널톡 서비스에 약 15분 간 지속된 서비스 장애가 발생했습니다. 00:37 ~ 00:52 시간대에 메인 데이터베이스로 사

channel.io