목차
밤마다 도는 정리 작업이 어느 날 한 번 실패했는데, 아무도 모르고 지나간 적이 있나요? 하루에 한 번만 도는 작업은 한 번 실패하면 다음 기회가 24시간 뒤라서, 작은 실패가 하루치 일을 통째로 비워 버려요. 이 글은 그런 작업에 재시도를 넣을지, 넣는다면 몇 번을 얼마 간격으로 할지, 그래도 안 되면 어떻게 알릴지 정하는 순서를 정리해요.
혹시 이런 고민 있으신가요?
- 매일 새벽에 도는 삭제나 집계 작업이 있는데, 잘 도는지 확인해 본 적이 한 번도 없어요.
- 에러 알림에 "데이터베이스에 연결할 수 없어요" 같은 메시지가 가끔 보이는데, 다음 날 다시 보면 멀쩡해서 그냥 넘겼어요.
- 재시도를 넣고 싶은데, 몇 번을 얼마나 기다려야 하는지 기준이 없어요.
이 글을 읽으면
- 예약 작업의 실행 기록을 날짜별로 늘어놓고, 실패가 한 번인지 반복인지 가려낼 수 있어요.
- 재시도 횟수와 대기 시간을 실패가 풀리는 데 걸리는 시간에서 거꾸로 계산할 수 있어요.
- 재시도가 실패를 숨기지 않도록 기록하고 보고하는 방법을 정할 수 있어요.
1단계. 예약 작업이 언제 돌았는지 날짜별로 확인하기
먼저 알아야 할 용어가 하나 있어요. 크론(cron)은 "매일 새벽 3시에 이 함수를 실행해 줘"처럼 시각을 정해 두면 그때마다 자동으로 일을 시키는 장치예요. 알람시계와 비슷한데, 알람이 울리는 순간 한 번만 일을 시작한다는 점이 달라요. 놓치면 다음 알람은 내일이에요.
이 특징 때문에 예약 작업은 "실패했다는 사실"이 눈에 안 띄어요. 사람이 화면에서 누르는 기능은 실패하면 바로 불평이 들어오지만, 새벽 작업은 아무도 보고 있지 않아요. 그래서 고치기 전에 먼저 해야 할 일은 기록을 날짜별로 늘어놓는 거예요.
저희 쪽에서는 보관 기간이 지난 신청 정보를 지워 주는 작업이 이 경우였어요. 에러 추적 도구를 열어 같은 에러를 날짜순으로 정렬해 보니, 열흘 넘게 매일 같은 시각에 같은 메시지로 실패하고 있었어요. 메시지는 "데이터베이스 서버에 닿을 수 없어요"였고, 원인은 데이터베이스의 주소를 찾지 못했다는 것이었어요. 그 사이 지워졌어야 할 데이터는 한 줄도 지워지지 않았어요.
| 기록에서 보이는 모양 | 뜻하는 것 |
|---|---|
| 하루 건너 한 번씩 실패 | 환경이 불안정하거나 시간대가 겹치는 다른 작업이 있어요 |
| 매일 같은 시각에 같은 메시지 | 그 시각에 무언가가 일어나요. 재시도로 넘길 수 있는 경우가 많아요 |
| 어느 날부터 계속 실패 | 코드나 설정이 바뀐 거예요. 재시도로는 안 풀려요 |
| 성공 기록 자체가 없음 | 작업이 아예 안 도는 거예요. 실패 알림도 안 와요 |
마지막 줄이 가장 위험해요. 성공 기록을 남기지 않으면 "조용히 안 도는 작업"을 영영 못 찾아요.
TIP
예약 작업은 끝날 때 몇 건을 처리했는지 로그를 한 줄 남기게 하세요. 그 줄이 오늘 안 올라왔다는 사실이 가장 빠른 경보가 돼요.
2단계. 실패가 얼마나 오래 가는지 재서 재시도 값 정하기
기록을 봤다면 이제 "이 실패가 몇 초 만에 풀리는가"를 알아내야 해요. 재시도 횟수와 간격은 감으로 정하는 값이 아니라, 실패가 풀리는 시간에서 거꾸로 나오는 값이에요.
저희가 쓴 단서는 두 가지였어요. 첫째, 같은 컨테이너의 상태 확인 요청은 15초마다 같은 데이터베이스 주소로 새 연결을 맺는데 계속 정상이었어요. 그러니 주소를 못 찾는 구간은 작업이 도는 정각 전후의 아주 짧은 시간이었어요. 둘째, 지금까지 남은 실패는 모두 몇 초 안에 주소가 다시 잡히는 모양이었어요. 그래서 이 실패는 "잠깐 지나가는 문제"로 판단했어요.
여기서 선택지가 세 갈래로 나뉘어요.
| 선택지 | 장점 | 단점 |
|---|---|---|
| 작업 안에서 몇 번 더 시도하기 | 코드 몇 줄이면 돼요. 새 장치가 필요 없어요 | 앱이 그 사이 꺼지면 재시도도 사라져요 |
| 작업을 하루에 여러 번 돌리기 | 한 번 실패해도 다음 회차가 메워요 | 같은 일을 여러 번 해도 안전하게 짜야 해요 |
| 작업 대기열 도구 쓰기 | 재시도와 기록이 정식으로 지원돼요 | 새 장치를 운영해야 해요 |
저희는 첫 번째를 골랐어요. 이유는 단순해요. 실패가 몇 초짜리로 보였고, 새 장치를 들이지 않는 쪽이 가볍다고 판단했어요. 두 번째 방법도 쓸 만한데, 지우는 작업은 같은 조건으로 여러 번 돌려도 결과가 같아서 어울릴 것 같지만, 저희는 해 보지 않았으니 후보로만 적어 둘게요.
값은 이렇게 정했어요.
// 실패한 뒤 다시 시도하기까지 기다리는 시간. 두 번 더 시도해서 총 세 번이에요.export const RETRY_DELAYS_MS = [30_000, 120_000]처음 실패하면 30초를 기다리고, 또 실패하면 120초를 기다려요. 합쳐도 2분 30초라서 하루 한 번 도는 작업에는 부담이 없어요. 간격을 점점 늘린 건, 짧은 문제는 첫 대기에서 풀리고 조금 긴 문제는 두 번째 대기에서 풀리도록 하기 위해서예요.
여러분이 바꿀 곳은 숫자예요. 실패가 풀리는 시간이 몇 분이라면 간격도 몇 분으로 늘리고, 실패가 몇 시간 간다면 이 방법은 맞지 않아요. 그 경우는 재시도가 아니라 원인을 고쳐야 하는 문제예요.
참고
저희 코드는 실패의 종류를 가리지 않고 다시 시도해요. 가능하다면 연결 실패나 시간 초과처럼 "다시 하면 풀릴 수 있는 오류"에만 재시도하세요. 코드의 버그로 난 오류를 세 번 반복해도 세 번 다 실패할 뿐이고, 실패를 늦게 알게 돼요. 저희 코드에는 아직 이 구분이 없어서, 따라 하신다면 여러분 쪽에서는 넣는 편이 좋아요.
3단계. 재시도를 넣되 실패를 숨기지 않기
재시도의 가장 큰 위험은 실패를 삼키는 거예요. 재시도 코드를 넣고 나서 "알림이 안 오니 잘 되나 보다" 하고 안심하면, 다시 아무도 모르는 상태가 돼요. 그래서 세 가지를 지켜야 해요.
- 재시도에 들어갈 때마다 기록을 남겨요.
- 마지막 시도까지 실패하면 예외를 그대로 던져서 에러 추적 도구에 올려요.
- 성공하면 처리한 건수를 남겨요.
실패하면 두 번 더 시도하고, 세 번 다 실패하면 에러로 올려요
NestJS 로 쓴 코드는 이런 모양이에요. 크론 데코레이터가 달린 함수가 반복문으로 시도를 감싸요.
@Cron("0 3 * * *")async deleteExpired() { for (let attempt = 0; ; attempt++) { try { return await this.deleteOnce() } catch (error) { // 마지막 시도였다면 삼키지 않고 그대로 던져요 if (attempt === RETRY_DELAYS_MS.length) throw error const delay = RETRY_DELAYS_MS[attempt] Sentry.logger.warn("Expired lead deletion failed, retrying", { attempt: attempt + 1, delayMs: delay, error: (error as Error).name, }) await this.wait(delay) } }}핵심은 두 줄이에요. throw error 는 세 번 다 실패했을 때 에러를 평소처럼 위로 올려서 에러 추적 도구에 찍히게 해요. 재시도 중의 실패는 경고 기록으로만 남기고요. 이렇게 하면 에러 목록에는 "정말 사람이 봐야 하는 실패"만 남아요.
바꿔야 할 부분은 기록 항목이에요. 위 코드는 몇 번째 시도인지, 얼마를 기다리는지, 오류 이름을 남겨요. 오류 메시지 전체는 연결 주소나 내부 정보가 섞일 수 있어서 이름만 남겼어요. 여러분 환경에 맞게 어떤 값이 새어 나가도 되는지 먼저 정하세요.
TIP
재시도 로그에는 "몇 번째 시도에서 성공했는지"도 남기세요. 한 달 뒤에 로그를 보고 "대부분 첫 재시도에서 풀린다"는 사실을 확인하면 대기 시간을 줄여도 된다는 근거가 되고, 반대로 세 번째까지 자주 가면 간격을 늘려야 해요.
4단계. 재시도가 원인을 가리지 않게 따로 적어 두기
재시도를 넣으면 겉으로는 문제가 사라져요. 그래서 오히려 근본 원인을 잊기 쉬워요. 이 작업의 경우 원인은 작업이 도는 정각쯤 이름 풀이가 순간적으로 끊기는 것으로 보였고, 그 시각에 무엇이 일어나는지는 서버 운영 쪽에 있는 문제였어요. 코드를 고치는 쪽에서는 닿을 수 없는 자리였어요.
이럴 때는 두 가지를 분리해서 기록해요.
- 이 변경이 하는 일: 하루치 삭제가 통째로 빠지는 것을 막아요.
- 이 변경이 하지 않는 일: 정각에 연결이 끊기는 원인을 없애지 않아요. 그 일은 별도 할 일로 남겨요.
이 구분을 안 적어 두면 몇 달 뒤 누군가 "재시도가 있으니 문제없다"고 판단해서 근본 원인을 영영 안 보게 돼요. 반대로 재시도 로그가 쌓이는 모습은 근본 원인이 여전히 있다는 증거가 되기도 해요.
되돌리는 비용도 같이 적어 두세요. 저희 경우는 반복문을 지우고 한 번만 시도하는 함수로 되돌리면 끝이고, 저장된 데이터에는 아무 영향이 없어요. 재시도 코드는 작아서 되돌리기 쉬워요. 이 쉬운 정도가 "일단 넣어 보자"를 허락해 주는 근거였어요.
마지막으로 검증도 하세요. 연결이 실패하는 상황을 테스트에서 만들어 두 번 실패하고 세 번째에 성공하는지, 세 번 다 실패하면 예외가 올라오는지를 확인해요. 대기 시간은 테스트에서 가짜 타이머로 바꿔야 테스트가 몇 분씩 걸리지 않아요.
it("두 번 실패해도 세 번째에 성공하면 끝낸다", async () => { const service = new LeadRetentionService(prisma) service.wait = async () => {} // 기다리는 시간은 건너뛰어요 deleteOnce .mockRejectedValueOnce(new Error("연결 실패")) .mockRejectedValueOnce(new Error("연결 실패")) .mockResolvedValueOnce(3) await expect(service.deleteExpired()).resolves.toBe(3) expect(deleteOnce).toHaveBeenCalledTimes(3)}) it("세 번 다 실패하면 예외를 그대로 올린다", async () => { deleteOnce.mockRejectedValue(new Error("연결 실패")) await expect(service.deleteExpired()).rejects.toThrow("연결 실패") expect(deleteOnce).toHaveBeenCalledTimes(3)})두 번째 테스트가 특히 중요해요. 이 테스트가 없으면 나중에 누가 throw 를 지워도 모든 테스트가 통과하고, 실패는 조용히 사라져요. 지키고 싶은 약속은 "성공하는 경우"보다 "끝까지 실패하는 경우"에 있어요.
TIP
새벽 작업의 시각은 서버의 시간대 기준으로 돌아요. 한국 시간 새벽 3시를 원했는데 서버가 세계 표준시라면 낮 12시에 돌고 있을 수 있어요. 기록의 시각을 보고 어느 시간대인지 한 번은 확인하세요.
자주 묻는 질문
Q1. 재시도를 아예 무한으로 두면 안 되나요?
권하지 않아요. 원인이 코드나 설정에 있다면 영원히 실패하면서 서버 자원만 쓰고, 실패를 알리는 시점도 오지 않아요. 횟수에는 끝이 있어야 하고, 끝에 닿으면 사람에게 알려야 해요.
Q2. 재시도 때문에 같은 일이 두 번 실행되면 어떡하죠?
가장 중요한 질문이에요. 재시도는 일이 중간까지 되다가 실패한 경우에도 같은 코드를 처음부터 다시 돌려요. 지우기처럼 "조건에 맞는 것을 지운다"는 작업은 두 번 해도 결과가 같아서 안전해요. 반면 "메일을 보낸다"나 "금액을 더한다"는 두 번 하면 문제가 되니, 이미 했는지 표시를 남기는 장치가 먼저 필요해요.
Q3. 앱이 여러 대일 때는 어떻게 되나요?
각 앱이 같은 시각에 같은 작업을 시작하므로, 재시도 이전에 "한 번만 돌게 하는 장치"를 따져야 해요. 이 글의 방법은 앱이 한 대이거나 작업이 두 번 돌아도 안전한 경우에 맞아요.
정리
- 하루 한 번 도는 작업은 한 번의 실패가 하루치 일을 비우니, 성공 기록부터 남기세요.
- 재시도 횟수와 간격은 실패가 풀리는 시간에서 거꾸로 정해요. 몇 초짜리 문제에는 30초와 120초면 충분했어요.
- 재시도 중의 실패는 경고로 남기고, 마지막 시도까지 실패하면 예외를 던져 에러로 보고하세요.
- 재시도는 원인을 없애는 게 아니니, 근본 원인은 별도로 적어 두세요.
- 두 번 실행돼도 안전한 작업인지 먼저 확인하세요.
조용히 도는 작업일수록 실패했을 때 소리가 나게 만들어 두는 것이 가장 오래 가는 방법이에요.