김솔비 블로그
기술 블로그

스마트스토어가 안 해주는 디지털 상품 자동발송, 직접 만들었다

15분 읽기

들어가며

나는 네이버 스마트스토어에서 카카오톡 테마, PPT 템플릿 같은 디지털 상품을 판다. 그런데 스마트스토어는 무형 상품의 자동 발송을 지원하지 않는다. 테마 파일 하나가 팔려도 내가 주문을 확인하고, 구매자 연락처를 찾고, 파일이나 링크를 손으로 보내야 했다. 주문이 늘수록 그대로 노동이 된다.

단순히 "주문이 오면 링크를 보낸다"로 끝나는 문제도 아니었다.

  1. 구매자마다 받고 싶은 채널이 다르다. 카카오톡, 이메일, 문자. 스마트스토어에서는 이걸 옵션(수령 방법: 이메일)으로 받을 수밖에 없다.
  2. 네이버는 구매자 연락처를 온전히 주지 않는다. 개인정보 보호 때문에 ki***@naver.com처럼 가려서 내려온다. 그래서 옵션에 "받으실 이메일을 입력해 주세요"를 넣고, 구매자가 직접 적은 값을 써야 한다.
  3. 디지털 상품은 유출되면 끝이다. 누가 흘렸는지 추적할 수단이 필요하다.
  4. 옵션마다 파일이 달라야 한다. 컬러: 핑크를 산 사람과 컬러: 옐로우를 산 사람에게 같은 파일을 보낼 수는 없다.

기성 발송 솔루션도 있었다. 하지만 옵션별 파일 분기, 유출 추적, 다운로드 횟수 제어를 원하는 대로 넣을 수 없었다. 무엇보다 내 상품 파일을 남의 서버에 올려 두고 싶지 않았다. 그래서 직접 만들었다. 이름은 Digital Dispatch Center다.

항목내용
형태1인 개발, 실제 운영 중인 스토어에 붙어서 돌아간다

무엇을 만들었나

전체 흐름

[네이버 스마트스토어 결제]
        │
        ├─ ① 웹훅                 ← 실시간
        └─ ② 폴링 스케줄러          ← 웹훅 누락 대비 안전망
                    │
                    ▼
        [주문 정규화]
        · 가려진 값 폐기, 믿을 만한 연락처 선별
        · 구매자가 고른 수령 방법(채널) 파싱
                    │
                    ▼
        [자동발송 규칙 엔진]
        트리거(결제완료/배송시작/배송완료/구매확정/주문취소/수동)
          × 채널(카카오 알림톡/이메일/문자) × 메시지 템플릿 × 디지털 컨텐츠
        · 즉시 발송 또는 N분/시간/일 뒤 예약 발송
                    │
                    ▼
        [다운로드 토큰 발급]
        · 32자리 랜덤, 만료일·최대 다운로드 횟수 제한
        · 옵션별 매핑이 있으면 그 옵션의 파일로 발급
                    │
                    ▼
        [채널 발송] 카카오 알림톡 → 실패하면 문자로 대체 발송
                    │
                    ▼
        [고객 다운로드]
        · 다운로드 시점에 주문번호 워터마크 주입 (유출 추적, 구매자 개인정보는 넣지 않음)
        · 취소·반품되면 토큰 즉시 만료

그 밖에 전화번호 인증으로 구매 내역을 조회하는 고객 포털, 매니저 여러 계정, 발송 통계, 관리자 알림(새 주문, 발송 실패)이 있다. 다운로드할 때 파일에 주문번호를 심는 유출 추적 워터마크도 있는데(구매자 이름·연락처는 넣지 않는다), 이건 분량이 커서 별도 글로 나눴다.

설계에서 고민한 것들

웹훅만 믿지 않는다

웹훅, 1분 주기 폴링, 관리자용 수동 "주문 불러오기"(최대 30일 백필)를 같이 돌린다.

