(HOME)(SIDE-PROJECT)
(KO/EN)

클라우드호스피탈 · 2023.03 ~ 2024.12

다국어 병원 플랫폼

Frontend Developer · SaaS 프로젝트

전 세계 병원과 환자를 연결하는 의료 플랫폼입니다. 메인 사이트, 병원별 SaaS 웹사이트, 운영용 어드민을 함께 개발했고, 해외 개발자와 QA가 섞인 팀이라 이슈와 PR은 영어로 소통했습니다.

WebSiteUrl ->

맡은 일

50 → 1,000+
상담 예약 고객사 성장에 기여
-46%
Admin First Load JS (289 → 155kB)
-33%
HTML 초기 로딩 (1.79s → 1.2s)
40+
지원 언어 (LTR, RTL)

역할과 협업

  • Frontend Developer · SaaS 프로젝트 (홈페이지 빌더, 상담 예약 시스템 담당)
  • GitHub Issues/Projects, 2주 스프린트, 팀 위키 문서화, Slack
  • Next.js
  • React
  • TypeScript
  • SWR
  • React Hook Form
  • Framer Motion
  • Tailwind CSS
  • next-auth
  • Storybook

담당한 서비스

  • SaaS: 병원마다 자체 도메인 웹사이트를 만들어 주는 홈페이지 빌더. 담당자
  • Admin: 병원, 의사, 예약, 콘텐츠를 관리하는 운영 도구. 레거시 마이그레이션
  • iCH: 병원 검색과 예약을 제공하는 메인 사이트. SEO 개선

SaaS · 2023.06 ~ 2024.12

병원마다 웹사이트를 만들어 주는 SaaS

SaaS 프론트엔드를 담당하며 기능 개발부터 릴리즈 관리까지 맡았습니다. 아랍어를 포함한 40개 이상 언어 환경과 병원별 도메인이 핵심 과제였습니다.

페이지 에디터와 렌더러

병원이 어드민에서 페이지와 다단계 헤더, 푸터 메뉴를 직접 편집하면 SaaS가 그대로 렌더링하는 구조를 만들었습니다. 메뉴 순서는 드래그로 바꾸고, 항목마다 전화, 이메일, 위치 아이콘을 고를 수 있습니다. 순서 변경이 있는 동적 폼은 useFieldArray로 구현했습니다.

위는 어드민의 헤더, 푸터 메뉴 편집 화면, 아래는 실제 병원 사이트에서 펼친 다단계 헤더
위: 어드민의 헤더, 푸터 메뉴 편집 화면 / 아래: 실제 병원 사이트에 렌더링된 다단계 헤더 (병원 정보와 사진은 모자이크)

40개 이상 언어와 LTR, RTL 대응

Tailwind의 pl, mr 같은 방향 클래스는 RTL에서 뒤집히지 않아, 코드마다 dir 삼항 연산자가 늘어나고 있었습니다. CSS 논리 속성(ps, pe, start, end)으로 바꾸는 규칙과 반응형 우선순위 함정을 위키로 정리해 팀에 공유했습니다. 40개 이상 언어의 번역은 GPTs 기반으로 자동화했습니다.

LTR과 RTL에서 padding 적용 결과를 비교한 팀 위키 문서

글로벌 상담 예약 시스템

예약 화면을 Framer Motion 인터랙션으로 고도화하고, Walk-in 예약과 취소, 환불 흐름을 백엔드와 API 단위로 설계했습니다. 고객사가 50곳에서 1,000곳 이상으로 늘어나는 동안 예약 시스템 개발을 담당했습니다.

검색엔진이 읽을 수 있는 페이지로, 더 빠르게

검색 노출이 필요한데도 CSR로 구현돼 있던 페이지를 SSR과 SSG로 바꾸고, SSR로만 그리던 페이지 중 자주 바뀌지 않는 곳은 SSG와 On-Demand Revalidation으로 옮겼습니다.

서버에서 API를 하나씩 기다리던 곳은 Promise.all로, 일부가 실패해도 화면은 그려야 하는 곳은 Promise.allSettled로 병렬화해 HTML 초기 로딩을 1.79초에서 1.2초로(약 33%) 줄였습니다. Azure Search 기반 사이트맵 생성 로직도 고쳐 빠져 있던 약 5%의 페이지를 복구하고, 언어별 alternate 링크를 추가했습니다.

