Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/EN

Multi-Country Ultrasound Delivery Service and Console

초음파 영상을 산모 앱으로 보내는 다국가 전송 서비스

한국 영상 시스템은 그대로 두고 세 나라로 넓힌 서버와 운영 콘솔

영상 원본은 한국 시스템 안에

산부인과 초음파 영상을 산모 앱으로 보내주던 서비스를, 같은 형태 그대로 인도네시아와 베트남, 미국으로 내보내는 프로젝트였습니다. 영상 원본과 병원·바코드 데이터는 한국에서 이미 돌아가고 있던 별도 시스템(코드에서는 mommybox라고 부릅니다)이 쥐고 있었고, 그 시스템은 고치지 않고 연동만 하는 것이 전제였어요.

조건은 나라마다 달랐습니다. 결제는 인도네시아와 미국이 앱스토어 인앱결제, 베트남은 현지 PG인 VNPAY였고, 화면에 나가는 언어는 EN·ID·VI·KO·ES 다섯 가지였습니다. 영상을 얼마에 팔지, 아니면 아예 무료로 풀지도 국가별 설정이었고요.

화면 문구와 국가별 설정은 나라별로 어드민에서 직접 바꿀 수 있어야 했습니다. CMS가 필요했던 이유입니다.

이 글에서는 원본 시스템에서 영상을 빌려 오는 연결과, 볼 권리를 계산하는 라이선스 두 가지만 다룹니다. MySQL과 MongoDB를 함께 쓴 이야기, 두 리전 배포와 시크릿 관리는 각각 따로 다룰 가치가 있는 주제라 이 글에서는 접습니다. 읽기와 쓰기를 가르는 방식은 뒤에서 요약만 남겨둡니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

스토어 심사 없이 바꾸는 법

영상 원본은 기존 시스템에 그대로 두고, 병원 바코드로만 연결했습니다.

영상 파일과 병원 마스터를 복제하면 두 시스템이 영원히 어긋납니다. 대신 아기에게 바코드를 등록해두고 필요할 때마다 원본 시스템에 물어보는 쪽을 골랐어요. 자체 DB에는 좋아요·댓글·조회수·공유처럼 새로 만든 것만 ultrasoundUUID를 키로 붙습니다. 원본 API는 고칠 수 없는 데다 바코드 하나씩만 조회할 수 있는 엔드포인트가 섞여 있어서, GraphQL 필드마다 호출이 터지지 않도록 DataLoader로 요청 단위 배칭을 깔아야 했습니다.

NestJS + GraphQL 모노레포를 apps/app, apps/admin, apps/batch-job 세 앱으로 쪼갰습니다.

세 앱이 도메인 로직(libs/modules)은 공유하되 인증과 배포 수명주기는 완전히 다르길 원했습니다. 앱 서버는 JWT, 어드민 서버는 express 세션 쿠키와 SMS 2차 인증을 쓰고, 배치 잡은 아예 상시 떠 있지 않습니다. 어드민에서 만든 데이터를 앱이 즉시 읽어야 해서 DB는 하나를 공유해야 했습니다. 그래서 경계는 배포 단위로만 긋고, 리포지터리 계층은 libs에 한 벌만 뒀습니다.

앱에 나가는 문구·아이콘·주차 콘텐츠를 전부 서버와 CDN으로 뺐습니다.

문구는 language_pack 테이블에 넣고 /app-translation/resources?lng=&ns= 로 i18next 리소스 JSON을 그대로 내려줍니다. 아이콘은 Figma 플러그인이 선택한 컴포넌트를 SVG로 export해서 S3에 올리고 CloudFront로 서빙하고요. 스토어 심사를 기다리지 않고 어드민에서 직접 고치게 하려는 목적이었습니다. 응답에 CacheInterceptor가 걸려 있어 반영이 즉시가 아닙니다. 어드민 화면에도 "It may take up to a day for changes to take effect." 라고 적어두고, 대신 DEV와 PROD 값을 나란히 비교하는 장치를 붙였습니다.

