[Frontend] 텔레그램 리디자인 개발하기 ① – 구현 과정과 고민들

YuminPark·2026년 4월 23일

Frontend

목록 보기
13/17
post-thumbnail

신촌IT연합동아리 CEOS 프론트엔드 세션 3~4주차 프-디 협업 과제로 진행한 텔레그램 리디자인 개발 기록입니다.
1편에서는 구현 과정과 고민들을, 2편에서는 개발하며 새롭게 알게 된 것들유용하게 활용한 설정들을 정리할 예정입니다.

🔗작업 관련 링크

코드는 최종 버전 기준으로 작성되었습니다.
따라서 본문에서 설명한 내용보다 더 발전된 형태로 구현되어 있을 수 있습니다.
자세한 코드는 위 Github Repository를 참고해 주세요!


✈️3주차 과제

레이아웃 설계

3주차 과제 범위는 채팅방 구현이었지만, 컴포넌트 제작 전에 구현해야 할 전체 화면을 먼저 파악했습니다. 노치, 상태바, 네비바, 홈 인디케이터 등 공통 레이아웃 요소들이 존재했고, 이를 감싸는 PublicLayout 설계가 필요하다고 판단했습니다.

const publicRoutes: RouteObject[] = [
  { index: true, element: <SplashPage /> },
  {
    path: "/",
    element: <PublicLayout />,
    children: [
      { path: "profile", element: <ProfilePage /> },
      { path: "contact", element: <ContactPage /> },
      { path: "chat", element: <ChatListPage /> },
      { path: "chat/:id", element: <ChatRoomPage /> },
      { path: "setting", element: <SettingPage /> },
    ],
  },
];

PublicLayoutOutlet으로 하위 페이지를 렌더링하는 공통 레이아웃 컴포넌트입니다. useMatch로 채팅방(/chat/:id) 진입 여부를 확인해, 채팅방에서는 네비바와 배경색을 제거했습니다.

const PublicLayout = () => {
  const isChatRoom = useMatch("/chat/:id");

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100">
      <main className="relative flex h-203 w-93.75 flex-col overflow-hidden bg-white shadow-md">
        <StatusBar />
        <div className="flex-1 overflow-y-auto">
          <Outlet />
        </div>
        <div
          className={`absolute inset-x-0 bottom-0 flex flex-col gap-2 ${!isChatRoom && "bg-indicator"}`}
        >
          {!isChatRoom && (
            <div className="px-4">
              <Navibar />
            </div>
          )}
          <HomeIndicator className="pt-5.25 pb-2" />
        </div>
      </main>
    </div>
  );
};

홈 인디케이터는 항상 하단에 고정되고, 네비바와 bg-indicator 배경은 채팅방이 아닐 때만 렌더링 시켰습니다.

채팅방

메시지 그룹핑

채팅 UI에서 가장 먼저 고민한 건 메시지를 어떻게 묶을 것인가였습니다.
카카오톡처럼 같은 사람이 연속으로 보낸 메시지는 하나의 덩어리처럼 표시되고, 시간이 바뀌거나 다른 사람이 메시지를 보내면 새 그룹이 시작되도록 구현하고 싶었습니다.

이를 위해 두 단계의 유틸 함수를 작성했습니다.

// messageGroup.ts

// 1단계: 같은 userId + 같은 날짜 → 하나의 그룹(MessageItem[][])으로 묶음
export function groupMessages(messages: MessageItem[]): MessageItem[][] { ... }

// 2단계: 그룹 내에서 메타데이터 계산
// pointedCornerSet  → 말풍선 꼬리(모서리)를 그릴 메시지 인덱스
// showReadStatusSet → 읽음 상태·시간을 표시할 메시지 인덱스
export function computeMessageMeta(group: MessageItem[]): {
  pointedCornerSet: Set<number>;
  showReadStatusSet: Set<number>;
} { ... }

isSameTimeGroup은 userId,날짜,분 단위 시간이 모두 같을 때 true를 반환합니다.
시간 그룹이 바뀌는 지점에서만 말풍선 꼬리와 읽음 상태를 표시하면 되기 때문에, 두 Set을 단일 순회로 동시에 계산했습니다.

ChatRoomPage에서는 이 결과를 날짜별로 한 번 더 묶어 렌더링합니다.