Admin · 2023.03 ~ 2023.06

레거시 어드민을 SWR과 React Hook Form으로

여러 사람을 거친 어드민은 1,000줄이 넘는 컴포넌트와 복사해 붙인 코드가 많았고, Redux와 개발이 멈춘 Formik에 묶여 있었습니다. 스프린트 회의에서 React Hook Form 전환을 제안해 바로 채택됐고, 상세 페이지 마이그레이션과 폼 전환을 맡았습니다.

289kB → 155kB
First Load JS shared by all
208kB → 47.3kB
_app 청크
363kB → 201kB
다수 페이지의 First Load JS
next build 결과 비교. 왼쪽 Before 289kB, 오른쪽 After 155kB
next build 결과. 왼쪽 Before, 오른쪽 After (경로명은 모자이크). Redux → SWR, Formik → React Hook Form, react-select 제거

검색 조건을 몰라도 같은 데이터를 꺼내 쓰는 훅

SWR은 API 주소 전체를 이름표로 써서 데이터를 저장합니다. Admin의 API 주소에는 ?page=1&limit=10 같은 검색 조건이 붙어 있어서, 다른 컴포넌트가 이미 받아 둔 데이터를 쓰려면 같은 검색 조건까지 props로 계속 넘겨야 했습니다.

데이터를 부를 때 짧은 이름을 붙이는 규칙을 정하고, 그 이름으로 SWR 캐시에서 key를 찾아 데이터를 꺼내 읽는 훅을 만들어 props 전달을 없앴습니다. Admin 18개 파일에서 썼고, 만든 이유와 사용법은 팀 위키에 정리했습니다. (2023.04 ~ 05)

// 부모: 데이터를 부를 때 이름을 붙인다
getArticleContributorsSWR(searchOption, 'getArticleContributors')

// 자식: 검색 조건 없이 이름만으로 꺼내 읽기
const { data } = useSWRGetCacheData('getArticleContributors')

// 새로고침은 SWR 2.0 기본 기능으로도 가능
mutate(key => typeof key === 'string'
  && key.includes('getArticleContributors'))

이름으로 새로고침하는 기능도 함께 만들었지만, 당시 쓰던 SWR 2.0의 mutate 필터 함수로도 할 수 있었습니다. 지금이라면 key 앞에 고정 이름을 둔 배열 key와 검색 조건을 URL에서 읽는 커스텀 훅으로, 캐시를 검색할 필요 자체를 없애겠습니다.

disabled를 걸어도 막히지 않던 중복 제출

저장 버튼을 빠르게 누르면 같은 데이터가 두 번 저장됐습니다. SWR mutation의 onSuccess 안에서 라우팅하면 React Hook Form의 isSubmitting이 그 Promise를 기다리지 않아 버튼이 일찍 풀리는 것이 원인이었습니다.

  • handleSubmit에 넘기는 함수에서 upsert를 await
  • 제출 버튼에 disabled={isSubmitting}
  • 라우팅은 onSuccess 밖에서 await router.push
  • props로 받는 upsert 타입에 Promise 명시
어드민에서 저장 버튼을 눌렀을 때 요청이 한 번만 가는 모습

팀 위키와 블로그로 공유. 삭제 요청 중복도 같은 방식으로 수정

Troubleshooting

원인을 끝까지 찾았던 문제들

프론트엔드에서 끝나지 않는 문제가 많았습니다. 재현 조건과 근거를 정리해 백엔드, QA와 같은 이슈에서 해결했습니다.

로그인한 계정과 다른 사람의 세션이 보이는 문제

Admin, next-auth, 2024.06 ~ 07

증상

A 계정으로 로그인했는데 세션에는 B 계정 이메일이 들어오는 일이 간헐적으로 발생했습니다. 해당 계정을 쓴 적 없는 동료 PC에서도 재현돼 프론트 캐시 문제는 아니었습니다. 발생 조건을 좁혀 보니, 누군가 웹앱 관리의 Logo 탭을 연 뒤 서버가 재시작되기 전에 로그인한 경우였습니다.

원인

브라우저용으로 만든 setInterceptor(session)를 API route에서도 호출하고 있었습니다. 서버는 프로세스 하나를 모든 사용자가 공유하는데, interceptor를 지우는 코드가 return 뒤에 있어 실행되지 않아 그 사람의 토큰이 공용 axios 인스턴스에 계속 남았습니다.

