글 목록으로개발 경험

건강검진 예약을 기존 서비스에 연결한 과정

검사 선택과 예약 일정, 완료 확인을 연결한 일러스트

글의 주제를 표현한 일러스트예요. 실제 서비스 화면은 아니에요.

건강검진은 원래 세이프닥 안에서 예약하지 않았어요. 외부 사이트에서 정보를 보고 전화로 문의하는 방식이었어요. 한 제휴 파트너가 세이프닥에서 검진 예약을 받고 싶다고 요청하면서, 기존 프로그램에 검진을 넣는 일을 시작했어요.

이제 사용자는 복지몰에서 검진을 살펴보고 신청과 결제를 해요. 병원과 운영팀은 각 관리자 서비스에서 프로그램과 예약을 관리하고요.

세이프닥 사용자는 대부분 제휴 기업을 통해 들어와요. 예약할 병원이 부족하면 원하는 지역과 검진을 찾기 어렵고, 병원도 예약이 있어야 입점할 이유가 생겨요. 이 작업에서는 이미 제휴한 병원의 검진을 복지몰 안에서 신청할 수 있도록 연결했어요.

기존 비급여 프로그램은 병원이 등록하고 운영팀이 검수했어요. 병원 관리자에서 올려 검수를 요청하면 Back-Office에서 관련 법규와 서비스 정책에 맞는지 보고 승인해요. 그 뒤 복지몰에 공개되고 사용자가 방문예약을 신청해요. 병원 관리자의 등록과 검수 요청, Back-Office 검수는 제가 처음 만든 화면이기도 해요.

하지만 검진은 기업별 검진 코드를 쓰거나 예약금을 미리 결제해요. 구성도 기본 검사와 그룹마다 정해진 개수를 고르는 선택 검사로 나뉘고요. 기존 폼에 진료과만 하나 늘리는 일은 아니었어요. 스펙에서 빠진 동작은 개발팀장과 부팀장에게 확인하며 정했어요.

그림은 직접 확인한 화면을 바탕으로 다시 그렸고, 코드는 설명에 필요한 부분만 남겼어요.

같은 프로그램이라도 비급여와 검진은 등록하는 사람부터 신청 뒤에 일어나는 일까지 달라요

같은 프로그램이라도 비급여와 검진은 등록하는 사람부터 신청 뒤에 일어나는 일까지 달라요

검수 입구를 막는 단위를 파트너에서 프로그램으로 옮겼어요

비급여 프로그램은 병원이 신규 등록이나 수정, 삭제 검수를 요청하면 검수중 상태가 돼요. 운영팀이 Back-Office에서 검토해 반려하면 사유가 병원 쪽 검수 내역에 남고, 승인하면 프로그램과 노출 일시가 정해져요.

검진은 운영팀이 Back-Office에서 구성까지 직접 등록해요. 병원은 결과만 조회하므로 검수를 요청할 필요가 없어요. 병원 관리자의 등록과 삭제, 검수요청 버튼을 막고 상세의 입력도 잠가야 했어요.

처음에는 파트너의 진료과에 검진이 있으면 잠갔어요. 그러면 검진과 피부과를 같이 하는 병원은 피부과 프로그램도 수정할 수 없고 검수 메뉴도 없어져요. 병원 전체를 막는 것과 검진 프로그램만 막는 것을 나눠야 했어요.

  • 프로그램 상세는 조회한 프로그램의 진료과로 가려요. 파트너 조회에 기대지 않아요.
  • 메뉴와 목록의 읽기 전용 모드는 진료과가 건강검진뿐인 파트너에게만 켜요. 이런 파트너는 검수를 요청할 프로그램이 아예 없으니까요.
  • 검진과 다른 진료과를 함께 가진 파트너에게는 신규 등록과 예전 임시저장 불러오기, 반려 건 재요청이 모두 같은 폼을 거쳐요. 그래서 폼의 카테고리 검증이 건강검진 진료과 제출을 거절하게 했어요. 공용 캐스케이더에 한 곳만 쓰는 prop을 늘리지 않으면서, 이미 저장된 임시저장, 반려 건에 실린 건강검진 진료과까지 한 자리에서 잡으려는 선택이었어요.

검진 판별은 진료과 이름 대신 id를 쓰도록 바뀌었어요. 화면마다 비교하지 않고 공용 isHealthCheckDepartment로 모았어요. 환경별로 같은 id인지 진료과 목록 API에서 확인한 내용도 상수 옆에 뒀어요.

같은 검사 구성을 편집하고, 잠그고, 선택하게 했어요

검진 구성에는 기본 검사 목록과 선택 그룹이 있어요. 그룹은 이름과 pickCount, 선택 가능한 검사들을 가져요. Back-Office에서 저장한 이 값을 병원 관리자는 읽기 전용으로 보여 주고, 복지몰은 상세 요약과 신청할 때의 선택 화면으로 보여 줘요.

같은 검진 구성을 Back-Office는 편집하고, 병원 관리자는 잠근 채 보여 주고, 복지몰은 요약과 선택 화면으로 그려요

