CLOVA Nursing AI Agent

제품 · 협업

대표 글

회원가입을 없애니 막혔던 도입이 풀렸다 - 계정을 병상에 둔 제안

병원에서 쓰는 서비스인데, 도입 직전에 막혔다. 원인은 기능이 아니라 회원가입 4스텝이었다. 이메일까지 받는 가입 절차가 20~30대에겐 익숙하고 편리했지만, 실제 사용처는 60대 이상 암 병동 환자였다. 이메일이 없는 분도 많고, 가입까지의 단계 자체가 너무 큰 장벽이라 "이대로는 도입이 어렵다"는 말이 현장에서 나왔다. 이 글은 그 막힘을, 회원가입을 없애고 계정을 병상에 두자고 팀에 제안해 푼 이야기다. 제안이 코드가 되는 과정에서, 이미 있던 백엔드 필드·미들웨어 게이트·소켓 이벤트가 어떻게 그대로 재료가 됐는지까지 적었다.

도입을 막은 한 마디 - 현장 수간호사의 피드백

계약 전, 실제로 서비스를 쓸 수간호사에게서 온 피드백이었다. 그런데 손쉽게 고칠 수 있는 상황이 아니었다. 제약이 셋이나 겹쳐 있었다.

  • 리소스가 없었다. 팀은 이미 다음 프로젝트로 옮겨가 있었다. 우리 조직은 프로젝트당 기획·디자인·PM을 한 사람이 맡는 구조라 가용 리소스가 더 빠듯했고, 도입까지 남은 시간도 얼마 없었다
  • 전화번호 로그인은 막혀 있었다. 가입을 간소화하자는 의견은 전부터 있었지만, 병원 민감 데이터 특성상 바로 도입하기엔 이슈가 있었다
  • 그렇다고 가입을 빼면 동의를 받을 데가 사라졌다. 서비스 도입의 필수 조건이 환자별 동의인데, 회원가입을 통째로 들어내면 그 동의를 받을 화면이 없어진다

동의는 필수, 가입은 장벽, 대안은 막힘, 시간은 없음 - 여기서 멈춰 있었다.

질문을 뒤집다 - 계정을 사람이 아니라 병상에 두면?

그래서 문제를 다시 적었다. "동의는 유지하고 회원가입·로그인만 유저에게서 뺀다면, 병상별 계정 재사용을 개발적으로 가장 적은 비용으로 푸는 방법은 뭘까?" 여기서 내가 제안한 건 계정을 환자가 아니라 병상에 두는 모델이었다.

  • 입원 시 이름만 입력하고 동의를 받으면 → 그 병상 계정에 patient_id를 생성
  • 퇴원 시 → 해당 계정의 환자를 초기화(비움)
  • 한 병상 = 하나의 고정 계정, 환자는 회원가입이 아니라 입·퇴원으로 교체된다
병상 계정 로그인 모델을 팀에 제안한 회의 정리본
팀에 제안한 회의 정리본 - "병상당 로그인 계정, 퇴원 후 재사용, 계정당 동일한 user_id 사용하되 환자별 patient_id로 구분".

기존 로그인 체계는 그대로 두고, 강원대 암병동 계정에만 이 등록 로직을 얹는 게 요구사항이었다. 계정은 user_id로 구분되고, 그 계정 안에서 환자가 입원할 때마다 새 patient_id가 생긴다.

왜 적은 리소스로 가능했나 - 백엔드 필드를 먼저 열어봤다

제안이 현실적이려면 "구조를 새로 짜야 한다"가 아니어야 했다. 그래서 BE 필드부터 봤다 - 계정별로 patient_iduser_id이미 유니크로 관리되고 있었다. 즉 "계정 재사용"은 새 테이블이나 스키마가 아니라 patient_id의 생성·초기화만으로 가능했다. BE 스키마 변경은 0이었다. 실제로 이 등록은 백엔드에 이렇게 정의됐다 - POST /patients/admission, 설명 한 줄은 "현재 로그인한 계정(병상 계정)에 새로운 patient를 등록합니다."

