같이 먹어도 되는 약 — 지금 드시는 약을 담으면, 식약처가 함께 먹지 말라고 정해 둔 조합이 있는지 찾아드려요
토스 앱 속 WebView 미니앱 · 비게임 · 광고 수익형 · 서버 없음
약 이름을 두 글자만 쳐서 담고 → 두 개 이상 담기면 확인 버튼 하나 → 식약처가 병용금기로 정해 둔 조합이면 왜 안 되는지 사유까지 → 자주 먹는 묶음은 조합으로 저장해 다음에 한 번에 불러오는 흐름.
| 무엇을 | 약을 여러 개 먹을 때 "이거 같이 먹어도 되나"를, 약 상자에 적힌 이름만으로 확인해요 |
| 어디서 | 토스 앱 내 미니앱 (화면 하나짜리 앱) |
| 누구를 위해 | 혈압약·당뇨약에 감기약·진통제가 얹히는, 매일 여러 알을 챙겨 먹는 사람 |
| 누가 | 1인 기획·개발 (프론트엔드 + 공공 API 실측 검증 + 심사/정책 대응) |
| 약 검색 | 제품명 두 글자 이상이면 타이핑이 멈춘 뒤 검색 · 업체명과 전문/일반 구분까지 표시 |
| 병용금기 확인 | 담은 약(최대 10개)의 모든 짝을 확인하고, 금기면 식약처가 적어 둔 금기 사유를 그대로 보여줘요 |
| 내 약 · 최근 담은 약 | 별표로 고정하는 즐겨찾기, 최근 담은 약 12개까지 자동 기록 · 기록 지우기 버튼 상시 노출 |
| 조합 저장 | "타이레놀정500밀리그람 외 2개" 처럼 이름을 자동으로 지어 저장 · 한 번 눌러 다시 담기 |
이 프로젝트에서 제일 크게 갈린 결정입니다. 처음 만든 앱은 사실상 항상 "문제 없어요" 라고 답하고 있었습니다.
처음에는 약 검색을 e약은요(getDrbEasyDrugList) 로 했습니다. 이름·효능·복용법이 사람이 읽기 좋게 정리돼 있어 자연스러운 선택이었어요. 그런데 이 카탈로그는 품목이 4,772개뿐이고 대부분 일반의약품입니다. 실제로 여기서 검색되는 약 2,100개를 훑어 병용금기를 찾아봤더니 결과는 0건이었습니다.
병용금기는 대부분 전문의약품 사이에서 걸립니다. 즉 사용자가 e약은요에서 고를 수 있는 약들끼리는 애초에 부딪힐 일이 거의 없고, 앱은 무엇을 담든 초록색 화면을 띄웁니다. 틀린 답을 주는 것보다, 항상 안심시켜 주는 것이 더 위험합니다. "앱이 괜찮다고 했는데" 가 성립해버리니까요.
그래서 검색 카탈로그를 DUR 품목정보(getDurPrdlstInfoList03) 로 바꿨습니다. 이 목록에 있는 약은 전부 DUR 판정 대상이라, 검색해서 담은 약이 그대로 금기 판정으로 이어집니다. 검색과 확인이 같은 데이터 위에서 도는 구조가 된 것이죠.
대신 잃은 것도 분명히 적어둡니다 — DUR 목록에는 효능·복용법 같은 설명이 없습니다. e약은요는 그 상세정보용 자리(getDrugDetail)로만 남겨뒀고, 카탈로그가 작아 없는 약이 많아서 없으면 그냥 안 보여주는 쪽으로 처리합니다.
허가취소 품목은 담지 않습니다. DUR 목록에는 허가가 취소된 품목도 함께 옵니다.
CANCEL_NAME이"정상"이 아닌 항목은 검색 결과에서 뺍니다 — 지금 살 수도, 먹을 수도 없는 약을 담게 하면 확인 자체가 헛돌아요.
둘 다 문서만 읽어서는 안 나오고, 실제로 불러봐야 드러났습니다.
(a) numOfRows 최대는 1000 이 아니라 500 입니다.
1000을 넣으면 데이터가 잘리는 게 아니라 API 가 에러를 돌려줍니다. 병용금기 목록은 약 하나에도 수백 건이 걸리므로 한 번에 다 받을 수 없고, totalCount 로 페이지 수를 계산해 끝까지 이어받습니다. 금기를 한 건이라도 놓치면 사람이 다치는 데이터라, 여기서만큼은 "첫 페이지만" 이 허용되지 않습니다.
(b) 결과가 0건이면 응답에 items 키가 아예 없습니다.
body.items 가 없는 것을 오류로 판정했더니, "금기 없음" 이라는 정상 케이스마다 오류 화면이 떴습니다. 가장 흔하게 나오는 정답이 통째로 에러가 되는 셈이죠. 그래서 body 자체가 없을 때만 오류로 보고, items 가 없으면 빈 목록으로 다룹니다.
if (json.body == null) throw new Error("약 정보를 불러오지 못했어요");
return { items: json.body.items ?? [], totalCount: Number(json.body.totalCount ?? 0) };그 밖에 실측으로 정리한 것들:
- 금기 관계는 데이터에 양방향으로 다 들어 있습니다. A→B 와 B→A 가 모두 있어서, 담은 약마다 한 번씩만 조회해도 짝을 놓치지 않습니다.
- 여러 약을 동시에 조회하지 않고 하나씩 순서대로 부릅니다. 공공데이터포털이 동시 요청을 잘 버티지 못했습니다.
- 같은 품목이 중복으로 내려옵니다. 품목기준코드(
itemSeq)로 걸러냅니다. - 한 조합에 사유가 여러 개 걸려도 경고는 한 번만 띄웁니다. 같은 경고를 세 번 보여주면 사용자는 셋 다 무시하게 됩니다.
무슨 약을 먹는지는 그 사람의 병을 그대로 드러내는 정보입니다. 그래서 설계 원칙으로 못 박았습니다.
- 자체 서버가 없습니다. 브라우저가 식약처 공공 API 를 직접 부릅니다. 검색어와 품목코드는 공공데이터포털로만 나가고, 중간에 우리 서버를 거치지 않습니다 — 애초에 보낼 곳이 없습니다.
- 최근 담은 약 · 즐겨찾기 · 저장한 조합은 전부 기기
localStorage(pill-check:네임스페이스)에만 둡니다. 계정도 로그인도 없습니다. - 권한을 하나도 요구하지 않습니다.
permissions: []— 위치·연락처·카메라 어느 것도 받지 않습니다. - 기록 지우기 버튼을 상시 노출합니다. 남의 폰을 잠깐 빌려 쓸 수도 있으니, 지우는 길이 항상 화면에 보여야 합니다.
- 시크릿 모드처럼 저장이 막힌 환경에서는 저장만 조용히 실패하고 앱은 계속 동작합니다.
공공데이터포털 인증키는 브라우저에서 직접 호출하는 구조상 번들에 그대로 들어갑니다. 무료·공개 데이터라 감수한 절충이고, 결제·개인정보처럼 민감한 키에는 이 방식을 쓰면 안 됩니다.
"아침에 먹는 세 알" 같은 묶음은 매번 다시 담기 번거롭습니다. 그래서 조합으로 저장하는데, 여기엔 함정이 하나 있습니다 — 사용자는 같은 약을 매번 다른 순서로 담습니다.
키를 담은 순서 그대로 만들면 A|B 와 B|A 가 다른 조합이 되어, 같은 묶음이 두 개 세 개 쌓입니다. 그래서 키는 품목코드를 정렬해서 만듭니다.
function comboKeyOf(drugs: Drug[]): string {
return drugs.map((d) => d.seq).sort().join("|");
}덕분에 순서가 달라도 같은 조합으로 찾아내고(findCombo), 이미 있으면 다시 저장하지 않으며(saveCombo), 지금 담은 약이 저장된 조합과 같으면 버튼이 "저장됨" 으로 바뀝니다.
이름은 autoComboLabel() 이 자동으로 짓습니다. 약 이름은 타이레놀정500밀리그람(아세트아미노펜) 처럼 길어서, 괄호 앞부분만 쓰고 나머지는 개수로 접습니다 — 타이레놀정500밀리그람 외 2개. 40~50대 사용자에게 조합 이름을 타이핑시키지 않기 위한 선택입니다.
npm run check:dur 이 이 로직을 네트워크 없이 검사합니다 — 순서 바꿔 저장해도 1개인지, 다른 조합은 따로 쌓이는지, 짝짓기 개수가 맞는지(약 3개 → 짝 3개), 같은 약을 두 번 담으면 하나로 줄어드는지.
이 앱은 판단을 대신하지 않습니다. 식약처가 공개한 DUR 병용금기 정보를 그대로 보여줄 뿐이고, 실제로 먹을지 말지는 약사·의사가 정합니다.
특히 이 두 가지는 화면에 고정으로 띄웁니다.
- "금기 없음" 은 "안전함" 이 아닙니다. DUR 병용금기는 식약처가 등록해 둔 조합의 목록이지, 모든 약물 상호작용의 목록이 아닙니다. 등록되지 않은 상호작용도, 개인의 상태에 따라 달라지는 것도 여기엔 없습니다.
- 금기가 나와도 스스로 약을 끊지 마세요. 처방받은 약이라면 의사가 이미 알고 검토한 조합일 수 있습니다. 화면은 "끊으세요" 가 아니라 "이 화면을 약사·의사에게 보여주고 상의하세요" 라고 말합니다.
진단·복약 지도·용량 안내는 하지 않고, 앞으로도 넣지 않습니다.
전부 식품의약품안전처 공공데이터이고, 미리 표로 구워두지 않고 그때그때 직접 부릅니다.
| 쓰임 | 엔드포인트 |
|---|---|
| 약 검색 (카탈로그) | DURPrdlstInfoService03/getDurPrdlstInfoList03 |
| 병용금기 조회 | DURPrdlstInfoService03/getUsjntTabooInfoList03 |
| 상세정보 (효능·복용법·주의사항) | DrbEasyDrugInfoService/getDrbEasyDrugList — 검색에는 쓰지 않아요 |
응답에서 실제로 쓰는 필드는 ITEM_SEQ(품목기준코드) · ITEM_NAME · ENTP_NAME(업체) · ETC_OTC_CODE(전문/일반) · CANCEL_NAME(허가상태), 금기 쪽은 MIXTURE_ITEM_SEQ(부딪히는 상대 약) · PROHBT_CONTENT(금기 사유)입니다.
| 자리 | 형태 | 갱신 |
|---|---|---|
| 화면 하단 고정 | 문구 강조형 배너 | 30초 |
| 본문 맨 아래 (면책 문구 아래) | 이미지 강조형 배너 | 30초 |
30초인 이유: 토스 배너는 AdMob 기반이고, AdMob 은 30초보다 잦은 갱신을 무효 트래픽으로 봅니다. 더 짧게 잡으면 수익보다 제재 위험이 커져요. 화면이 가려져 있는 동안(document.hidden)에는 갱신을 멈춥니다 — 노출로 잡히지도 않으면서 호출만 쌓이니까요.
이미지형 자리는 소재가 잘리지 않도록 고정 높이가 아니라 최소 높이로 잡았습니다. 반대로 하단 고정 배너는 고정 높이여야 합니다 — 위로 자라면 본문을 덮습니다. 광고그룹 ID 가 비어 있으면 하단 자리는 아예 만들지 않습니다. 만들어두면 배너 없이 96px 흰 여백만 남아요.
리워드 광고·전면 광고는 넣지 않았습니다. 약을 확인하러 온 사람을 광고로 한 번 막는 흐름은 이 앱에서는 쓰지 않습니다.
| 영역 | 사용 기술 |
|---|---|
| 언어 | TypeScript 5.7 (strict) |
| 프론트엔드 | React 18, Vite 6 |
| 플랫폼 SDK | @apps-in-toss/web-framework 3.0.2 (WebView 브릿지 · 인앱광고 · 이벤트 로그) |
| 데이터 | 식약처 공공 API 직접 호출 — 백엔드 없음 |
| 상태 | 화면 로컬 상태 + localStorage |
| 자체 점검 | npm run check:dur — 네트워크를 타지 않는 순수 로직 검사 |
src/
├─ features/home/HomeScreen.tsx 검색 · 담기 · 확인 · 결과 · 면책 (화면 하나)
├─ components/
│ ├─ BannerAd.tsx 배너 2지면 · 30초 갱신 · 화면 가려지면 정지
│ └─ ScreenLayout.tsx 흰 카드 컨테이너
├─ lib/
│ ├─ dur.ts 검색 · 페이지네이션 · 금기 짝 찾기
│ ├─ pillbox.ts 최근 · 즐겨찾기 · 조합(정렬 키)
│ ├─ analytics.ts 콘솔 전환 지표 로그
│ └─ env.ts 광고그룹 ID · 공공데이터 인증키
└─ App.tsx 셸 · 하단 고정 배너
scripts/
├─ check-dur.ts dur.ts · pillbox.ts 자체 점검
└─ shot*.mjs puppeteer-core 화면 캡처
npm install
npm run dev # 브라우저 (광고는 자리 없이 통과)
npm run check:dur # 짝짓기 · 중복 제거 · 조합 키 자체 점검
npm run typecheck
npm run lint
npm run build # vite build + ait build → pill-check.ait
npm run deploy.env 에 아래 세 값을 넣습니다. 값은 공공데이터포털과 앱인토스 콘솔에서 각자 발급받으세요.
VITE_DATA_KEY= # 공공데이터포털 인증키 (없으면 검색이 안 돼요)
VITE_AD_GROUP_ID_BANNER= # 문구 강조형 (하단 고정)
VITE_AD_GROUP_ID_BANNER_IMAGE= # 이미지 강조형 (본문 하단)
광고그룹 ID 는 비워둬도 앱은 그대로 동작합니다 — 배너 자리만 생기지 않아요. VITE_ 변수는 빌드 시점에 번들로 구워지니 값을 바꾸면 반드시 다시 빌드하세요. 실제 광고는 토스 앱/샌드박스에서만 렌더됩니다.