@Transactional(readOnly = true) 를 안 붙이면 복제본으로 안 간다
읽기 복제본을 붙였는데 주 서버 부하가 그대로예요. 라우팅 설정도 맞는데요. 조회만 하는 코드도 읽기 전용이라고 선언하지 않으면 쓰기로 취급되는 이유와 그 한 줄이 바꾸는 것을 정리했어요.
조회만 하는 API 인데 DB 지표를 보면 쓰기 트랜잭션으로 잡혀요. 커밋 수가 조회 요청 수만큼 올라가고, 복제본으로 보내려고 했던 트래픽이 계속 주 서버로 갑니다.
읽기 전용 복제본을 추가했는데 주 서버 부하가 안 줄어요. 라우팅 설정을 몇 번이나 확인해도 맞게 돼 있고요.
원인은 애플리케이션이 모든 요청을 쓰기 트랜잭션으로 열고 있었다는 거예요. 조회만 하는 코드여도 프레임워크가 그렇게 열면 DB 는 쓰기 트랜잭션으로 취급합니다.
코드에는 SELECT 밖에 없는데 말이죠. 복제본을 붙이는 데 쓴 돈과 시간이 통째로 헛돈 상태입니다.
왜 그런가
트랜잭션이 읽기 전용인지는 코드가 무엇을 하느냐가 아니라 어떻게 선언했느냐로 정해져요.
선언하지 않으면 기본은 쓰기 가능한 트랜잭션이에요. DB 는 이 트랜잭션이 나중에 쓸지도 모른다고 가정하고 그에 맞는 준비를 해요. 트랜잭션 ID 를 할당하고, 복제 라우팅을 하는 계층은 이걸 보고 주 서버로 보냅니다.
읽기 전용으로 선언하면 여러 가지가 달라져요.
DB 쪽에서는 쓰기용 트랜잭션 ID 를 안 잡아요. 읽기만 하는 트랜잭션은 ID 없이도 스냅샷만 있으면 되거든요. 이게 앞서 본 ID 순환 압력을 줄이는 데도 기여해요.
프레임워크 쪽에서는 변경 추적을 건너뛸 수 있어요. 영속성 컨텍스트가 스냅샷을 떠 두고 나중에 바뀐 걸 찾아 쓰는 작업을 안 하니 메모리와 CPU 를 아껴요.
라우팅 계층에서는 복제본으로 보낼 수 있어요. 이게 실무에서 제일 큰 차이예요. 읽기 트래픽의 대부분을 복제본으로 옮길 수 있느냐가 여기서 갈립니다.
정리하면 읽기 전용은 성능 옵션이 아니라 선언이에요. 선언하지 않으면 시스템의 어떤 계층도 그게 읽기라는 걸 알 수 없습니다.
코드로 보면
읽기는 읽기라고 선언한다:
@Transactional
List<Order> list() { return repo.findAll(); }
-- 쓰기 트랜잭션으로 열린다
-- 라우터가 주 서버로 보낸다
@Transactional(readOnly = true)
List<Order> list() { return repo.findAll(); }
· DB 쪽에서 확인
SHOW default_transaction_read_only;
SET TRANSACTION READ ONLY; -- 세션에서 직접
· 복제본을 아예 읽기 전용으로 못 박아 두면
· 실수로 쓰기가 가도 그 자리에서 막힌다
ALTER DATABASE app SET default_transaction_read_only = on;✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
읽기 전용 선언이 실제로 무엇을 보장하는지는 계층마다 달라요. 이걸 구분해야 합니다.
프레임워크 수준의 읽기 전용은 대개 힌트예요. 변경 추적을 끄고 라우팅에 신호를 주지만, 그 안에서 쓰기를 시도하면 막지 않는 구현도 있어요. 그러면 조용히 써져요.
DB 수준의 읽기 전용은 강제예요. 쓰기를 시도하면 에러가 나요. 복제본은 물리 복제라면 애초에 쓰기가 불가능하고요.
그래서 안전을 원하면 DB 쪽에서도 걸어야 해요. 복제본 접속용 계정이나 데이터베이스에 읽기 전용을 기본값으로 두면, 라우팅이 잘못돼서 쓰기가 가더라도 그 자리에서 실패해요. 조용히 데이터가 갈리는 것보다 훨씬 낫습니다.
복제본으로 읽기를 보낼 때 반드시 따라오는 문제가 복제 지연이에요. 방금 쓴 데이터를 바로 읽으면 아직 안 보일 수 있어요. 주문하고 주문 목록으로 이동했는데 방금 만든 주문이 없는 상황이죠.
이건 라우팅 규칙으로 풀어요. 흔한 방식이 셋이에요. 쓰기 직후 일정 시간은 주 서버로 보내기, 사용자별로 마지막 쓰기 시각을 기록해 그 이후 일정 시간은 주 서버로 보내기, 그리고 읽기가 특정 시점 이후를 봐야 한다는 걸 명시적으로 요구하기.
그리고 읽기 전용이라고 트랜잭션이 길어도 되는 건 아니에요. 앞 편에서 본 대로 스냅샷을 붙잡아 청소를 막습니다. 리포트 세션이 대표적이에요.
처방
- 조회 전용 메서드에 읽기 전용을 선언하세요. 기계적으로 붙일 수 있는 규칙이라 가장 값싼 개선이에요.
- 기본값을 읽기 전용으로 두고 쓰는 곳만 예외로 하는 방식도 검토하세요. 서비스 계층 상위에 읽기 전용을 걸고, 쓰기 메서드에서만 덮어쓰는 구조요. 빠뜨림이 줄어듭니다.
- DB 쪽에서도 강제하세요. 복제본 접속 계정에 읽기 전용을 기본값으로 걸면 라우팅 실수가 조용히 넘어가지 않아요.
- 복제 지연에 대한 규칙을 정하세요. 쓰기 직후의 읽기를 어떻게 처리할지 정하지 않으면, 복제본을 붙이는 순간 "방금 만든 게 안 보인다"는 버그가 쏟아집니다.
- 읽기 전용 트랜잭션도 짧게 유지하세요. 리포트나 분석 세션의 트랜잭션 나이를 따로 보세요.
- 실제로 복제본으로 가고 있는지 확인하세요. 설정을 바꿨다고 트래픽이 옮겨 간 게 아니에요. 주 서버와 복제본의 쿼리 수를 비교하면 바로 드러납니다.
지금 확인해 보세요
정말 복제본으로 가고 있나:
· 주 서버와 복제본의 트랜잭션 수를 비교한다
SELECT datname, xact_commit, xact_rollback
FROM pg_stat_database WHERE datname = current_database();
복제본 쪽이 안 늘면 라우팅이 안 되고 있다
· 지금 세션이 읽기 전용인가
SHOW transaction_read_only;
· 복제본에 읽기 전용을 강제해 두었는가
SELECT name, setting FROM pg_settings
WHERE name = 'default_transaction_read_only';
· 읽기 전용인데 긴 트랜잭션이 있는지 (청소를 막는다)
SELECT pid, now()-xact_start AS age, left(query,50)
FROM pg_stat_activity
WHERE xact_start < now() - interval '1 minute';핵심 한 줄읽기는 읽기라고 선언해야 읽기다.