// ChatRoomPage.tsx
const dateGrouped = useMemo(() => {
  const groups = groupMessages(messages);
  return groups.reduce<DateGroup[]>((acc, group) => {
    const date = group[0].date;
    const { pointedCornerSet, showReadStatusSet } = computeMessageMeta(group);
    // date가 같은 그룹끼리 묶어서 MessageDate 구분선을 최소화
    ...
  }, []);
}, [messages]);

메시지 버블

Message 컴포넌트는 React.memo로 감싸 불필요한 리렌더링을 방지했습니다.
내 메시지(my)와 상대 메시지(friend)는 정렬, 색상 외에도 프로필 표시 방식이 다릅니다.

// Message.tsx
const Message = memo(({ msg, isPointed, showRead, ... }: Props) => {
  const isMyMsg = msg.type === "my";

  return (
    <div className={`flex ${isMyMsg ? "justify-end" : "justify-start"}`}>
      {!isMyMsg && (
        isPointed
          ? <ProfileIcon userId={msg.userId} />
          : <div className="size-7.5 shrink-0" /> // 프로필 자리 유지용 빈 공간
      )}
      <ChatBox ... />
      {showRead && <ChatRead isRead={msg.isRead} time={msg.time} />}
    </div>
  );
});

isPointedtrue인 메시지에만 말풍선 꼬리를 붙이고, 연속 메시지에서는 프로필 아이콘 대신 동일한 너비의 빈 div를 두어 들여쓰기를 맞췄습니다.

입력 영역 — Textarea 동적 높이와 IME 처리

입력창은 한 줄에서 시작해 최대 120px까지 자동으로 늘어나도록 구현했습니다.

// TextField.tsx
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  const el = e.target;
  const prev = el.offsetHeight;

  el.style.height = "auto";
  el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;

  const delta = el.offsetHeight - prev;
  if (delta !== 0) onHeightChange?.(delta);
};

textarea가 커지면 스크롤 컨테이너의 하단 여백이 줄어들어 뷰가 밀리는 문제가 있었습니다.
이전 메시지를 확인하며 답장을 작성하는 상황을 떠올렸을 때, 입력창이 커질수록 보고 있던 메시지가 계속 위로 밀린다면 불편할 것이라 생각해 delta만큼 scrollTop을 즉시 보정하는 방식으로 해결했습니다.

// ChatRoomPage.tsx
const handleTextareaHeightChange = useCallback((delta: number) => {
  if (scrollRef.current) scrollRef.current.scrollTop += delta;
}, []);

한글 입력 처리도 신경 썼습니다. IME 조합 중에 Enter를 누르면 브라우저가 전송과 개행을 동시에 발생시키는 경우가 있어, isComposing 플래그로 조합 완료 전 Enter는 무시하도록 처리했습니다.

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
    e.preventDefault();
    handleSend();
  }
};

텍스트가 없을 때는 파일 선택·음성 버튼을, 텍스트가 있을 때는 전송 버튼만 표시해 인터페이스를 텔레그램과 유사하게 구성했습니다.

처음에는 PublicLayout에서 NavibarTextField를 함께 조건부 렌더링하려 했지만, 입력창은 채팅방 페이지 내부 상태와 밀접하게 연결되어 있어 공통 레이아웃보다 채팅방 페이지 내부에서 관리하는 편이 더 적절하다고 판단했습니다.

자동 스크롤

스크롤은 세 시점에 실행됩니다.

// 1. 채팅방 진입 또는 관점 전환 시
useEffect(() => {
  scrollToBottom();
}, [chatRoomId, perspective]);

// 2. 메시지 전송 직후 — DOM 업데이트 이후 실행
setTimeout(scrollToBottom, 0);

// 3. textarea 높이 변경 시 — 절대값이 아닌 delta 보정
scrollRef.current.scrollTop += delta;
  1. 채팅방 진입 시 가장 최근 메시지부터 보이도록 스크롤을 최하단으로 이동시켰습니다. 관점 전환 시에도 새로운 진입으로 간주해 동일하게 처리했습니다.

  2. 메시지 전송 직후에는 React 상태 업데이트가 아직 DOM에 반영되기 전일 수 있어, setTimeout(..., 0)으로 다음 태스크로 미뤄 처리했습니다.

  3. 입력창 높이가 변경될 때는 절대값으로 최하단 이동 대신, 높이 변화량(delta)만큼 scrollTop을 보정하는 방식을 택했습니다. 이전 메시지를 보며 답장을 작성하는 상황에서 시선이 끊기지 않도록 하기 위해서입니다.

