홈으로 이동컴포넌트 라이브러리로 이동이모티콘 스토리지로 이동
GitHub 프로필 새 창으로 열기
입사하자마자 두 달 만에 완료한 서비스 전체 UI/UX 개편기 배너
2024년 3월 12일·읽는 데 약 3분

입사하자마자 두 달 만에 완료한 서비스 전체 UI/UX 개편기

관련 글

Moong Trip

2024. 3. 5.

Zoom SDK를 활용한 강의 관제 시스템 만들기

2024. 4. 9.

Playwright을 통한 e2e 테스트 도입기

2024. 5. 30.

문제 상황

좋은 기회로 TOPIA Live를 운영하는 스타트업에 합류하게 되었습니다. TOPIA Live는 물리적 거리 때문에 양질의 영어 사교육을 받기 어려운 학생들을 위해 만들어진 온라인 영어 학습 플랫폼입니다. 오프라인 TOPIA가 오랫동안 쌓아온 커리큘럼과 학습 리소스를 기반으로, 오프라인 학원에서는 충분히 활용하기 어려웠던 학생의 성장 데이터를 추적하고, 기존 화상영어 서비스에서 자주 보이던 커리큘럼·콘텐츠 부족 문제를 해결하려는 서비스라는 점이 흥미로웠습니다.

합류 시점에 팀은 서비스 전체 디자인 개편을 한창 진행 중이었고, 적응할 틈 없이 바로 업무를 나눠 맡아야 했습니다. 당시 서비스는 오프라인 TOPIA 학원에서 쓰던 Campus Manager 프로젝트를 기반으로, 그 위에 TOPIA Live에 필요한 기능을 덧붙여온 구조였습니다. 그러다 보니 레거시 코드와 복잡하게 얽힌 화면이 많았고, 기술 스택도 React 16과 일반 CSS에 머물러 있었습니다.

여러모로 상황이 좋지 않았습니다. 3월 학기 오픈 전에 모든 디자인 개편을 마무리해야 해서 실질적인 개발 마감은 2월 중순으로 한 달 반 정도 남아있었고, 작업을 맡을 프론트엔드는 저와 몇 주 먼저 합류한 비슷한 연차의 개발자, 둘이 전부였습니다. 심지어 코드를 살펴보니 공통으로 쓰일 만한 UI도 아직 컴포넌트화되어 있지 않았고, 대부분의 파일이 JS로 작성되어 있어 타입을 붙여 TS/TSX로 옮기는 작업도 함께 필요했습니다.

이상적으로는 디자인 시스템을 먼저 잡고 Storybook으로 컴포넌트를 문서화하며 진행하는 게 맞았지만, 그럴 여유가 없었습니다. 그럼에도 불구하고, 페이지마다 동일한 코드를 복사 붙여넣기 해서 페이지를 만드는 방법은 추후 유지보수에 더 큰 공수가 들어가게 되는 조삼모사인 상황임을 누구보다 잘 알고 있었습니다. 따라서 사용 빈도가 높거나 구현에 손이 많이 가는 요소부터 공통 컴포넌트로 만들어 동료 개발자와 공유하고, 새로운 사용 사례가 생길 때마다 필요한 만큼 확장하기로 했습니다.

반복되는 UI를 공통 컴포넌트로 관심사 분리하기

여러 페이지를 연달아 구현하다 보니 반복해서 등장하는 요소들이 보이기 시작했습니다. Button, Icon, TextInput처럼 작은 UI 요소부터 Select와 Accordion처럼 상태와 동작을 가진 요소, Modal Container와 Panel, Desktop/Mobile처럼 화면 구조를 잡는 요소까지 다양했습니다. 공통화할 수 있는 요소들을 모아 각각의 역할에 따라 분류하면 다음과 같이 정리할 수 있었습니다.

단계대표 요소
기초 요소Button, Link, Icon, Badge, Divider
입력 요소TextInput, TextArea, Checkbox, Radio, Switch, Select, DropdownMenu
상태·피드백Alert, Toast, Spinner, Progress, Skeleton, ErrorMessage
복합 요소Tabs, Accordion, Pagination, Breadcrumb, Search, DatePicker
데이터 표시Card, List, Table, EmptyState, ErrorState
레이아웃 기반Panel, Container, Stack, Spacing, Desktop/Mobile

컴포넌트는 호출하는 코드만 보고도 구조와 동작을 예측할 수 있도록 설계했습니다. 그 중 하나를 예시로 들자면 컴포넌트 합성 패턴으로 구현한 Select 컴포넌트입니다. 하나의 컴포넌트에 모든 설정과 표현 방식을 props로 전달하는 대신, Trigger, Value, Content, Item으로 역할을 나누고 필요한 요소를 조합해 화면 구조를 선언하도록 했습니다. 상태 관리는 value와 onValueChange로 드러내되, 옵션의 열림 상태나 포커스 이동, 키보드 조작처럼 디테일한 동작은 컴포넌트 내부에 구현하여 외부에서는 알 필요가 없도록 설계했습니다. 실제 사용하는 방법은 다음과 같습니다.

typescript
Copied
1<Select2  value={semesterId}3  onValueChange={setSemesterId}4  aria-label='학기 선택'5>6  <Select.Trigger>7    <Select.Value placeholder='학기를 선택해주세요.' />8  </Select.Trigger>910  <Select.Content>11    {semesters.map((semester) => (12      <Select.Item13        key={semester.id}14        value={semester.id}15        textValue={`${semester.label} ${semester.className}`}16      >17        <SemesterOption18          semester={semester.label}19          className={semester.className}20        />21      </Select.Item>22    ))}23  </Select.Content>24</Select>