axios는 호출 옵션을 합친 뒤에 request interceptor를 실행합니다. next-auth가 로그인할 때 본인 토큰을 헤더로 직접 넣어 userinfo를 호출해도, 남아 있던 interceptor가 마지막에 헤더를 덮어써 남의 토큰으로 사용자 정보를 받아 오고 있었습니다.

해결

1차로 정리 코드를 return 앞으로 옮겼지만, await하는 사이 다른 사람이 로그인하면 여전히 덮어써지는 동시성 문제가 남았습니다. 그래서 API route는 공용 인스턴스를 쓰지 않고, getServerInstance(req)로 요청마다 토큰을 담은 axios 인스턴스를 새로 만들어 넘기도록 바꿨습니다. 서버 코드에서 interceptors를 변경하지 못하게 ESLint 규칙도 추가했습니다.

결과

서로 다른 토큰으로 userinfo를 동시에 50번 호출하고 응답 이메일을 비교하는 테스트를 만들었습니다. 수정 전에는 다른 사용자의 응답이 섞여 나왔고, 수정 후에는 모두 일치했습니다. 이 테스트를 남겨 같은 실수가 다시 들어오지 않게 했습니다.

Google 검색 노출이 줄어든 이유를 데이터로 확인

iCH, SEO, 2024.02

상황

메인 사이트의 검색 노출이 계속 줄고 있었습니다. 지연 로딩 때문이라는 추측이 있었지만, SEO 키워드 대부분이 어드민에서 입력하는 Description에서 나온다는 점에 주목했습니다.

확인

운영 DB에서 Description이 비었거나 200자 미만인 번역 데이터를 세었습니다. 병원 약 3만 건, 의사 약 34만 건, 진료과목 약 4만 6천 건이었습니다.

후속 작업

어드민에 Description 검증 추가, 필드별 번역으로 비용 절감, 의미 없는 div와 span을 시맨틱 태그로 교체하는 작업을 이슈로 나눴습니다.

Description이 비었거나 200자 미만인 데이터를 세는 SQL
테이블명과 결과 건수는 모자이크

병원이 늘수록 커지는 서브도메인 배포 비용

SaaS, Azure Application Gateway, 2024.07 ~ 11

문제

데모용 서브도메인 SaaS도 병원마다 따로 배포돼, 병원이 늘어날수록 배포 비용이 같이 늘었습니다.

설계

여러 서브도메인을 Application Gateway 하나로 받아 CSR로 만든 App Service 하나에 URL 변경 없이 연결하는 구조를 제안하고 테스트 환경에서 검증했습니다. 런타임 환경변수로 관리하던 병원 정보는 메타데이터 API 호출로 대체했습니다.

기록

구성 절차와 M1 Mac에서 Docker 이미지가 arm64로 빌드되는 문제(buildx로 해결)를 위키 문서로 남겼습니다.

a-hospital.icloudhospital.com ─┐
b-clinic.icloudhospital.com  ──┼─▶ Application Gateway
…                            ──┘          │
                                          ▼
                             App Service ×1 (SaaS, CSR)
                                          │
                                          ▼
                          hospitalSlug → metadata API

개발 밖의 일

DevOps 인력 공백에 대비해 SaaS 운영 인수인계를 받았고, 반복되는 수작업은 자동화했습니다.

SaaS 운영 인수인계

2024.07

5회차에 걸쳐 ArgoCD, Prometheus 모니터링, 도메인 구매와 위임, AKS 배포 흐름을 인수받았습니다.

OAuth 등록 자동화

2024.01

새 병원 서브도메인이 생길 때마다 Google, Facebook 콘솔에 리다이렉트 URL을 손으로 등록하던 작업을 Selenium 스크립트로 자동화했습니다.

정리해서 공유한 글

문제를 해결하면 팀 위키에 먼저 남기고, 공개해도 되는 내용은 블로그로 옮겼습니다.

온보딩 없이 시작한 레거시 마이그레이션, 번들 사이즈 최적화, Formik에서 React Hook Form으로 바꾸자고 제안한 과정, SaaS 프로젝트 인수인계.

FrontEnd Developer

KoJaem Portfolio

Copyright 2026. all rights reserved.

Designed by Jinju Woo · Implemented by KoJaem