이유: 웹훅은 유실된다. 재배포로 서버가 내려간 사이에 온 주문은 영영 모른다. 디지털 상품은 "결제했는데 안 옴"이 곧바로 문의로 이어진다.

대가: API 요청 한도를 훨씬 빨리 쓴다. 그래서 토큰 캐싱과 쿨다운이 따라왔다.

  • 처음엔 메서드마다 OAuth 토큰을 새로 받았다. 1분마다 도는 스케줄러가 주문 N건을 처리하면 토큰 발급만 N번이었다.
  • 지금은 만료 60초 전까지 토큰을 재사용한다.
  • 429를 받으면 90초 동안 호출 자체를 건너뛴다. 막힌 상태에서 계속 두드리면 차단이 더 길어진다.

추측해서 보내지 않는다

가려진 이메일이나 전화번호(ki***@naver.com, 010-****-1234)는 무조건 버린다. 보낼 곳을 못 찾으면 발송을 보류하고 관리자에게 알린다.

추측해서 만든 주소로 보내면 엉뚱한 사람에게 상품 다운로드 링크가 나간다. 안 보내는 것보다 나쁜 결과다.

연락처에는 신뢰도 순서를 매겼다.

  1. 구매자가 옵션에 직접 입력한 값 (가장 믿는다)
  2. 주문서의 수령인 정보
  3. 네이버 계정에 등록된 연락처 (가장 안 믿는다)
java
// 옵션에 직접 남긴 번호 → 배송정보(수령인) → 계정 고정값 순으로 신뢰한다. String buyerPhone = ContactExtractor.extractPhone(inputOption); if (isBlank(buyerPhone)) buyerPhone = ContactExtractor.extractPhone(productOption); if (isBlank(buyerPhone)) buyerPhone = extractReceiverPhone(productOrder); boolean buyerPhoneTrusted = !isBlank(buyerPhone); if (!buyerPhoneTrusted) { // 가려진 값(010-****-1234)은 발송처로 쓸 수 없으므로 폐기 — 추측값으로 대체하지 않는다. String raw = order.path("ordererTel").asText(""); buyerPhone = !raw.contains("*") ? raw : ""; }

그리고 낮은 신뢰도의 값이 높은 신뢰도의 값을 덮어쓰지 못하게 막았다(buyerPhoneTrusted). 나중에 자동 동기화가 돌면서 이미 맞게 저장된 번호를 계정 고정값으로 되돌리는 버그를 막기 위해서다.

발송 로직을 if-else가 아니라 규칙 데이터로

AutomationRule 엔티티 하나가 트리거 × 채널 × 템플릿 × 컨텐츠 × 지연 시간 × 수신자 모드를 담는다. 관리자가 화면에서 조합한다.

이유: "결제완료면 이메일, 배송완료면 카톡" 같은 분기를 코드에 박으면 판매 방식이 바뀔 때마다 배포해야 한다.

대가: 규칙 여러 개가 동시에 걸릴 때 무엇이 실행되는지 코드만 봐서는 보이지 않는다. 중복 발송 위험이 생겨서, 나중에 옵션 연동 모드(상품 하나에 규칙 하나 + 채널별 템플릿)를 따로 만들었다.

다운로드 횟수는 DB의 조건부 UPDATE로 센다

java
@Modifying(clearAutomatically = true) @Query("UPDATE DownloadToken t SET t.downloadCount = t.downloadCount + 1 " + "WHERE t.id = :id AND t.downloadCount < t.maxDownload") int incrementDownloadCountIfAllowed(@Param("id") Long id);

"읽고 → 검사하고 → 저장"하면, 두 요청이 동시에 들어올 때 둘 다 검사를 통과한다(경쟁 상태). 조건부 UPDATE는 DB가 한 번에 판단한다. 바뀐 행이 1이면 다운로드 슬롯을 확보한 것이고, 0이면 그 사이에 한도가 찬 것이다.

