일하면서 만난 문제 가운데 제일 비쌌던 것들은 오류가 난 것이 아니라 성공한 것처럼 보였던 것들이다. 터지면 로그가 남고 알림이 오고 그날 고치지만 조용히 실패하면 몇 달 뒤에 다른 문제를 조사하다 우연히 발견한다.
Table of contents
Open Table of contents
유형 1 — 반환값으로만 알리는 실패
라이브러리가 실패를 예외가 아니라 반환값으로 알리는 경우가 있는데 호출하는 쪽이 그 값을 안 받으면 실패를 모른다. Laravel 9 공식 문서가 적듯 Storage::put() 은 쓰기에 실패하면 false 를 돌려주며 디스크 설정에 'throw' => true 를 줘야 UnableToWriteFile 예외가 된다.
한 번은 이 때문에 product_images 에는 행이 있고 S3 에는 파일이 없는 상태가 수백 건 쌓였다. 두 가지 방법이 있었는데 Storage::put() 호출부마다 if (! …) 를 붙이는 것과 디스크 설정에 'throw' => true 를 켜는 것이다. 뒤쪽을 골랐다. 이유는 호출부를 하나 놓치는 것이 바로 이 실패의 원인이라서다. 대신 그때까지 조용히 넘어가던 쓰기 실패가 한꺼번에 예외로 터져 며칠간 알림이 몰리는 것을 감수했다.
유형 2 — 0건이 가리는 두 가지
없다는 것과 못 찾았다는 것은 다른 사실인데 결과는 똑같이 COUNT(*) = 0 으로 나온다. 이름 정규화가 다르거나 권한이 없어 오류가 버려졌거나 컬럼이 다른 값을 담고 있어도 결과는 같다.
그래서 0건이 나오면 그 조회가 무엇을 측정했는지부터 의심해야 한다. 확실히 존재하는 것으로 한 번 돌려 보면 조회 자체가 도는지 아닌지가 갈린다.
유형 3 — 조건이 전부 생략되는 자리
->when($sku, …) 처럼 선택적 조건만 덧붙이는 쿼리에서 값이 전부 비면 WHERE 자체가 사라진다. 중복 검사에 그런 코드가 있으면 아무것도 넣지 않았을 때 전부가 중복으로 판정된다.
반대 방향은 더 위험한데 대상을 고르는 쿼리에서 조건이 사라지면 전부가 대상이 된다. 어느 쪽이든 코드는 정상으로 돌고 오류도 나지 않는다.
유형 4 — 정상처럼 보이는 재시도
작업이 실패했는데 status 를 그대로 두면 계속 재시도되고 재시도가 도는 것 자체는 정상으로 보인다. 어떤 요청이 몇 분마다 다시 시도되며 attempts 가 만을 넘도록 실패하고 있었는데 아무도 몰랐다.
재시도가 돈다는 사실이 오히려 처리 중인 것처럼 보였기 때문이다. 반대로 실패를 완료로 기록하면 재시도 대상에서도 빠져 영영 처리되지 않으므로 이쪽이 더 나쁘다.
유형 5 — 갱신을 가로채는 캐시
배치가 데이터를 갱신하고 Cache::forget() 까지 하는데도 화면이 그대로인 경우가 있었다. 찾아보니 앞단에 apcu 로컬 캐시가 하나 더 있었고 그것이 있으면 무조건 먼저 반환하고 있었다.
배치 설계 전체가 무력화돼 있었는데 오류는 하나도 없었다. 무효화가 닿지 않는 층이 하나만 있어도 갱신 경로 전체가 무의미해진다.
검증 — 성공 개수와 대상 개수의 대조
찾는 방법은 몇 가지로 모인다. 대상 개수와 성공 개수를 대조하고 0건이 나오면 실패해야 하는 입력과 성공해야 하는 입력으로 각각 돌려 대조군을 만든다.
행위의 성공을 결과의 성공으로 삼지 않는 것이 핵심이다. 명령이 성공했다는 것과 의도한 상태가 됐다는 것은 다르므로 만든 개수가 아니라 다시 조회한 개수를 본다.
정리
Storage::put()처럼 예외 대신 반환값으로 실패를 알리는 함수가 있다- 0건은 없음과 못 찾음을 구분하지 못한다
when()으로 붙인 조건이 전부 생략되면WHERE자체가 사라진다- 대상 선택 쿼리에서 조건이 사라지면 전부가 대상이 된다
- 재시도가 도는 것은 정상처럼 보인다
- 실패를 완료로 기록하면 재시도에서도 빠진다
- 앞단 캐시 하나가 갱신 설계를 통째로 무력화한다
- 대상 개수와 성공 개수를 대조하고 0건에는 대조군을 돌린다