카테고리 없음

프론트엔드 개발자로서 10개월, 무엇을 하고 배웠나

JeanneLee57 2024. 10. 11. 15:06

마지막 업데이트: 2024-10-18

 

지난 10개월을 돌아보며

프론트엔드 개발자로 일을 시작한 지 이제 곧 10개월이 된다. 그동안 여러 일을 맡아서 해 보면서 장애물을 만나기도 하고, 한가지 문제로 며칠동안 고민하고 토론하면서 답을 찾아나가기도 했다. 그 과정이 유익했고 배운 것이 많았기에 중간 기록을 해두려 한다. 물론 단순 구현을 하거나 큰 어려움을 겪지 않았던 작업들도 있었기 때문에 모든 일의 내용을 적지는 않을 것이고, 특별히 기록할 만하고 기억에 남는 것들을 위주로 적어 보려 한다.

(본문에 첨부된 코드는 실제와 다르며 이해를 돕기 위해 간소화 및 변형되었다.)

서비스 소개

지난 10개월간 일한 회사는 모듈화된 애플리케이션을 사용자의 필요에 따라 선택해 통합적으로 이용할 수 있는 플랫폼을 제공했다. 각각의 모듈은 마이크로프론트엔드 아키텍쳐로 구성되어 독립적이면서도 필요에 따라 상호작용이 가능하게 구성돼 있었다. 내가 속한 팀은 여러 모듈 중에서 스케줄을 관리하는 '캘린더' 애플리케이션을 주되게 담당했다.(이하 서비스로서의 캘린더는 따옴표 없이 캘린더, 유저가 가진 일정의 집합인 캘린더는 따옴표를 붙여 '캘린더'로 표시하겠다.)

 

캘린더의 주요 기능들

- 일정을 캘린더에 보여주기

- 여러 분류에 따라 나눈 '캘린더' 리스트 관리(예. 개인 일정 캘린더, 회사 일정 캘린더 등)

- '캘린더'에 일정을 등록하고 수정, 삭제

- 일정과 '캘린더'를 공유(개인별 공유, 그룹 공유)

 

사용한 기술 스택

React, Typescript, MobX + Context API, Styled Component, Emotion, Yarn Berry

 

직접 경험해 본 마이크로프론트엔드 아키텍쳐의 장점과 단점

마이크로프론트엔드 아키텍쳐를 차용해 여러 모듈화된 애플리케이션을 독립적으로 개발하고 배포하는 식으로 작업이 이뤄졌다. 이 방식으로 실제 업무를 진행해 보니 장점과 단점이 뚜렷하게 느껴졌다.

 

장점1: 코드 베이스의 경량화

각각의 모듈의 독립성이 크다 보니 각 팀에서 관리해야 할 코드의 양이 적고, 복잡도도 낮은 것을 느낄 수 있었다. 덕분에 거대한 하나의 애플리케이션을 개발해야 할 때보다 효율이 높고 유지 보수하기도 수월했다.

 

장점2: 명확한 책임

각 모듈의 독립성이 높고 모듈이 해야 할 책임이 아주 명확했다. 각 모듈은 자신의 기능에 집중하고 다른 모듈에 의존하는 부분이 있다면 제공되는 api를 활용하면 되었다.

 

장점3: 기술 스택의 자유도

각 팀에서 어떤 기술 스택을 사용할지 자율성이 보장되었다. 팀 내에서 어떤 스택을 사용하는 것이 적절한지 토론해서 결정할 수 있는 점이 좋았다.

 

단점1: 업데이트 사항을 알기 어려움

의존하고 있는 타 모듈이 버전 업데이트를 하면서 업데이트 내역을 알 수 없어서 원인을 알 수 없는 에러가 나거나 잘 작동하던 컴포넌트가 작동을 안 하는데도 모르고 있는 경우가 있었다. 모듈이 독립돼 있지만 서로 의존하고 있기도 해서 발생하는 문제점이었다. 이런 문제를 최소화하기 위해서 노션 문서로 버전 업데이트 내역을 기록하고 공지하도록 내부적으로 운영했다.

 

단점2: 의존성 최신화가 강제됨