관점 전환

이 프로젝트는 실제 로그인 없이 한 브라우저에서 여러 사용자의 시점을 시뮬레이션합니다.
perspective 필드가 현재 "내 시점"인 사용자 ID를 가리키고, 헤더 텍스트 클릭 시 참가자 배열을 순환하며 전환됩니다.

// chatStore.ts
switchPerspective: (chatRoomId) => {
  const room = findRoom(chatRoomId);
  const participants = [room.myUserId, ...room.friendUserIds];
  const idx = participants.indexOf(room.perspective);
  const next = participants[(idx + 1) % participants.length];

  // 전환 시점에서 새 관점의 미읽음 메시지를 모두 읽음 처리
  markMessagesRead(chatRoomId, next);
  set({ perspective: next });
},

시점이 바뀌면 메시지 버블의 type이 재계산되어 좌우 배치가 자동으로 반전됩니다.

상태 관리 — Zustand 커스텀 스토리지

persist 미들웨어 기본 설정은 모든 채팅방이 하나의 localStorage 키에 직렬화되어, 데이터가 커질수록 읽기/쓰기 비용이 선형으로 증가합니다. 이를 채팅방 ID별로 키를 분리 저장하는 커스텀 스토리지로 해결했습니다.

// chatStore.ts
const chatRoomStorage = {
  getItem: (key) => {
    const versionRaw = localStorage.getItem("chat-store-version");
    if (Number(versionRaw) !== CHAT_STORE_VERSION) {
      localStorage.clear();
      localStorage.setItem("chat-store-version", String(CHAT_STORE_VERSION));
      return null; // 초기 데이터로 리셋
    }
    return localStorage.getItem(`chat-room-${extractId(key)}`);
  },
  setItem: (key, value) =>
    localStorage.setItem(`chat-room-${extractId(key)}`, value),
  removeItem: (key) =>
    localStorage.removeItem(`chat-room-${extractId(key)}`),
};

CHAT_STORE_VERSION이 바뀌면 전체 localStorage를 초기화해 데이터 스키마 변경으로 인한 파싱 오류를 방지했습니다.

초기에는 userName으로 사용자를 구분하려 했지만, 동명이인 가능성을 고려해 고유한 userId 기반으로 변경했습니다.

OG 설정

index.html에 Title, Description, 파비콘, OG 태그를 설정했습니다.
카카오톡 등 메신저에 링크를 공유했을 때도 미리보기가 정상적으로 노출되도록 구성했습니다.

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Telegram Redesign — CEOS 23rd</title>
    <meta property="og:title" content="Telegram Redesign — CEOS 23rd" />
    <meta name="description" content="CEOS 23rd Frontend × Design" />
    <meta property="og:description" content="CEOS 23rd Frontend × Design" />
    <meta property="og:image" content="https://react-messenger-23rd.vercel.app/og-image.png" />
    <meta property="og:url" content="https://react-messenger-23rd.vercel.app" />
    <meta property="og:type" content="website" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

og:image는 외부 크롤러가 접근하는 리소스이므로 상대 경로 대신 배포 URL 기준 절대 경로를 사용했습니다.


✈️4주차 과제

공통 컴포넌트 — 프로필

여러 컴포넌트에서 크기와 형태만 다른 프로필 컴포넌트가 반복적으로 등장했습니다.
아래 사진과 같이 채팅방 말풍선 옆, 네비바, 채팅 리스트, 프로필 페이지에서 각각 다른 사이즈가 필요했고, 이를 하나의 Profile 컴포넌트로 통합하고자 했습니다.
이미지 파일 없이 배경색과 이름 첫 글자를 기반으로 렌더링하는 방식으로 직접 구현했습니다.

// Profile.tsx
interface ProfileProps {
  name: string;
  profileColor: string;
  type?: "chatroom" | "navibar" | "chatlist_1" | "chatlist_2" | "chatlist_3" | "profile_big";
}