배포와 인프라 구성
배포와 인프라 구성

영상은 빌려 오고 권리만 계산

영상은 자체 DB에 없습니다. 아기에게 병원 바코드를 걸어두면 MommyboxService가 그 바코드로 원본 시스템에 영상 목록을 물어보고, 그 위에는 좋아요·댓글·공유 링크만 얹습니다. 반대 방향도 있습니다. 병원에서 새 영상이 올라오면 원본 시스템이 /ultrasound/webhook/ultrasoundAdded 로 알려주고, 이 훅에서 바코드로 아기를 찾아 통계 행을 만들고, 일시정지된 라이선스가 있으면 자동으로 재시작하고, 바우처가 걸린 바코드라면 첫 영상 시각을 기준으로 라이선스를 부여합니다. 결제는 인앱결제 영수증 검증과 VNPAY IPN 두 갈래가 같은 purchases 테이블로 합류하고, 합류 지점에서 라이선스가 생깁니다.

핵심 데이터 모델
핵심 데이터 모델

일시정지한 날수만큼 만료일이 스스로 밀리는 라이선스

purchase_licenses 에 startAt 과 endAt 을 두고, 운영자가 일시정지를 누르면 endAt 을 UPDATE 하는 방식이 가장 먼저 떠오릅니다. 재시작할 때 정지했던 날수를 더해주면 되니까요. 그런데 이러면 "이 사람 만료일이 왜 이렇게 됐지"를 나중에 아무도 설명하지 못합니다. 정지와 재시작이 여러 번 반복되고, 그 사이에 운영자가 손으로 시작일을 당기고 종료일을 미루기까지 하면, 컬럼 하나에 모든 사연이 뭉개져 버리거든요. CS가 들어왔을 때 되돌릴 방법도 없습니다.

라이선스 행에는 startAt 과 durationInDays 만 남기고, PAUSE·RESTART·MANUAL_UPDATE 를 purchase_license_changed 테이블에 append-only로 쌓았습니다. 조회할 때마다 getLicenseEnabledDateRanges 가 이 로그를 시간순으로 재생해서 "볼 수 있는 기간"을 구간 배열로 다시 계산합니다. 정지 구간은 잘라내고 그 길이만큼 마지막 구간 뒤에 붙이고, 아직 재시작되지 않은 정지는 오늘 이후를 통째로 닫은 다음 내일부터 남은 기간을 다시 엽니다. 한 걸음 더 나가서, 접근 판정을 "오늘 구독 중인가"가 아니라 "이 영상의 uploadedAt 이 활성 구간 안에 들어가는가"로 두었습니다.

라이선스가 상태가 아니라 사건의 합이 되니, 어드민의 Change logs 모달은 그 테이블을 그대로 보여주기만 하면 됩니다. 계산이 순수 함수라 "중단되지 않은 일시정지가 존재한다면 그만큼 종료일자가 뒤로 밀려야 함" 같은 규칙을 날짜만 넣어 단위 테스트로 고정할 수 있고요. 판정 기준을 업로드 시각으로 잡은 덕분에 영상 단위 과금과 기간제 구독이 하나의 규칙으로 합쳐집니다. 라이선스를 여러 장 가진 유저는 mergeDateRanges 로 구간을 합쳐 한 번에 판정하고, "2023년 1월 31일 13시 이전 업로드분은 무료" 같은 정책 변경도 구간 비교 앞에 조건 하나를 얹는 것으로 끝났습니다.

mutation 요청은 읽기까지 통째로 primary로 보낸다

읽기는 리플리카, 쓰기는 마스터. 교과서적인 분리인데 GraphQL에서 이대로 하면 곧 이상한 버그를 만납니다.

PrismaService 를 요청 스코프로 만들고, GraphQL 요청 본문의 쿼리 문서를 파싱해 이 요청이 mutation인지 query인지 먼저 판단하게 했습니다. mutation이면 그 요청 안의 모든 접근이 primary로, query면 replica로 갑니다.

