MOTY: 올해의 영화 공유 토이 프로젝트

목차

  1. TL;DR
  2. 어쩌다 시작한 토이 프로젝트
  3. 할 건 많은데, 시간은 없네
  4. 페이지별로 톺아보는 MOTY 구현기
  1. 리팩토링, 이런 것들에 집중했어요
  1. 나오면서: 좋아하는 마음으로 개발을 한다는 것

TL;DR

MOTY는 연말을 맞아 '올해의 영화'를 친구들과 공유할 수 있는 웹 서비스입니다. 단순한 기능 구현을 넘어 웹 접근성, UX등을 고려하며 "누구나 쉽게 이용할 수 있는 서비스"를 만들고자 했습니다.

어쩌다 시작한 토이 프로젝트

크리스마스가 지나고 친구와 함께 이야기를 나누던 중 친구가 진행 중인 토이 프로젝트에 대해서 알게 되었습니다. 올해의 음악을 골라 공유하는 서비스였어요. 친구 이야기를 듣다가 저도 연말을 장식할 수 있는 토이 프로젝트를 하면 어떨까라는 생각이 들었고, 고민하다가 올해의 영화를 골라 공유할 수 있는 서비스를 만들기로 결심했어요.

할 건 많은데, 시간은 없네

제가 구현하고자 하는 토이 프로젝트의 주요 기능은 대략 다음과 같았습니다:

문제는 새해까지 시간이 얼마 남지 않았기에, 기획부터 첫 버전의 배포까지 해커톤처럼 빠르게 진행해야 했어요. 밤을 샐 각오를 하고, 노트에 초안을 끄적인 다음, 피그마를 통해 간단한 프로토타이핑을 완성했습니다.

최대한 빠른 시간 안에 개발을 진행하고 배포해야 했기 때문에, 불필요한 기술 스택은 배제 했습니다. 특히 기술 블로그를 개발한 뒤 의존성 문제로 인해 유지보수에 어려움을 겪었던 경험이 있었기에, 사용하지 않는 의존성은 아예 거들떠보지도 않았어요.

라이브러리는 React와 Tailwind CSS, 개발 도구와 언어로는 Vite와 TypeScript를, 코드 품질 도구로는 ESLint와 Prettier를 사용하기로 했습니다. 특히 Vite의 템플릿에 제공되는 ESLint의 경우 9버전이기 때문에 Prettier와의 포맷팅 룰 충돌이 일어나지 않는다는 점에서 추가적인 플러그인을 설치할 필요가 없어져서 초기 환경 설정이 한결 편했습니다.

API는 TMDB에서 제공하는 Open API를 사용했습니다. 기본적인 검색 기능도 제공하고 있어서, 관련된 로직에 크게 힘을 쏟지 않아도 됐어요. 이후 간단하게 폴더 구조를 만들고, 바로 개발에 들어갔습니다.

페이지별로 톺아보는 MOTY 구현기

MOTY Preview

0. MOTY의 플로우 생각해보기

사용자는 MOTY를 다음과 같은 플로우로 이용할 수 있습니다:

  1. 사용자 이름 입력
  2. 영화 검색
  3. 한줄평 작성
  4. 결과 페이지를 확인 후 공유

각 단계에서 입력된 정보는 쿼리 스트링을 통해 다음 단계로 전달돼요. 이는 CSR 환경의 일회성 토이 프로젝트라는 특성을 고려했을 때, 별도의 상태관리나 서버 구현 없이도 필요한 정보를 효율적으로 전달할 수 있는 방식이라고 생각했기 때문입니다.

1. 사용자 이름 입력 페이지

MOTY를 사용하는 사용자의 이름을 입력받아요

이 페이지에서는 사용자 이름에 대한 유효성 검사를 진행이 핵심 기능이었습니다. 사용자의 이름으로 어떤 문자열을 허용할지 등의 기준을 생각한 뒤, 다음과 같은 기준을 세웠어요:

// validation.ts
export const NAME_VALIDATION: ValidationRules = {
	patterns: {
		emoji: /[\u{1F300}-\u{1F9FF}]/u,
		validChars: /^[ㄱ-ㅎ가-힣a-zA-Z0-9]+$/,
	},
	messages: {
		emoji: '이모지는 사용할 수 없어요.',
		invalidChars: '한글, 영문, 숫자만 사용할 수 있어요',
		empty: '이름을 입력해 주세요.',
		length: '10글자 이하로 입력해 주세요.',
		space: '공백은 사용할 수 없어요.',
	},

	// 그 외 규칙들...
};

문자열에 대한 패턴은 정규식으로 처리하고, 나아가 사용자에게 유효성 검사가 통과하지 못한 이유를 자세하게 설명해줄 수 있는 메시지도 포함해 객체 형태의 상수로 관리했습니다.

// validation.d.ts
declare type ValidationRules = {
	maxLength: number;
	patterns: Record<ValidationPatternKey, RegExp>;
	messages: Record<ValidationMessageKey, string>;
	rules: Record<ValidationRulesKey, boolean>;
};

또한 잘못된 키 사용을 방지하고, 타입 안정성을 확보하기 위해 Record 유틸리티 타입을 사용해 객체의 키-값 쌍을 정의했어요.

2. 영화 검색 페이지

올해의 영화로 선정할 영화를 검색하고, 한줄평을 입력해요

검색 페이지는 4개의 페이지 중 가장 많은 기능을 구현해야 했던 페이지였습니다. 사용자는 검색 페이지에서 다음과 같은 플로우로 페이지를 사용하게 됩니다:

먼저 검색 기능은 TMDB에서 제공하는 Open API를 사용했습니다. 키워드를 기반으로 요청을 보내고, 해당하는 정보를 응답으로 제공해 줄 수 있는 검색 API예요. JSON 형태로 제공되는 응답에는 현재 페이지와 함께 영화의 정보에는 id, 장르, 제목, 줄거리 등이 포함되어 있습니다. 다만 이 검색 API에는 아쉬운 점이 하나 있었는데, 이후 리팩토링 섹션에서 살펴보도록 할게요.

const { searchTerm, setSearchTerm, fetchState, handleSearch, loadMore } =
	useMovieSearch();

영화 목록을 탐색하는 기능은 IntersectionObserver를 활용한 무한 스크롤을 통해 구현했습니다. 사용자가 무한 스크롤을 사용할 때, 영화를 다 불러왔는지의 여부를 알려주기 위해 observerRef에 해당하는 요소에 메시지를 표시할 수 있도록 했어요. TMDB의 검색 API가 페이지네이션까지 지원하고 있었기 때문에, 간단하게 현재 페이지 수를 인자로 넘겨주는 것 만으로도 원하는 응답을 제공받을 수 있었습니다.

Moty Infinite Scroll

그리고 영화를 선택하면, 모달 창에서 한 줄 평을 입력하고 결과 페이지로 이동하게 됩니다. 한 줄 평이 너무 길어지는 것을 방지하기 위해서 이름과 마찬가지로 글자 수 제한을 뒀는데, ‘몇 자 까지가 한줄평인가’라는 생각이 문득 들기도 했습니다. 정답은 없지만요.

3. 결과 페이지

선정된 영화를 티켓 형식으로 보여주고, 이미지로 저장하거나 카카오톡 또는 URL을 통해 공유해요.

결과 페이지는 사용자의 이름, 영화 정보 및 한줄평을 쿼리 스트링으로 받아 렌더링하고, 이를 공유할 수 있는 기능을 가지고 있어요. 공유 방식은 카카오톡, URL 복사, 이미지 저장 3가지로 결정했습니다. 카카오톡을 통한 공유는 kakao developers의 공식 문서에서 상세하게 정보를 제공해 주고 있었기 때문에 수월하게 구현했습니다. 채팅방에 공유했을 때 보여지는 메시지 템플릿도 쉽게 설정할 수 있었어요.

window.Kakao.Share.sendCustom({
	// 내가 설정한 템플릿 id로 간편하게 적용
	templateId: 115794,
	templateArgs: {
		USERNAME: username,
		TITLE: movieTitle,
		THUMB: imageUrl,
		QUOTE: quote,
	},
});

