제품 · 협업
대표 글회원가입을 없애니 막혔던 도입이 풀렸다 - 계정을 병상에 둔 제안
병원에서 쓰는 서비스인데, 도입 직전에 막혔다. 원인은 기능이 아니라 회원가입 4스텝이었다. 이메일까지 받는 가입 절차가 20~30대에겐 익숙하고 편리했지만, 실제 사용처는 60대 이상 암 병동 환자였다. 이메일이 없는 분도 많고, 가입까지의 단계 자체가 너무 큰 장벽이라 "이대로는 도입이 어렵다"는 말이 현장에서 나왔다. 이 글은 그 막힘을, 회원가입을 없애고 계정을 병상에 두자고 팀에 제안해 푼 이야기다. 제안이 코드가 되는 과정에서, 이미 있던 백엔드 필드·미들웨어 게이트·소켓 이벤트가 어떻게 그대로 재료가 됐는지까지 적었다.
도입을 막은 한 마디 - 현장 수간호사의 피드백
계약 전, 실제로 서비스를 쓸 수간호사에게서 온 피드백이었다. 그런데 손쉽게 고칠 수 있는 상황이 아니었다. 제약이 셋이나 겹쳐 있었다.
- 리소스가 없었다. 팀은 이미 다음 프로젝트로 옮겨가 있었다. 우리 조직은 프로젝트당 기획·디자인·PM을 한 사람이 맡는 구조라 가용 리소스가 더 빠듯했고, 도입까지 남은 시간도 얼마 없었다
- 전화번호 로그인은 막혀 있었다. 가입을 간소화하자는 의견은 전부터 있었지만, 병원 민감 데이터 특성상 바로 도입하기엔 이슈가 있었다
- 그렇다고 가입을 빼면 동의를 받을 데가 사라졌다. 서비스 도입의 필수 조건이 환자별 동의인데, 회원가입을 통째로 들어내면 그 동의를 받을 화면이 없어진다
동의는 필수, 가입은 장벽, 대안은 막힘, 시간은 없음 - 여기서 멈춰 있었다.
질문을 뒤집다 - 계정을 사람이 아니라 병상에 두면?
그래서 문제를 다시 적었다. "동의는 유지하고 회원가입·로그인만 유저에게서 뺀다면, 병상별 계정 재사용을 개발적으로 가장 적은 비용으로 푸는 방법은 뭘까?" 여기서 내가 제안한 건 계정을 환자가 아니라 병상에 두는 모델이었다.
- 입원 시 이름만 입력하고 동의를 받으면 → 그 병상 계정에
patient_id를 생성 - 퇴원 시 → 해당 계정의 환자를 초기화(비움)
- 한 병상 = 하나의 고정 계정, 환자는 회원가입이 아니라 입·퇴원으로 교체된다

user_id 사용하되 환자별 patient_id로 구분".기존 로그인 체계는 그대로 두고, 강원대 암병동 계정에만 이 등록 로직을 얹는 게 요구사항이었다. 계정은
user_id로 구분되고, 그 계정 안에서 환자가 입원할 때마다 새 patient_id가 생긴다.
왜 적은 리소스로 가능했나 - 백엔드 필드를 먼저 열어봤다
제안이 현실적이려면 "구조를 새로 짜야 한다"가 아니어야 했다. 그래서 BE 필드부터 봤다 - 계정별로
patient_id와 user_id가 이미 유니크로 관리되고 있었다. 즉 "계정 재사용"은 새 테이블이나 스키마가
아니라 patient_id의 생성·초기화만으로 가능했다. BE 스키마 변경은 0이었다. 실제로 이 등록은
백엔드에 이렇게 정의됐다 - POST /patients/admission, 설명 한 줄은 "현재 로그인한 계정(병상 계정)에
새로운 patient를 등록합니다."
이 발견 덕에 팀이 막혀 있던 다른 고민도 같이 풀렸다. 재사용을 하려면 이전 환자의 기록을 새 필드에 보관하느냐, 지우느냐를 정해야 했는데 - 이게 리소스 부담이라 결정을 못 내리고 있었다. 퇴원 시 병상 계정의 환자만 비우면 이전 세션과 분리되니, 그 논쟁에 더는 막혀 있지 않아도 됐다.
접근을 가르는 장치도 새로 만들 게 아니었다. 이미 간호사 온보딩이 같은 모양으로 돌고 있었다 - 로그인은
됐지만 agree_to_policy가 false면 adminPageLoader가 /admin/onboarding으로만 보내고, 동의가 끝나면
서비스로 풀어준다. 미들웨어가 토큰의 한 필드를 읽어 페이지를 가르는 이 패턴을, 환자 쪽에 그대로
가져오기만 하면 됐다.
입원 - 4스텝을 2스텝으로
회원가입 폼을 버리지 않았다. 기존 UI를 그대로 살리되 받는 값과 스텝만 줄였다. 이름 입력 → 약관 동의, 딱 두 스텝이다.


[입원] 이름 입력(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으로 읽었다 - 등록된 환자가 없으면
name이 null이기 때문이다. 미들웨어가 그 값을 로더로 넘긴다.
// 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이 그 증거다 - 권한은 한 비트로 가를 수 있으면 가른다. 환자 접근은 토큰의
name이null인지 아닌지로 갈렸고, 이건 간호사 온보딩이agree_to_policy로 이미 쓰던 패턴이었다. 새 게이트가 아니라 같은 게이트의 필드만 바꾼 것이다 - 같은 개념이라도 데이터의 수명과 화면의 커서는 다른 층이다. 병상 계정의
patient_id와 대시보드의 포커스용patientId를 섞지 않은 덕에, 입원·퇴원·연속 퇴원에서 상태가 엉키지 않았다 - 퇴원을 로그아웃이 아니라 재등록으로 본 게 모델의 핵심이었다. 계정을 병상에 붙박아 두고 환자만 갈아 끼우니, 쿠키 삭제와 로그인 왕복이 통째로 사라졌다
- 가장 큰 설계 결정은 현장의 한 마디에서 나왔다. 사용자에게서 출발하니 역제안의 근거가 분명했고, 흐름을 그려 보여주니 합의가 빨랐고, 멈춰 있던 도입이 풀렸다