기본값이 맞는 쪽으로 정해지니, 대부분의 리졸버는 this.prismaService.client() 라고만 쓰면 됩니다.

유저가 할 수 있는 일

이메일과 문자 인증으로 운영 콘솔에 들어간다
이메일과 문자 인증으로 운영 콘솔에 들어간다

이용자를 찾아 아기, 바코드, 결제 내역을 한 화면에서 본다
이용자를 찾아 아기, 바코드, 결제 내역을 한 화면에서 본다

이용자의 초음파 라이선스를 멈추거나 기간을 손본다
이용자의 초음파 라이선스를 멈추거나 기간을 손본다

결제 한 건을 찾아 환불 처리한다
결제 한 건을 찾아 환불 처리한다

병원에 나눠줄 바코드 바우처를 발급한다
병원에 나눠줄 바코드 바우처를 발급한다

국가별 가격 티어와 약관, 스토어 문구를 정한다
국가별 가격 티어와 약관, 스토어 문구를 정한다

앱에 나가는 문구를 고치고 운영 환경으로 올린다
앱에 나가는 문구를 고치고 운영 환경으로 올린다

정산용 엑셀을 요청하고 진행률을 지켜본다
정산용 엑셀을 요청하고 진행률을 지켜본다

임신 주차별 콘텐츠와 홈 카드 순서를 나라별로 채운다
임신 주차별 콘텐츠와 홈 카드 순서를 나라별로 채운다

푸시와 팝업을 만들어 특정 국가나 병원에 보낸다
푸시와 팝업을 만들어 특정 국가나 병원에 보낸다

국가별 챗봇 프롬프트와 데일리 질문을 손보고 대화를 들여다본다
국가별 챗봇 프롬프트와 데일리 질문을 손보고 대화를 들여다본다

커뮤니티 신고를 처리하고 공지와 매거진을 올린다
커뮤니티 신고를 처리하고 공지와 매거진을 올린다

병원과 클리닉 정보를 확인하고 들어온 피드백을 읽는다
병원과 클리닉 정보를 확인하고 들어온 피드백을 읽는다

관리자 계정과 감사 로그를 관리하고 내부 도구를 쓴다
관리자 계정과 감사 로그를 관리하고 내부 도구를 쓴다

초음파 영상 링크를 열어 앱 없이 본다
초음파 영상 링크를 열어 앱 없이 본다

초대 링크를 열어 아기의 가족으로 합류한다
초대 링크를 열어 아기의 가족으로 합류한다

1 / 1

한 저장소에 ORM 세 개

가장 부끄러운 건 OpenAI API 키가 소스에 국가별로 하드코딩된 채 남아 있었다는 점입니다(libs/modules/momitalk-ai-chat/lib/momitalk-ai-chat.service.ts). 나머지 시크릿은 전부 Doppler로 옮겨두고도 이 한 군데를 정리하지 못했어요. 다시 만든다면 이건 첫 주에 끝냈을 겁니다.

데이터 접근 계층도 깔끔하지 않습니다. TypeORM으로 시작한 코드 위에 Prisma를 얹고 Kysely raw까지 더해져서, 같은 테이블을 세 가지 방법으로 읽는 파일이 공존합니다. 마이그레이션을 끝까지 밀어붙이지 못한 채 기능 일정에 밀린 결과예요. EXPORT_AGGREGATE_PAID_VOUCHERS_BY_MONTHLY copy 같은 디렉터리 이름이 그대로 남아 있는 것도 같은 이유입니다.

테스트는 라이선스 기간 계산처럼 틀리면 돈이 걸리는 곳에만 있고, 리졸버 레벨 계약 테스트가 없습니다. 모든 걸 아름답게 정리하고 있지는 않았지만, 적어도 돈과 접근 권한이 걸린 자리만큼은 근거가 남게 만들려고 했습니다.

Read next

QR로 들어와 비회원도 참여하는 캠페인 응모 사이트

K House of Pepsi — 2021