프론트엔드 과제 테스트 잘하는 법

기능구현은 누구나 다 한다

프론트엔드 개발자 구직/구인 시장은 다른 개발직군과 다르게 과제테스트가 일반적이다. 시장의 90%이상을 잠식하고 있는 React로 작은 데모앱을 개발하는 방식으로 역량을 평가받는다. 내 경험 상 4~5시간 내의 짧은 시간으로 코딩테스트처럼 진행되는 곳도 있고 3일 정도만 주는 곳도 있었지만 대부분은 일주일의 시간을 주고 이미 세팅된 프로젝트를 완성하는 방식이었다. 보통 도메인 관련된 데모앱을 만드는 형식이다. 증권 서비스에선 호가창을 구현하라고 했고, 패션 커머스 서비스에선 쿠폰 발급이나 장바구니 같은 기능을 구현하는게 과제였다.

사실 요구조건대로 만드는게 어렵진 않다. 요구조건만 맞춰서 개발하라고 하면 아직 현업을 해보지 않으신 개발자분들도 충분히 하실 수 있을 수준이다(놀랍게도 정말 그렇다) 프론트엔드 개발이라는게 워낙 신경쓸 부분이 많고, 과제 난이도를 높히지 않아도 판별해낼 부분이 많기 때문이지 않을까 생각한다.

지금은 감사하게도 좋은 조직에서 업무를 하고 있지만, 나도 3개월 전까지는 열심히 이직을 시도하는 주니어 개발자였다. 그 과정에서 과제 테스트는 정말 크고작게 20개 정도는 해본 것 같다. 오늘은 그 데이터를 기반으로 프론트엔드 과제 테스트 잘하는 법을 한번 소개해보려 한다. *리액트 기준이다.

그간 본 과제 테스트 목록

기본 원칙

3개의 꼭지로 정리해보자.

Must to Have

  • 절대적으로 기한 준수 - 돈을 받고 일을 하는 프로는 절대 기한을 넘기지 않는다. 회사였다면 적절한 커뮤니케이션을 통해 기한을 조정하겠지만, 과제테스트는 그러기 힘들다. 나도 예비군이 겹쳐서 회사에 메일을 보내 기한을 늘린 적은 있다. 하지만 정해진 데드라인은 꼭 지켰다.
  • 절대적으로 요구사항 만족 - 적당히 이 정도 구현하면 대충 코드 작성한거 보시고, 추가로 넣은 혼을 쏙 빼놓을 라이브러리나 애니메이션을 보고 합격시키지 않을까? 절대 아니다. 꿈 깨자. 기본 요구사항은 무조건 만족해야한다.

Nice To Have

  • ReadMe 쓰기 - ReadMe는 프로젝트 시작부터 보이는 문서다. 안써도 된다고 해도 쓰는걸 추천한다. 플로우 차트 같은 것으로 유저 플로우를 그리고, 상태의 흐름 같은 것들을 도식화해서 전반적인 설계를 표현하고, 사용자 경험을 위해 당신이 요구조건 외적으로 추가한 요소들을 기입하자. 어려웠던 문제와 그 해결 방법을 기입해도 좋다.
  • 성능 최적화 - 리렌더링 정도만 체크해도 좋다. 더 나아가 Lighthouse 정도만 테스트해봐도 해보면 좋을 시도들이 도출되니, 시간이 남는다면 성능 최적화 부분을 확인해보자 (이미지나 폰트 캐싱, 코드 스플릿, SEO…)
  • 테스트 코드 - 테스트 코드는 전부 잘 작성만 한다면 추가 점수를 얻을 수 있는 것 같다. 하지만 전부 완벽하게 작성할 거 아니면 그냥 작성하지 말자. 괜히 긁어 부스럼 안만들어도 원래 요구조건만 잘 작성해도 합격할 수 잇다.
  • 반응형 - 모바일 오리엔티드 서비스를 하는 회사에 지원했다면 어느정도 모바일 반응형을 구현하는 것도 좋다. 구현하고 ReadMe에 한 줄 적을 수 있고, 사용자 경험에 대한 본인의 아이덴티티를 어필할 수 있다.
  • git - 신경쓰지 말라고 해도 커밋 메시지를 잘 작성하고, 컨벤션에 맞게 작성하자. git issue와 PR을 활용해도 좋다. 실제 회사에서 일하는 방식과 유사하기 때문에 프로젝트 매니징의 관점에서도 어필할 수 있다.

