logo
마이다스 지원서 리뉴얼 프로젝트 출시 회고

이미지 보기

마이다스 지원서 리뉴얼 프로젝트 출시 회고

장장 7개월의 대규모 프로젝트를 마무리하며

  • 24.11.10 작성

  • 읽는 데 67

TOC

들어가며

회고에 대한 감상

2번째 블로그 개발도 궤도에 올랐고, 글또에도 참여하게 되면서 본격적으로 글을 많이 쓰고 있어요. 개발자답게 기술 포스트를 쓰고 싶은데 계속 회고할 거리가 생기는 것 같아요. 특히 그동안 회고글 작성을 거의 안 해왔는데, 글또 덕분에 회고를 많이 하게 되네요.

작년에 개발자로서의 첫 1년을 마무리하고 회고를 작성했는데, 용두사미... 아니 다 마치지도 못했어요. 그렇게 어느덧 2년을 바라보고 있는데 2년차 회고는 꼭 작성해보려 해요.

첫 프로젝트 회고

각설하고 시작하자면, 이번 10월 30일, 저희 팀 기획자, 디자이너, 개발자 총 20명 내외의 팀원들이 7개월 동안 박 터지게 고생하며 개발한 대규모 프로젝트가 드디어 출시를 했습니다!! 🎉🎉🎉 무려 메이저 버전이 바뀌는 주요 프로젝트였고, 리뉴얼의 처음부터 개입해서 전후 비교가 확실할 수 있는 프로젝트였어요.

템플릿 Main

그래서 이번 출시동안 제가 어떤 개발을 했고 어떤 부분에 신경을 썼는지 개발적, 감정적 회고를 작성해보려고 해요. 정말 실력적으로도 많이 성장하고 느낀 점도 많았던 프로젝트였거든요. 시작해볼게요!

그 전에 배경 소개부터

지원서 프로젝트 내용의 연속이라 이해를 위한 배경 설명


(시간이 없으시다면 넘어가셔도 좋아요.)

마이다스의 채용솔루션

저는 2024년 11월 현재, 판교에 본사를 두고 있는 마이다스 그룹의 마이다스인이라는 계열사에서 2년차 프론트엔드 개발자로 일하고 있어요. 저희 마이다스인은 AI역량검사, 개발자검사 등 기업의 인사담당자와 입사 지원자를 연결하는 채용 시장에서 여러 영향을 주고 있어요. 주요 고객은 기업의 인사담당자들이고, 고객들이 지원자를 채용하고 관리하는 B2B SaaS 채용솔루션 서비스를 개발하고 판매하는 것을 주력 아이템으로 삼고 있어요.

공채용인지, 수상시, 상시용인지에 따라 여러 서비스들이 있지만, 한 가지 예시로 공채용 SaaS를 사용하는 경우엔 기업 인사담당자들은 이런 통합 관리 서비스에서 채용과 지원자 관리에 대한 다양한 기능들을 사용할 수 있어요.

insight

근데 이제 오랜 세월을 곁들인

이번에 리뉴얼한 서비스는 2016년부터 개발이 시작되어 8년의 역사를 가지고 있어요. 당시 채용 시장에서는 모바일 기기 대응이 다양하지 않아 반응형보다는 데스크탑 환경 위주로 개발되었고, 인사담당자들이 커스터마이징한 지원서의 항목들은 지원자들이 지원하는 지원서에서 그대로 보여지기 때문에, 마찬가지로 데스크탑 환경에서만 작동했어요. 그리고 UI도 낡고, 사용성도, 안정성도 떨어지는 상태였죠.

insight

인사담당자들에게는 지원서 커스터마이징에도 한계가 있다거나 기타 여러 불편들이 오랜 세월 누적되었고, 불만과 VoC가 계속 되었어요. 설상가상 많은 레거시 프로젝트가 그렇듯, 오랜 세월이 쌓이며 히스토리는 많아졌고, 초기에 개발을 담당했던 선배 개발자분들은 남아있지 않기도 했죠. 그러니 개발 속도가 나지 않는 건 기본이고, 사소한 변경에도 사이드가 터지는 건 부지기수였어요.

그래서 저희는 이런 문제를 해결하고자, 주요 프로세스를 완전히 모던한 프로젝트로 리뉴얼하기로 결정했어요. 그게 바로 마이다스 지원서 리뉴얼 프로젝트였습니다.

문제 정의

지원서 리뉴얼의 기대효과

위에서 정의한 문제들을 토대로, 이를 해결하는 세부 기획이 결정되었어요. 아래는 리뉴얼을 통해 해결할 수 있는 누적 VoC들이에요. (사진은 내용을 확인할 수 없게 캡쳐했고 많다는 것만 알아주세요.)

누적 VoC

지원서 리뉴얼을 통해 해결하고자 했던 VoC의 개수는 총 133개, 그리고 각 이슈들은 커스텀 질문을 추가하거나 순서를 변경하는 등의 사용성을 크게 향상시키는 것들도 많았기에 이들이 모여 엄청난 파급력을 만들 수 있었어요. 이 중 90% 이상은 회사의 맞춤형 지원서를 커스텀하고 싶다는 니즈였고, 이 점에 주목해 최대한 커스텀 요소를 열어주는 것, 그리고 향상된 UI와 반응형 지원에 초점을 맞췄어요.

이제부터는 어떤 개발을 했는지 정리해볼게요.

공통 레이아웃

MFE 도입 배경

저희 서비스는 기본 서비스 플로우에 리뉴얼한 서비스 페이지들을 끼워넣는 방식인데, DevOps 측에서 프록시 설정을 통해 리뉴얼한 서비스 페이지를 path prefix를 기준으로 구분해요. 메인 서비스 플로우가 진행되는 도메인에서 path들을 보며, 리뉴얼 프로젝트에 해당하는 path로 시작한다면 리뉴얼 프로젝트의 자원을 서빙하는 방식으로 기존 플로우의 일부를 들어내고 대체한 거죠. 예를 들면 기존 레거시 프로젝트에서 path가 /v1/resume/로 시작하는 경로로 변경된다면, 바로 배포된 리뉴얼 프로젝트의 해당 path로 리다이렉트되는 방식입니다.

공통 레이아웃

이는 유저에게 하나의 서비스를 이용하는 것 같은 사용성을 주기 위함이었어요. 개발적으로는 레파지토리도, 코드 구현도 다르지만 말이에요. 그래서 서비스마다 공통된 레이아웃을 사용하는데, 서비스마다 레이아웃을 각각 가지고 있다면, 관리 포인트의 증대와 유지보수의 불편이 예상되었어요. (이미 3개의 동일한 모습의 레이아웃이 프로젝트마다 있었어요.)