이 발견 덕에 팀이 막혀 있던 다른 고민도 같이 풀렸다. 재사용을 하려면 이전 환자의 기록을 새 필드에 보관하느냐, 지우느냐를 정해야 했는데 - 이게 리소스 부담이라 결정을 못 내리고 있었다. 퇴원 시 병상 계정의 환자만 비우면 이전 세션과 분리되니, 그 논쟁에 더는 막혀 있지 않아도 됐다.

접근을 가르는 장치도 새로 만들 게 아니었다. 이미 간호사 온보딩이 같은 모양으로 돌고 있었다 - 로그인은 됐지만 agree_to_policyfalseadminPageLoader/admin/onboarding으로만 보내고, 동의가 끝나면 서비스로 풀어준다. 미들웨어가 토큰의 한 필드를 읽어 페이지를 가르는 이 패턴을, 환자 쪽에 그대로 가져오기만 하면 됐다.

입원 - 4스텝을 2스텝으로

회원가입 폼을 버리지 않았다. 기존 UI를 그대로 살리되 받는 값과 스텝만 줄였다. 이름 입력 → 약관 동의, 딱 두 스텝이다.

입원 1스텝 - 신규 환자 이름 입력입원 2스텝 - 이용약관 동의 후 환자 등록 완료
실제 입원 흐름 - (좌) 이름 입력 → (우) 약관 동의 후 "환자 등록 완료". 상단 인디케이터가 2스텝임을 보여준다.
[입원] 이름 입력(1~4자 한글) ─→ 이용약관 동의
   └─ POST /patients/admission { name, agree_to_policy, input_type }
        └─ BE: 병상 계정(user_id)에 새 patient_id 생성
             └─ router.replace('/patient')  → 바로 서비스 진입

이름 검증은 회원가입과 별개의 스키마로 뺐다. 병동 특성상 이름은 짧은 한글이라, 규칙을 좁게 잡았다.

// entities/auth/schema/admission-form-schema.ts
export const admissionFormSchema = z.object({
  name: z
    .string()
    .min(1, { message: '이름을 입력해주세요.' })
    .max(4, { message: '이름은 최대 4자까지만 입력할 수 있습니다.' })
    .regex(/^[가-힣]+$/, { message: '이름은 한글만 입력할 수 있습니다.' }),
  agreeToPolicy: z.boolean().refine((value) => value, {
    message: '필수 약관에 동의해야 합니다.',
  }),
});

두 스텝을 통과하면 폼이 그대로 등록 API로 흘러간다. 회원가입과 달리 이메일도 비밀번호도 없다.

// features/patient-admission/ui/admission-step.tsx
const onSubmit = async (data: TAdmissionFormValues) => {
  await patientAdmission.mutateAsync({
    body: {
      name: data.name,
      agree_to_policy: data.agreeToPolicy,
      input_type: '음성',
    },
  });
  router.replace('/patient');
};

Steps 컴포넌트에 넘기는 배열도 ['이름 입력', '약관 동의'] 둘뿐이다. 기존 회원가입 스텝바·인풋·약관 체크박스는 재사용하고, 단계 수만 깎았다. 무엇이 달라졌는지 한 표로 정리하면 이렇다.

항목기존 회원가입병상 계정 모델
계정 단위사람(환자)마다 1계정병상(기기)마다 고정 1계정
입력 스텝4스텝2스텝 (이름 입력 → 약관 동의)
받는 값이름·이메일·비밀번호 등이름 + 이용약관 동의만
식별email/password 로그인병상 계정 user_id, 환자 patient_id
환자 교체없음(1인 1계정)입·퇴원으로 교체
BE 스키마-변경 0 (기존 유니크 필드 재사용)

퇴원 - 로그아웃이 아니라 등록 페이지로

퇴원을 "로그아웃"으로 다루면 병상 계정이 죽어버린다. 그래서 퇴원의 종착지를 로그인이 아니라 다음 환자의 등록 화면으로 바꿨다. 기존엔 퇴원 완료 시 쿠키를 지우고 /patient/login으로 내보냈는데, 그 로직을 걷어내고 소켓 이벤트가 오면 환자 기기를 등록 페이지로 되돌리게 했다.