gif

Not To Have

  • 임의판단 금지 - 모호한 요구사항은 항상 나온다. 제발 임의판단 하지 말고 메일을 보내자.
  • 요구사항 체크 - 누락하는 순간 불합격이다. 그냥 적당히 잘 되네 하고 넘기는 과제테스트는 없다. 요구사항을 전부 구현해야 그때부터 과제테스트 시작이다.
  • 뇌빼기 코딩 금지 - 모든 코드엔 이유가 있어야 한다. 귀찮아서 뇌빼고 코드 작성하다보면 꼭 필요하지 않은 코드가 들어가거나 관습적으로 작성하게 된다. 이런건 면접때 만나면 곤란하다.
  • AI는 알고 쓰자 - AI를 막을 순 없다. 그 대신 그 결과물을 적재적소에 잘 쓰는게 중요하다. 결과물을 무조건 신뢰하지 말자. 이것도 그냥 복붙했다가 면접 때 해당 코드에 대해 답변 못하고 한순간에 털릴 수 있다.

좋은 웹문서

우리는 Web Frontend Engineer로서 좋은 웹문서를 만들어야한다. 좋은 웹문서라 함은 대외적으로는 사용자 경험을 높히고, 대내적으로는 개발자 경험을 높혀 결론적으로 비즈니스에 도움을 주는 제품이다.

우리의 직군은 React Engineer가 아니라 Web Frontend Engineer다. 사용자가 브라우져를 통해 실행 가능한 모든 웹문서를 다룬다. 언젠가 조직이 리액트가 정답이 아닐 수 있다는 생각을 하거나 다른 방식으로 리액트 개발을 하자고 결정하면 좋은 웹문서 만들기라는 펀더멘틀은 지킨 채로 방법론만을 바꾸면 될 일이다.

자 그럼 React의 문법을 잘 사용하는 것 이전에 해야할 일은 좋은 웹문서 만들기다.

웹문서 구조

웹문서에는 응당 지켜야 하는 원칙이 있다. 너무 많아서 다 적기 힘들지만 간단한 예시를 들어보자면 다음과 같다.

  1. head 태그에는 웹문서의 메타데이터가 위치한다. *웹문서의 제목(title), 외부 참조 파일(link), 스타일(style), 자바스크립트(script)
  2. body 태그는 웹문서의 가시영역에 해당한다.
  3. 웹문서는 접근성을 지원하기 위한 노력을 해야한다. 스크린 리더를 위한 대체 택스트를 제공하거나 키보드만으로도 인터렉션 가능한 요소를 탐색할 수 있도록 tabindex같은 속성을 포함해야한다.
  4. 모바일 사용률이 높은 웹문서의 경우 반응형을 고려할 수 있다.
  5. 사용자 경험을 위해 script 태그는 body태그 끝부분에 위치시거나 defer 프로퍼티를 부여하자.
  6. ….

과제테스트에서 시작부터 next.js같은 프레임워크를 쓰면 자동으로 다 채워주기도 하는데, 그렇지 않거나 특별한 기능들을 넣어야 할 경우에는 웹표준을 지키도록 하자. 브라우져가 웹문서를 파싱하고 필요한 자원을 다운받는 과정과도 관련이 있기도 해서 적용하면 좋다. 대부분은 해도 좋고 안해도 좋은 부분들이지만 제대로 하면 분명 어필이 된다.