더군다나 레이아웃을 사용하는 app마다 사용중인 스타일 라이브러리에 맞게 마이그레이션해서 이식해야 한다면 더 큰일이었죠. 그래서 저희는 MFE(Micro Front-End) 방식으로 런타임에 모듈을 로딩해 합칠 수 있도록 설계했어요.

모듈 마이그레이션

저는 여기서 SCSSRedux, 그리고 자체 아키텍처로 구성된 기존 레이아웃을 EmotionTanstack-Query를 활용해 로직과 스타일을 변경하여 마이그레이션 하는 작업을 맡았어요. 그리고 이 MFE 모듈의 일부 주요 기능들을 storybook의 interaction testMSW를 활용하여 인터렉션 테스트를 개발했어요.

인터렉션 테스트 인터렉션 테스트

현재 3개의 서로 다른 서비스에서 제가 만든 공통 레이아웃을 사용하고 있습니다. 이번 개발 이후에 꽤나 큰 변경 기능이 생겨 레이아웃에 기능 추가가 있었는데, 한 곳에서 관리되니 훨씬 DX와 안정성의 향상을 모두가 체감하고 있어요.

MFE의 좌절

아쉬운 점은 MFE는 별도의 app처럼 빌드와 배포가 이루어지고 별도의 도메인을 가져야 해요. 그런데 고객사 중 일부는 보안적인 이유로 도메인의 화이트리스트를 관리하기 때문에 MFE를 위한 정적 도메인이 나와야 하는데요, S3나 cloudfront 방식은 CDN을 사용하기 때문에 정적 IP를 가지지 못합니다. 때문에 ECS 방식으로 배포를 해야 정적 IP를 적용할 수 있었어요.

하지만 별도 ECS와 CI/CD 프로세스를 추가로 구축하는 걸 DevOps 측과 협의하는 과정에서 소통이 잘 되지 않았고, 아쉽지만 MFE를 포기하게 되었어요. monorepo의 package로 관리되고 있었기 때문에 서비스마다 정적 import하여 기존 방식처럼 서비스가 빌드할 때 함께 빌드되도록 되고 있습니다.😭😭

템플릿 설정 RNB

지원서 리뉴얼에서 인사담당자들을 위한 기능의 핵심은 커스터마이징 기능이었어요. 한 번 설계된 템플릿을 기반으로 공고 베이스를 만들고, 개별 공고에 맞춘 추가적인 수정을 거쳐 최종적으로 공고를 만들어내는 방식인거죠.

RNB 동작 화면

이를 위해 5명의 프론트엔드 개발자가 템플릿 설정 개발에서 시작했는데, 저는 이 중 템플릿 설정 RNB 부분을 맡았어요.

RNB의 역할

지원서 기능 요구사항과 배경 소개

200종의 지원서 질문 요소는 대부분 규격화된 타입의 구조로 구성됩니다. 예를 들면 텍스트형, 숫자형, 선택형, 서술형 등등 특정 타입의 항목들이죠. 그리고 또다른 분류가 있어요. 기존 구버전의 지원서에서 제공하는 '기존 항목'과 이번 개편으로 사용자가 추가적으로 커스텀할 수 있는 '추가 항목'에 대한 분류입니다. 이 분류에 따라 RNB에서 조작할 수 있는 부분이 달라야 하고, 때문에 구분된 RNB 설정 페이지가 필요했어요.

그런데 이게 완전히 분류별로 딱 맞아떨어지지는 않았어요. 몇몇 항목들은 독특한 UI와 동작을 가졌고, 이는 곧 시스템에 대한 예외이자, 엣지 케이스가 되는 상황이었죠. 즉, 완전히 개별관리를 하기도, 그렇다고 완전히 시스템화하기도 어려운 상황이었어요. 정리하면 이렇습니다.

  • 질문의 타입에 따라 UI는 규격화된다.
  • 질문의 기원에 따라 조작 가능한 영역은 제한되어야 한다. (기존 항목과 커스텀 항목)
  • 특정 질문들에 대해서는 별도로 조작하는 기능과 UI가 달라야 한다. (엣지 케이스 고려)
RNB 동작 화면

RNB 섹션 모듈

그래서 각각의 RNB 구성 요소들을 원자화해서 많이 쪼개고, 합성하는 방식으로 개발했어요. 최대한 atomic한 부분으로 개별 요소를 만들고, 이를 합성한 molecule들인 section, 그리고 기타 성격에 따른 나머지 요소들로 컴포넌트를 쪼개고 분류했어요. 아키텍처 단위가 아닌 코드 레벨에서 atomic pattern을 사용한거죠. section들은 사용할 때 외부에서 내부 props를 수정할 수 있도록 최대한 native spec을 사용부에 노출하는 구조로 개발했어요.

개별 모듈들이 각각 무엇인지, 그리고 어떤 인터페이스를 가지고 어떻게 사용하는지 팀원들에게 알리기 위해 스토리북으로 모듈들에 대해 작성하기도 했어요.

RNB 모듈 스토리북

Composition Pattern

그리고 Composition Pattern를 활용하여 각 요소들이 atom인지, section인지, container인지 등등 성격과 계층을 나누고자 했어요. 이를 통해 각 요소들이 유기적이면서도 보다 더 계층적인 구조를 가지게 되었고, RNB 하나만 import하더라도 하위의 모든 컴포넌트들을 사용할 수 있었기 때문에 편리한 DX를 제공할 수 있었어요. (물론 RNB 모듈의 크기가 커지는 단점이 있으나 DX의 향상이 더 크게 작용했어요.) 그래서 사용부를 보여드릴게요.

<RNB>
  <RNB.Layout.Header title={'항목 수정'} />
  <RNB.Layout.Body>
    <RNB.Section.FieldUseStatus sn={sn} border />
    <RNB.Section.FieldName title={title} sn={sn} border />
    <RNB.Section.BottomGuide guideInfo={guideInfo} sn={sn} border />
    <RNB.Section.Container border>
      <RNB.TitleContainer>
        <RNB.Title>하위 항목</RNB.Title>
        <RNB.SubField.MandatoryChanger parent={selectedField} />
      </RNB.TitleContainer>
      <RNB.SubField.ConfigList
        subFieldList={subFieldPropList}
        onClickSubFieldAction={(subFieldItemSn) => {
          setCurrentSettingSubFieldItemSn(subFieldItemSn);
        }}
      />
    </RNB.Section.Container>
    {match(currentSettingSubFieldItemSn)
      .with(병역구분_하위항목.sn, () => (
        <병역구분_하위항목_설정 item={병역구분_하위항목} />
      ))
      .with(복무기간_하위항목.sn, () => (
        <복무기간_하위항목_설정 item={복무기간_하위항목} />
      ))
      .otherwise(() => null)}
  </RNB.Layout.Body>