이미지 저장 기능은 캡처 기능을 구현할 때 자주 사용되는 라이브러리인 html2canvas를 사용했습니다. DOM을 파싱하고 CSS를 계산한 뒤, Canvas API를 사용해 화면을 그리고 이를 이미지 데이터로 변환해 저장하는 과정을 통해 캡처를 가능하게 하는 라이브러리입니다. 다만 API를 통해 불러온 이미지를 사용했기 때문에 CORS 오류가 발생했는데, 다양한 방법을 통해 해결해 보려 했음에도 불구하고 여전히 해결하지 못한 상황입니다. 이미지 저장 자체는 정상적으로 작동하고 있습니다.

리팩토링, 이런 것들에 집중했어요

그렇게 이틀 만에 완성된 MOTY였지만, 보다 나은 서비스를 위해서는 가야 할 길이 멀었어요. 성능 최적화, UX, 접근성, SEO 등 다양한 부분에서 개선이 필요한 부분이 보였습니다. 실사용자들로부터 피드백을 받고, 이를 분석한 후 하나씩 적용해 나갔어요.

UX & UI

아까 검색 페이지에서 검색 API에 문제가 하나 있었다고 했던 것, 기억하시나요? 바로 TMDB의 검색 API가 시맨틱 검색(Semantic Search)을 지원하지 않는다는 것이었습니다.

시맨틱 검색이란 단순한 키워드 매칭이 아닌, 콘텐츠의 의미와 맥락을 이해하여 사용자의 검색 의도에 맞는 결과를 제공하는 검색 방식이에요.

예를 들어서, 사용자가 ‘인사이드 아웃’이라는 영화를 검색하고 싶을 때, ‘인사이드아웃’이라고 붙여 쓴 키워드를 통해 검색을 진행해도 키워드를 바탕으로 의미있는 검색 결과를 제공하는 것이 바로 시맨틱 검색입니다. 하지만 사용중인 TMDB API가 시맨틱 검색을 지원하지 않아서, 이로 인해 발생하는 문제를 처리해야 했어요.

MOTY Search Refactor

그래서 별도의 검색 알고리즘을 구현하는 대신, 검색 결과 없음 컴포넌트에 검색 팁을 표시하고 이를 페이지 진입 시점에 노출하여 사용자가 올바른 검색어를 입력할 수 있도록 안내했습니다. 이를 통해 추가 개발 리소스 없이도 더 나은 사용자 경험을 제공할 수 있었어요.

접근성

다음으로 신경썼던 부분은 웹 접근성입니다. 서비스를 개발하며 접근성을 고려해야 하는 이유는 너무나도 많고, 또 중요하지만, 웹이 가진 힘을 생각해본다면 한 문장으로 귀결될 수 있을 것 같습니다.

"웹의 힘은 그것의 보편성에 있다. 장애에 구애없이 모든 사람이 접근할 수 있는 것이 필수적인 요소이다." - Tim Berners-Lee

물론 모범적인 웹 접근성의 모든 기준을 개인이 고려하기에는 너무나도 그 항목이 방대합니다. 당장 한국형 웹 콘텐트 접근성 지침(KWCAG)만 살펴보더라도, 54페이지에 달하는 지침을 가지고 있어 이걸 어디서부터 어떻게 적용해야 하나 싶기도 했습니다. 그럼에도 가능한 많은 접근성을 적용해 보고자 했고, 따라서 Claude에 해당 지침을 업로드하고 코드를 살펴보는 식으로 접근성을 고려한 리팩토링을 진행했습니다. 예시를 몇 가지 살펴볼까요?

MOTY 404

위 이미지는 404 오류가 발생했을 때 리다이렉션 되는 페이지의 모습입니다. 이 페이지에서 시각 장애가 없는 사용자라면 *'아, 404 오류가 발생했고, 이로 인해 이 페이지에 접근했구나. 버튼을 눌러 메인 페이지로 돌아가야지'*라는 판단을 충분히 할 수 있겠지만, 시각 장애를 가진 사용자라면 이러한 정보를 스크린리더에 의존해야 합니다. 또한 tab 키를 사용해 탐색할 경우, 버튼에만 포커스가 가기 때문에 h1과 p 태그의 내용은 자동으로 읽히지 않습니다.