덤으로 배운 것도 있다. clearAutomatically = true가 없으면 벌크 UPDATE 뒤 같은 트랜잭션에서 다시 조회해도 영속성 컨텍스트에 남은 옛 값이 나온다. DB에는 반영됐는데 코드에서는 안 바뀐 것처럼 보인다.

옵션 매칭은 "포함 + 최장 일치"

주문 옵션 텍스트에 저장된 옵션명이 포함되면 매칭한다. 비교 전에 공백을 지우고 소문자로 맞춘다(컬러: 핑크와 컬러:핑크). 여러 개가 걸리면 가장 긴 이름을 고른다. 핑크와 핫핑크가 함께 등록된 상품에서 "핫핑크" 주문이 "핑크" 매핑으로 새는 걸 막기 위해서다.

실제로 터진 것들

모든 주문의 전화번호가 똑같았다

증상: 발송은 되는데, 주문마다 다른 사람인데도 전화번호가 전부 같았다.

원인: 네이버 응답의 ordererTel을 쓰고 있었다. 이름만 보면 "주문자 전화번호"인데, 실제로는 네이버 계정에 등록된 고정 연락처였다. 구매자가 주문할 때 입력하는 수령인 연락처는 완전히 다른 필드였고, 나는 그걸 아예 보지 않고 있었다.

해결: 앞에서 말한 신뢰도 순서(옵션 입력값 → 수령인 정보 → 계정 연락처)로 고르게 했다.

배운 것: API 필드 이름은 의미를 보장하지 않는다. 실제 주문 데이터를 눈으로 확인하기 전까지는 전부 추측이다.

수령인 정보가 "나중에" 채워진다

증상: 위 문제를 고쳤는데도 일부 주문은 여전히 계정 번호로 저장됐다.

원인: 주문이 생긴 직후에는 네이버 쪽에 배송 정보가 아직 반영되지 않은 상태였다. 몇십 초에서 몇 분 뒤에야 채워진다.

해결: 상태 동기화가 돌 때마다, 더 믿을 만한 값이 새로 보이면 저장된 주문의 연락처를 자동으로 고친다. 단, 신뢰도가 더 높을 때만 고친다. 아니면 거꾸로 되돌리는 버그가 된다.

배운 것: 외부 시스템의 데이터는 나중에 맞춰지는 것(최종 일관성)을 가정해야 한다. 한 번 읽었다고 끝이 아니다.

있는 가드와 동작하는 가드

증상: 로컬에서 네이버 API를 부르면 BCrypt에서 Invalid salt version이라는 뜬금없는 예외가 났다.

원인: "자격증명이 설정되지 않았으면 호출하지 않는다"는 가드가 있었다. 그런데 설정 파일의 자리표시자 값과 코드가 비교하는 문자열이 서로 달랐다. 조건이 절대 참이 되지 않아서 가드가 아무것도 막지 못했다. 게다가 어떤 메서드에는 secret 확인 자체가 빠져 있었다.

해결: isConfigured() 도우미 하나로 통일했다.

배운 것: 있는 가드와 동작하는 가드는 다르다. 방어 코드는 일부러 조건을 만족시켜 발동하는 걸 눈으로 봐야 믿을 수 있다. 같은 검사가 호출하는 곳마다 복붙돼 있으면 반드시 하나는 틀린다.

반품·취소를 통째로 놓치고 있었다

증상: 판매자센터에는 "반품완료"인데 내 화면에는 여전히 "결제완료"였다. 다운로드 링크도 살아 있었다.

원인: productOrderStatus에서 CANCEL, RETURN 같은 단어만 찾고 있었다. 그런데 네이버는 반품이 접수 단계일 때 productOrderStatus는 그대로 두고, 진행 상태를 claimStatus라는 별도 필드에 넣는다.

해결: claimStatus를 상태 문자열에 합쳐서 같이 잡히게 했다. 단 CANCEL_REJECT(반품 거절, 즉 정상 주문)는 뺐다. 안 그러면 "CANCEL"에 걸려 오탐이 난다.