</RNB>

특이한 컨벤션

그런데.. 위의 사용부 코드를 보고 이상한 것들이 보이지 않나요?

한글 컨벤션

폴더 구조 일부
Record의 key도 한글

네, 저희 지원서 프로젝트는 한글 코딩 컨벤션을 채택해 사용하고 있어요. 200종에 달하는 모든 지원서 요소들을 각각 영어로 번역해 컴포넌트를 만들려고 하다보니 인지 조화가 오지 않았고 팀원들끼리 각각 어떤 요소를 칭하는지 이해하기도 어려웠어요. 그래서 제가 농담 반 진담 반으로 "이럴 거면 한글 쓰면 안 되나ㅋㅋㅋ" 라고 하다가 "어라, 괜찮을지도?"라는 분위기로 이어져서 한글 컨벤션을 도입해보려고 시도했어요. 실제로 한글을 사용한다면 얻게 되는 DX가 엄청나게 예상됐거든요.

다양한 레퍼런스들을 찾아보았고, 토스의 한글 코딩 컨벤션과 관련된 문서들도 참고했어요. 하지만 컴포넌트 이름까지 한글로 하는 경우는 찾아볼 수 없어서 직접 시도해보기로 했어요. 배포까지 모든 예상되는 난관을 다 적용해보았을 때 문제가 되지 않는다는 PoC를 거쳐 한글 컴포넌트명과 디렉토리명을 도입했어요. 생소하긴 했지만 DX는 훨씬 올라갔죠. 파일을 이동시킬 때 Symbolic이 잘 안 따라오는 등 리팩토링할 때 아쉬운 부분이 있긴 했으나, 현재와 미래를 위해 너무 잘한 판단이었다고 자신해요.

ts-pattern

위 코드에서 match 함수를 보셨나요? 이것은 ts-pattern 라이브러리를 사용한 것이에요.

ts-pattern 자료

ts-pattern은 TypeScript에서 패턴 매칭을 지원하는 라이브러리로, 분기 처리에 대한 코드의 안정성과 가독성을 높일 수 있어요. 저희 코드에서는 switch문을 사용하지 않고도 tsx에서 분기처리를 쉽게 해준다던지, 불필요한 if-else문을 줄이기 위해 사용했어요. 팀원들과 함께 하던 프론트엔드 스터디에서 언급되었던 패턴인데 팀원 중 한 분이 이 패턴의 적용에 대한 제안을 하고 코드 작성도 해주셨어요.

가장 큰 설득 포인트는 switch 문은 tsx 내부에서 사용하지 못하고 별도의 함수로 빼서 사용해야 하는 반면, ts-pattern은 그렇지 않고 바로 tsx에서 사용할 수 있다는 점이었어요. 이에 공감하여 정식으로 자리 잡게 되었어요.

템플릿 설정 고도화

템플릿 설정이 궤도에 오른 이후 프론트에서 필요한 개발 영역이 넓어졌어요. 예를 들면 지원자들을 위한 B2C 채용 지원 폼(공고 템플릿과 WYSIWYG)에 대한 개발이나 특정 고객사에서 원하는 다국어 지원 등이었죠. 프론트 구성원이 찢어져 각자의 전문 영역을 개발하게 되었고, 저는 템플릿 설정에 남아 빠른 빌드업 과정에서 발생한 저품질 코드 개선 및 고급 기능 구현을 담당하게 되었습니다.

여기부터는 코드 구현부가 있어 내용이 깁니다.

스크롤 포커싱

"항목의 표시를 변경할 때 더 인지가 잘 되면 좋겠어요."

지원서 템플릿은 필요할 것 같은 질문 항목들의 후보를 만들어 놓아요. 그리고 "필요한 건 켜고, 불필요한 건 끄고, 없는 거는 만들어라!" 의 사용성을 제공하고 있습니다. 그런데, 항목들을 끄고 켤 때 어디에 있는 항목인지 인지가 잘 되지 않는다는 Pain Point를 가지고 있었어요. 그래서 사용자가 변경을 더 잘 인지할 수 있게 스크롤을 이동시켜주자는 기획 요구사항으로 이어졌습니다.

그렇게 스크롤 포커싱 기능을 개발하게 되었어요.

스크롤 포커싱

scrollIntoView

이 스크롤 포커싱은 element.scrollIntoView() 메서드를 사용했어요. scrollIntoView는 element의 시작/중앙/끝(inline, block)으로 여러 방식(behavior)으로 스크롤을 해주는 기능을 제공해요.

간단한 임시 코드로 설명할게요.

const Component = () => {
  const ref = useRef<HTMLDivElement>(null);
  const scrollFocus = () => {
    ref.current.scrollIntoView({
      behavior: 'smooth',
    });
  };

  return <div ref={ref}>...</div>;
};

React에서는 위처럼 dom에 걸어둔 ref를 활용해 scrollIntoView를 적용할 수 있어요. 함수 property에 대해 더 자세히 설명된 블로그 레퍼런스가 있으니 필요하시다면 참고하시기 바랍니다.

RefMap

다만 위의 예시는 컴포넌트 내부에서 ref를 만들고 등록했기 때문에 scope가 컴포넌트에 한정되어 있어요. 요구사항은 페이지 내부에서 어떤 컴포넌트의 ref에도 접근할 수 있어야 했기 때문에 전역적인 ref의 관리가 필요했죠. 그래서 RefMap이라는 개념을 고안해보았습니다. 설계 방식은 다음과 같습니다.

RefMap의 설계
  1. 전제 : focusScroll에는 ref가 필요하다.
  2. provide : ref를 전역 store에서 생성하고 focusScroll이 필요한 컴포넌트마다 ref를 전역 store에서 끌어와 걸어준다.
    1. API를 통해 받아온 nested한 아이템 데이터를 순회하며 sn를 수집한다.
    2. 계층별 아이템의 sn을 key로 하고 ref를 value로 하는 Map을 만든다.
  3. consume : focusScroll을 하는 hook 역시 전역 scope에서 관리하고 전역 scope의 ref를 찾아 활용한다.
    1. 위의 RefMap에서 sn으로 ref를 조회한다.
    2. 조회된 ref를 ReactDom의 ref에 등록한다.
    3. ref를 활용한 scrollIntoView를 사용하고, 스크롤을 포커싱한다.

