타입스크립트 지옥에서 살아남기: 모달 컴포넌트 리팩토링 사투기on 비디오 시퀀스 지옥에서 살아남기-버퍼링과 싸우다
타입스크립트 지옥에서 살아남기: 모달 컴포넌트 리팩토링 사투기
들어가며: 타입스크립트와 나의 불편한 동거
타입스크립트. 이 얼마나 아름다운 이름인가. ‘타입’과 ‘스크립트’의 조합. 마치 혼돈의 자바스크립트 세계에 질서를 부여하겠다는 야심찬 포부가 느껴질 정도. 하지만 현실은 종종 더 큰 혼돈으로 이어진다. 특히 기존 코드베이스에 타입을 덧씌우는 과정은 마치 고양이에게 목욕을 시키는 것과 같다. 격렬한 저항과 함께 온몸에 긁힘 자국만 남는.
최근 내가 맞닥뜨린 도전은 바로 이런 상황이었다. MotionGallery(100DaysUIs) 프로젝트에서 썸네일 클릭 시 다양한 미디어를 모달로 띄우는 기능을 구현하는 과정에서, 타입스크립트와의 사투가 시작됐다. 외부 애니메이션 라이브러리 없이, 순수 React와 Tailwind CSS만으로 모달을 구현해야 했고, 기존의 엉망진창 데이터 구조는 덤으로 따라왔다.
지옥의 시작: 중복된 타입과 불필요한 필드들
처음 마주한 galleryItem.ts 파일은 마치 오래된 다락방 같았다. 필요한 물건도 있지만, 대부분은 언제 어디서 왔는지 모를 잡동사니들로 가득했다.
// Before: 중복과 혼란의 향연
interface GalleryItem {
id: string;
title: string;
type: '' | 'image' | 'video'; // 여기 타입이 있고
media?: {
type: 'image' | 'video' | 'iframe'; // 여기도 타입이 있다!?
url: string;
};
link?: string;
// 기타 등등 불필요한 필드들...
}
보이는가? type이라는 필드가 두 곳에 존재한다. 하나는 GalleryItem 자체에, 또 하나는 media 객체 내부에. 게다가 값도 일치하지 않는다. 이런 구조가 어떤 문제를 일으키는지는 불 보듯 뻔하다.
- 타입 불일치:
GalleryItem.type은 빈 문자열을 허용하지만,media.type은 그렇지 않다. - 중복 정의: 미디어 타입을 두 곳에서 관리하니 일관성 유지가 불가능하다.
- 불필요한 복잡성: 왜 미디어 정보를 두 곳에 나눠 저장하는가?
이런 상황에서 타입스크립트는 친절하게도(?) 수십 개의 빨간 줄로 나를 환영했다. “타입 ‘string’은 ‘image’ | ‘video’ | ‘iframe’ 타입에 할당할 수 없습니다.” 같은 오류 메시지가 화면을 뒤덮었다.
해결책: 단순함이 미덕이다
문제를 해결하기 위한 첫 번째 단계는 데이터 구조를 단순화하는 것이었다. 중복된 type 필드를 제거하고, 미디어 정보는 media 객체에만 명확하게 정의하기로 했다.
// 😌 After: 단순하고 명확한 구조
interface GalleryItem {
id: string;
title: string;
// type 필드 제거
media?: {
type: 'image' | 'video' | 'iframe';
url: string;
};
link?: string;
}
이렇게 하면 미디어 타입은 media.type에서만 관리되고, 불필요한 중복이 제거된다. 또한 media가 없고 link만 있으면 외부 링크, media가 있으면 미디어 모달, 둘 다 없으면 안내 메시지를 표시하는 명확한 로직을 구현할 수 있었다.
모달 컴포넌트: 외부 의존성 없이 애니메이션 구현하기
다음 도전은 모달 컴포넌트 자체였다. 요구사항은 명확하다. motion.dev 같은 외부 라이브러리 없이, 순수 React와 Tailwind CSS로 자연스러운 애니메이션과 UX를 구현하기.
// MediaModal.tsx의 핵심 부분
const MediaModal: React.FC<MediaModalProps> = ({ item, isOpen, onClose }) => {
// ESC 키 처리 및 스크롤 방지 로직
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden'; // 스크롤 방지
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = ''; // 스크롤 복원
};
}, [isOpen, onClose]);
if (!isOpen || !item) return null;
// 미디어 타입별 렌더링 로직
const renderMedia = () => {
if (!item.media) {
return item.link ? (
<a href={item.link} target="_blank" rel="noopener noreferrer">
외부 링크로 이동
</a>
) : (
<p>미디어 정보가 없습니다.</p>
);
}
switch (item.media.type) {
case 'image':
return <img src={item.media.url} alt={item.title} className="max-h-[80vh] max-w-full" />;
case 'video':
return <video src={item.media.url} controls autoPlay className="max-h-[80vh] max-w-full" />;
case 'iframe':
return <iframe src={item.media.url} className="w-full h-[80vh]" title={item.title} />;
default:
return <p>지원하지 않는 미디어 타입입니다.</p>;
}
};
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-75 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
>
<div
className={`bg-white rounded-lg p-4 max-w-4xl w-full transform transition-transform duration-300 ${
isOpen ? 'scale-100' : 'scale-95'
}`}
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{item.title}</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
×
</button>
</div>
<div className="flex justify-center">
{renderMedia()}
</div>
</div>
</div>
);
};
Tailwind CSS의 transition, opacity, scale 등의 클래스를 활용하여 모달이 부드럽게 나타나고 사라지는 애니메이션을 구현했다. 또한 ESC 키 누름, 배경 클릭 시 모달이 닫히고, 모달이 열려 있을 때는 배경 스크롤이 방지되도록 했다.
전체 데이터 흐름: 명확한 구조가 중요하다
이 모든 것을 연결하는 데이터 흐름도 중요했다. 다음은 전체 구조를 보여주는 다이어그램이다:
flowchart TD
subgraph Pages
MG(MotionChallenge100Days.tsx)
end
subgraph Data
GI(galleryItem.ts)
end
subgraph Components
MM(MediaModal.tsx)
end
GI -- galleryItems[] --> MG
MG -- props: items, onItemClick --> GalleryGrid
GalleryGrid -- onItemClick(item) --> MG
MG -- props: item, isOpen, onClose --> MM
MM -- 미디어 렌더링/닫기 --> MG
MotionChallenge100Days.tsx 컴포넌트가 상태 관리와 이벤트 처리를 담당하고, MediaModal.tsx는 순수하게 UI 렌더링에만 집중하도록 구성했다. 이렇게 하면 각 컴포넌트의 책임이 명확해지고, 유지보수와 확장이 용이해진다.
결론: 단순함을 향한 여정
이번 리팩토링을 통해 다시 한번 깨달은 것은 ‘단순함이 미덕’이라는 오래된 진리다. 복잡한 타입 구조와 중복된 필드는 단기적으로는 편리해 보일지 모르지만, 장기적으로는 유지보수의 악몽이 된다.
타입스크립트는 강력한 도구지만, 그 힘은 우리가 얼마나 명확하고 일관된 데이터 구조를 설계하느냐에 달려 있다. 이번 경험을 통해 배운 교훈은 다음과 같다:
- 중복을 제거하라: 같은 정보를 여러 곳에 저장하지 말자.
- 단순하게 유지하라: 필요한 필드만 포함하고, 불필요한 복잡성은 제거하자.
- 책임을 명확히 하라: 각 컴포넌트가 무엇을 담당하는지 분명히 하자.
이러한 원칙을 따르면, 타입스크립트는 적이 아닌 강력한 동맹이 될 수 있다. 그리고 모달 컴포넌트처럼 사용자 경험에 중요한 요소도 외부 의존성 없이 깔끔하게 구현할 수 있다.
다음에는 어떤 리팩토링 도전이 기다리고 있을지 모르겠지만, 이번 경험은 분명 그때도 유용한 지침이 될 것이다.