이것 또한 마이크로프론트엔드 아키텍쳐의 태생적 한계라기보다는 우리 업무의 특수성 때문이었을 수 있는데, 정기 패치를 할 때 여러 모듈이 의존하는 공통 모듈에 있어서는 반드시 모든 모듈이 버전을 최신화해야 했다. 우리 모듈의 기능과는 관련이 없는 업데이트 사항이 있더라도 반드시 전체 모듈이 의존성을 최신화해야 했기 때문에 번거로운 점이 있었다. 게다가 비슷한 형상을 공유하는 프로젝트가 여러 개 있어서 모든 프로젝트에 의존성 최신화를 해줬어야 했는데, 이 문제는 모노레포를 적용해 일부 개선할 수 있었다.

 

데이터 업데이트 방식을 효율화한 경험: UX를 고려한 최적화

'캘린더' 리스트 dto 변경의 필요성

기존에 하나의 유저에 매핑된 '캘린더'의 리스트는 1depth의 플랫한 데이터로 주어졌다.

// 기존 '캘린더' 데이터 형식
{ "캘린더리스트": [ 캘린더1, 캘린더 2, 캘린더 3 ... ] }

그런데 유저가 속한 톡방에서 '캘린더'를 생성하고, 그 톡방의 '캘린더'를 각 방 하위에 depth를 두고 보여주는 기획이 추가되면서 dto를 변경할 필요가 생겨났다.

{
    "내캘린더": [
        {
            "룸id": "xxxx",
            "캘린더리스트": [
                내 캘린더1,
                내 캘린더2,
                ...
            ]
        }
    ],
    "톡방캘린더": [
        {
            "룸id": "a",
            "캘린더리스트": [
                룸a 캘린더1,
                룸a 캘린더2,
                ...
            ]
        },
        {
            "룸id": "b",
            "캘린더리스트": [
                룸b 캘린더1,
                룸b 캘린더2,
                ...
            ]
        }
    ]
}

 

dto 변경에 따른 데이터 업데이트의 비효율 발생

이렇게 depth가 깊어지다 보니 프론트 쪽에서 이 데이터를 가지고 있다가 업데이트를 해줘야 할 때('캘린더' 이름이나 색상을 변경하는 등) 업데이트 작업이 까다로워졌다.

기존에는 캘린더id만으로 업데이트가 필요한 '캘린더'를 쉽게 탐색할 수 있었지만, 이제는 업데이트가 필요한 '캘린더'를 찾기 위해서 여러 depth를 내려가서 순회를 돌아야 했다. 자연히 업데이트를 위한 함수도 복잡해지고 가독성이 심하게 떨어졌다.

// 처음에 '캘린더' 업데이트를 위해 작성했던 함수의 일부

function updateList(list, roomIdToUpdate, calendarIdToUpdate, type, value) {
      return list.map(({ roomId, roomName, calendarList }) =>
        !roomId || roomId === roomIdToUpdate
          ? {
              roomId,
              roomName,
              calendarList: calendarList.map(cal =>
                cal.id === calendarIdToUpdate ? new CalendarModel({ ...cal.dto, [type]: value }) : cal
              ),
            }
          : { roomId, roomName, calendarList }
      );
    }

 

UX를 고려하면서 업데이트 방식 개선하기

팀 내에서 이 문제를 두고 여러 차례 의견 교환을 한 끝에 효율을 개선한 업데이트 방식을 찾아낼 수 있었다.

업데이트 방식을 변경할 때 유의했던 점은 이 서비스를 사용하는 유저의 입장에서 보았을 때, 자신이 가진 '캘린더'를 조회하는 작업이 상대적으로 빈번하게 일어나고, 이 '캘린더'의 정보를 변경하는 일은 상대적으로 적게 일어난다는 점이었다. 업데이트의 편의성을 위해 데이터를 그냥 플랫한 형식으로만 저장해 버릴 수도 있지만, 그렇게 되면 '캘린더' 리스트 데이터를 보여주기 위해서 다시 데이터를 룸별로 묶는 연산이 필요하게 될 것이었다. 이런 연산이 반복적으로 일어나는 것은 사용자 플로우에서나 성능 면에서나 비효율적이라고 여겨졌다. 그래서 depth를 둔 데이터와 플랫한 데이터를 모두 유지할 수 있는 방식을 고민해 보았다.

핵심은 자바스크립트에서 참조형 자료가 메모리 주소에 대한 참조로 저장된다는 점과, 우리 팀에서 상태 관리 도구로 사용하는 MobX에서 같은 객체를 참조하는 모든 상태가 동기화된다는 점을 이용하는 것이었다.