이 과정을 코드 레벨로 설명해보겠습니다. 원래 계층별로 sn을 수집하는 방식이 다르거나, refMap도 다양하고, 여러 복잡한 비즈니스 로직이 있습니다. 하지만 생략할 부분은 생략하고 동작 방식만 설명해볼게요.

2.가. 데이터 구조 순회로 sn 수집

tree 형식의 데이터는 계층별로 분기가 가능합니다. RefMap에 등록할 아이템 계층에 대해 key로 사용할 아이템별 sn을 수집합니다. linear한 구조의 snList가 수집됩니다.

const getSnList = (item: Item): string[] => {
  const traverse = (item: Item) => {
    switch (item.type) {
      case ITEM_TYPE.FIELD:
        result.push(item.sn);
        break;
      ...
      default:
        item.children.forEach((child) => traverse(child));
        break;
    }
  };

  const result: string[] = [];
  traverse(item);
  return result;
};
2.나. RefMap 만들기

전역 store에 RefMap을 만드는 action을 정의합니다. 저희는 전역상태 라이브러리로 zustand를 사용하고 있습니다. api 조회 시점에서 데이터가 유효함을 확인한 뒤, RefMap을 만드는 createRefMap action을 호출하여 store에서 RefMap을 만들어줍니다.

import type { MutableRefObject } from 'react';
import { createRef } from 'react';

type ItemRef<T extends HTMLElement> = MutableRefObject<T>;
export type ItemRefMap<T extends HTMLElement> = Map<string, ItemRef<T>>;

const store = createStore((set) => ({
  createRefMap: (item: Item) => {
    const snList = getSnList(item);
    const refMap = new Map();
    snList.forEach((sn) => refMap.set(sn, createRef()));
    set({ refMap });
  },
}));
3.가. ref 조회 hook

사용부에서는 컴포넌트별로 item의 sn을 이용해 ref를 조회하고, 또 등록할 겁니다. 그래서 sn을 key로 하여 조회하는 hook을 만들었습니다.

export const useGetRefState = <T extends HTMLDivElement>(
  sn: string,
): MutableRefObject<T> =>
  store((state) => state.refMap.get(sn)) as MutableRefObject<T>;
3.나. ref 등록

scrollIntoView의 예시에서 컴포넌트 내부에서 useRef를 활용해 ref를 만들어 등록했다면, 이제 ref 조회 hook을 통해서 전역 store에 등록된 ref를 등록해줄 수 있겠죠. 사용부 예시 코드를 보여드리겠습니다.

const Component = ({ item }: Props) => {
  const { sn } = item;

  // key로 ref 조회
  const ref: MutableRefObject<HTMLDivElement> = useGetRefState(sn);

  const { scrollFocus } = useScrollFocus(sn);
  const handleClick: MouseEventHandler = (e) => {
    e.stopPropagation();
    onClick?.();
    scrollFocus();
  };

  return (
    <div ref={ref} onClick={handleClick}>
      ...
    </div>
  );
};

실제 스크롤 포커싱은 미리보기 부분과 RNB 부분을 모두 scroll한다거나 화면 내부에 있는지 판단해서 스크롤을 취소하는 등 고급 로직들이 있기 때문에 별도 hook으로 추상화되어 있습니다.

URI fragment

"외부에서 바로 스크롤 포커스된 상태로 진입이 가능해야 해요."

원래 특정 item의 RNB의 표시는 미리보기 item을 클릭하는 방법 밖에 없었어요. 그래서 관리를 더 컴팩트하게 하고자 RNB 관련 로직을 미리보기 item이 연결하고 다른 컴포넌트에서는 노출되지 않도록 설계가 되어 있었죠. 그런데, 외부에서 특정 item의 RNB를 열 수 있게 하는 요구사항이 생긴 겁니다! 예를 들면 외부에서 특정 아이템에 대한 심화 설정을 해야할 때 이 템플릿으로 연결돼야 하는데, 바로 아이템에 포커스된 채로 설정을 할 수 있어야 하는 거죠.

그래서 구조 설계 변경이 불가피했고 재설계 및 변경 대응을 통해 해결했습니다.

url frament

이 과정에서 url에 hash로 값을 달아주는 URI fragment를 사용하면 딱 적절하겠다고 생각했어요.

URI fragment의 정의

The fragment of a URI is the last part of the URI, starting with the # character. It is used to identify a specific part of the resource, such as a section of a document or a position in a video. The fragment is not sent to the server when the URI is requested, but it is processed by the client (such as the browser) after the resource is retrieved.

출처: MDN - URI fragment

즉, 리소스 검색 후 클라이언트에서 섹션의 위치를 식별하는 데에 사용되는 조각이며, URI의 마지막에 # 기호로 시작하는 데이터 조각이에요. MDN 등 공식 사이트를 포함해 많은 웹사이트에서 url만으로 특정 위치로 이동시키기 위한 '국룰'처럼 사용되는 방법이죠. 이걸 도입해보았습니다.

위에서 scroll focus와 ref에서 다룬 sn을 URI fragment의 데이터로 사용하고 싶었어요. 그렇다면 재설계는 어떻게 해야할까 고민이 되었고, 이렇게 풀어보려 했습니다.

  • 기존 : 미리보기 → RNB로 표시하는 방식
  • 변경 : URI fragment에 렌더링을 의존하는 방식
  • 방식 : PubSub 구조 - 미리보기와 RNB에서 URI fragment의 변경을 발행구독

이런 관점인거죠. 흐름을 flow chart로 살펴보겠습니다.

RNB 아이템을 클릭하면 해당하는 미리보기 아이템도 스크롤 포커싱되면 좋겠어요.

event flow

파란색이 rnb item, 빨간색이 preview item이라고 용어를 정리해볼게요. 둘은 같은 item에 대한 데이터를 바라보고 있고, 서로 유기적으로 맞물려 조작됩니다. 그렇다면 코드적으로는 이런 구독-발행 구조를 가지고 있습니다.