// features/socket/hooks/use-socket-error.ts
case '퇴원 처리가 완료되었습니다.':
  toast.error('퇴원 처리되었습니다.\n3초 후에 환자 등록 페이지로 이동합니다.');
  setTimeout(() => {
    router.replace(`/${type}/patient-admission`);
  }, 3000);
  break;
[퇴원] 간호사 퇴원 처리 (PATCH /patients/:patient_id/status { status: '퇴원완료' })
   └─ 소켓: '퇴원 처리가 완료되었습니다.'
        └─ 환자 기기: 3초 후 router.replace('/patient/patient-admission')
             └─ 병상 계정은 살아있고, 환자만 비워져 다음 등록을 기다린다

로그아웃을 없앤 게 핵심이다. 계정(로그인)은 병상에 붙박이로 두고, 그 위에서 환자만 갈아 끼운다.

접근제어 - 한 필드가 화면 전체를 가른다

병상 계정에 지금 등록된 환자가 있는지 없는지, 이 1비트가 화면 전체의 권한을 가른다. 채워져 있으면 서비스, 비어 있으면 등록 페이지. 프론트에서는 이 비트를 토큰의 name으로 읽었다 - 등록된 환자가 없으면 namenull이기 때문이다. 미들웨어가 그 값을 로더로 넘긴다.

// middleware.ts
.with(P.string.startsWith('/patient'), () =>
  patientPageLoader(request, response, data.role, data.name)
)

로더는 두 줄의 리다이렉트로 갈림길을 만든다. 등록 전(name == null)엔 등록 페이지 밖으로 못 나가고, 등록 후(name != null)엔 등록 페이지로 되돌아갈 수 없다.

// features/loader/utils/patient.ts
const isAdmissionPage = pathname === '/patient/patient-admission';
 
if (!name && !isAdmissionPage) {
  return NextResponse.redirect(new URL('/patient/patient-admission', request.url));
}
if (name && isAdmissionPage) {
  return NextResponse.redirect(new URL('/patient', request.url));
}

URL을 직접 쳐서 우회하는 것도 여기서 막힌다. 앞서 본 간호사 온보딩(agree_to_policy 게이트)과 정확히 같은 구조라, 새 개념을 배울 필요도 리뷰 부담도 없었다. 있던 패턴에 필드 하나만 바꿔 끼운 셈이다.

간호사 대시보드 - 병상에 환자를 붙이고 떼기

병상 계정 모델이 서려면, 간호사가 계정과 병상을 손으로 주무를 수 있어야 했다. 그래서 대시보드에 병상 단위 CRUD를 붙였다. 배정과 해제는 병상 ID를 키로 도는 한 쌍의 API다.

병상 배정   POST   /patients/bed/:bed_id   { user_id, socket_id }
병상 해제   DELETE /patients/bed/:bed_id   { user_id, socket_id }
환자 수정   PUT    /patients/:patient_id
퇴원 처리   PATCH  /patients/:patient_id/status  { status, socket_id }

배정 다이얼로그는 PatientAssignmentDialog > PatientListWithSubmitButton > PatientList로 쪼개, 로딩·빈 목록·본문을 각각 early-return으로 갈랐다. 배정 대상은 bed_assign: false(아직 병상이 없는 입원 환자)만 무한스크롤로 불러온다. 손이 겹치는 곳이라 충돌 상태를 백엔드가 문구로 돌려주는데, 그대로 토스트에 태웠다.

  • 해당 병상에 배정된 다른 환자가 있습니다. (DuplicatedBedAssignedResponse)
  • 이미 병상이 해제된 환자입니다. (NoBedAssignedResponse)
  • 퇴원처리 되었거나 존재하지 않는 환자입니다. (NotFoundPatientResponse)
  • 병실에 남은 병상이 없습니다. / 이미 존재하는 병상입니다. (병상 추가·삭제 시)

여러 간호사가 같은 병동을 동시에 볼 때 목록이 어긋나지 않도록, 배정·해제·수정·퇴원 요청엔 요청자의 socket_id를 실어 보낸다. 백엔드가 patient_update 소켓을 뿌리면, 요청자를 제외한 나머지 간호사 화면이 환자 목록 쿼리를 무효화한다.

