website: 유저 원인 아닌 발송 실패는 재시도 + 알람 원인 분류
모집 안내·초대·비번재설정 메일이 실패하면 email_log에 영구 FAILURE로 남고, 원인이 우리 쪽(OCI 릴레이 순간 장애·SMTP 인증 등)이든 수신자 쪽(주소 형식 오류)이든 구분 없이 #113 실패 임계치 알람이 똑같이 셌다. 이 구분을 만드는 작업이었다.
첫 커밋은 단순했다. AddressException이면 유저 쪽 원인이니 즉시 포기, 그 외는 재시도해서 SUCCESS로 수렴시키자는 것. 최대 3회, 2초 간격으로 재시도하고 email_log에는 중간 실패가 아니라 최종 결과 한 줄만 남기도록 했다. 재시도 중간의 일시적 실패까지 FAILURE로 쌓으면 결국 성공한 건까지 알람이 세어버려서 관측 소음과 "결국 성공했다"는 결과를 섞으면 안 된다는 판단이었다.
여기까지는 재시도 여부만 다뤘는데, 바로 다음 커밋에서 email_log.failure_cause(USER_CAUSED/SYSTEM_CAUSED)를 추가해 알람 카운트에서 유저 원인을 빼는 작업을 했다. 그런데 이 이분법이 문제였다. 이름은 "유저/시스템"이라고 해놓고 실제 판정 기준은 "AddressException이냐 아니냐"뿐이었다. 리뷰에서 "이게 실제로 유저/시스템을 구분하는 기준이 맞냐"는 지적을 받고 나서 다시 보니, 재시도 여부와 알람 대상 여부는 서로 다른 축인데 하나로 뭉뚱그려놓은 게 문제였다.
그래서 실제 발생 가능한 예외를 표로 펼쳐서 다시 설계했다.
if (e instanceof AddressException) return RECIPIENT_ADDRESS_INVALID;
if (isRecipientRejection(e)) return RECIPIENT_ADDRESS_REJECTED_BY_SERVER;
if (e instanceof MailAuthenticationException) return SMTP_AUTHENTICATION_FAILED;
if (e instanceof MailException) return SMTP_CONNECTION_FAILED;
if (e instanceof TemplateProcessingException) return TEMPLATE_RENDERING_FAILED;
if (e instanceof NullPointerException) return INVALID_INPUT;
return UNKNOWN_FAILURE;
이 순서가 중요한데, 자바 상속 관계상 MailAuthenticationException은 MailException의 하위 클래스라 MailException 체크를 먼저 두면 인증 실패까지 전부 거기 걸려서 더 구체적인 분류엔 영영 도달하지 못한다. 구체적인 타입을 항상 넓은 타입보다 먼저 검사해야 했다. SendFailedException만 예외적으로 e.getCause()까지 열어봐야 했는데, Spring이 이 체크 예외를 직접 안 던지고 MailSendException으로 한 번 감싸서 올리기 때문이다.
7가지로 나눈 다음엔 각 값마다 "재시도할지"와 "알람 울릴지"를 독립된 축으로 판단했다. INVALID_INPUT(호출자가 null을 넘긴 경우)이나 TEMPLATE_RENDERING_FAILED는 재시도해도 소용없지만 우리 코드 버그라 알람은 울려야 한다. INVALID_INPUT을 알람 대상에 넣은 이유도 짚어볼 만한데, 실제 유저가 폼에 값을 아예 안 넣고 제출했다면 컨트롤러 검증 단계에서 이미 걸러졌어야 한다. 여기까지 null이 넘어왔다는 건 우리 코드가 값을 제대로 안 채우고 호출한 버그이거나 DB에 이상 데이터가 있었다는 뜻이라, 유저가 지금 실수한 것과는 성격이 다르다고 봤다.
UNKNOWN_FAILURE도 처음엔 "안 중요해서 분류 안 한 것"처럼 보일 수 있는데 반대다. 정체를 모르는 예외를 안전한 쪽(재시도도 하고 알람도 울림)으로 잡아둔 것이다. 원인을 모르는 실패를 함부로 무시하면 진짜 장애를 놓칠 수 있어서다.
이렇게 재설계하고 나서 테스트를 다시 점검하다가 또 하나 걸린 게 있었다. mock으로 예외 객체를 직접 만들어 던지는 테스트는 classify()의 타입 분기 로직만 검증할 뿐, "진짜 그 상황에서 그 예외가 실제로 나오는가"는 별개 문제였다. 그래서 가능한 것부터 실제 상황으로 바꿨다. TEMPLATE_RENDERING_FAILED는 mock TemplateEngine 대신 존재하지 않는 템플릿 디렉터리를 가리키는 진짜 SpringTemplateEngine을 붙여서 Thymeleaf가 실제로 던지는 예외를 받았다. SMTP_AUTHENTICATION_FAILED는 Mailpit을 --smtp-auth-file로 띄워 특정 자격증명만 허용하게 하고 일부러 틀린 비밀번호로 접속해서 진짜 SMTP 535 응답을 받아냈다. 이건 Python smtplib로 먼저 이 설정이 진짜 거부를 일으키는지 재현해본 다음 Java 테스트로 옮겼다.
RECIPIENT_ADDRESS_REJECTED_BY_SERVER만은 끝까지 mock으로 남았다. Mailpit이 "들어오는 메일을 전부 캡처"하는 도구라 RCPT TO 거부 자체를 구조적으로 재현할 수 없기 때문이다. 이건 알려진 한계로 코드 주석과 문서에 그대로 남겼다.
테스트를 실제 상황으로 바꾸는 과정에서 OCI Email Delivery 공식 트러블슈팅 문서(SMTP 응답 코드표)를 찾아 대조하게 됐는데, 여기서 두 가지를 정정했다. 하나는 SMTP_AUTHENTICATION_FAILED를 재시도 대상에서 뺀 것이다. OCI 문서에 "421 Too many auth failures, try again later"라는 반복 인증실패 IP 단위 스로틀이 명시돼 있었다. 자격증명이 실제로 깨졌을 때 자동으로 여러 번 재시도하면 이 스로틀을 스스로 유발할 수 있고, 스로틀은 같은 IP에서 나가는 다른 정상 발송까지 함께 막을 위험이 있다. 한 통의 재시도 이득보다 전체 발신 경로를 막을 위험이 훨씬 커서 1번만 시도하고 즉시 포기하도록 바꿨다.
다른 하나는 이름 자체를 RECIPIENT_REJECTED_BY_SERVER에서 RECIPIENT_ADDRESS_REJECTED_BY_SERVER로 바꾼 것이다. 처음엔 "메일함이 없음/가득참"이라 생각했는데, OCI 문서엔 그런 응답이 없었다. 있는 건 553 Invalid email address, 즉 RFC-822 형식 재검증뿐이었다. infra/docs/email-delivery.md에 이미 정리해둔 계층 구분(①우리→OCI 접수, ②OCI→수신 메일서버)을 다시 보면, "메일함이 진짜 존재하는지"는 ②단계의 결과라 OCI Logging을 별도 조회해야 나오는 값이지 mailSender.send()가 던지는 예외로는 애초에 알 수 없는 정보였다. 그래서 "우리 클라이언트 쪽 검증이 놓친 주소 형식 문제를 OCI가 대신 잡아준 경우"로 의미를 정정했다.
이 발견을 계기로 infra/docs/RUNBOOK.md의 알람 대응 절차도 갱신했다. 조회 SQL에 failure_cause 컬럼을 추가하고, 수신자 원인은 이제 알람에서 아예 제외된다는 점, SMTP_AUTHENTICATION_FAILED는 재시도 없이 즉시 실패로 남아 자격증명을 고쳐도 그 메일 자체는 저절로 재발송되지 않는다는 점을 명시했다. OCI Alarm Definition 자체(임계치·윈도우·심각도)는 손대지 않았다 — 알람은 push-email-failure-metric.py가 계산해 미는 숫자만 보고, 이번에 바뀐 건 그 계산 로직뿐이라서다.
마지막으로 "실패 임계치 알람 말고, 지금 발송이 잘 되고 있는지는 어떻게 보나"라는 질문이 나와서 push-email-success-metric.py를 추가했다. push-email-failure-metric.py와 완전히 같은 패턴으로 5분 윈도우마다 SUCCESS 건수를 custom metric으로 push하는데, 알람은 걸지 않았다. 클럽 사이트라 발송이 간헐적이라서 "5분간 성공 0건"이 상시 정상 상태일 수 있고, 절대 임계치로는 판단이 안 되기 때문이다. OCI Monitoring Metrics Explorer에서 실패 메트릭과 나란히 그래프로 볼 수 있게만 해뒀다.
스키마 쪽은 email_log에 failure_cause nullable 컬럼을 추가하는 마이그레이션 하나만 붙였고, 이 컬럼 도입 이전의 과거 FAILURE 행이나 분류 실패 케이스는 NULL로 남는데 안전하게 알람 카운트에 포함시켰다. 놓치는 것보다 오탐이 낫다는 원칙을 여기도 그대로 적용했다.
돌아보면 첫 설계(USER_CAUSED/SYSTEM_CAUSED)가 틀렸던 이유는 이름이 주장하는 기준과 실제 코드의 판정 기준이 어긋나 있었기 때문이었다. 표로 다 펼쳐놓고 재시도 여부·알람 여부를 각각 물어보는 방식으로 바꾸고 나서야 이름과 판정 기준이 맞아떨어졌다. 그리고 mock으로 만든 예외가 통과하는 테스트와 실제 상황에서 그 예외가 정말 나오는지를 확인하는 테스트는 다른 걸 검증한다는 것도 이번에 다시 확인한 셈이다. 모집 안내 메일의 개별 재발송 수단이 없다는 점과 발송 이력 조회 UI가 없다는 점은 이번 범위 밖으로 남겨뒀다.