type prop 하나로 깔끔하게 사이즈와 스타일을 결정하고 싶었습니다.
분기문 대신 상수 객체에 타입별 스타일을 미리 정의해두고 조회하는 방식을 택했습니다.

// constants/profile.ts
export const PROFILE_VARIANTS: Record<ProfileType, { container: string; text: string }> = {
  chatroom:    { container: "size-7.5 rounded-15",  text: "text-[10.909px]" },
  navibar:     { container: "size-5 rounded-full",   text: "text-[7px]" },
  chatlist_1:  { container: "size-11 rounded-full",  text: "..." },
  chatlist_2:  { container: "size-9 rounded-full ring-2 ring-white", text: "..." },
  chatlist_3:  { container: "size-7 rounded-full ring-2 ring-white", text: "..." },
  profile_big: { container: "size-20 rounded-full shadow-profile", text: "..." },
};

프로필 배경색은 CSS 변수로 6가지 그래디언트를 미리 정의해두고, 사용자마다 고정된 인덱스를 할당했습니다. 같은 사람은 어느 화면에서도 항상 동일한 색상으로 표시됩니다.

채팅 리스트

채팅방은 마지막 메시지 시간 기준 역순으로 정렬하되, 고정된 채팅방은 항상 최상단에 위치하도록 구현했습니다.
메시지 전송 시 정렬 순서가 실시간으로 반영됩니다.

// ChatListPage.tsx
const sortedChatRooms = useMemo(() => {
  const pinned = roomsArray.find(r => r.chatRoomId === PINNED_CHAT_ROOM_ID);
  const others = roomsArray
    .filter(r => r.chatRoomId !== PINNED_CHAT_ROOM_ID)
    .sort((a, b) => getChatTimestamp(b) - getChatTimestamp(a));
  return pinned ? [pinned, ...others] : others;
}, [roomsArray]);

각 항목에는 최신 메시지, 타임스탬프, 읽음 체크, 고정 여부를 표시했습니다.
이미지 메시지는 텍스트 미리보기 대신 '사진을 보냈습니다.'로 대체하는 방식을 택했습니다.

lastMessage={lastMessage.imageUrl ? "사진을 보냈습니다." : lastMessage.message}

채팅방 참여자 수(1~4명)에 따라 프로필 이미지 레이아웃이 다르게 렌더링되도록 구현했습니다.
1명은 원형 단일 프로필, 2명은 대각선 배치, 3명은 상단 1개 + 하단 2개, 4명은 2×2 격자로 구성했습니다.

전체 미읽음 메시지 수는 네비바 '대화' 아이콘에 표시되며, 99개 이상이면 99+로 노출되도록 했습니다.

이름 기반 실시간 채팅방 검색도 구현했습니다.
검색어를 포함하는 참여자가 있는 모든 채팅방이 필터링되어 노출됩니다.
스크롤 시 검색창이 가려지는 문제를 디자이너님과 함께 고민하다가, 스크롤이 발생하면 헤더에 돋보기 아이콘을 노출하고 클릭 시 최상단으로 부드럽게 이동하는 방식으로 해결했습니다.

// useScrolled.ts
const useScrolled = () => {
  const [scrolled, setScrolled] = useState(false);
  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    setScrolled(e.currentTarget.scrollTop > 0);
  };
  return { scrolled, handleScroll };
};

읽음 상태(회색 → 초록 체크)는 나(지민)의 관점 기준으로 동작하도록 구현했습니다.

  1. 예를 들어 지민 시점에서 메시지를 보내고 나오면 상대방이 아직 읽지 않아 회색 체크가 표시되고, 채팅방에 재진입해 상대방 관점으로 전환하면 읽음 처리되어 지민 목록에 초록 체크로 표시됩니다.
  2. 단체 채팅방의 경우 참여자 중 한 명이라도 읽으면 초록 체크로 표시됩니다.

연락처

연락처는 이름순 / 시간순 두 가지 정렬을 지원합니다.
기본은 마지막 접속 순 정렬이며, 헤더 우측 아이콘 클릭으로 이름순으로 토글할 수 있습니다.

이름순 정렬에서 같은 성이 연속될 때는 뒤에 오는 항목의 성 태그를 숨겨 시각적으로 그룹처럼 보이게 했습니다. 검색 중에는 섹션 헤더가 자동으로 숨겨집니다.

