orders.status 가 잘못된 레코드가 여럿 있어서 일괄로 고쳐야 했다. 평소 원칙대로 OrderRepository.save() 를 도는 코드로 짰다가 실행 직전에 멈췄다.
Table of contents
Open Table of contents
증상 — 실행 직전에 본 리스너
Order 엔티티에는 @PostUpdate 로 상태 변경을 감지하는 OrderStatusListener 가 걸려 있었다. 상태가 바뀌면 연동된 외부 거래소마다 헤지 전송 작업을 ActiveMQ 에 넣는 코드였다.
정정 대상이 수백 건이고 각각 거래소가 여럿이니 실행하면 작업이 수천 개 쏟아진다. 정정 자체는 성공하지만 그 작업들이 외부로 나가면서 2차 사고가 된다.
원인 — 직접 질의가 건너뛰는 것
JdbcTemplate.update() 로 값을 바꾸면 엔티티를 거치지 않아 @PostUpdate 가 타지 않으므로 부수효과 없이 값만 바뀐다. Jakarta Persistence 명세대로 일괄 갱신은 DB 갱신에 직접 매핑되고 영속성 컨텍스트와 동기화되지 않는데 리스너는 엔티티 단위 사건이라 그 경로에서 뜨지 않는다. 평소에는 애플리케이션 계층을 거치는 것이 원칙이라 이 방식을 피한다.
그런데 이번에는 그 부수효과 자체가 문제였다. 원칙이 막으려던 것과 지금 막아야 하는 것이 반대 방향이었다.
판단 기준 — 전파를 원하는가라는 기준
두 가지 방법이 있었다. save() 를 쓰되 리스너에 일시 차단 플래그를 넣는 것과, JdbcTemplate 로 직접 갱신하는 것이다. 뒤쪽을 골랐다. 이유는 차단 플래그는 정정이 끝난 뒤 되돌리는 것을 잊으면 정상 흐름까지 멈추기 때문이다. 대신 리스너가 하던 이력 기록을 손으로 채워야 하는 것을 감수했다.
두 경우를 놓고 보니 기준이 하나로 정리됐다. 이 변경의 전파를 원하면 애플리케이션 계층이 맞고 원하지 않으면 직접 질의가 맞다. 정정 스크립트는 대개 후자인데 이미 잘못된 상태를 바로잡는 것이지 그 변경을 외부에 알리려는 것이 아니기 때문이다.
조치 — 우회한 만큼 손으로 채운다
직접 질의로 가면 리스너가 하던 일 중 필요한 것도 안 되는데 이 경우 audit_logs 기록이 그랬다. 그래서 리스너가 쓰는 형식을 그대로 재현해 이력을 손으로 넣었다.
전부 채울 필요는 없고 필요한 것만 채우면 된다. 우회는 부수효과를 통째로 끄는 것이므로 그중 무엇이 필요했는지를 따로 세어야 한다.
함께 확인한 세 규칙
절차를 확정하기 전에 각 단계를 코드로 검증하면서 관련 규칙 셋을 같이 확인했다. wallet_status = 'SUSPENDED' 가 서면 모든 작업 생성이 차단되므로 나중에 재등록하려면 그 상태를 먼저 풀어야 한다.
실행 분기가 아예 없는 작업 종류도 있었는데 만들어지기만 하고 아무것도 하지 않는 이력 표시용이었다. retry_count 상한이 셋이라 그보다 큰 값을 세우면 재시도 대상에서 영구히 빠지는데 이것을 모르면 왜 실행이 안 되는지를 엉뚱한 곳에서 찾게 된다.
정리
- 저장을
save()로 하면@PostUpdate리스너가 발화한다 - 일괄 정정에서는 그 발화가 2차 사고가 된다
JdbcTemplate직접 질의는 부수효과 없이 값만 바꾼다- 판단 기준은 이 변경의 전파를 원하는가 하나다
- 정정 스크립트는 전파를 원하지 않고 정상 흐름은 원한다
- 같은 갱신이라도 목적에 따라 도구가 반대가 된다
- 우회했으면 리스너가 하던 것 중
audit_logs같은 필요한 것을 손으로 채운다 - 절차를 확정하기 전에 각 단계를 코드로 검증한다