java
// 반품이 '접수' 단계면 productOrderStatus는 그대로 두고 claimStatus에만 들어온다. // 단 CANCEL_REJECT(반품 거절 = 정상 주문)는 합치면 "CANCEL" 키워드에 걸려 오탐이 난다. if (claimStatus.isEmpty() || claimStatus.toUpperCase().contains("REJECT")) { return productOrderStatus; } return productOrderStatus + "/" + claimStatus;

배운 것: 상태가 필드 하나로 표현될 거라는 가정이 틀렸다. 그리고 부분 문자열 매칭은 부정형(REJECT, DENY)에서 반드시 사고가 난다.

로그인했는데 로그인 화면으로 튕긴다

증상: 세션은 살아 있는데, 북마크나 이메일 링크로 들어오면 로그인 화면이 떴다.

원인: 쿠키가 SameSite=Strict였다. 이 설정에서는 주소창 직접 입력, 북마크, 외부 링크 같은 최상위 탐색에도 쿠키가 실리지 않는다.

해결: Lax로 바꿨다. 같은 사이트 안의 정상적인 이동에는 쿠키가 실리고, CSRF의 실제 위협인 "다른 사이트에서 자동으로 보내는 POST"는 여전히 막는다.

배운 것: 보안 설정을 가장 빡빡한 값으로 두는 게 늘 옳지는 않다. 위협 모델을 알고 고르는 것과 최댓값을 고르는 것은 다르다.

고객에게 http://localhost:8080/… 링크가 나갔다

원인은 두 가지였다.

  1. 스케줄러에는 HTTP 요청이 없다. 예약 발송은 요청 없이 돌기 때문에 요청의 Host로 주소를 만들 수 없다. 공개 도메인을 설정값으로 따로 줘야 했다.
  2. Cloudflare Tunnel 뒤에서는 서버가 평문 HTTP만 본다. forward-headers-strategy 설정이 없으면 링크가 전부 http://로 나갔다.

배운 것: 내가 보는 URL과 고객이 받는 URL은 다른 문제다. 리버스 프록시 뒤에서는 특히 그렇다.

버튼에 {다운로드링크}가 그대로 찍혔다

원인: 템플릿 본문은 변수를 치환했는데 버튼 URL은 치환하지 않았다. 템플릿 마법사가 버튼 URL을 {다운로드링크} 형태로 저장하는데, 발송 직전 렌더링 경로에서 버튼만 빠져 있었다.

배운 것: 같은 데이터가 "본문"과 "버튼"으로 나뉘어 저장되면, 한쪽에만 적용되는 변환이 반드시 생긴다. 테스트로 고정해 두지 않으면 다시 깨진다.

그 밖에도

  • gmail의 "mail" 때문에 카카오톡 선택이 이메일로 인식됐다. 구매자가 수령 방법: 카카오톡 / abc@gmail.com이라고 적으면 채널 키워드 검사에서 mail이 걸렸다. 키워드 검사 전에 이메일 주소를 먼저 지워서 해결했다.
  • DELIVERY_COMPLETION을 "배송시작"으로 읽었다. DELIVER가 들어 있으면 배송시작으로 판정했는데, 이 값은 DELIVER와 COMPLET을 둘 다 품고 있다. 완료를 먼저 검사하도록 순서를 바꿨다.
  • 파비콘을 바꿨는데 옛 색으로 보였다. 브라우저는 파비콘을 아주 오래 캐시한다. 모든 템플릿의 파비콘 링크에 ?v=2를 붙였다. 고객이 보는 화면까지 포함해서다.
  • 이미 진행된 주문을 자동발송하면 안 된다. 놓친 주문을 백필하면 이미 "구매확정"인 주문이 딸려 온다. 네이버 데이터만으로는 "한 번도 안 보낸 주문"과 "보냈는데 우리 기록만 사라진 주문"을 구분할 수 없다. 그래서 자동발송하지 않고 경고만 남기고, 관리자가 확인한 뒤 보내게 했다. 구분할 수 없는 상황에서 자동으로 움직이면, 틀렸을 때 비용이 큰 쪽으로 틀린다.

