← 개발 로그 목록

website: OCI Email Delivery 메일 발송 기반 구축

/ 9분 분량 / 개발 로그

멋쟁이사자처럼 경희대 사이트 프로젝트에서 어드민 초대·비밀번호 재설정 기능(#74)이 실제로 메일을 보낼 수 있도록, OCI Email Delivery를 이용한 메일 발송 기반을 처음부터 구축한 PR입니다. dev 브랜치에 병합 완료됐습니다.

요약

이 PR은 코드 한 줄 짜는 것보다 인프라 설정과 DNS 등록, 그리고 그 과정의 의사결정을 문서로 남기는 데 방점이 찍혀 있습니다. noreply@likelion-khu.com이라는 주소로 메일을 보낼 수 있는 통로를 OCI Email Delivery로 만들고, SPF·DKIM·DMARC 검증까지 전부 PASS시킨 뒤 실제 발송 테스트로 검증했습니다. 추가된 코드는 149줄, 삭제는 0줄인데 그중 대부분이 설계 결정과 실행 과정을 정리한 infra/email-delivery.md 문서입니다.

배경 및 목적

#74는 어드민 초대와 비밀번호 재설정 기능인데, 이게 동작하려면 초대 메일과 재설정 링크 메일이 실제로 나가야 합니다. 그런데 지금까지 사이트에는 메일을 보낼 방법 자체가 없었습니다.

문제는 단순히 "아무 서버에서 noreply@likelion-khu.com 이름으로 메일을 쏜다"고 되는 게 아니라는 점입니다. Gmail 같은 수신 서버는 "이거 진짜 likelion-khu.com이 보낸 거 맞아?"를 확인하는 절차를 거치고, 이걸 통과 못하면 스팸함으로 가거나 아예 거부당합니다. 신생 서버가 직접 메일을 발송하면 IP 평판이 없어서 이런 일이 흔하게 일어납니다.

그래서 이 PR은 #75(메일 발송 기반 구축)라는 선행 이슈로, #74가 실제로 동작하기 위한 전제조건을 채우는 작업이었습니다.

구현 내용

발송 구조 선택

BE가 Gmail 같은 수신자 메일서버에 직접 SMTP로 붙어 보내는 것도 기술적으로는 가능하지만, PR 설명에서 이미 명확하게 이유를 밝히고 있습니다. 신규 클라우드 IP는 평판이 없어 스팸함행이거나 거부되기 쉽고, 포트 25 아웃바운드 차단이나 PTR·워밍업·불만 피드백 루프 관리까지 직접 하는 건 이미 풀린 문제를 재발명하는 셈이라는 겁니다. 그래서 이미 쓰고 있는 OCI의 Email Delivery(신규 계약 없이 월 3,000통 무료)를 대행 서비스로 선택했습니다.

핵심 구조는 이렇습니다.

BE(Spring Boot) --SMTP AUTH--> OCI Email Delivery 릴레이 --실제 발송--> 수신자 메일서버(SPF/DKIM/PTR 검증)

BE는 OCI 릴레이에 인증만 하고, 실제 인터넷 발송과 발신 IP 평판 관리, DKIM 서명은 전부 OCI 쪽에서 처리합니다.

IAM 계정과 권한 분리

dbclient에 적용했던 것과 동일한 최소권한 패턴을 그대로 가져왔습니다. 사람 계정을 재사용하지 않고 전용 IAM 유저 smtp-mailer를 새로 만들어서, 콘솔 로그인 없이 SMTP 자격증명만 발급받게 했습니다. 그룹 email-senders와 정책(Allow group email-senders to use email-family in tenancy)도 별도로 구성해서, 이 계정이 유출돼도 피해 범위가 "메일 발송"으로 국한되도록 했습니다.

stage/prod 분리는 발신주소나 Email Domain, DKIM까지 전부 나누지는 않고 SMTP 자격증명만 나눴습니다. 문서에 그 이유가 정리돼 있는데, DB나 Storage처럼 나누는 이유는 "실제 데이터 오염 방지"인데 메일은 stage가 보내도 결국 같은 실제 수신자에게 가기 때문에 주소를 나눠도 오염 자체는 못 막는다는 겁니다. 대신 자격증명만 나누면 "한쪽이 유출돼도 다른 쪽은 회전 안 해도 된다"는 실질적 이득을 최소 비용으로 얻을 수 있다고 판단했습니다.

DNS 등록 — SPF, DKIM, DMARC

받는 쪽이 "이 메일이 진짜 likelion-khu.com이 보낸 게 맞는지"를 확인하는 곳이 우리 도메인의 DNS입니다. 호스팅케이알(도메인 관리 업체)에 다음을 등록했습니다.

TXT  @                              v=spf1 include:ap.rp.oracleemaildelivery.com ~all
CNAME mail-tokyo-20260706._domainkey mail-tokyo-20260706.likelion-khu.com.dkim.nrt1.oracleemaildelivery.com
TXT  _dmarc                         v=DMARC1; p=none; rua=mailto:...

여기서 실제로 겪은 시행착오가 하나 있습니다. DNS 값이 정확한데도 DKIM 상태가 한동안 NEEDS_ATTENTION(NEED_DNS)로 남아있었던 것입니다. 생성 후 약 65분이 지나서야 ACTIVE로 전환됐는데, OCI가 SPF와 DKIM을 서로 다른 주기로 재확인하는 것으로 보인다고 문서에 남겨뒀습니다. DNS가 맞다면 조급해하지 말고 기다리면 된다는 걸 pm/docs/learnings.md에 재사용 가능한 통찰로 기록했습니다.

DMARC도 이슈의 필수 요구사항은 아니었지만 사칭 방지 차원에서 같이 등록했는데, 처음엔 p=none 상태에서도 DMARC 검증이 FAIL로 나와서 당황할 뻔했습니다. 확인해보니 정렬 실패가 아니라 단순히 DMARC 레코드 자체가 아직 없어서 발생한 FAIL이었고, 레코드 등록 후 재발송하니 SPF·DKIM·DMARC 전부 PASS로 확인됐습니다. 이것도 learnings에 "DMARC 레코드 부재 시 FAIL 오독 주의"로 남겼습니다.

gitleaks 도입

커밋 메시지를 보면 이 PR을 진행하는 중에 Copilot 리뷰가 문서 안에 있던 개인 이메일과 OCID 노출을 잡아준 일이 있었던 것 같습니다. 이걸 계기로 PR마다 시크릿·PII를 자동으로 스캔하는 gitleaks를 CI에 추가했습니다.

- name: gitleaks 스캔 (이 PR이 추가하는 커밋만)
  run: |
    docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
      detect --source /repo --config /repo/.gitleaks.toml \
      --log-opts="origin/{{ github.event.pull_request.head.sha }}" \
      --redact -v --exit-code 1

공식 gitleaks-action은 v2부터 조직 소유 레포에 유료 라이선스를 요구해서, 대신 오픈소스 CLI를 Docker로 직접 돌리는 방식을 택했습니다. 스캔 범위도 base 브랜치 대비 이 PR이 새로 추가하는 커밋으로 한정했는데, 그렇지 않으면 과거 히스토리에 이미 있던 항목 때문에 무관한 PR까지 막힐 수 있어서입니다.

.gitleaks.toml에는 기본 룰(AWS 키, 개인키, 각종 토큰)을 그대로 쓰면서 OCI OCID 패턴과 개인 이메일(gmail/naver/daum/kakao 등) 패턴을 추가 룰로 넣었고, noreply@likelion-khu.com 같은 조직 주소는 allowlist로 예외 처리했습니다.

환경변수 자리표시자 추가

infra/.env.stage.example, infra/.env.prod.example에 MAIL_* 자리표시자를 추가했습니다.

MAIL_SMTP_HOST=smtp.email.ap-tokyo-1.oci.oraclecloud.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USERNAME=
MAIL_SMTP_PASSWORD=
MAIL_FROM_ADDRESS=noreply@likelion-khu.com

실제 자격증명 값은 레포에 남기지 않고 infra/.env.email.local(gitignore 처리)에 보관했고, BE 팀에는 별도의 안전한 채널로 전달하기로 했습니다.

배운 점 및 개선점

가장 인상 깊었던 건 DKIM 활성화 지연 사례입니다. 설정을 다 맞게 했는데도 상태가 곧바로 반영되지 않으면 "뭔가 잘못됐나" 하고 조급해지기 쉬운데, 실제로는 그냥 서비스 쪽 재확인 주기의 문제였습니다. 이런 걸 겪고 나서 바로 흘려보내지 않고 learnings.md에 남겨둔 게 다음에 비슷한 상황을 마주칠 팀원(혹은 미래의 나)에게 도움이 될 것 같습니다.

또 하나는 DMARC FAIL을 "정렬 실패"로 오해할 뻔했던 부분입니다. 레코드가 아예 없어서 FAIL이 나는 경우와 실제로 SPF/DKIM 정렬이 깨져서 FAIL이 나는 경우는 원인이 완전히 다른데, 에러 메시지만 보고 성급하게 결론 내리지 않고 원인을 한 단계 더 파고든 게 맞았던 것 같습니다.

stage/prod 자격증명 분리 결정도 배울 점이 있었습니다. "무조건 다 나눠야 안전하다"가 아니라, 나눴을 때 실제로 얻는 이득이 무엇인지(이 경우엔 "한쪽 유출 시 로테이션 범위 축소")를 따져서 나눌 부분과 공유할 부분을 구분한 접근이 실용적이라고 느꼈습니다.

이 PR 자체는 여기서 끝나지 않고, BE에 SMTP 자격증명과 엔드포인트를 전달하는 작업과 JavaMailSender 실제 연동·재검증이 #74에서 이어질 예정입니다. stage 메일에 [STAGE 테스트] 같은 구분 표시를 붙이는 것도 BE 코드 관례로 남겨둔 요청사항이라, 다음 단계에서 실제로 반영되는지 지켜봐야 합니다.