event flow
  1. Publisher : URI fragment의 변경과 미리보기 스크롤을 기대하는 flow의 시작은 rnb item입니다. 이게 발행자가 됩니다.

  2. Publish Event : URI fragment의 정보를 가져오거나 변경하는 getter, setter를 담은 useUriFragment라는 hook을 만들었습니다. publisher인 rnb item은 이 hook을 사용해 URI fragment의 정보를 변경하는 publish event를 발행합니다.

  3. Event Channel : useUrifragment은 발행 topic인 URI fragment를 변경해줍니다. publisher와 subscriber 간의 브로커 역할을 하는 것이 event channel이죠.

  4. Subscriber : HashChangeLister라는 컴포넌트가 URI fragment의 변화를 째려보고 있습니다. 즉, 구독을 하고 있는 거죠. 구독을 하는 동시에 URI fragment의 변화를 감지하면 HashFocusScrollHandler라는 컴포넌트에게 변경된 URI fragment를 전달합니다. HashFocusScrollHandler는 위에서 언급된 useScrollFocus라는 hook을 활용해 preview item에 적절한 스크롤 처리를 해주고, sn에 맞는 세부 RNB도 열어주게 됩니다.

외부에서 특정 아이템을 포커스한 상태로 진입되게 하고 싶어요.

이 경우, preview item은 scroll focus되어야 하고, rnb item은 해당 item에 대한 세부 rnb가 open되어야 합니다.

event flow

그리고 코드 레벨 플로우는 이렇습니다.

event flow

하지만 동작은 똑같아요. 오히려 중간부터 시작하는 거라 더 간결합니다. url에 URI fragment를 달고 들어오면, Subscriber인 HashChangeListenerHashFocusScrollHandler에 URI fragment(sn)를 전달하여 preview item은 scroll focus, rnb item은 openRnb를 하는거죠.

화면에 있는지에 따라 판단

"화면에 아이템이 있으면 스크롤하지 말아주세요."

화면에 아이템이 있는데도 scroll이 자꾸 된다면 사용성이 오히려 더 떨어지겠죠. 그래서 useScrollFocus hook 내부에서는 스크롤 전에 itemRef가 화면 내에 있는지 판단해서 스크롤 과정을 생략하는 로직도 추가했어요. element.getBoundingClientRect() 메서드를 사용했어요.

간단한 조건문 구현부이지만, 기획 쪽에서는 많이 좋아했던 갓성비 기능이라 기억에 남아 첨부합니다.

const focusScroll = () => {
  if (ref.current.getBoundingClientRect().top >= 0 &&
    ref.current.getBoundingClientRect().bottom <=
    window.innerHeight
  ) {
    return;
  }
  ...
};

Hide Animation

"아이템이 화면에서 사라질 때는 더 UI적으로 명확해야 할 것 같아요."

비활성 상태의 아이템을 활성 상태로 전환하는 것은 변화가 분명하고 결과가 화면에 남으니 괜찮았어요. 그런데, 활성 상태의 아이템을 비활성 상태로 전환하는 건 사라짐이 분명하지 않았죠. 그래서 이에 대한 인지 강화가 필요했고, 사라지는 애니메이션을 추가해달라는 디자인/기획 요구사항이 있었어요. 결과는 이렇습니다.

Hide Animation

비활성화 버퍼 구조

간단해보이지만, 사실은 원래 Active 상태만 있으면 되는 걸, 사라지는 중인 아이템들을 추가로 관리하는 시스템이 필요했어요. 다시 말하면, 기존엔 ACTIVEINACTIVE였다면, ACTIVEINACTIVATINGINACTIVE의 단계를 거쳐야 하는 거죠.

어차피 Active 상태는 데이터 구조에서 가지고 처리하기 때문에 INACTIVATING에 대한 전역 상태 store를 추가로 설계했어요. 비활성화중인 item을 임시 buffer에 담았다가 animation duration만큼 시간이 지나면 inactive시켜주는 방식이고, 핵심은 inactive시키는 함수를 callback으로 두어, sn을 key로, callback을 value로 저장하는 것이었죠. 함수도 값으로 저장하는 JS의 일급객체 강점을 활용했습니다.

전역 store에 Map으로 관리

역시나 zustand를 사용했고, state와 action을 분리해서 저장하고, selector로 구분해 사용하는 구조입니다. 레퍼런스도 많은 구조 설계라 보안 문제 없이 코드를 공개합니다.

interface InactivatingItem {
  callback: () => void;
}

type StateType = {
  itemMap: Map<string, InactivatingItem>;
};

type ActionType = {
  addItem: (sn: string, value: InactivatingItem) => void;
  removeItem: (sn: string) => void;
  clearItemMap: () => void;
};

const store = createStore<StateType & { actions: ActionType }>((set) => ({
  itemMap: new Map(),
  actions: {
    addItem: (sn, value) => {
      set((state) => state.itemMap.set(sn, value));
    },
    removeItem: (sn) => {
      set((state) => state.itemMap.delete(sn));
    },
    clearItemMap: () => {
      set({ itemMap: new Map() });
    },
  },
}));

export const useGetInactivatingItemMapState = () =>
  store((state) => ({
    inactivatingItemMap: state.itemMap,
  }));

export const useGetInactivatingItemState = (sn: string) =>
  store((state) => state.itemMap.get(sn));

export const useItemInactivatingMapActions = () =>
  store((state) => state.actions);
활성상태 관리 hook

그리고 기존에는 데이터 구조적으로 관리하던 활성상태를 별도로 관리하는 useItemActive라는 hook을 만들어 데이터 구조 store와 INACTIVATING을 관리하는 buffer store를 유기적으로 연결했습니다. 코드는 생략하고 요구사항으로 넘어가겠습니다.

Delay 처리

"화면 밖에 있는 아이템은 scroll focus 이후 사라지기 시작하게 해주세요."

그래서 hook의 사용부에서 scrollFocus를 처리하는 과정에서 이 아이템이 화면 안에 있는지 없는지 return하는 flag를 반환받고, 이를 setItemActiveStatus 함수의 options.isImmediate에 넣어 delay를 조건 처리해줍니다. 그 결과 화면 밖에 있으면 스크롤되는 시간을 기다렸다가 비활성 상태로 추가해주는 딜레이 기능을 구현했어요.

Hide Animation Delay

Skip 처리

"한 아이템이 비활성화되고 있을 때 다른 아이템을 또 비활성화하면 비활성화중이던 애니메이션을 스킵해주세요."

빠르게 아이템을 비활성화 시키는 경우에 사라지는 애니메이션이 계속 유지된다면 UI/UX적으로 좋지 않아보였어요. 그래서 애니메이션 도중 다른 애니메이션을 시작해야 한다면 기존 애니메이션을 빠르게 스킵하고 새 애니메이션을 시작하는 기능을 구현했어요.

Hide Animation Skip

일괄 처리

"한 번에 여러 아이템을 비활성화하고 싶어요."

