결제를 취소하고 저장 성공까지 떴는데, 화면이 다시 그려지면 취소 이전 상태가 보였다. 새로고침하면 정상이고 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는 "낡아도 된다"는 선언이다. 붙이기 전에 그게 맞는지 묻는다.
'웹개발' 카테고리의 다른 글
| HTTPS 리다이렉트 무한 루프 — 앱이 원래 프로토콜을 모르기 때문이다 (0) | 2026.09.28 |
|---|---|
| 프론트 검증은 UX지 보안이 아니다 — 서버 검증 누락이 남긴 것 (0) | 2026.09.28 |
| 차단했는데 12시간을 더 쓴다 — 세션 인증에는 무효화가 없다 (0) | 2026.09.21 |
| 메서드 이름이 트랜잭션을 결정한다 — Spring AOP 일괄 트랜잭션의 함정 (0) | 2026.09.17 |
| npm ci와 npm install 차이 — 속도가 아니라 락 파일을 대하는 태도다 (0) | 2026.09.14 |
댓글