// ContactPage.tsx
const sortedByName = [...contacts]
  .sort((a, b) => a.name.localeCompare(b.name, "ko"))
  .map((user, i, arr) => ({
    ...user,
    surnameDisplay:
      i > 0 && getSurname(arr[i - 1].name) === getSurname(user.name)
        ? ("hidden" as const)
        : ("show" as const),
  }));

surnameDisplay"hidden"인 항목은 성 태그에 invisible을 적용해 공간은 차지하되 보이지 않게 처리했습니다. 검색 필터 후에도 남은 배열에서 성 표시 여부를 다시 계산해야 하는데, 이 재계산도 같은 로직으로 처리했습니다.

시간순 정렬에서는 사용자별 마지막 접속 시간을 구해야 했습니다.
모든 채팅방의 메시지를 한 번 순회하며 Map에 최신값을 갱신하는 방식으로 O(n)에 처리했고, 컴포넌트 외부에서 모듈 로드 시 단 한 번만 계산하도록 구성해 리렌더링과 완전히 무관하게 만들었습니다.

// ContactPage.tsx (컴포넌트 외부에서 한 번만 계산)
const lastSeenMap = new Map<number, { date: string; time: string }>();
for (const room of messagesData.chatRooms) {
  for (const msg of room.messages) {
    const current = lastSeenMap.get(msg.userId);
    if (!current || isMoreRecent(msg, current)) {
      lastSeenMap.set(msg.userId, { date: msg.date, time: msg.time });
    }
  }
}

채팅방

3주차 과제에서 대부분 구현했기 때문에, 이번 주는 단체방 확장이미지 전송 기능 구현에 집중했습니다.

단체방 지원

1:1 구조를 단체방으로 확장하기 위해 타입부터 수정했습니다.

// types/message.ts
// 변경 전
interface ChatRoom {
  friendUserId: number;
  perspective: MessageType; // "my" | "friend"
}

// 변경 후
interface ChatRoom {
  friendUserIds: number[];
  perspective: number;      // 사용자 ID로 변경
}

interface MessageItem {
  userId: number; // 발신자 ID 추가
  ...
}

perspective문자열에서 실제 사용자 ID로 바뀌면서 단체방에서도 누구의 시점인지 명확하게 추적할 수 있게 됐습니다. 메시지 그룹핑도 type 기준에서 userId 기준으로 변경했습니다. 단체방에서는 여러 명이 "friend" 타입이므로 type으로는 같은 사람 여부를 판단할 수 없기 때문입니다.

// messageGroup.ts
// 변경 전
if (lastMsg?.type === msg.type && lastMsg?.date === msg.date)

// 변경 후
if (lastMsg?.userId === msg.userId && lastMsg?.date === msg.date)

isSameTimeGroup도 동일하게 userId 기준으로 수정했습니다.
헤더 타이틀은 현재 관점 사용자를 제외한 나머지 참가자 이름을 , 로 이어 붙여 표시하도록 구현했습니다.

// ChatRoomPage.tsx
const otherIds = [chatRoom.myUserId, ...chatRoom.friendUserIds]
  .filter(id => id !== perspective);

const headerTitle = otherIds
  .map(id => getUserById(id)?.name ?? "")
  .filter(Boolean)
  .join(", ");

단체방에서는 연속된 친구 메시지 사이 여백도 별도로 고민했습니다.
서로 다른 두 친구가 번갈아 메시지를 보낼 때는 mt-8로 충분한 간격을 주고, 같은 친구의 메시지가 이어질 때는 mt-3으로 좁혀 시각적으로 그룹임을 표현했습니다.

const prevIsMyMsg = groups[groupIdx - 1][0].msg.userId === perspective;
mt = !isMyMsg && !prevIsMyMsg && isMultiFriend ? "mt-3" : "mt-8";

관점 전환도 참가자 수에 맞게 수정했습니다.
단체방에서는 myUserIdfriendUserIds를 합친 배열을 순환합니다.

// chatStore.ts
const participants = [room.myUserId, ...room.friendUserIds];
const next = participants[(participants.indexOf(room.perspective) + 1) % participants.length];

이미지 전송

이번 과제는 API 연결 없이 JSON 파일로 데이터를 관리하는 구조였기 때문에, FileReader로 파일을 Data URL로 변환해 store에 직접 저장하는 방식을 택했습니다.