시멘틱 태그(Sementic Tag)

좋은 문서 구조에서 가장 중요한 요소 중 하나가 시멘틱 태그다. 시멘틱은 “설명이 필요없는”으로 해석하면 된다. 딱히 설명하지 않아도 알 수 있도록 태그에도 분명한 의미를 부여하는 것이다.

웹개발을 하루라도 해봤다면 div, span 정도만 알아도 웹문서의 가시영역을 구성하는데는 문제가 없다는 사실을 알 수 있다. 하지만 생각보다 시멘틱 태그를 쓰는 것은 SEO, 개발자 경험에서 중요한 역할을 한다.

아래 코드를 보자. main 태그엔 이 웹문서의 주요한 내용이 담겨있고, aside 태그엔 보조 이미지가 들어가 있다. 코드를 읽는 사람 입장에서는 main태그 안에 있는 내용은 이 웹문서의 주요한 내용이고, aside 태그 안에 있는 내용은 부연 설명임을 태그 이름만 보고도 알 수 있다. aside 태그 내의 img태그에 lazy loading이 되어있는 부분도 자연스럽게 이해하고 넘어갈 수 있다.

<main>
	<img src="important-image.jpg" alt="Main Content Image" />
</main>

<aside>
	<img src="sidebar-image.jpg" alt="Sidebar Content Image" loading="lazy" />
</aside>

선술했다시피 과제 테스트의 요구사항을 구현하는 것은 단기간 리액트 교육을 받은 학생도 할 수 있다. 그냥 화면에 그리라는 것을 그린다고 좋은 웹문서가 되진 않는다. 위에서 언급한 모든걸 구현하라고 하는건 아니지만, 이 과정에서 기본적인 실수는 하지 말아야 한다.

나는 최소한의 SEO요소(웹문서 제목과 설명 등)를 넣거나 반응형, 대체 텍스트 등은 넣었었다. 당연하게도 기본적인 시멘틱 태그는 준수하며 작성했다.

리액트

리액트 기본 원칙 기억하기

리액트 개발자라면 아마 Thinking in React라는 문서를 알고있을 것이다. 이 문서는 리액트라는 라이브러리가 어떤 철학으로 전통 웹 개발을 바라보는지 설명한다. 이 문서는 아래 5가지 내용을 순차적으로 받아들이도록 유도한다.

  1. UI를 계층구조로 나누기
  2. 정적인 UI 만들어보기
  3. 최소한의 상태 정의해보기
  4. 상태가 있어야할 위치 파악하기
  5. 역방향 데이터 흐름 제어하기

잘 생각해보면 이건 실무에서 리액트 앱을 개발하는 과정이다. 과제테스트에서는 이런 부분이 더 도드라지게 보인다.

  1. 컴포넌트 하나에 많은 요소가 한꺼번에 포함된 경우 - 1번 원칙 위배
  2. 최상단에 있으면 안될 state가 최상위에서 모든 컴포넌트를 리렌더링 시키는 경우 - 4번 원칙 위배
  3. state 2개만 써도 될 컴포넌트에 4개의 state를 정의해서 성능을 낮추고 복잡도를 높히는 경우 - 3번 원칙 위배

구체적인 예시를 하나 들어보자. 아래 컴포넌트는 배너 데이터를 호출해서 데이터 정합성을 확인하고 캐러셀 배너를 렌더링한다.

export const HomeRollingCarousel = async () => {
	const { banners } = useFetchBanner();

	if (!isBanners(banners)) return null;

	return <HomeCarousel banners={banners} />;
};

여기에 임시적으로 읽음 처리를 해달라는 요구사항이 들어왔다. 클라이언트에 상태를 하나 추가하면 될 듯 하다. 아래와 같이 작성했다고 가정해보자.

