시작
나는 약국 도메인에서 일하는 IT 종사자다. 약사를 위한 앱은 이미 많다. 바로팜, Pillens(지금은 바로팜에 합쳐졌다), 약국매니저 같은 앱이 저마다 기능을 내놓고 있었다. 그 기능들을 보면서 궁금해졌다. 저걸 나도 혼자 만들 수 있을까. 돈을 벌려는 게 아니라, 앱을 직접 출시해 보는 것이 목표였다.
무엇을 만들지 고민하다 약국을 사업장으로 다시 봤다. 약국도 결국 매출과 비용을 챙겨야 하는 소상공인이다. 더 파는 것만큼 새는 돈을 막는 것도 중요하다.
약국에 돈이 되는 정보는 건강보험심사평가원(심평원) 공고에 올라온다. 문제는 그걸 챙겨 보는 일 자체가 번거롭다는 점이었다. 시장을 조사할 당시 사이트는 느렸고, 정보가 옛 사이트와 새 사이트에 나뉘어 있어 각각 들어가 봐야 했다. 그래서 처음 떠올린 건 “공고 배달” 이었다. 공고가 나오면 앱 푸시로 보내 주되, 약가인하나 회수처럼 환불·반품으로 바로 돈이 되는 공고만 골라 보내기로 했다.
그중 금액이 가장 큰 건 약가인하였다. 그런데 약가인하는 정산 방식이 특이하다. 차액을 심평원에 신청해 돌려받는 게 아니라, 그 약을 산 도매상에 반품하는 방식으로 처리된다. 약국은 편의점처럼 한 곳에서 발주하지 않는다. 같은 약도 더 싸게 파는 도매상을 계속 찾아 옮겨 다니고, 도매상마다 담당 영업사원이 따로 있다. 약가인하 공고가 뜨면 약사가 대상 품목을 확인해 영업사원에게 넘기는 구조다.
여기서 일이 꼬인다. 약은 어디서 사도 똑같이 생겨서, 같은 약을 여러 도매상에서 샀다면 어느 도매상 물건인지 가려내기 어렵다. 결국 한 도매상에 몰아서 넘기는 일이 생기고, 거래 관계상 영업사원은 그걸 받아 주게 된다. 이 구조 자체를 시스템으로 바꾸려는 곳도 있다. 나는 그보다 훨씬 작은 일을 하기로 했다. 약사가 약가인하 대상을 빠르게 찾고, 재고를 스캔해 목록으로 만들고, 그 목록을 담당자에게 바로 보낼 수 있는 업무 도구다.
무엇을 만들었나
약국통은 그렇게 나온 약가인하 특화 업무 도구다. 약가인하 공고가 나오면 앱 푸시로 알려 준다. 약사는 약가인하 목록에서 이번 달 대상 품목을 보고, 재고의 바코드를 찍어 스캔 목록을 만든다. 목록은 엑셀이나 PDF 로 내보내 이메일이나 메신저로 담당자에게 보낸다. 앱을 깔지 않은 사람도 볼 수 있게, 로그인 없이 이번 달 대상 품목과 보유 수량별 차액을 계산하는 웹 페이지를 따로 열었다.
만들면서 범위가 여러 번 바뀌었다. 최초 기획에는 없던 교품 마켓 아이디어가 중간에 떠올라 직접 시도해 봤다. 남는 약을 다른 약국과 바꾸거나, 처방약이 떨어졌을 때 근처 약국에서 구하는 기능이다. 글을 제품명이 아니라 바코드의 표준코드로 연결하고, 회수 대상 품목은 올리지 못하게 막고, 약국의 정확한 위치 대신 거리 구간만 보여주도록 설계했다.
만들다 보니 궁금해졌다. 이렇게 필요해 보이는 기능을 남들은 왜 안 하고 있을까. 시장을 조사해 보니 안 하는 데는 이유가 있었다. 8월 4일 첫 출시에서 마켓 화면을 걷어냈다. 알약 사진으로 약을 찾는 기능도 각인 인식 정확도가 출시 기준에 못 미쳐 1.0 에서 뺐다. 그 과정은 pill-ocr 에 따로 적었다.
첫 커밋은 7월 13일, 이름을 약국통으로 정한 건 7월 29일이다. 8월 15일 첫 심사를 통과한 1.0 의 중심은 두 가지다. 약가인하 스캔, 그리고 약을 펼쳐 찍으면 기기 안의 모델이 세어 주는 알약 수량 세기다. 바코드 의약품 조회와 회수 공고 확인, 공문·업계 뉴스 모음은 그 곁에 붙은 기능이다.
스택 — 가볍게 시작해, 커지자 같은 틀로 옮겼다
API 는 처음에 프레임워크 없이 Node 의 http 모듈 하나로 시작했다. 기능이 늘어 서버 파일 하나에 라우트가 몰리자 8월 16일 NestJS 모듈 구조로 전부 옮겼다. fika 에서 쓰던 틀이라 의존성 주입과 테스트 구조를 그대로 가져올 수 있었다. 응답 형식·상태 코드·헤더·본문 크기 한도는 바꾸지 않았고, 기존 테스트와 주요 라우트 흐름으로 동작이 그대로인지 확인한 뒤 옮겼다. 옮기면서 어디서도 호출되지 않던 라우트는 지웠다.
앱은 Expo(React Native)로 iOS 를 먼저 냈다. 웹은 React 19 로 앱과 같은 버전에 맞춰 공유 타입 충돌을 없앴다. 데이터는 PostgreSQL 에 두고, 알약 사진 검색 실험에 쓴 이미지 임베딩도 같은 DB 의 pgvector 에 두었다.
아키텍처 — fika 와 같은 집, 밖에서 들어오는 데이터
인프라는 fika 와 같은 Mac Studio 의 k3d 클러스터다. 요청은 nginx 에서 traefik 을 거쳐 정적 웹과 API 로 가고, 상태는 클러스터 밖 PostgreSQL 과 MinIO 에 둔다. 약국통이 다른 점은 데이터 대부분이 밖에서 들어온다는 것이다. 약품 마스터, 식약처 공개정보와 낱알 이미지, 심평원 약가, DUR·회수 정보를 주기적으로 받아 온다.
이 수집은 처음에 GitHub Actions 스케줄로 돌았고, 8월 17일 클러스터의 CronJob 으로 옮겼다. 약가는 매일 08:30, 안전정보는 매일 03:40, 약품 마스터와 이미지는 매주 일요일에 돈다. 약가 전체를 처리하는 배치는 메모리 2GB 에서 죽어 4GB 로 올렸다. 뉴스·공문처럼 짧은 주기의 수집은 API 안의 스케줄러가 맡는다.
오프라인 우선을 버린 날
처음 설계는 오프라인 우선이었다. 첫 실행에 약 76만 행을 받아 기기 SQLite 에 저장하고, 검색과 스캔은 기기 안에서 끝냈다. 데이터 버전이 하나만 바뀌어도 전체를 다시 받아야 했고, 첫 진입이 그만큼 느렸다.
8월 9일 전제를 다시 봤다. 조제실은 폐쇄망이 아니었다. 오프라인을 전제로 한 설계가 실제 환경과 맞지 않았다. 검색과 스캔을 서버 조회로 바꾸고, 기기 쪽 동기화 화면과 SQLite 의존성을 걷어냈다. 앱은 바로 열리고, 서버에서 고친 데이터가 즉시 반영된다.
공공 데이터를 그대로 믿지 않기
공공 API 는 가끔 정상처럼 보이는 부실 응답을 준다. 배치가 받은 것만으로 테이블을 새로 발행하는 구조였기 때문에, 한 번은 공개정보가 27,582건에서 5,228건으로 줄며 효능과 이미지가 대량으로 사라졌다. 그 뒤로 수집 건수가 기존의 80% 아래로 떨어지면 발행을 거부하고, 사람이 명시적으로 허용할 때만 줄어든 데이터를 반영한다. 심평원 약가는 현재·이력 파일이 각각 하나이고 기준일이 같을 때만 진행하며, 파일 해시가 같으면 건너뛴다.
식약처 이미지 서버는 동시 요청에 429 를 돌려줘 약가인하 목록의 이미지가 여섯 번 중 한 번꼴로 비었다. 이미지를 한 번 받아 MinIO 에 미러링하고 우리 쪽에서 서빙하도록 바꿨다. 받아 올 때는 간격을 두고 순차로 요청하고, 429 가 오면 물러났다가 다시 시도한다.
기록이 없어서 보이지 않던 스캔
앱의 핵심 동작인 바코드 스캔은 조회만 하고 아무것도 남기지 않았다. 로그를 봐도 스캔이 일어났는지조차 알 수 없었고, 몇 시간치 로그가 전부 부팅 로그뿐이었다. 8월 19일 스캔 이벤트 테이블을 만들어 성공과 실패를 모두 기록하게 했다. 기록 저장이 실패해도 사용자 응답은 그대로 나가고, 로그인하지 않은 스캔도 계정 없이 남긴다. 하루 1만 건이면 1년에 365만 행이라 집계 테이블이나 파티셔닝이 필요해질 수 있다는 점은 커밋에 적어 두었다.
운영하며 겪은 일
두 번의 App Store 반려. 앱 심사는 생각보다 훨씬 까다로웠고, 이미 있는 앱과 같은 기능으로는 출시조차 어려웠다. 8월 7일 빌드는 모션 권한 설명이 없다는 이유로 반려됐다. 앱은 모션 API 를 직접 쓰지 않았지만, 링크된 라이브러리가 참조하면 설명 문구가 필요했다. 8월 14일 심사에서는 카메라 사전 안내의 “카메라 허용하기” 버튼이 권한을 유도한다는 이유로 거절됐다. 버튼을 “계속” 으로 바꾸고 거부했을 때 설정으로 가는 길을 따로 둔 뒤, 다음 날 첫 심사를 통과했다.
외부 사이트가 잘못된 값을 보냈다. 7월 31일 저녁부터 매시간 수집 배치가 실패했다. 외부 사이트가 약품 코드 자리에 ATC 코드를 내보냈고, 검증 예외 하나가 파싱 루프 전체를 멈췄다. 레코드 단위로 예외를 잡아 이상한 건만 건너뛰고, 건너뛴 수와 이유를 로그에 남기게 했다.
점점 느려지는 목록. 약업 시그널 목록을 8번 연속 부르면 응답이 527ms 에서 1,093ms 로 늘었다. 카드 100장을 고르려고 이벤트 전체를 정렬해 읽고 있었다. 메모리 저장소 시절의 함수 모양을 PostgreSQL 구현이 그대로 따라간 탓이다. 필요한 ID 만 기본키로 읽는 함수를 따로 만들었다.
돌아보며
개발자로서 목표였던 출시는 이뤘다. 공공 데이터 수집 배치부터 App Store 심사까지 혼자 한 바퀴를 돌렸다. 처음부터 반품 구조를 바꾸기보다, 약사가 대상을 찾고 스캔하고 보내는 작은 업무 하나를 자동화하는 데 범위를 두었고 그 범위는 지켰다.
가장 크게 틀린 건 첫 전제였다. 오프라인 우선은 그럴듯했지만 조제실 환경을 확인하지 않고 정한 것이었고, 76만 행 동기화를 만들고 걷어내는 데 시간을 썼다. 공공 데이터를 그대로 믿지 않는 장치는 반대로 사고를 한 번씩 겪으며 하나씩 쌓였다. 80% 게이트, 이미지 미러링, 레코드 단위 예외가 모두 그렇게 들어왔다.
아쉬운 건 만든 뒤의 일이다. 앱은 만드는 것으로 끝나지 않고, 누군가 유용하게 써야 의미가 있다. 약국통은 아직 사용자를 확보하지 못했다.
플랫폼 선택도 놓쳤다. 약사들의 연령대를 생각하면 안드로이드를 쓰는 사람이 훨씬 많은데, 출시를 먼저 한다는 목표에 맞춰 iOS 부터 냈다. 막상 안드로이드를 내려고 보니 Google Play 는 개인 개발자 계정에 테스터 12명이 14일 동안 비공개 테스트에 참여해야 정식 출시를 신청할 수 있게 한다. 홍보를 시작해야 할 시점에, 정작 약사들이 가장 많이 쓰는 스토어에는 앱을 올리지 못한 사면초가에 가까운 상황이 됐다. 미리 생각하지 못한 벽이었고, 지금은 테스터를 빠르게 모을 방법을 찾고 있다.
알약 사진 검색 같은 고도화 과제가 남아 있지만, 지금 필요한 건 기능을 더하는 일이 아니라 안드로이드 출시와 약사에게 이 도구를 알리는 홍보 전략이다. 개발자로서의 일은 해냈고, 이제는 마케터로서의 능력을 키워야 할 시점이다.
출처
- 사용자 구술: 시작한 계기, 약사용 앱 시장, 약가인하 반품 구조, 출시 목표, 안드로이드 출시 과제
- Google Play Console 도움말: 신규 개인 개발자 계정의 앱 테스트 요건(테스터 12명, 14일)
apps/drug-scanner/{api,web,mobile}커밋 본문(2026-07-13 ~ 09-01),api/README.md,mobile/README.md,mobile/APP_STORE_LISTING.md- platform
apps/drug-scanner-{api,web}/prod.yaml— host, ingress, CronJob 배치, 장애 주석 - 포트폴리오 저장소 pill-ocr 사례의 타임라인(심사 통과일)
- 참조한 오픈소스(GitHub): NestJS · TypeORM · PostgreSQL · pgvector · MinIO · React · React Native · Expo · ONNX Runtime · k3d · Argo CD · Traefik.
- 인증서·세션·계정 비밀값, 약국·사용자 데이터, 운영 DB 행은 싣지 않는다.