만약 비활성화중인 item이 하나만 있어도 된다면 inactivatingItemMap이 아니라, sn 하나만, 또는 객체 하나만 저장하는 방식이었을 거에요. 하지만 여러 아이템을 한 번에 비활성화하길 원하는 요구사항에 의해 확장성 있게 Map 자료구조로 결정했습니다.

그래서 Skip option과 달리, 독립(Independent) 애니메이션이라면 다른 애니메이션에 영향을 주지 않고 각자의 애니메이션 라이프 사이클을 진행할 수 있도록 분기 처리해줬어요.

Hide Animation Multiple

단축키 기능

그 밖에도 기획 요구사항은 아니었지만, 단축키 기능을 개발해 기획자를 기쁘게 해드렸어요. useEffect로 라이프사이클 시작과 종료까지 keydown 이벤트에 대한 단축키별 함수를 호출하는 방식으로 조건 처리해줬어요. 나름 단축키마다 기대하는 동작이 있고, 동작에 사용되는 hook이 다르기 때문에 hook을 나누어 관심사를 분리하려 했어요.

예를 들면 이렇게 ESC 클릭으로 hash를 초기화하는 hook이라던가,

const useClearHashWhenEscape = () => {
  const { clearHash } = useUriFragment();

  useEffect(() => {
    const clearHashWhenEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        clearHash();
      }
    };

    document.addEventListener('keydown', clearHashWhenEscape);
    return () => document.removeEventListener('keydown', clearHashWhenEscape);
  }, [clearHash]);
};

Ctrl + S로 템플릿을 저장하는 hook 등을 분리해,

const useSaveResumeSettingWhenCtrlS = () => {
  const { handleSave } = useSaveResumeSetting();

  useEffect(() => {
    const saveWhenCtrlS = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        handleSave();
      }
    };

    document.addEventListener('keydown', saveWhenCtrlS);
    return () => document.removeEventListener('keydown', saveWhenCtrlS);
  }, [handleSave]);
};

이렇게 keyboardAction hook에 모아서 호출하는 방식으로 관심사를 나누고 도메인별로 묶는 것이었죠.

const useResumeSettingKeyboardAction = () => {
  useClearHashWhenEscape();
  useSaveResumeSettingWhenCtrlS();
  ...
};

export default useResumeSettingKeyboardAction;

기획 요구사항에 없지만, 개발자 관점에서 사용자를 위한 기능을 구현하고 기획자에게 선물처럼 내놓는 과정이 즐거웠어요. UX를 고려하는 개발자라면 더 주체적으로 기획 요구사항을 넘어선 개발 관점의 기획사항들에 신경을 더 써야겠다고 생각했습니다.

그 밖에도...

7개월은 긴 시간이에요. 제가 기존에 맡던 빌더 채용사이트 리뉴얼 지원자 사이트 연동 개발로그인 페이지 리뉴얼눈물이 젖은 다양한 개발 부분이 많지만, 지원서 리뉴얼의 핵심 부분이자 개발에 애정을 담았던 부분은 템플릿 설정 쪽이라 low한 코드 레벨까지 시간을 많이 들여 정리해 봤어요.

과정에서의 경험과 느낀점

제 개발 커리어가 곧 2년을 채워가는데, 이 기간 중 거의 1/3을 쏟아넣었어요. 그만큼 성장 곡선의 가파른 기울기를 경험했고, 프로젝트의 시작을 돌이켜보면 정말 많은 성장과 교훈을 남겼던 프로젝트였어요. 사건들을 조금 짚으며 정리해볼게요.

PoC의 중요성을 깨닫다

이번 프로젝트에서 저희는 기술에 얽매이지 않고 해보고 싶은 기술들에 대해서 도입을 상의하고 적극적으로 적용해보자는 자유로운 분위기를 조성했어요. 그리고 팀원 한 분이 FSD라는 아키텍처 패턴에 관심을 가져 도입을 제안했어요. (FSD는 Feature Sliced Design의 약자로, 도메인 중심과 높은 유지보수성을 주장하는 패턴입니다.)

결과적으로는 모두가 FSD를 도입하는 데에 찬성했어요. 더 좋다는 신생 아키텍처인데 못할 게 뭐가 있나 싶었죠. (그런데 대참사가 벌어짐.)

FSD 토크1

FSD의 러닝커브

초반부터 높은 수준의 도전 과제를 가지고 스프린트가 타이트하게 진행되었는데, FSD 패턴은 독자적인 구조와 규칙을 가지고 있었고, 이를 엄격하게 지켜야 유효한 아키텍처였어요. 그렇기에 저희는 초반부터 각자 러닝커브를 극복하기 위해 학습을 꽤 많이 진행했으나, 학습하고 해석한 영역이나 지식의 정도가 각자 달랐어요.

불분명한 layer 구분

그리고 너무 엄격한 layer 구조는 조금은 타협해서 자체적인 구조로 간소화해 적용하거나 public api에 대한 모순된 문제로 인하여 public api 룰을 없애는 등 규칙을 바꾸기 시작했어요. 더 심각한 문제는, 개발의 관성이 있다보니 누군가 어떤 부분에서는 FSD를 적용하는 것을 놓치거나, FSD를 위해 너무 많은 불필요한 구조를 만들어내기 시작했어요. 각자가 layer를 판단하는 기준도 모호했고요.

되돌아가다

그래서 이런 고충을 여러 스프린트동안 겪으며 개발 속도가 나지 않고 갑론을박이 진행되고 있을 때, 이 고충의 시작인 FSD에 대해 불만이 하나둘 나왔어요. 그리고 아키텍처의 재정의에 대한 회의를 진행하며 결국 FSD를 포기하고, 기존의 아키텍처로 돌아가기로 결정했죠.

FSD 포기1

교훈

다시 이때를 생각해보며 들었던 생각은, PoC를 제대로 하지 않았다는 것이에요. 개념 증명(Proof of Concept)의 약자인 PoC는 '기존 시장에 없었던 신기술이나 개념을 도입하기 전 이를 검증하기 위한 과정'을 의미해요. 저희는 FSD 패턴을 프로젝트에 도입해오면서, 우리 프로젝트에는 얼마나 적합한지, 팀원 각자의 인지 공감이나 기호는 어떻게 되는지, 그리고 정말 그들의 주장대로 효용이 있는지... 이런 판단 기준들을 세우고 더 명확하게 검증하고 도입했어야 했다고 생각했습니다.

소통은 초기일수록 중요하다

인터페이스 소통의 부재

