본문으로 건너뛰기
이창준
전체 프로젝트

재직 당시 서비스명 라이프멘토

텔링

AI와 대화하며 경험을 정리하고 자기소개서와 면접을 준비하는 취업 준비 서비스

기간
2025.09 – 2026.02
역할
Frontend Engineer
팀
(주)크루베이션
기술
  • Next.js
  • TypeScript
  • Tailwind CSS
  • React Query
  • Recoil
텔링 메인 페이지. 경험 정리부터 자기소개서와 면접 준비까지 돕는다는 문구가 보인다
서비스 공개 페이지

사내 화면은 공개할 수 없어서 공개 페이지와 문제 해결 과정으로 대신합니다.

배경

대학과 기관이 학생들에게 제공하는 AI 취업 준비 서비스입니다. 실제 사용자가 계속 쓰는 서비스라서 새 기능만큼 기존 화면의 안정성과 반응 속도가 중요했습니다.

맡은 일

  • 경험 정리, 자기소개서, 면접 준비 화면 개발과 운영
  • 제휴 기관 관리자용 통계 화면 개편: 기관 전체와 학과별, 기능별 사용 현황과 화면별 엑셀 추출
  • AI 답변 스트리밍, 모바일 대응, 공통 UI 문제 해결
  • 기획, 디자인, 백엔드와 요구사항 조율

핵심 결정

  1. 결정 1

    전체를 다시 불러오지 않고, 바뀐 한 건만 동기화했습니다

    상황
    경험 목록에서 상세 모달을 열고 닫기만 해도 목록 전체 API가 다시 호출되고 모든 카드가 다시 그려졌습니다.
    선택
    원인을 목록 조회 effect의 의존성, 배열 전체 교체, 즐겨찾기 조회 범위, 부모와 자식 렌더 결합의 네 가지로 나눴습니다. 모달을 닫을 때 수정된 한 건만 받아 병합하고, 즐겨찾기 조회 기준을 id 목록으로 좁히고, 카드를 memo로 분리한 뒤 콜백 참조를 고정했습니다.
    이유와 결과
    바뀌지 않은 항목의 객체 참조가 유지돼야 React가 렌더를 건너뛸 수 있습니다. 모달 여닫기로 인한 목록 재조회가 사라졌고, React Profiler 단일 측정 기준 렌더 시간도 15.7ms에서 13.5ms로 줄었습니다.
  2. 결정 2

    채팅과 초안 생성에 서로 다른 방식을 썼습니다

    상황
    AI 답변은 생성되는 대로 보여 주는 편이 좋지만, AI를 두 번 호출하는 자기소개서 초안 생성은 중간에 실패하면 추적과 재시도가 필요했습니다.
    선택
    채팅은 SSE(EventSource)로 받아 말풍선이 자라듯 표시하고, Next.js Route Handler를 프록시로 두어 CORS와 버퍼링 문제를 줄였습니다. 초안 생성은 추적 가능한 요청 방식을 유지하고 진행 표시로 대기 경험을 보완했습니다.
    이유와 결과
    초안 생성은 실패하면 어디서 멈췄는지 알고 다시 시도할 수 있어야 해서, 빨라 보이는 쪽 대신 추적이 되는 쪽을 골랐습니다.
  3. 결정 3

    모바일에서는 hover 대신 탭으로 안내했습니다

    상황
    선택할 수 없는 항목의 안내 툴팁이 모바일에서만 깜빡이거나 다른 문구로 바뀌었습니다. 터치 환경에서 섞여 들어온 마우스 이벤트가 하나의 상태를 번갈아 덮어쓰고 있었습니다.
    선택
    matchMedia로 hover 가능한 기기를 판별해 데스크톱은 hover, 모바일은 탭하면 1.5초 뒤 자동으로 닫히도록 입력 방식을 분리하고, 타이머 정리까지 커스텀 훅으로 옮겼습니다.
    이유와 결과
    입력 방식이 다른 기기에서 같은 이벤트 코드를 공유하면 상태가 경쟁합니다. 규칙을 훅 하나에 모아 다른 선택 화면에서도 같은 문제가 생기지 않게 했습니다.

코드로 보기

모달을 닫을 때 수정된 항목만 병합하기 (단순화한 예시)
const handleClose = useCallback(async () => {
  const closingId = openedItem?.id;
  setOpenedItem(null);
  if (closingId == null) return;

  const detail = await fetchItemDetail(closingId);
  // 목록 전체를 다시 받지 않고, 바뀐 한 건만 교체한다
  setRows((prev) =>
    prev.map((row) => (row.id === closingId ? mergeDetail(row, detail) : row)),
  );
}, [openedItem]);

바뀌지 않은 행은 기존 객체를 그대로 두기 때문에, memo로 분리한 카드는 다시 그려지지 않습니다.

결과

  • 모달을 여닫는 것만으로 목록 전체를 다시 불러오던 요청이 사라지고, 수정 결과는 해당 항목에만 반영됩니다.
  • 문제 해결 과정을 증상, 원인, 해결, 효과, 검증 순서로 남겨 같은 유형의 문제를 다시 찾아볼 수 있게 했습니다.

관련 글