본문 바로가기
웹개발

Spring readOnly 트랜잭션을 리더로 보내면 생기는 read-after-write 문제

by 꼼냥냥 2026. 9. 13.
728x90

결제를 취소하고 저장 성공까지 떴는데, 화면이 다시 그려지면 취소 이전 상태가 보였다. 새로고침하면 정상이고 DB도 정상이었다. 화면만 틀렸다. 원인은 @Transactional(readOnly = true)를 리더 복제본으로 보내는 라우팅이었다. 저장 직후의 조회가 복제가 따라오기 전의 리더를 읽은 것이다. readOnly = true는 "읽기만 한다"가 아니라 "이 결과는 조금 낡아도 된다"는 선언이다. 방금 바꾼 것을 다시 보는 화면에는 붙이면 안 된다.

구조부터

이 앱은 읽기와 쓰기 DB를 나눠 쓴다. Spring의 AbstractRoutingDataSource로, 현재 트랜잭션이 읽기 전용이면 리더 복제본으로, 아니면 라이터로 보낸다.

protected Object determineCurrentLookupKey() {
    return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
         ? "reader" : "writer";
}

그래서 조회 메서드에는 관례적으로 이걸 붙인다.

@Transactional(readOnly = true)
List<Map<String, Object>> getPayment(Map<String, Object> param);

읽기 부하를 복제본으로 넘기는 흔한 구성이고, 대부분의 화면에서 잘 돈다. 리더를 처음 붙일 때는 트래픽이 안 가서 문제였는데, 가기 시작하자 이번엔 너무 잘 가서 문제가 됐다.

무엇이 어긋났나

저장 흐름이 이렇게 되어 있었다.

1. POST /suap/insertPaymentTrn   → {"message":"OK"}   (라이터에 씀)
2. GET  /suap/getPaymentDetail   → 화면 다시 그리기   (리더에서 읽음)

1번과 2번은 별개의 요청이다. 그리고 2번은 readOnly = true라 리더로 간다.

복제 지연이 몇십 밀리초만 있어도 2번이 1번 이전의 상태를 읽는다. 저장은 됐는데 화면은 옛날 값을 보여준다.

원장을 확인해 보니 DB는 정상이었다. 자동 저장이 한 번 더 돌면 올바른 값으로 갱신됐다. 데이터는 멀쩡하고 보이는 것만 틀린 상태였다.

이런 게 제일 다루기 어렵다. 사용자는 "저장이 안 된다"고 말하고, 개발자는 DB를 열어 "정상인데요"라고 답한다. 둘 다 맞다.

왜 재현이 잘 안 되나

개발 환경에는 보통 복제본이 없다. 하나의 DB에 읽고 쓰니 지연이 0이다. 개발에서는 절대 안 난다.

운영에서도 지연이 짧으면 안 난다. 부하가 몰릴 때, 큰 배치가 돌 때만 벌어진다. 그래서 "가끔 그래요"로 접수되고 재현이 안 돼서 닫힌다.

고치는 방법 두 가지

최소 수정 — 그 조회를 라이터로 보낸다

상세 조회 서비스 메서드에 읽기·쓰기 트랜잭션을 건다. 그러면 안에서 부르는 DAO 메서드가 readOnly를 새로 열지 않고 바깥 트랜잭션에 참여하므로 라이터를 탄다.

@Transactional                       // readOnly 를 빼면 writer 로 간다
public Map<String, Object> getPaymentDetail(Map<String, Object> param) {
    ...
}

싸다. 대신 그 조회의 읽기 부하가 라이터로 넘어간다.

더 나은 방법 — 왕복을 없앤다

애초에 문제는 쓰고 나서 다시 읽는 것이다. 쓰기 응답에 갱신된 상세를 같이 실어 보내면 두 번째 요청 자체가 사라진다.

// {"message":"OK"} 대신
return Map.of("message", "OK", "detail", updatedDetail);

같은 쓰기 트랜잭션 안에서 만든 값이므로 지연이 낄 자리가 없다. 프론트 협의가 필요하지만 구조적으로 옳다.

어디를 봐야 하나

이 패턴은 쓰기 직후 같은 데이터를 다시 조회하는 모든 화면에 있다.

찾는 방법은 단순하다. 프론트에서 저장 성공 콜백 안에 조회 호출이 있는 곳을 본다.

$.post("/xxx/insertYyy", data, function(res) {
    if (res.message === "OK") {
        loadDetail();      // ← 여기
    }
});

그 loadDetail이 타는 서비스가 readOnly = true면 후보다.

규칙으로 정리하면

@Transactional(readOnly = true)는 "이 메서드는 읽기만 한다"가 아니라 "이 결과는 조금 낡아도 된다"는 선언이다.

목록, 통계, 검색은 낡아도 된다. 방금 내가 바꾼 것을 다시 보는 화면은 안 된다.

읽기 전용을 붙이기 전에 한 번 묻는다 — 이 조회가 방금 쓴 것을 읽나?

정리

  • 읽기 전용 트랜잭션을 리더로 라우팅하면 쓰기 직후 조회가 옛 값을 읽는다.
  • 데이터는 정상이고 화면만 틀린다. 사용자와 개발자가 서로 다른 것을 보고 있다.
  • 복제본이 없는 개발 환경에서는 재현되지 않는다.
  • 최소 수정은 그 조회를 라이터로 보내는 것, 더 나은 방법은 쓰기 응답에 결과를 실어 왕복을 없애는 것.
  • readOnly = true는 "낡아도 된다"는 선언이다. 붙이기 전에 그게 맞는지 묻는다.

댓글