개발단에서 프론트엔드와 백엔드는 저희 중 각 파트의 리드 개발자분들께서 직접적으로 소통하시고 나머지 개발자들은 결정사항을 전달받았어요. 소통의 토픽 중 중요한 일부는 지원서를 구성하는 데이터의 인터페이스였어요.

초기 프로젝트 구성에서는 백엔드에서 바로 조회나 수정 API가 나올 수 없었기 때문에 프론트엔드에서는 자체적인 목업 데이터를 기반으로 화면을 구성해야 했어요. 그런데 프론트엔드와 백엔드의 인터페이스가 알고보니 너무나도 달랐다는 걸 프로젝트가 한참 속도를 내던 중 발견하게 되었어요.

// 프론트엔드에서 사용한 인터페이스
{
  "id": 000,
  "sn": 000,
  "itemSn": 000,
  "questionType": "TEXT",
  "placeholder": "이름을 입력해주세요",
  "maxLength": 20,
  ...
}

프론트엔드는 위처럼 linear한 구조로 아이템을 설계한 반면 백엔드는 아래처럼 한 단계 nested한 구조로 아이템을 설계했어요.

// 백엔드에서 사용한 인터페이스
{
  "id": 000,
  "sn": 000,
  "itemSn": 000,
  "questionType": "TEXT",
  "questionTextItemSetting": {
    "placeholder": "이름을 입력해주세요",
    "maxLength": 20,
    ...
  }
}

사실은 하나의 예시였지만, 이 밖에도 필드명이 다르다던지, 하위 데이터 구조가 또 달라진다던지 하는 부분이 있었어요.

잘못된 첫 단추의 여파

이 장면을 처음 봤을 때 다들 정말 놀랐어요. 인터페이스가 이렇게 다를지 다들 그림을 너무 크게 그린 거였죠. 인터페이스에 맞춰 목업 데이터를 구성하고 컴포넌트 인터페이스도 설계했으니까요. 그래서 인터페이스 합의를 마친 뒤, 확정된 인터페이스로 충돌을 해결하는 과정도 꽤나 오래 걸렸어요. 그래서 이후부터는 누구 할 것 없이 각자 담당하는 상대 파트의 개발자와 직접 소통하고 결정 사항을 채팅방에 전달하는 등 모두가 참여하는 망형 소통 구조로 변화하게 되었어요.

패인은 무엇이었을까

저는 이 망형 소통 구조도 정답이 아니라고 생각해요. 이런 인터페이스 설계의 경우에는 소수 엘리트끼리 결정 후 전파가 되는 게 맞다고 생각해요. 그래서 많은 경험에 입각한 현명한 결정과 설계가 있으리라는 믿음으로, 리드 개발자들끼리의 결정과 탑다운으로 전달받는 소통 구조에 대해서는 긍정적이었어요. 결과적으로는 리드 개발자간의 소통이 잘 이뤄지지 않은 것이 아쉬웠고, 실무를 조금 놓고 방향성을 잡는 데에 집중을 했어야 했다고 말이죠. 초기 인터페이스 결정의 파급력과 영향력을 다시 한 번 체감했습니다.

더 본질을 파고들면 역할과 결정, 책임이 명확해야 한다고 생각이 들었습니다. 개발 상황이 너무 빠듯하다보니 리드 개발자들께서 실무에 많이 참여할 수 밖에 없었고, 그래서 큰 그림을 그릴 여유가 없었어요. 하지만 그게 패인이었죠. 큰 그림을 그리는 리더이자 엘리트는 실무보다 높은 시야에서, 중요한 방향을 결정하고 전달하는 역할이 분명해야 함을 알게 되었어요. 한 사람 분의 업무량을 치는 게 아니라 가속도를 만드는, 조직 시너지를 만드는 일인 거죠.

소통 부재와 실패의 교훈

정리하면 이런 교훈을 얻었습니다.

  • 개발 초기의 소통과 용어, 생각의 싱크를 맞추는 시간과 노력이 반드시 필요하다.
  • 역할에 따라 어떤 행동이 조직과 성과에 도움이 될지 잘 판단해야 한다.
  • 같은 리소스로 더 큰 가치를 만드는 것에 대해 고민해야 한다.

시연 중심 시나리오

저희는 장장 7개월에 달하는 긴 개발 기간을 가졌기 때문에, 스프린트를 쪼개며 중간중간 달성 목표를 쪼개며 갈 필요가 있었어요. 그런데, 이번 프로젝트에서는 시연 중심 시나리오를 통해 이를 해결했어요. 각 시연 시나리오는 '이번에 만든 것까지만 배포해도 사용자들이 사용이 가능하다'라는 것을 목표로 개발 영역까지에 대해 완성도 있게 만드는 것을 목표로 했어요. 이게 Agile 개발 방식이라고 하더라고요?

이슈 대시보드

그리하여 각 스프린트마다 달성한 시연 시나리오가 모여, 최종적으로는 전체 서비스 플로우를 만들어낼 수 있게 된 거죠. 여기에는 이 방식의 효용과 아쉬운 점을 모두 느낄 수 있었어요.

장점과 효용

우선 효용이라면, 개발자들이 개발을 할 때 '이번 스프린트에서는 이 시나리오를 완성해야 한다'라는 명확한 목표가 있었기 때문에 개발에 대한 방향성이 명확했어요. 그리고 인수 테스트의 역할을 했기 때문에 요구사항에 대해 달성하고자 하는 수준이 정확해서 이를 충족하기 위한 개발을 할 수 있었죠. 그리고 시연 시나리오를 충족해나간다면 다른 영역은 걱정하지 않아도 된다는 점도 있었어요.

현실과 한계

시연 시나리오의 이상적인 기대효과와 달리, 실제로는 200개가 넘는 지원서 요소를 하나하나 시연을 통해 설정할 수 없어서 일부만 시연에서 확인하고 나머지는 될 거라 기대하고 넘어가야 했어요. 예를 들면 저희는 구버전 채용사이트와 신버전 채용사이트, 구버전 채용공고와 신버전 채용공고 등 다양한 유저 케이스가 있는데, 시연 시나리오에서는 시간관계상 신버전 채용사이트의 신버전 채용공고만 테스트했어요. 그래서 어쩌면 구버전은 고려하지 않은 개발을 할 수도 있었죠. 하지만 나중에 문제가 될 걸 뻔히 아는데 눈 감고 넘어갈 수 없었어요. 시연 시나리오 외적으로 개발자들이 알고 있는 내용에 대해서는 계속 개발적으로 챙기고 기획에 물어물어 개발을 진행해야 했어요. 다시 말하면 개발자들의 주인의식과 열정이 없었다면 빈틈이 많은 채로 검증 스프린트를 맞이할 수 있었겠다 싶은 거였죠.