이를 위해 '캘린더' 리스트 데이터를 최초로 받아올 때 calendarList와 calendarMap으로 나누어 각 '캘린더'를 저장했다. 두 상태에 나누어 저장하되, 각각의 '캘린더' 객체가 동일한 메모리 주소를 참조하고 있게 했다.

    const calendarObj = { 내캘린더: [], 톡방캘린더: [] };
    const calendarMap = new Map();

    (['내캘린더', '톡방캘린더'] as const).forEach(type => {
      data[type].forEach(( calendarList ) => {
        calendarList.forEach(calendar => {
        	calendarObj[type].calendarList.push(calendar) //depth가 있는 데이터
            calendarMap.set(`${calendar.roomId}-${calendar.calId}`, calendar) //플랫한 Map
        });
      });
    });
    
    this.setCalendarMap(calendarMap);
    this.setCalendarList(calendarObj);

 

이후 업데이트를 할 때는 calendarMap을 사용하여 빠르게 값을 변경하고, MobX가 calendarList로 관리되고 있는 observable 객체의 변화를 감지해 자동으로 재렌더링을 수행하게 된다.

  updateCalendarModel(calId, roomId, type, value) {
    const key = `${roomId}-${calId}`;
    const model = this.calendarMap.get(key);
    model[type] = value;

    this.calendarMap.set(key, model);
  }

 

이런 식으로 데이터를 이중화함으로써 업데이트의 효율을 개선하고 코드의 가독성도 높일 수 있었다. 그 과정에서 유저 플로우에 대해서도 고려하고 의견을 나눈 것이 큰 도움이 되었다.

 

앱 진입 시점의 복잡한 로직 최적화하기: 작업의 책임을 어디에 둘 것인가

데이터 변경 요청 및 응답 과정의 정합성이 맞지 않는 문제

다른 앱을 통해서 캘린더 애플리케이션에 진입할 때 특정 '캘린더'만 노출시키고 나머지 '캘린더'는 숨김 처리하는 로직을 처리해서 사용자에게 보여줘야 하는 요구사항이 있었다. 이를 구현하기 위해서 유저가 가지고 있는 '캘린더' 리스트를 받아온 뒤에 숨김 처리 및 특정 '캘린더'만 표시하는 로직을 추가했다. 그런데 숨김 처리 요청에 대한 응답 및 처리를 기다리는 동안 숨김 처리 되기 이전의 '캘린더' 목록이 전부 보이거나, 심지어는 '캘린더' 리스트 목록 조회 요청에 대한 응답이 숨김 처리 요청에 대한 응답보다 나중에 와서 숨김 처리가 제대로 수행되지 않는 문제가 있었다.

 

 

서버 요청 최적화 및 클라이언트측으로 로직 책임 이동

이렇게 정합성이 맞지 않는 것에는 기존 방식에서 effect훅이 불필요하게 여러 차례 불리면서 '캘린더' 리스트를 여러 차례 fetch하기도 하는 등 여러 문제가 복합적으로 얽혀 있어서 처음에는 해결하기가 쉽지 않았다.

그래서 생각 끝에 서버에 여러 차례 요청과 응답을 반복하기보다는 특정 '캘린더'만 표시하고 나머지는 숨김 처리하는 요청을 별도로 보낼 수 있는 api를 새로 만들도록 백엔드 팀원과 협의했다. 또, '캘린더' 리스트 응답을 받아서 클라이언트측의 전역 상태로 저장한 후에 숨김 및 표시 요청에 대한 로직을 별도로 수행하기보다는 최초로 '캘린더' 리스트 상태를 저장하는 단계부터 해당 '캘린더'를 표시할 것인지 말 것인지를 처리한 후에 저장하도록 변경해서 상태가 여러 차례 변경되고, 그에 따라 불필요하게 재렌더링이 이뤄지는 과정을 제거하도록 했다.

낙관적 업데이트 적용

뿐만 아니라 낙관적 업데이트 방식에 따라 클라이언트측에서 가지고 있는 데이터를 기반으로 특정 '캘린더'만 노출하도록 처리된 '캘린더' 리스트를 먼저 저장한 뒤, 나중에 해당 '캘린더'만 표시하고 나머지는 숨김하는 요청을 보내도록 작업했다. 그에 따라 요청이 실패했을 경우 요청 실패에 대한 알림을 띄우고 '캘린더' 리스트 상태를 롤백하는 시나리오도 추가하도록 기획자와 협의했다.