같은 검진 구성을 Back-Office는 편집하고, 병원 관리자는 잠근 채 보여 주고, 복지몰은 요약과 선택 화면으로 그려요

등록할 때 같은 검사 항목을 여러 그룹에 넣으면 한 예약에 중복해서 들어갈 수 있어요. 그래서 다른 그룹에 이미 있는 항목은 선택하지 못하게 했어요.

typescript
/** owner 를 뺀 나머지 그룹이 이미 가져간 검사항목 id. 다른 그룹의 선택지는 잠근다. */
export function getItemIdsTakenByOtherGroups(values: CompositionValues, owner: 'included' | number): number[] {
  const fromIncluded = owner === 'included' ? [] : (values.includedItemIds ?? []);
  const fromGroups = (values.selectableGroups ?? []).flatMap((group, index) =>
    index === owner ? [] : (group?.itemIds ?? []),
  );
  return uniq([...fromIncluded, ...fromGroups]);
}

미리보기도 실제 복지몰과 비슷하게 바꿨어요. 그룹명과 항목명이 한 줄에 놓인 표로는 고객이 볼 구성을 가늠하기 어려웠어요. 상세의 검사 구성 섹션을 가져와 기본 검사는 개수로, 선택 검사는 그룹별 카테고리와 칩으로 그렸어요. 미리보기의 항목 보기 버튼은 모양만 남기고 그룹은 펼쳐 뒀어요.

검진을 다른 진료과로 바꿀 때는 요청 순서에서 문제가 났어요. 프로그램 수정은 성공했는데 검진 구성을 비우는 요청이 실패하면, 다시 읽은 프로그램은 검진이 아니라 구성 조회부터 꺼져요. 재시도해도 남은 구성을 지우지 않고 창이 닫혔어요. 저장 훅이 실패한 비우기를 기억하도록 바꾸고, 등록과 수정 모달, 상세가 이 훅을 함께 쓰게 했어요. 재시도 테스트도 이 순서를 따라가요.

HEALTH_CHECK_ITEM_UNAVAILABLE로 수정이 거절된 경우에는 응답에 삭제된 검사 항목이 섞여 있었어요. 화면에서 몰래 걸러 저장하는 대신 그 응답을 백엔드 담당자에게 전달했어요.

상세 화면은 조건을 얹지 않고 둘로 나눴어요

동료가 만든 비급여 상세 화면에 검진 조건을 추가할 수도 있었어요. 그런데 쿠폰과 전화상담을 뺄 자리를 찾고 검사 구성을 넣으려니, 여기저기에 healthCheck 분기가 필요했어요.

검진은 쿠폰 조회와 쿠폰 적용가를 쓰지 않고, 전화상담 대신 검사 구성이 있어요. 어느 쪽을 고치든 두 화면의 분기를 함께 읽게 두기보다는 상세 View를 따로 만들기로 했어요.

상세 페이지는 조회, 최근 본 시술 저장, 화면 선택만 하고, 두 View가 각자 자기 구조를 조립해요

상세 페이지는 조회, 최근 본 시술 저장, 화면 선택만 하고, 두 View가 각자 자기 구조를 조립해요

어느 View를 그릴지는 programInfo.healthCheck가 정해요. 신청 폼을 고르는 기준도 같은 값이에요.

tsx
// 설명을 위해 줄이고 이름을 바꾼 코드예요. 페이지는 조회·최근 본 시술 저장·화면 선택만 해요.
function ProgramPage({ programId }: Props) {
  const { data: program } = useQuery(programDetailOptions(programId));
  useSaveRecentProgram(program);
 
  if (!program) return null;
 
  return program.programInfo.healthCheck ? (
    <HealthCheckProgramDetailView program={program} />
  ) : (
    <ProgramDetailView program={program} />
  );
}

각 View가 자신의 전체 구성을 그려요. 기존 구획과 상단바, 비로그인 버튼과 예약 버튼, 인증 뒤 이동 훅과 이벤트 헬퍼는 함께 써요. 가격 구획은 쿠폰 아코디언을 직접 알지 않도록 아래 슬롯을 열었어요. 비급여는 쿠폰 적용가를 넣고 검진은 비워 둬요. 분석 이벤트는 같은 모양을 유지하고, 검진의 쿠폰 칸에는 할인 전 가격을 넣었어요.

신청 폼도 따로 뒀어요. 검진에는 검사 선택과 공단 검진 여부, 주소와 기업 검진 코드, 청약철회 안내가 필요해요. 비급여에는 쿠폰과 추가 문의가 있어요. 이 둘을 한 폼에서 조건으로 계속 가르지 않도록 나눈 거예요.

병원 상세의 카드는 응답에 healthCheck가 없어 같은 값을 쓸 수 없었어요. 그 목록에서는 진료과로 판별하고 검진 카드의 전화상담을 뺐어요.

택 N을 채우면 남은 칩을 잠갔어요

선택 검사는 정밀 영상검사 중 2개처럼 그룹마다 고를 개수가 정해져 있어요. 처음에는 한 그룹을 채우면 다음 그룹을 자동으로 열어 달라는 요청이 있었어요.