또다른 문제는 '일을 위한 일'이 탄생한다는 거였죠. 시연을 위한 임시 데이터들을 쌓는 임시 일에도 개발자들의 리소스가 들어가고, 시연 범위를 조정하고 시연 시나리오를 작성하는 과정 역시 기획자의 상당한 리소스를 소모하게 했죠. 그래서 중간 포인트를 잡기 위한 리소스 소모에 후반에는 조금 지치기도 했어요.

결론

시연 시나리오 방식은 빛과 어둠이 확실한 방법이에요. 장기 프로젝트 특성상 '당장 이번 시연으로 끝나도 서비스 출시가 된다'는 지표는 달성하기 어렵겠지만, 명확한 데드라인에 명확한 미션이 정해진다는 것은 개발자들에게 꽤나 깔끔하죠. 하지만 잘 관리될 필요가 있겠다고 느꼈습니다.

디자이너의 고충에 공감하다

검증 스프린트 막판에 MVP에는 그렇게까지 중요하지 않은 디자인 이슈들을 미친듯이 쳐낸 적이 있었어요. MVP 개발에 집중하다보니 계속 밀리게 됐지만, 내심 불편한 마음으로 가지고 있던 덩어리들이었거든요.

디자인 이슈

그런데 디자인 의도가 잘 이해가 안 가거나, 조금 상의가 필요한 부분이 있어 디자이너분을 자리로 모셨는데 출시 직전인 시점에서 디자인 수정을 해주는 것에 감동을 받으시더라고요. 출시 전에는 항상 디자인보다는 기능적인 이슈나 버그 등등 더 중요도가 높은 이슈에 치여서 디자인 이슈는 우선순위가 많이 밀리는데 이 시기에 처리해줘서 고맙다는 거였어요.

그 우선순위에 따른 밀림에 대해서는 이해는 되지만 참 아쉽기도 하고 뿌듯하기도 했어요. 누군가의 노력에 순위를 매길 순 없을텐데 항상 중요도가 밀린다는 사실이 씁쓸하더라고요. 저에게도 눈엣가시였던 디자인 이슈들을 해결함과 함께 디자이너도 기쁘게 해드려서 좋은 기억이었어요.

이슈 깎는 개발자

저희는 10월 말까지 모든 개발을 마친다는 데드라인을 가지고 계속 달려왔어요. 그래서 위의 시연 중심 시나리오를 통과하기 위해서 계속 도전적인 시연 범위를 결정하고 MVP 개발 달성을 위해 달렸죠. 그렇게 10월 초, 최종 검증 스프린트에 들어가며 중간중간 하지 못했던 디테일한 검증을 시작했고, 이슈가 미친듯이 늘어났어요. 기획자는 엄청나게 QA와 추가 기획을 만들며 이슈를 만들어냈고, 개발자들은 이슈를 깎아내기 위해 노력했어요.

특히나 제가 전문으로 담당한 템플릿 설정 부분은 초기 기획부터 있어왔던 영역이었고, 이후 기획이 진행되며 UI나 기능들이 수정된 곳들이 많았어요. 문구가 어색하거나 추가적인 기능이 필요하다고 느껴져서 기능 추가도 꾸준히 있었습니다 그래서 저의 템플릿 설정 이슈는 정말 많았고, 이슈를 쳐내기 바빴어요. 금요일에 4개까지 줄인 이슈가 월요일에 20개로 늘어나있고, 하루 종일 10개를 치니까 다음날 30개가 되어있는 걸 발견하기도 했죠.

아무튼 그래서 2주의 검증 기간동안 저는 155개의 이슈를 해결했어요. 전체 개발자가 15명 내외인데, 이 중 800개 중 150개 정도의 지분을 가지고 있어요. 놀라웠습니다. (위에서 언급한 PM이자 기획자인 K님은 검증 기간동안 이슈를 500개나 만들어 모두를 놀라게 했습니다.)

이슈 대시보드

마치며

실패하며 성장하다

정말 큰 프로젝트였던 만큼 많은 일이 있었던 것 같아요. 잘못된 판단과 선택으로 시간과 공수를 낭비한 일도 있었고, 개발적으로 헤매고 버벅이던 일도 많았어요. 그런데 프로젝트의 시작을 돌이켜보면 이 과정에서 모든 실패와 힘든 감정은 성장의 거름이 되었어요. 이후에는 '이런 일이 있어서 이런 구조보단 이게 나을 것 같다'하는 일이 많아졌는데, 실패의 사례에서 또 같은 실패를 하지 않을 수 있는 지뢰 탐지 능력이 발달하지 않나 싶네요.🤣

성공... 대성공!!

다행히 실패는 일시적이며 부분적이었고, 결과는 대성공이었어요. 출시의 전후로 저희 서비스의 주요 고객사에 직접 방문해 인사담당자들을 대상으로 시연을 미리 하고 현재와 이후를 보여주는 퍼포먼스를 진행했는데요, 현장의 반응은 폭발적이었다고 해요. 그동안 오래된 서비스임에도 경쟁사들이 지원하고 있지 않는 강력한 기능들과 성능을 보여주었기에 울며 겨자먹기로 사용하고 있던 고객사들도 분명히 있었을텐데, 이번에 큰 변화를 만들어내며 또 한 번 저희 서비스의 아성을 입증할 수 있을 것이라 생각해요.

멋진 우리 팀과 함께 해서

이 과정에서 저희 팀 모두 고생하지 않은 사람들이 없어요. 모두들 밤이고 새벽이고 주말이고 할 것 없이 자기 일에 충실하고 진심으로 서비스가 잘 되길 바라는 사람들이었어요. 우리가 멋진 서비스를 만들어가는 일에 자긍심으로 가지고 서로 의지하며 함께 했던 것이죠. 저는 이 협동의 과정과 그 산출물 모두가 너무 값진 것 같아요. 너무 뿌듯하고 이런 큰 서비스에 일조할 수 있었다는 점에 감사함을 느끼고 있어요.

진짜 마치며

지금까지 지원서 리뉴얼 프로젝트 회고였습니다. 저의 2024년이 많이 녹아있는 서비스라 감상과 첨언을 많이 하다보니 사족이 길었네요. 긴 글 읽어주셔서 감사합니다!

profile

FE Developer 박승훈

노력하는 자는 즐기는 자를 이길 수 없다