<h1 className="text-4xl font-bold" id="not-found-title">404 오류</h1>
<p className="mt-2 text-center text-xl" id="not-found-description">
	죄송합니다.
	<br aria-hidden="true" />
	요청하신 페이지를 찾을 수 없습니다.
</p>

// h1과 p에 대한 label을 button에 연결
<button aria-describedby="not-found-title not-found-description">
	메인 페이지로 돌아가기
</button>

이러한 상황을 고려하여 button 요소에 aria-describedby 속성을 부여하고, h1과 p 태그의 id를 연결했습니다. 이를 통해 스크린리더 사용자가 tab 키로 버튼에 도달했을 때 '메인 페이지로 돌아가기 버튼' 뿐만 아니라 '404 오류'와 '죄송합니다. 요청하신 페이지를 찾을 수 없습니다.'라는 설명도 함께 읽어주어, 현재 상황과 버튼의 용도를 충분히 이해할 수 있도록 했습니다.

MOTY Contrast

또한 저시력이나 색각 장애가 있는 사용자도 콘텐츠를 불편 없이 인식할 수 있도록 텍스트와 배경색 간의 적절한 명도 대비를 제공하는 것이 중요합니다. WCAG 지침은 텍스트와 배경색 간의 명도 대비가 4.5:1 이상이 되어야 한다고 규정하고 있는데, 이를 확인할 수 있는 도구를 사용해 해당 지침을 만족할 수 있도록 UI를 개선했습니다.

SEO

결과 페이지의 링크를 공유했을 때 opengraph 이미지와 함께 쿼리 스트링으로부터 받아온 메타 정보를 표시하고자 했습니다. 그러나 문제가 있었습니다. CSR 환경에서는 검색엔진 크롤러가 서버로부터 받는 초기 HTML만을 읽기 때문에 동적으로 생성되는 메타 태그를 제대로 인식하지 못합니다. 이를 해결하기 위해서는 react-helmet-async와 같은 라이브러리와 함께 프리렌더링 도구(React-Snap)를 사용할 수 있지만, 서버가 없는 순수 CSR 환경에서는 이러한 방식도 완벽한 해결책이 되지 못합니다. 또한 react-helmet-async는 최근 유지보수가 거의 이루어지지 않고 있어 안정성에도 우려가 있었습니다. 따라서 이 프로젝트에서는 불완전한 동적 SEO 대신, index.html에 필수적인 메타 정보만을 정적으로 명시하는 방식을 선택했습니다.

나오면서: 좋아하는 마음으로 개발을 한다는 것

MOTY는 2024년 연말을 맞이하여 '올해의 영화'를 공유할 수 있는 웹 서비스입니다. 사용자의 이름을 입력받고, 영화를 검색하여 한줄평과 함께 공유할 수 있는 기능을 제공하고 있어요.

서비스를 개발하며 가장 신경 썼던 부분은 "좋아하는 마음으로 만들되, 제대로 된 서비스를 만들자"였습니다. 단순히 영화를 검색하고 공유하는 기능을 넘어서, 장애가 있는 사용자도 불편함 없이 서비스를 이용할 수 있도록 웹 접근성 지침을 준수하려고 했고, 시맨틱 검색이 지원되지 않는 상황에서도 사용자가 원하는 영화를 쉽게 찾을 수 있도록 UX를 개선했으며, CSR 환경에서 SEO를 최적화하기 위해 고민했습니다.

결과적으로 제가 좋아하는 영화라는 주제로, 누구나 불편함 없이 이용할 수 있는 서비스를 만들 수 있었다는 점에서 이번 프로젝트는 특별한 의미를 가집니다. 개발이 단순히 기능을 구현하는 것을 넘어, 더 나은 웹을 만들어가는 여정이 될 수 있다는 것을 다시 한 번 느낄 수 있었습니다 🙂