// ChatRoomPage.tsx
const handleFile = useCallback(
  (file: File) => {
    if (!file.type.startsWith("image/")) return;
    const reader = new FileReader();
    reader.onload = e => {
      const imageUrl = e.target?.result as string;
      sendImage(chatRoomId, imageUrl, formatISODate(now), formatDisplayTime(now));
      setTimeout(scrollToBottom, 0);
    };
    reader.readAsDataURL(file);
  },
  [sendImage, chatRoomId, scrollToBottom],
);

readAsDataURL()은 비동기로 동작하기 때문에 onload 콜백 안에서 store 업데이트와 스크롤을 처리했습니다. 스크롤은 마찬가지로 setTimeout(..., 0)으로 DOM 업데이트 이후 실행합니다.
store의 sendMessagesendImage가 동일한 MessageItem 구조를 공유하도록 설계했습니다. 이미지 메시지는 message: ""에 imageUrl만 채우는 방식으로 타입을 분기하지 않고 통일했으며, 텍스트 말풍선과 동일한 cornerClass 로직을 공유해 말풍선 꼭짓점도 동일하게 처리했습니다.

프로필

프로필 페이지 구현에서 가장 고민한 부분은 영속성이었습니다.
업로드한 이미지와 즐겨찾기 상태가 새로고침 후에도 유지되도록 profileStore를 별도로 분리하고 persist 미들웨어를 적용했습니다.
채팅 상태와 달리 구조가 단순해 커스텀 스토리지 없이 기본 미들웨어를 그대로 사용했습니다.

// profileStore.ts
export const useProfileStore = create<ProfileState>()(
  persist(
    set => ({
      starredSrcs: [],
      uploadedImages: [],
      toggleStar: src =>
        set(state => ({
          starredSrcs: state.starredSrcs.includes(src)
            ? state.starredSrcs.filter(s => s !== src)
            : [...state.starredSrcs, src],
        })),
      addUploadedImage: src =>
        set(state => ({ uploadedImages: [src, ...state.uploadedImages] })),
    }),
    { name: "profile-store" },
  ),
);

업로드 이미지는 배열 앞에 추가해 항상 최신순으로 표시되도록 했습니다. 즐겨찾기 토글은 starredSrcs에 포함 여부를 확인해 추가/제거를 처리하는 방식으로 구현했고, 이를 Set으로 변환해 O(1)로 조회할 수 있도록 했습니다.

const starredSet = useMemo(() => new Set(starredSrcs), [starredSrcs]);

설정

설정 페이지는 이번 과제 범위에 포함되지 않아 플레이스홀더로 처리했습니다. react-spinners의 ScaleLoader를 서비스 primary 색상으로 적용했는데, 여러 스피너 중 텔레그램의 분위기와 가장 잘 어울린다고 판단해 선택했습니다.

const SettingPage = () => (
  <div className="flex h-full flex-col items-center justify-center gap-5">
    <LoadingSpinner />
    <p className="font-body-3 text-gray-500">준비중이예요!</p>
  </div>
);

스플래시

다른 메신저들의 말풍선 로고와 달리 비행기 모양인 텔레그램 로고에 착안하여, 이륙 애니메이션을 구현하고 싶었습니다.
따라서 Framer Motion의 variants로 비행 궤적을 선언했습니다.

// constants/splash.ts
export const PLANE_VARIANTS: Variants = {
  idle: {},
  fly: {
    x:       [0, -15, 400],
    y:       [0,  10, -350],
    rotate:  [0, -10,   25],
  },
};

export const PLANE_TRANSITION: Transition = {
  duration: 0.55,
  times: [0, 0.15, 1],
  ease: "easeIn",
};

키프레임을 세 구간으로 나눠 처음 15% 구간에서 살짝 반대 방향으로 빠졌다가 남은 85% 구간에서 우상단으로 급가속하는 궤적을 만들었습니다.
easeIn과 맞물려 처음엔 천천히, 화면을 벗어날수록 빠르게 사라지는 느낌을 주고 싶었습니다.
화면 전환은 타이머 대신 onUpdate로 비행기의 실제 x 좌표를 추적해 트리거했습니다. onUpdate는 매 프레임 호출되므로 hasNavigated ref로 중복 navigate 호출을 방지했습니다.