옵션별로 다른 파일 보내기

가장 최근 작업이다. 상품 하나에 컬러: 핑크, 컬러: 옐로우 같은 옵션이 있으면 옵션마다 다른 파일을 보내야 한다.

설계 판단

  1. 옵션 목록을 우리 DB에 복사해 두지 않았다. 원본은 네이버 스토어이고, 판매자는 언제든 옵션을 바꾼다. 복사본은 금방 낡는다. 그래서 "관리자가 실제로 파일을 지정한 옵션"만 매핑으로 남긴다.
  2. 화면을 열 때마다 네이버 API를 부르지 않는다. 요청 한도가 있으니 "옵션 불러오기" 버튼을 눌렀을 때만 조회한다.
  3. 파일 경로를 매핑 테이블에 두지 않았다. 파일을 올리면 디지털 컨텐츠를 자동으로 만들어 연결한다. 파일을 찾아가는 길을 토큰 → 디지털 컨텐츠 하나로만 유지하려는 것이다. 처음엔 file_path 컬럼을 설계했지만, 쓰이지 않는 컬럼은 나중에 "이건 왜 안 쓰이지" 하는 혼란만 준다고 보고 지웠다.
  4. 같은 옵션에 파일을 다시 올려도 기존 컨텐츠를 덮어쓰지 않는다. 이미 발송된 링크가 옛 컨텐츠를 가리키고 있어서, 덮어쓰면 어제 산 고객이 받는 파일까지 바뀐다. 새 컨텐츠를 만들고 매핑만 옮긴다.

실제 응답을 찍어 보니

실제 운영 상품의 옵션 응답을 찍어 보니 옵션이 두 종류였다.

optionSimple: { groupName: "수령 방법", name: "이메일" }            ← 선택형
optionCustom: { groupName: "상품을 받을 이메일 주소를 입력해주세요" } ← 직접입력형

직접입력 옵션은 이름이 선택지가 아니라 질문 문구였다. 답은 주문마다 다르다. 그런데 내 화면은 여기에도 파일을 매핑할 수 있게 열어 두고 있었다. 질문 문구에 파일을 걸면, 그 항목이 있는 모든 주문이 같은 파일을 받는다. 기본 설정을 바꾼 것과 다름없다.

그래서 직접입력 옵션은 목록에는 보여 주되(관리자가 "이 옵션은 왜 안 보이지" 하지 않게), 매핑 대상에서는 뺐다. 그리고 이 실제 응답을 회귀 테스트로 박제했다. 선택형에는 price 필드가 아예 없고, 직접입력형에는 name 없이 groupName만 온다는 것까지.

문서와 샘플 JSON만 보고 짠 파싱은 절반만 맞는다. 실제 응답 한 번이 설계 결함 하나를 잡아냈다.

운영: 라즈베리파이와 자동 롤백

왜 라즈베리파이인가

  • 고정 비용이 0원이다.
  • 상품 파일이 내 손을 떠나지 않는다.
  • 외부에는 Cloudflare Tunnel로 노출해서 공유기 포트를 열지 않는다.

대가: 기동이 느리다(30초 이상). 그래서 배포 헬스체크를 5초 간격 24번, 최대 2분까지 기다리게 했다.

배포와 자동 롤백

GitHub Actions가 푸시마다 테스트 → jar 빌드 → Tailscale 접속 → 배포 → 재시작 → 헬스체크를 한다. 새 jar가 헬스체크를 통과하지 못하면 백업해 둔 이전 jar로 되돌린다.

