이미지 업로드를 외부 서비스로 넘기는 작업을 했다. 넘기기 전에 물어야 할 것이 있었는데 업로드가 실패하면 DB 가 어떻게 되느냐였다.
Table of contents
Open Table of contents
사전 준비 — 실패를 주입했다
코드를 읽는 것만으로는 확신이 안 섰다.
https://.../does-not-exist.jpg
does-not-exist.jpg 를 보내면 검증은 통과하고 다운로드 단계에서 실패한다.
URL 검증이 호스트만 보기 때문에 가능한 방법이었다. 실패 지점을 정확히 고를 수 있으니 어느 단계에서 깨지는지가 갈린다.
검증 — 등록 경로
먼저 v2RegisterProduct 쪽을 봤다.
public function v2RegisterProduct($data)
{
DB::transaction(function () use ($data) {
$this->inner($data); // 모든 행 저장
}); // 커밋
$this->flush(); // 외부 업로드
}
DB::transaction 이 끝난 뒤에 flush 로 외부에 올린다.
실패를 주입하니 이렇게 됐다.
DB 전부 롤백
외부 아직 아무것도 안 올라감
ROLLBACK 으로 되돌아가 정리할 것이 남지 않았다.
결과 — 수정 경로는 달랐다
modifyProduct 는 순서가 달랐다.
public function modifyProduct($product, $data)
{
$product->fill($data);
$product->save(); // 트랜잭션 없음
$this->flush(); // 외부 업로드
}
save 가 트랜잭션 없이 즉시 반영되고 그 뒤에 flush 가 돈다.
같은 실패를 주입했다.
미디어 보존 (롤백됨)
이름 바뀐 채로 남음 ← 여기
미디어는 되돌아갔는데 이름 같은 스칼라 필드가 바뀐 채로 남았다.
원인 — 트랜잭션 경계의 차이
v2RegisterProduct 와 modifyProduct 를 나란히 놓으니 이유가 보였다.
[등록] 전부 저장 → 커밋 → 업로드
업로드 실패 시 커밋 전이면 롤백
[수정] save() 로 즉시 영속 → 업로드
업로드 실패해도 save() 는 이미 끝남
flush 가 트랜잭션 안에 있느냐 밖에 있느냐가 갈랐다.
같은 서비스의 등록과 수정인데 원자성이 달랐다. 코드만 읽었으면 둘 다 트랜잭션이 있거나 둘 다 없을 것이라고 가정했을 것이다.
조치 — 한쪽만 손대기
결과가 갈렸으므로 할 일도 갈렸다.
[등록] 비이미지 실패는 구조상 이미 롤백으로 충족
외부 정리 로직을 따로 만들 필요 없음
[수정] 시작 시점 스냅샷 → 업로드 실패 처리에서 복원
v2RegisterProduct 쪽은 손대지 않았다.
이미 되는 쪽을 함께 고치면 바꿀 이유가 없는 코드를 건드리게 되고 그만큼 위험이 는다. 무엇을 안 고칠지 정하는 것도 이 조사의 결과였다.
선택지 — 단일 게이트
외부 위임 여부를 정하는 자리는 코드 전체에 하나였다.
if (BflowImageUploader::enabled()) { ... }
enabled 하나가 등록과 수정과 하위 생성과 전파와 본문 등록의 진입점을 모두 가른다.
이것이 양날이었다.
[불가능] 일부 경로만 새 방식으로 (혼합 모드)
[보장] 끄면 전 경로가 옛 방식으로 떨어짐
단계적 전환은 못 하지만 되돌리는 길이 하나로 확실하다.
혼합이 되면 어떤 경로가 새 방식인지 추적해야 하고 문제가 나면 어느 쪽인지부터 가려야 한다. 이미지 경로가 여섯 곳이라 그 추적 비용이 커서 전환이 불편한 대신 상태가 단순한 쪽을 골랐다.
정리
- 같은 서비스여도 등록과 수정의 원자성이 다를 수 있다
- 저장과 커밋 뒤에 외부로 나가면 실패해도 정리할 것이 없다
- 트랜잭션이 없으면 스칼라 필드가 먼저 영속된다
- 조치를 한쪽에만 하면 된다
- 구조가 이미 충족하는 쪽은 건드리지 않는다
- 실패를 주입해 확인한다
- 코드만 읽으면 두 경로가 대칭이라고 가정하게 된다
- 검증이 호스트만 보면 실패 지점을 정확히 고를 수 있다
- 단일 게이트는 부분 활성이 안 되는 대신 되돌리기가 확실하다
- 경로가 여럿이면 상태 단순함이 전환 편의보다 낫다