// SplashPage.tsx
const hasNavigated = useRef(false);

<motion.div
  variants={PLANE_VARIANTS}
  animate={flying ? "fly" : "idle"}
  transition={PLANE_TRANSITION}
  onUpdate={({ x }) => {
    if (typeof x === "number" && x > 150 && !hasNavigated.current) {
      hasNavigated.current = true;
      navigate("/chat");
    }
  }}
>
  <AirplaneIcon className="h-40 w-30" />
</motion.div>

✈️전체 구현 모습

자세한 동작은 서비스 링크에서 직접 확인하실 수 있습니다.

스플래시 채팅 리스트 연락처
스플래시 채팅 리스트 연락처
채팅방 프로필 설정
채팅방 프로필 설정

✈️느낀 점

주관적이니 참고만 부탁드립니다.

협업 관점

  • 같은 화면을 보더라도 프론트엔드 개발자와 디자이너가 중요하게 생각하는 포인트가 다를 수 있다고 생각합니다. 따라서 처음에 최대한 많은 부분을 먼저 맞춰두는 것이 중요한 것 같습니다. 예를 들어 날짜가 바뀌었을 때 날짜선을 말풍선으로부터 얼마나 띄울지, 입력이 매우 길어지거나 수치가 많아지면 어떻게 처리할지 같은 것들이 그렇습니다. 커뮤니케이션을 촘촘히 가져가는 게 결국 가장 좋은 것 같습니다. 엣지 케이스를 잡을 수도 있고 놓친 부분들도 파악할 수 있다고 생각합니다.
  • 전 저의 시간도 소중하지만 그만큼 디자이너님의 시간도 정말 소중하다고 생각하여 연락을 빠르게 보고 깔끔하게 설명드리는 것이 중요하다고 생각합니다.
  • 개발자가 디자인 용어를 전부 알면 좋겠지만, 전부 알 수는 없습니다. 반대로 디자이너님도 마찬가지로 생각합니다. 따라서 서로 이해 가능하도록 소통하는 것이 정말 중요한 것 같습니다.
    저는 최대한 개발 용어 사용을 지양하는 편이고, 디자이너님께서 이해하실 수 있도록 표현하는 편입니다.

개발 관점

  • 앞서 말했듯 개발부터 우다다 하기보다는 큰 그림을 먼저 보고 어떤 레이아웃을 잡을지, 어떤 구조와 스택을 선택할지 충분히 정하고 들어가는 것이 가장 빠른 길이라 생각합니다.
  • 컴포넌트 하나를 만들 때도 확장성 있게 설계하는 것을 추천드립니다. 언제 children을 쓰고 언제 props를 써야 할지, 진짜 "설계"에 집중하는 게 중요한 것 같습니다.
  • 이번 과제에서 memo를 넘어서 시간 복잡도까지 고려하는 시각을 기른 것 같아 좋았습니다. Set으로 O(1) 조회를 한다거나, 컴포넌트 외부에서 한 번만 계산하도록 구성한다거나 하는 것들!
  • 그리고 이 과제를 시작하기 전에 일부러 초기 세팅에 꽤 시간을 쏟았습니다. ESLint, Prettier, Husky 등을 그냥 복붙하지 않고 각각의 역할을 제대로 이해하고 설정하고 싶어서였는데 생각보다 얻어가는 게 많으니, 다들 한 번쯤 시간 내서 파보시는 걸 추천드립니다.

✈️마무리

개인적으로 애정이 정말 많이 가는 과제였습니다.
디자이너님이 열심히 리디자인해주신 만큼 저도 의도에 맞게 구현하려고 꽤 노력했고, 개발하는 내내 재밌었습니다. 이 글과 PR에 다 담지 못할 정도로 고민을 많이 한 것 같습니다.
제 강점인 꼼꼼함과 엣지 케이스를 잘 짚어내는 점을 많이 녹여냈고, 그 과정에서 텔레그램 서비스 자체도 리서치하면서 서비스 이해도를 높이는 데 집중했습니다.

실제로 디자이너님이 극찬을 많이 해주셔서 정말 감사했습니다.😻
또한 멋진 디자이너님과 매칭시켜주신, 그리고 프-디 협동이라는 의미있는 세션 준비해주신 CEOS에게도 감사함을 전합니다.

0개의 댓글