특정 판매자의 상품 1만 2천 건을 전부 SUSPENDED 로 바꾸는 artisan 명령을 만들어 돌렸다. 상태는 잘 바뀌었는데 channel_jobs 에 12만 건이 쌓였다.
Table of contents
Open Table of contents
원인 — 저장마다 도는 관찰자
명령은 대상을 chunk(500) 으로 나눠 돌면서 상태를 바꾸고 save() 하는 형태였다. ProductObserver 의 updated 가 상태 변경을 감지하면 연결된 채널 수만큼 UPDATE_PRODUCT 작업을 만들고 있었다.
상품 하나당 연결 채널이 평균 열 개이므로 곱하면 12만 건이 된다. 화면에서 한 건을 바꿀 때는 맞는 동작인데 그 설계가 대량 처리에서도 그대로 돌면서 건수만큼 반복된 것이다.
배경 — 만들어진 것과 필요한 것
이 작업에서 필요한 것은 각 채널에 SUSPEND 를 알리는 것이었다. 관찰자가 만든 것은 UPDATE_PRODUCT 였다.
유형도 다르고 갱신 작업은 상품 정보를 다시 밀어 넣으므로 판매중지 의도와 맞지도 않았다. 정리하면 필요 없는 것이 대량으로 생기고 필요한 것은 안 생긴 상태였다.
조치 — 조용히 저장하고 직접 만들기
두 가지 방법이 있었는데 건별로 saveQuietly() 를 쓰는 것과 쿼리 빌더의 update() 한 문장으로 바꾸는 것이다. 뒤쪽을 골랐다. 이유는 Laravel 공식 문서대로 대량 갱신에서는 모델을 조회하지 않아 saved 와 updated 이벤트가 아예 안 나가고 1만 2천 번의 저장이 한 문장이 돼서다. 대신 이벤트가 하던 일을 전부 직접 해야 하는 것을 감수했다.
필요한 작업은 조건을 걸어 INSERT ... SELECT 한 문장으로 만들었다. channel_product_id IS NOT NULL 인 것만 대상으로 했는데 채널 식별자가 없으면 상대에 등록된 적이 없어 중지할 대상 자체가 없기 때문이다.
주의 — 조용히 사라지는 이력
관찰자를 건너뛰면 이력도 안 남는데 이것이 조용한 손실이라 더 위험하다. 나중에 누가 언제 이것을 내렸는지에 답할 수 없게 된다.
그래서 product_histories 를 직접 삽입하되 admin_id 는 NULL 로 비워 뒀다. 실제로 사람이 한 것이 아닌데 특정 담당자 번호를 넣으면 감사 기록이 왜곡되기 때문이다.
검증 — 배치를 만들 때 보는 것
이 일 뒤로 대량 처리 명령을 만들 때 두 가지를 먼저 본다. 모델에 관찰자와 booted() 와 이벤트 리스너와 트레이트 중 무엇이 붙어 있는지를 하나씩 열어 저장 시 무엇이 일어나는지를 확인한다.
그리고 건별 저장을 한 문장으로 바꿀 수 있는지를 보는데 바꿀 수 있으면 모델 이벤트 문제가 함께 사라진다. 대신 이벤트가 하던 일 중 필요한 것을 관찰자 코드를 그대로 읽어 목록으로 만들어야 하고 기억으로 옮기면 빠진다. 실제로 처음에는 이력 삽입을 빠뜨렸다가 리뷰에서 지적받았다.
정리
- 관찰자는 저장 한 번마다 돈다
- 건별로 맞는 동작이 대량 처리에서 건수만큼 반복된다
- 만들어진 것과 필요한 것이 다를 수 있다
- 필요 없는 것이 대량으로 생기고 필요한 것은 안 생긴다
- 조용한 저장이나 쿼리 빌더로 모델 이벤트를 건너뛴다
- 건너뛰면 이벤트가 하던 일을 직접 해야 한다
- 배치 처리의 이력에는 담당자를 비워 둔다
- 필요한 처리 목록은 관찰자 코드를 읽어 만든다