export const HomeRollingCarousel = async () => {
	const { banners } = useFetchBanner();
	const [readBannerList, setReadBannerList] = useState<boolean[]>([]);

	useEffect(() => {
		setReadBannerList(new Array(banners.length).fill(false));
	}, [banners]);

	if (!isBanners(banners)) return null;

	const onClickBanner = (index: number) => {
		setReadBannerList(prevState => {
			const newReadBannerList = [...prevState];
			newReadBannerList[index] = true;
			return newReadBannerList;
		});
	};

	return (
		<HomeCarousel banners={banners} readBannerList={readBannerList} onClickBanner={onClickBanner} />
	);
};

예시로 든 저 코드는 잘 동작한다. 하지만 최선은 아니다. 저 읽음처리에 대한 상태는 캐러샐 배너에도, 배너 아이탬 쪽으로 들어가도 된다. 즉, 컴포넌트의 상태 중 하나의 위치가 최선이 아니였다는 뜻이다.

상태의 위치가 최선이 아닌 탓에, 해당 상태는 컴포넌트의 거의 최상단에 위치해서 캐러셀 아이템을 클릭할 때마다 캐러셀 자체를 리렌더링 시킬 것이다. 상태의 위치를 잘못 파악해서 성능에 영향을 준 사례이며, 이런 코드가 몇개만 늘어나도 성능은 물론이고 개발자 경험도 곤두박질 칠 것이다.

저렇게 작성한 코드도 요구사항은 만족할 수 있다. 하지만 리액트의 기본 원칙을 따르지 않으면 작은 문제들을 발생시키며, 우리가 회사에서 다룰 코드는 꽤 규모가 크기 때문에 이런 원칙을 지키는 것이 중요하다.

로직은 훅, 화면은 JSX

위에서 리액트의 원칙을 잘 지킨다고 해도 코드가 더러우면 함께 일하기 힘들다. 리액트에도 어느정도 정형화된 코드 작성 방법이 있는데 그 중 하나가 로직은 훅, 화면은 JSX다. 아래 코드를 한번 읽어 보자.

const ProductList = () => {
  const { data: products, setProduct } = useProductList();
  useSyncScroll()
  usePageViewEvent("product-list")
  const { isAdProducts } = useProductStore()

  if(isAdProducts){
	    return <ProductItem item={products[0]} isAd/>)
  }

  return (
    <ul>
	    {products.map(product => <ProductItem item={product} key={product.id}/>)}
    </ul>
  );
};

위 코드를 읽으면 어떤 느낌이 드는지 묻고싶다. 필요한 연산을 모두 훅으로 추상화하여 컴포넌트 최상단에 위치시켰다. 이는 논리와 뷰가 명확히 나누어져 있음을 의미한다.

뷰에 필요한 모든 상태는 훅이 반환하고 있다. Early Return 패턴을 사용하여 뷰를 렌더링하는 코드를 작성하였다.

나는 훅 이름을 통해 충분히 유추 가능한 논리도 있고, 그렇지 않은 것도 있다고 생각한다. 다만 그런 경우는 즉각 타고 들어가서 로직을 구체적으로 확인할 수 있으니 큰 상관은 없다.

중요한 점은, 컴포넌트에서 논리에 해당하는 부분과 뷰에 해당하는 부분이 명확히 나누어져 있다는 것이다. 논리는 훅으로 추상화되어 컴포넌트 최상단에 위치하여 누구나 이 부분에 논리가 위치하고, 남은 부분은 가시영역임을 알 수 있다.

그렇다면 반대로, 저 훅들이 추상화하고 있는 부분을 만약 추상화하지 않고 inline으로 쭉 풀어써보자. 아래 코드와 위 코드, 어떤 코드를 작성하는 개발자와 함께 일하고 싶은가?

import { useEffect, useState } from 'react';
import { firebase } from '@/firebase';
import ProductItem from '@/components/ProductItem';
import { useProductList, useProductStore } from '@/hooks';