그 결과, 이전에는 여러 차례 fetch하고 상태를 변경하는 과정에서 3회 이상 불필요하게 리렌더링이 이뤄지는 과정을 1차례의 리렌더로 최적화해 성능을 향상시키고 사용자 경험을 개선할 수 있었다.

 

모노레포 도입기

모노레포는 여러 프로젝트를 하나의 코드베이스로 관리하는 방식이다. 여러 개의 레포지토리를 각각 관리하는 멀티레포 방식과는 달리 모노레포는 프로젝트 간의 코드 공유 및 의존성 관리를 쉽게 할 수 있게 해준다. 우리 팀에서 모노레포를 도입한 이유는 여러 프로젝트에서 공통으로 사용하는 컴포넌트와 라이브러리를 효과적으로 관리하기 위함이었다. 실제로 사용해 보니 다음과 같은 장점을 누릴 수 있었다.

 

모노레포의 장단점

장점1. 코드 중복 제거

여러 프로젝트에서 동일하게 사용하는 공통 함수 및 컴포넌트를 한번 작성하면 모든 프로젝트에 적용할 수 있어 코드 중복을 줄일 수 있었다.

 

장점2. 유지 보수 용이성

공통 라이브러리나 유틸리티가 업데이트될 때 모든 프로젝트에 일관되게 적용할 수 있어 유지 보수에 용이했다.


장점3. 의존성 관리

앞서 언급했듯 의존성 업데이트가 강제될 때가 있었는데, 한 곳에서 의존성을 관리할 수 있기 때문에 의존성 충돌 문제를 줄이고, 라이브러리 버전 관리를 더 쉽게 할 수 있었다.

 

반면에 도입하면서 단점으로 느낀 점들도 있었다.

 

단점1. 빌드 시간 증가

레포가 커지면 빌드 시간도 그만큼 늘어나게 되어, 빌드하는 데 드는 시간이 크게 늘어났다. 팀에서는 이를 개선하기 위해 터보레포를 도입했다.


단점2. 복잡한 폴더구조

여러 프로젝트를 동시에 관리하게 되면서 폴더구조가 복잡해져 이를 확정하기 위해 여러가지를 고려해야 했다.

 

단점3. 초기 설정에 드는 시간

새로운 기술을 도입하면 그에 수반되는 러닝 커브와 초기 설정의 번거로움은 따라오게 되는데, 이번에 특히 설정 부분에서 시간을 많이 들였던 것 같다.

 

패키지 매니저 선택

모노레포를 구축하는 데 흔히 사용되는 패키지 매니저는 pnpm이다. 우리 팀은 여러 이유 때문에 pnpm이 아닌 yarn을 모노레포 구축에 사용했다. 가장 큰 이유는 기존 프로젝트가 이미 yarn 기반으로 설정되어 있었기 때문인데, 설정을 크게 바꾸지 않고도 모노레포 환경을 구축할 수 있었다는 점에서 잘 한 일이었다. yarn berry의 PnP를 그대로 사용할 수 있다는 것도 yarn을 유지한 이유였다.

 

터보레포를 이용한 빌드 속도 개선

모노레포를 도입하면서 빌드 속도가 크게 늘어난 것에 대한 해결책으로 터보레포를 도입하게 됐다. 터보레포는 모노레포 관리를 수월하게 해 주는 도구로, 캐싱 등의 기능을 제공해 모노레포의 사용성을 향상시켜 준다. 실제로 터보레포 도입 이후 빌드와 테스트 속도가 크게 개선되었다.


캐싱 기능으로 인한 의존성 에러

터보레포의 캐싱 기능으로 빌드와 테스트 속도를 크게 향상되었지만, 가끔 이로 인해 예상치 못한 문제가 발생하기도 했다. 캐싱 메커니즘이 이전 빌드 결과를 재사용하기 때문에, 의존성이나 환경이 변경될 경우 최신 변경 사항이 제대로 반영되지 않을 수 있다. 예를 들어, 새로운 패키지를 추가하거나 기존 패키지의 버전을 업그레이드한 후에도 이전 빌드 결과를 사용하게 되어, 원하는 대로 동작하지 않을 수 있다. 이러한 문제가 발생했을 때 터보레포의 캐시를 주기적으로 삭제하는 식으로 일단 대응 중인데, 이 부분에 있어서는 앞으로 더 개선이 필요할 것 같다.