bash
for i in $(seq 1 24); do sleep 5 if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/ | grep -qE "^(200|302|401|403)$"; then echo "DEPLOY_OK"; exit 0 fi done echo "New jar failed health check — rolling back to $BACKUP" cp "$BACKUP" app.jar && systemctl --user restart digital-download && exit 1

1인 개발에서 가장 무서운 건 내가 자는 사이 서비스가 죽어 있는 것이다. 그걸 코드로 막았다. 10분마다 가동 상태도 확인해서, 죽으면 GitHub 이슈를 자동으로 열고 살아나면 자동으로 닫는다.

남아 있는 부채: Flyway를 안 썼다

스키마는 ddl-auto: update로 만든다. db/migration/V*.sql 파일은 기준 문서로만 남긴다.

이유: 초기에는 스키마가 하루에도 몇 번씩 바뀌었고, 혼자 쓰는 DB였다. 마이그레이션 도구를 세팅하는 비용을 뒤로 미뤘다.

이자를 받아 간 순간: 최근에 진단용 테스트를 짜다가 깨달았다. 운영 프로필로 Spring 컨텍스트를 띄우는 것만으로 운영 DB에 테이블이 생겨 버린다. 결국 컨텍스트를 띄우지 않는 방식으로 우회했다. 지금은 운영 스키마를 자동으로 바꾸는 구조 자체가 무섭다.

회고

잘했다고 생각하는 것

  1. 주석에 "무엇"이 아니라 "왜"를 남겼다. 코드에 한글 주석이 많은데, 대부분 "이 값은 이래서 못 믿는다", "예전엔 이렇게 했다가 이런 사고가 났다" 같은 기록이다. 몇 주 전의 나는 완전히 남이다. 특히 외부 API의 함정은 코드만 봐서는 복원할 수 없다.
  2. 실패를 조용히 삼키지 않았다. 발송에 실패하면 상태를 ORDERED로 남겨 다시 보낼 수 있게 하고, 이벤트 로그에 남기고, 알림까지 보낸다.
  3. 테스트를 동작 명세로 썼다. 테스트 상당수가 "이 버그가 다시 나면 안 된다"는 회귀 테스트다. 실제 API 응답을 그대로 박아 둔 테스트가 특히 값어치를 했다.
  4. 자동 롤백 배포. 헬스체크 실패 시 자동 복구, 다운 감지 시 이슈 자동 생성으로 "내가 없을 때"를 대비했다.

아쉬운 것

  1. Flyway를 처음에 넣었어야 했다. 초기 속도를 벌어 준 대가를 지금 치르고 있다.
  2. UI를 너무 일찍, 너무 자주 갈아엎었다. 커밋 기록을 보면 style: 커밋이 눈에 띄게 많다. 발송 엔진의 신뢰성이 훨씬 중요한데, 목록 카드의 둥근 모서리를 몇 번이나 다시 만졌다.
  3. 실데이터 확인을 뒤로 미뤘다. ordererTel 사고, 직접입력 옵션 설계 결함, 그리고 워터마크 글에서 다룬 사고까지. 전부 실제 데이터나 실제 파일을 한 번만 열어 봤으면 생기지 않았을 문제다.
  4. 관리자 비밀번호를 임시로 약하게 둔 채 운영한 구간이 있었다. 급하다고 미룬 보안 설정은 잊힌다.

다시 한다면

  • 외부 API 연동은 진단용 스크립트와 테스트를 제일 먼저 만든다. 실제 응답을 찍어 보는 데 10분이면 되고, 그걸로 아끼는 시간은 며칠이다.
  • 마이그레이션 도구는 테이블이 몇 개 없을 때 넣는다. 테이블이 늘어나면 넣기 싫어진다.
  • UI 다듬기는 핵심 흐름이 실사용으로 검증된 뒤에 시작한다.

마치며

이 프로젝트에서 나를 가장 많이 괴롭힌 건 알고리즘도 아키텍처도 아니었다. 내가 안다고 생각한 외부 데이터였다.