const ProductList = () => {
	// useProductList 훅 부분
	const [products, setProducts] = useState([]);
	useEffect(() => {
		const fetchProducts = async () => {
			const response = await fetch('/api/products');
			const data = await response.json();
			setProducts(data);
		};
		fetchProducts();
	}, []);

	// useSyncScroll 훅 부분
	useEffect(() => {
		const handleScroll = () => {};
		window.addEventListener('scroll', handleScroll);
		return () => {
			window.removeEventListener('scroll', handleScroll);
		};
	}, []);

	// usePageViewEvent 훅 부분
	useEffect(() => {
		const trackViewEvent = (eventName: string) => {
			if (!eventName) {
				throw new Error('페이지 이벤트 이름이 없습니다');
			}
			firebase.pageView(eventName);
		};
		trackViewEvent('product-list');
	}, []);

	// useProductStore 훅 부분
	const [isAdProducts, setIsAdProducts] = useState(false);
	useEffect(() => {
		const checkAdProducts = async () => {
			const response = await fetch('/api/ad-products');
			const data = await response.json();
			setIsAdProducts(data.isAdProducts.length > 0);
		};
		checkAdProducts();
	}, []);

	if (isAdProducts) {
		return <ProductItem item={products[0]} isAd />;
	}

	return (
		<ul>
			{products.map(product => (
				<ProductItem item={product} key={product.id} />
			))}
		</ul>
	);
};

export default ProductList;

리렌더링 방지하기

리렌더링은 리엑트에서 렌더링이라는 동작이 불필요하게 발생하는 것을 말한다. 과제 테스트는 작은 어플리케이션을 만들다 보니 리렌더링으로 인한 성능저하를 눈으로 확인하기 힘들다. 하지만 어플리케이션의 크기가 커지면 눈에 보일 만큼 버벅인다. 또한 성능이 낮은 기기에서는 더욱 그렇다.

그렇기에 최대한 리렌더링을 필요한 만큼만 하는 방식으로 리액트 개발을 해야하고, 왜 리렌더링이 일어나는지에 대한 궁극적인 이유를 알고 있어야 한다. 리렌더링이 필요한 순간은 사용자가 보는 화면에 변화가 필요할 때다.

가장 많이 하는 실수 중에 하나가 아래와 같은 형태다. 리렌더링 되어야 할 부분만 리렌더링 되어야 하는데, 다른 것들도 함께 리렌더링 되는 경우.

숫자를 카운트다운 하는 저 컴포넌트는 아래 메타데이터 호출 컴포넌트와 관련이 없다. 그럼에도 컴포넌트의 호출 위치가 잘못되었다는 이유로 카운트 숫자가 바뀌는 매 초마다 다시 렌더링되고 있다.

화면-기록-2024-09-01-오후-10.10.38.gif

컴포넌트 위치나 상태의 올바른 갯수와 위치, 상태 관리 도구를 어디서 어떻게 호출하느냐에 따라 아래와 같이 꼭 리렌더링 되어야 할 때만 리렌더링할 수 있게 코드를 작성할 수 있다.

화면-기록-2024-09-01-오후-10.54.14.gif

메모이제이션 삼총사

과제 테스트에서 많이들 고민하시는 부분이 이 메모이제이션이다. 안쓰자니 찝찝하고 쓰자니 그렇게 드라마틱한 변화는 모르겠고.

메모이제이션은 양날의 검이다. 비용이 들지만 계산을 매번 하지 않아도 되게 도와준다. 리액트에는 메모이제이션을 따로 구현하지 않아도 API로 방법을 제시해준다. 아래 3가지 API가 그것들이다.

  1. memo
  2. useCallback
  3. useMemo