퇴원 후 우측 수정폼이 빈 화면으로 튀지 않게, 포커스도 손봤다. 대시보드는 현재 보고 있는 환자를 store에 patientId(선택 없음은 -1)와 patientIndex로 들고 있다가, 퇴원 처리 후 이전/다음 환자로 포커스를 넘긴다.

// 퇴원 처리 후, 남은 환자로 포커스 이동
match({ length: patientList?.length, index: patientIndex })
  .with({ length: 1 }, () => {
    setPatientId(-1); // 남은 입원중 환자가 없는 경우
    setPatientIndex(0);
  })
  .with({ index: 0 }, () => null)
  .with({ index: P.number }, () => {
    setPatientId(patientList[patientIndex - 1].patient_id);
    setPatientIndex(patientIndex - 1);
  })
  .otherwise(() => null);

여기서 setPatientId는 병상 계정의 환자 생성이 아니라, 간호사가 지금 보고 있는 환자를 가리키는 UI 포커스다. 병상 계정의 patient_id(입원 시 생성·퇴원 시 초기화)와는 층이 다르다 - 하나는 데이터의 수명, 하나는 화면의 커서다. 둘을 섞지 않은 덕에 연속 퇴원에서도 커서가 튀지 않았다.

설득은 문서가 아니라 흐름으로

제안을 글로만 들이밀지 않았다. 입원 → 서비스 → 퇴원 → 재등록으로 이어지는 흐름을 손으로 그려가며 화면 단위로 보여줬다. 추상적인 "이렇게 하자"보다, 눈에 보이는 화면 전환이 있으니 합의가 훨씬 빨랐다. "회원가입을 없앤다"는 큰 결정도, 그림 위에서는 "이 스텝 두 개를 지우고 이 리다이렉트 하나를 바꾼다"로 작아졌다.

결과

영업일 10일 안에 도입을 완료했다(강원대병원). 빠르게 띄운 뒤 PM 피드백에 맞춰 UI를 다듬어 현장 사용에 들어갔다. BE 스키마는 손대지 않았고, 프론트는 기존 회원가입 UI·미들웨어 로더·소켓 핸들러를 재사용해 등록 스텝만 새로 얹었다.

남긴 것

  • 계정의 단위는 기술이 아니라 사용자 환경이 정한다. 일반 웹은 "사람 = 계정"이지만, 병상이 고정이고 환자가 도는 곳에선 "병상 = 계정"이 맞았다. 그 한 줄의 재정의가 4스텝을 2스텝으로 줄였다
  • 제약이 겹칠수록 새 구조보다 있는 걸 다르게 쓰는 쪽이 빠르다. 리소스·시간·민감 데이터가 한꺼번에 막아설 때, 답은 이미 유니크하게 있던 patient_id·user_id와 이미 돌던 미들웨어 게이트를 재사용하는 것이었다. BE 스키마 변경 0이 그 증거다
  • 권한은 한 비트로 가를 수 있으면 가른다. 환자 접근은 토큰의 namenull인지 아닌지로 갈렸고, 이건 간호사 온보딩이 agree_to_policy로 이미 쓰던 패턴이었다. 새 게이트가 아니라 같은 게이트의 필드만 바꾼 것이다
  • 같은 개념이라도 데이터의 수명과 화면의 커서는 다른 층이다. 병상 계정의 patient_id와 대시보드의 포커스용 patientId를 섞지 않은 덕에, 입원·퇴원·연속 퇴원에서 상태가 엉키지 않았다
  • 퇴원을 로그아웃이 아니라 재등록으로 본 게 모델의 핵심이었다. 계정을 병상에 붙박아 두고 환자만 갈아 끼우니, 쿠키 삭제와 로그인 왕복이 통째로 사라졌다
  • 가장 큰 설계 결정은 현장의 한 마디에서 나왔다. 사용자에게서 출발하니 역제안의 근거가 분명했고, 흐름을 그려 보여주니 합의가 빨랐고, 멈춰 있던 도입이 풀렸다