하지만 이미 고른 검사를 바꾸는 중에도 개수가 차면 다음 그룹으로 넘어가요. 당시 구현은 정해진 개수를 넘겼을 때 먼저 고른 검사도 조용히 빼고 있었어요. 선택한 줄 알았던 검사가 사라질 수 있어서, 자동 이동 문제와 함께 담당자에게 동작을 다시 확인했어요.

그룹을 채우면 남은 칩이 잠기고, 모든 그룹을 채워야 선택 완료가 켜져요

그룹을 채우면 남은 칩이 잠기고, 모든 그룹을 채워야 선택 완료가 켜져요

그룹을 다 채우면 고르지 않은 칩을 잠그기로 했어요. 바꾸려면 먼저 하나를 해제해야 해요. 시트가 다음 그룹으로 넘어가거나 예전 선택을 밀어내지 않으니 선택 함수는 배열에 넣고 빼는 일만 하면 됐어요.

tsx
// 선택 로직은 넣고 빼기만 해요. 밀어내기와 pickCount 인자를 걷어냈어요.
export const toggleHealthCheckItem = (selected: number[], itemId: number) =>
  selected.includes(itemId) ? selected.filter((id) => id !== itemId) : [...selected, itemId];
 
// 넘치는 선택은 화면이 막아요(설명을 위해 줄인 코드예요).
const isFull = selected.length >= group.pickCount;
<Chip pressed={isSelected} disabled={isFull && !isSelected} onClick={() => onToggle(item.id)} />;

디자인에는 잠긴 칩의 모양이 없어서 공용 Chip의 비활성 스타일을 요청했어요. 이 시트에서 그 스타일을 사용해요. 테스트도 오래된 선택을 밀어내는 검사에서, 개수가 차면 잠기고 해제하면 다시 고를 수 있는 검사로 바꿨어요. 잠금을 제거하면 테스트가 실패하는지도 확인했어요.

신청부터 접수까지는 실패하고 돌아오는 길부터 정했어요

신청을 끝내고 금액 확인 시트를 지나면 결제로 가요. 개발 중에는 예약과 주문을 만드는 순서도 바뀌었어요. 예전에는 예약을 먼저 만들었지만, 새 checkout은 결제가 필요한지부터 정해요. 기업 지원과 0원 신청은 접수가 끝나고, 예약금이 있을 때만 주문을 받아 결제창을 열어요. 결제 안 한 예약을 남기지 않는 이 흐름에 맞춰 라우트와 목, E2E도 바꿨어요.

결제창을 다녀온 결과마다 사용자가 돌아갈 자리를 먼저 정했어요

결제창을 다녀온 결과마다 사용자가 돌아갈 자리를 먼저 정했어요

모바일 토스페이먼츠는 리다이렉트로 결과를 돌려줘서, 돌아오면 페이지 메모리가 비어 있어요. 입력한 값을 sessionStorage에 orderId와 함께 저장했어요. 같은 결제 시도이고 현재 폼 스키마를 통과할 때만 한 번 복원해요. 읽은 값은 유효하든 아니든 지워서 주소와 검사 선택이 다음 신청에 섞이지 않게 했어요.

결제 테스트 중에는 승인 응답이 성공인데 상태가 REFUNDED인 경우도 있었어요. 카드와 간편결제만 허용하는데 위젯에는 퀵계좌이체가 기본 선택돼 있었고, 그 수단으로 내면 서버가 바로 전액 환불해 예약을 만들지 않았어요. 결제수단은 SDK 옵션이 아닌 결제사 관리 화면의 위젯 설정에서 정해요. 프론트엔드에서는 예약이 없는데 예약 목록으로 보내던 결과 처리를 고쳤어요. 실패처럼 신청 화면으로 돌아가 값을 복원하고 이유를 알리며, 결과를 알 수 없을 때만 예약 목록으로 보내요.

checkout이 끝나도 결제창이 바로 뜨지는 않았어요. SDK를 기다리는 최대 10초 사이 신청 버튼이 풀려 다시 누르면 주문이 더 만들어졌어요. 주문 생성부터 결제창이 열릴 때까지 한 mutation으로 묶어 잠갔어요. 확인 시트가 열리기 전 연타는 이미 있는 폼 제출 상태로 막았고요.

새 검진 옆에서 기존 예약도 이어져야 했어요

검사 선택 잠금과 필수 약관, 결제 인계 값의 저장과 복원, 삭제는 Vitest로 확인해요. dev API를 쓰는 E2E에서는 토스 테스트 위젯을 열고, 실패 후 입력이 돌아오는지와 승인 요청 실패의 이동 경로, 기업 검진 코드를 따라가요.

관리자에서는 검진과 다른 과를 함께 하는 병원이 비급여 프로그램을 등록할 수 있는지, 진료과를 바꿀 때 검진 구성이 지워지는지를 확인해요. 등록과 조회는 dev API E2E와 계약 테스트로 이어 봤어요. 화면에서도 비급여 등록과 검수 요청, 승인, 예약을 진행하고 검진 등록과 신청, 결제까지 따라갔어요.

참고