결론은, 써야할만큼 복잡한 연산이면 당연히 써야하지만, 과제테스트에서 그 정도의 로직을 넣진 않는다. 어플리케이션의 크기를 생각한다면 과도한 연산이라고 생각한 그 로직이 앱 성능을 떨어뜨릴 수준은 아닐 확률이 높다. 반대로 생각하면 거의 쓸일이 없다는 뜻이다. 꼭 써야할 곳에만 쓰는게 무턱대고 useCallback, useMemo로 점철하는 것보단 낫다.

사용자 경험

보통 과제 테스트에서는 모든걸 다 정해주지 않는다. 적당히 틀만 짜주고 그 이상은 응시자에게 맞긴다. 가령 아이콘의 활용이나 추가적인 스타일 같은 것을 말한다.

생각보다 대충 만드시는 분들이 많은데, 보기 좋은 떡이 맛도 좋다고 기본적으로 프론트엔드 개발자라면 되도록 이쁘게 만들어보자.

나는 Radix 아이콘이나 MUI 아이콘 또는 Heroicons을 추천한다. 아이콘 컴포넌트도 제공하고 아니면 그냥 svg로 원하는 크기로 export해서 가져다 쓰면 되서, 전반적으로 아이콘 통일감을 줄 수 있다.

Radix

과제 테스트에서 화면을 그리는 부분은 생각보다 비중이 크지만 중요도는 낮다. 그렇기 때문에 UI 라이브러리도 적극적으로 가져다 쓰길 바란다. 하다보면 생각보다 UI에 신경쓸 겨를이 없다. 요즘 제일 핫한 shadcn를 활용해보자. tailwindcss로 커스텀이 쉬워서 UI에 큰 고민을 안해도 되게 해준다.

개발자 경험

과제테스트에서는 함께 일할 수 있는지도 판단한다. 코드를 작성하는 것 자체보다 코드를 읽는 것이 더 중요할 때가 많다. 일단 가독성있는 코드를 작성하는게 좋다. 설명이 되지 않는 부분엔 주석도 달고, Magic number로 의미를 알기 힘든 숫자에 의미를 부여하자. DRY 원칙에 따라 중복되는 코드는 모듈화해주고, 적절하고 일관된 추상화 수준을 유지하자.

과제 테스트는 프로젝트 구성 전체를 본다. 파일이나 폴더 명을 어떻게 짓는지 까지 본다는 뜻이다. 이는 개발자 경험과 매우 밀접한 관련이 있다.

  • 왜 utils 폴더를 lib 하위에 위치시켰지?
  • hooks 폴더는 왜 컴포넌트 폴더마다 만들었을까?
  • 파일 컨벤션은 왜 Pascal Case지?
  • 이 모듈은 default export고 저 모듈은 왜 named export지?

물론 일관성만 있다면 과제 테스트 과정에서는 별 탈 없을것이지만, 면접 땐 독특한 방법론에 대해 질문을 받을 수도 있다.

이밖에도

  • 관심사 분리를 통해 적절히 유틸 함수를 분리
  • 리액트 라이프사이클에 해당하는 로직은 훅으로 분리
  • 컴포넌트의 재사용성을 고려하여 적절히 props와 state를 선언
  • store를 통해 다양한 계층의 컴포넌트가 단일 진실(SSOT)로 구독하도록 설계

등이 있을 수 있다.

마치며

실제 서류를 통과하고 과제테스트를 받아보면, 요구사항이 그렇게 어렵지 않다. 중간중간 살짝 어려운 구현이 있긴 해도 시간이 충분해서 고민하다보면 풀리기 마련이다.

그럼에도 대부분의 회사에서 과제 합격률은 꽤 낮다. 나는 과제 테스트에서 요구사항을 전부 만족하도록 구현한 상태가 시작이라고 생각한다.

그 상태에서 사용자 경험, 개발자 경험을 높힐 수 있는 코드를 작성해야 한다. 그렇기에 7일이라는 시간을 주는 것이다. 이럴때면 그냥 코딩테스트가 더 나을지도 모르겠다.