Context와 Portal을 활용한 공통 모달 구조 설계

기존 코드에는 Popup, Drawer, BottomSheet 같은 모달 컴포넌트가 이를 사용하는 페이지 컴포넌트의 JSX 상단에 함께 선언되어 있었습니다. 페이지마다 isOpen, setIsOpen으로 열림 상태를 관리하고 조건에 따라 팝업을 렌더링하다 보니, 화면이 늘어날수록 비슷한 상태와 제어 코드가 반복됐습니다. 팝업이 부모 요소의 overflow나 z-index에 영향을 받는 경우도 있어, 실제 화면의 흐름과 관계없는 코드가 페이지 컴포넌트에 계속 쌓여있는 상태였습니다. 분명 개선이 필요해보였습니다.

페이지마다 흩어져 있던 팝업의 상태와 렌더링을 PopupContextProvider 한 곳으로 모았습니다. Provider는 현재 표시할 컴포넌트를 상태로 보관하고, 하위 컴포넌트에는 openPopup과 closePopup을 제공합니다. 실제 팝업은 페이지 내부가 아닌 루트의 #root-portal에 렌더링해, 팝업을 각 페이지의 DOM 구조와 분리했습니다. 내부 구조를 간단히 살펴보면 다음과 같습니다.

typescript
Copied
1type PopupOptions = {2  contents: ReactNode3}45type PopupContextValue = {6  openPopup: (options: PopupOptions) => void7  closePopup: () => void8}910const PopupContext = createContext<11  PopupContextValue | undefined12>(undefined)1314export function PopupContextProvider({15  children,16}: {17  children: ReactNode18}) {19  const [popup, setPopup] = useState<PopupOptions | null>(null)2021  const openPopup = useCallback((options: PopupOptions) => {22    setPopup(options)23  }, [])2425  const closePopup = useCallback(() => {26    setPopup(null)27  }, [])2829  const portalRoot =30    typeof window === 'undefined'31      ? null32      : document.getElementById('root-portal')3334  return (35    <PopupContext.Provider36      value={{ openPopup, closePopup }}37    >38      {children}3940      {portalRoot &&41        createPortal(42          <Popup43            isOpen={popup !== null}44            contents={popup?.contents ?? null}45            closePopup={closePopup}46          />,47          portalRoot,48        )}49    </PopupContext.Provider>50  )51}5253export function usePopupContext() {54  const context = useContext(PopupContext)5556  if (context === undefined) {57    throw new Error(58      'PopupContextProvider 내부에서 사용해야 합니다.',59    )60  }6162  return context63}

openPopup을 호출하면서 띄우고 싶은 컴포넌트를 넘기면, Provider가 이를 보관했다가 공통 Popup의 contents로 전달합니다. Popup은 받은 내용을 createPortal로 #root-portal에 렌더링하고, closePopup이 호출되면 상태를 비워 화면에서 제거합니다. 팝업 안에서도 usePopupContext()를 그대로 사용할 수 있어, 닫기 함수를 props로 계속 내려줄 필요도 없습니다.

typescript
Copied
1function TransferCancelButton({2  enquiryId,3  commentId,4}: {5  enquiryId: number6  commentId: number7}) {8  const { openPopup } = usePopupContext()910  const handleOpenPopup = () => {11    openPopup({12      contents: (13        <ConfirmTransferCancelModal14          enquiryId={enquiryId}15          commentId={commentId}16        />17      ),18    })19  }2021  return (22    <button type='button' onClick={handleOpenPopup}>23      전송 취소24    </button>25  )26}2728function ConfirmTransferCancelModal() {29  const { closePopup } = usePopupContext()3031  return (32    <Modal>33      {/* 팝업 내용 */}34      <button type='button' onClick={closePopup}>35        닫기36      </button>37    </Modal>38  )39}

호출부에서는 이제 더 이상 isOpen 상태를 만들거나 페이지 JSX에 팝업을 미리 배치할 필요가 없습니다. 이벤트가 발생했을 때 openPopup에 표시할 UI를 전달하는 것만으로 Modal, Drawer, BottomSheet를 열 수 있었고, dim 영역과 레이어 위치, 열고 닫는 흐름은 공통 구조에서 관리했습니다. 덕분에 페이지 컴포넌트는 팝업을 언제, 어떤 내용으로 띄울지만 결정하도록 역할을 줄일 수 있었습니다.

회고

촉박한 일정과 적은 인원에도 불구하고 전체 서비스의 디자인 개편을 성공적으로 해냈으며, 그 과정 또한 정신이 조금 없었지만 만족스러웠습니다. 단순히 화면 구현에만 급급하여 스파게티 코드가 양산되었을 수도 있었지만, 이후 유지보수를 생각하면 지속 가능하고 확장 가능한 코드 구조를 설계하면서 진행하는 것이 맞다고 판단하였습니다. 그리고 그 선택은 지금까지도 후회가 없는 상태입니다. 디자인 개편 프로젝트가 마무리된 이후, 공통 컴포넌트로 분리한 것들을 기반으로 디자이너와 페어를 맞춰 지속적으로 디자인 시스템을 확장하며 서비스의 구조를 더 단단하게 만들어나가고 있습니다.

홈 (Before)
홈 (After)
진도와과제 (Before)
진도와과제 (After)
공지사항 (Before)
공지사항 (After)
북몰 (Before)
북몰 (After)
  • 문제 상황
  • 반복되는 UI를 공통 컴포넌트로 관심사 분리하기
  • Context와 Portal을 활용한 공통 모달 구조 설계
  • 회고