토스 Frontend Fundamentals 모의고사 리뷰
토스 Frontend Fundamentals 1회차 모의고사를 풀면서 배웠던 내용들을 리뷰한 글입니다.
들어가며
최근 토스에서 진행한 Frontend Fundamentals 모의고사에 참여했다.
평소 프론트엔드에서의 클린코드, 추상화, 확장성을 고려한 설계 이런것들에 대해서 고민이 많았는데 이 고민을 해소해볼 수 있는 기회라 생각해서 신청하게 되었다.
모의고사를 풀고 해설을 보면서 가장 크게 와닿았던 내용들은 아래 두가지이다.
- 구현보다 구조를 먼저 떠올리기
- 역할과 책임이 명확한 인터페이스 설계하기
위 내용들을 기반으로 배웠던점과 생각들을 정리해보자.
과제의 요구사항
우선 과제는 토스의 적금 계산기를 구현하는것이였다.
마크업은 다 되어있고 기능만 구현하면 되었는데 간단한 기능이였지만 꽤나 고민할게 많았다.

서비스의 유지보수나 장기적인 확장성을 고려한 설계, 추상화 관점에 집중해서 기능을 구현해라
이게 핵심 과제였다.
나는 어떤 고민을 했는가
1. 폴더 구조에 대한 고민
우선 장기적인 확장성을 고려한 설계라는 단어를 듣고 자연스럽게 FSD를 떠올렸다.
왜 그럼 장기적인 확장성을 고려한 설계는 FSD냐? 라고 물을 수 있는데
소프트웨어의 요구사항은 엔지니어가 결정하지 않는다.
하지만 언제나 요구사항이 변하고 언제나 이 요구사항을 대응해야하는건 엔지니어이다.
그래서 비즈니스 요구사항이 바뀌는대로 폴더를 구분해놓으면, 적어도 바뀌는 범위는 제한할 수 있지 않을까? → 이렇게 등장한게 도메인 중심 설계이다.
도메인 중심 설계는 여러 계층을 넘나들지 않아도 되고 특정 도메인의 폴더만 열어보면 되어서 유지보수나 장기적인 확장성이 좋다고 생각한다.
이 장점을 가지고 갈 수 있는게 프론트엔드에서는 FSD라고 생각했다.
src/
├── app/
│ ├── providers/
│ │ └── index.tsx
│ ├── queryClient/
│ │ └── index.tsx
│ └── routers/
│ └── index.tsx
│
├── entities/
│ └── savings/
│ ├── api/
│ │ ├── index.ts
│ │ ├── productApi.ts
│ │ └── queries.ts
│ ├── model/
│ │ └── types.ts
│ └── ui/
│ └── SavingsProductItem/
│ └── SavingProductItem.tsx
│
├── features/
│ └── savings/
│ ├── CalculationResult/
│ │ └── index.tsx
│ ├── lib/
│ │ ├── calculations.ts
│ │ ├── filters.ts
│ │ ├── products.ts
│ │ └── recommendations.ts
│ └── ui/
│ ├── CalculationResult.tsx
│ ├── SavingsForm.tsx
│ └── SavingsProductList.tsx
│
├── pages/
│ └── SavingsCalculatorPage.tsx
│
├── shared/
│ ├── http/
│ │ └── index.ts
│ ├── lib/
│ │ ├── format.ts
│ │ ├── toast.ts
│ │ ├── utils.ts
│ │ └── withToast.ts
│ └── ui/
│ └── Tab/
│ └── index.tsx
│
├── App.tsx
├── main.tsx
└── vite-env.d.ts
그래서 폴더구조는 이렇게 구성했다.
2. Tab 컴포넌트 설계 - 확장성과 유연성
탭이 2~3개일 땐 괜찮았다. 하지만 탭이 5개, 10개로 늘어난다면?
JSX가 길어지고, 탭 추가마다 여러 곳을 수정해야 하는 구조가 될 것 같았다.
그리고 "탭 전환 시 URL도 함께 변경되면 좋겠다"는 요구가 언제든 올 수 있다는 생각이 들었다.
내가 선택한 방법
컴파운드 컴포넌트 패턴 + 데이터 기반 렌더링
// 컴파운드 컴포넌트로 UI 유연성 확보
<Tab value={activeTab} onChange={setActiveTab}>
<Tab.List>
{tabs.map(tab => (
<Tab.Trigger key={tab.id} value={tab.id}>
{tab.label}
</Tab.Trigger>
))}
</Tab.List>
{tabs.map(tab => (
<Tab.Panel key={tab.id} value={tab.id}>
{tab.component}
</Tab.Panel>
))}
</Tab>
// 탭을 데이터로 관리
const tabs = [
{ id: 'products', label: '상품 찾기', component: <ProductList /> },
{ id: 'result', label: '계산 결과', component: <CalculationResult /> }
];이렇게 하면
- 탭 추가는 배열에 객체 하나만 추가하면 됨
- URL 동기화가 필요하면
path필드만 추가하고onChange에서navigate호출 - UI 구조는 변경 없이 기능만 확장 가능
이렇게 생각했다.
3. HTTP 클라이언트 추상화 - 지금 필요한가?
현재는 tosslib의 HTTP 클라이언트를 쓰고 있다.
하지만 이런 생각이 들었다.
"나중에 axios로 바꿔야 한다면? SSR이 필요해진다면?"
특정 라이브러리에 직접 의존하면, 클라이언트 변경 시 API 코드 전체를 수정해야 한다.
내가선택한 방법
어댑터 패턴으로 HTTP 클라이언트를 추상화
// 인터페이스 정의
interface HttpClient {
get<T>(url: string, config?: any): Promise<T>;
post<T>(url: string, data?: any, config?: any): Promise<T>;
// ...
}
// tosslib 어댑터
class TosslibHttpAdapter implements HttpClient {
async get<T>(url: string) {
return tosslib.http.get<T>(url);
}
// ...
}
// BaseHttp에서 주입받아 사용
class BaseHttp {
constructor(private client: HttpClient, private baseURL: string) {}
get<T>(path: string) {
return this.client.get<T>(`${this.baseURL}${path}`);
}
}
이렇게 하면
- API 레이어는 구체적인 HTTP 라이브러리를 몰라도 됨
- 클라이언트 교체 시 어댑터만 바꾸면 됨
- 테스트 시
MockHttpClient주입 가능
솔직히 과도한 추상화가 아닐까 고민도 했다.
하지만 의존성을 역전시키는 것만으로도 장기적인 유지보수 비용을 줄일 수 있다고 판단했다.
4. 에러 처리 - 어느 레이어의 책임인가?
API 호출마다 try-catch를 쓰자니 중복이 너무 많고,
tanstack-query의 onError를 쓰자니 에러 처리가 Query에 종속되고,
HTTP 인터셉터를 쓰자니 UI 처리(toast)를 HTTP 레이어에서 하는 게 맞나 싶었다.
내가 선택한 방법
고차 함수로 에러 처리 레이어를 분리
// 고차 함수로 API 함수를 감싸기
function withToast<T extends (...args: any[]) => Promise<any>>(
fn: T,
errorMessage: string
): T {
return (async (...args: any[]) => {
try {
return await fn(...args);
} catch (error) {
toast.error(errorMessage);
throw error; // 상위에서도 처리 가능하도록
}
}) as T;
}
// 사용
export const fetchProducts = withToast(
() => http.get<Product[]>('/products'),
'상품 목록을 불러오는데 실패했습니다'
);
이렇게 하면
- 비즈니스 로직과 에러 처리가 분리됨
- API 함수는 에러 처리에 대해 몰라도 됨
- 에러는 다시 throw하기 때문에 tanstack query나 컴포넌트에서도 추가 처리 가능
에러 처리의 책임을 API 레이어와 UI 레이어 사이에 두되 둘 다에 종속되지 않는 별도 레이어로 분리한 것이다.
이 고민들은 결국 "어디까지가 적절한 추상화인가?"로 귀결되었다.
과도한 추상화는 복잡도를 올리지만 적절한 추상화는 변경에 유연한 구조를 만든다.
이번 모의고사를 통해 그 경계선을 조금은 느낄 수 있었다.
아쉬운점
다른 사람들의 코드 리뷰를 둘러보면서 한 가지 아쉬운 점이 있었다.
많은 사람들이 컴포넌트 설계, Suspense 활용, 커스텀 훅 분리 같은 React와 프론트엔드 생태계에 특화된 고민들을 깊게 했다.
이건 프론트엔드 과제였기 때문에 어찌보면 당연한 이야기이다.
반면 나는 HTTP 클라이언트 추상화, 고차함수를 활용한 같은 에러 처리 레이어처럼 범용적이고 인프라에 가까운 부분에 집중했던거 같다.
물론 이것도 중요한 고민이고, 실무에서도 필요한 부분이라고 생각한다.
하지만 돌이켜보니 "프론트엔드 개발자로서 프론트엔드 다운 고민을 더 깊이 할 수 있지 않았을까?" 하는 아쉬움이 들긴한다.
그렇지만 그 아쉬움은 Discussions에서 자연스럽게 토론하는 사람들로 인해서 조금이나마 해소할 수 있었다.

자연스럽게 커뮤니티가 형성되어서 사람들이 다양한 주제로 토론을 하고 있었는데 글을 읽고 댓글을 읽는것 만으로도 많은 공부가 되었다.
해설에서 얻은 인사이트
1. 화면 구조와 DOM을 1:1로 맞추기
해설을 보면서 가장 먼저 느낀 점은 "화면과 마크업 구조가 1:1로 매칭되면 진짜로 코드가 읽기쉬워진다"는 것이었다.
불필요한 wrapper는 줄이고, 화면 구조를 바로 DOM에서 읽을 수 있어야 한다.
<Page>
<main>
<title>적금 계산기</title>
</main>
<form>
<input .../>
<input .../>
</form>
</Page>
이 마크업은 실제 UI 위치와도 1:1로 매칭되기 때문에 눈으로 따라가기 쉬웠다.
2. 도메인 로직의 분리
문제를 풀면서 가장 '아 이거다!' 했던 부분이 있었는데.
도메인 로직은 부모, 표현은 자식
필터링, 정렬 등의 도메인 로직은 별도 함수로 분리하면서 컴포넌트에서는 props로 깔끔하게 받아 사용하는 패턴을 봤다.
// 도메인 로직 분리
const filterByAmount = (product) => { /* ... */ }
const filterByTerm = (product) => { /* ... */ }
<ProductList filters={[filterByAmount, filterByTerm]} orderBy={[...]}/>
그리고 ProductList 내부는 이렇게 간단하게 유지된다.
function ProductList({ filter, orderBy }) {
const { data } = useSuspenseQuery({
queryKey: ["products"],
queryFn: fetchProducts,
select: (raw) => {
let items = raw;
if (filters) filters.forEach(f => items = items.filter(f));
if (orderBy) items = [...items].sort(orderBy);
return items;
},
});
return (
<ul>
{data.map(p => (
<li key={p.id}>{p.name} - {p.price}</li>
))}
</ul>
);
}
왜 이렇게 하는 게 좋을까?
- ProductList는 더 이상 “상품을 어떻게 필터링할지”를 고민하지 않아도 된다.
- 그냥 ProductList는 "화면에 상품을 어떻게 보여줄지"만 책임지면 된다.
- "상품 가격순", "재고 있는 상품만", "카테고리별" 등 UI 목적이 달라지는 것은 부모의 관심사다.
3. input은 input답게 인터페이스는 표준을 따르기
이 부분이 개인적으로 가장 크게 와닿았다.
내가 만든 입력 컴포넌트들이 종종 이런 식이었다.
<AmountInput label="목표금액" goleValue={goleValue} /> // ❌ prop 이름 일관성 없음
<AmountInput label="목표금액" value={Number(goleValue)} /> // ❌ 외부에서 타입 보정…?하지만 Input 컴포넌트는 결국 HTML input을 감싼 형태이기 때문에 가장 표준적인 인터페이스는 이미 정해져 있다.
Best Practice 예시)
<AmountInput
label="목표금액"
value={goalValue} // 무조건 number
onChange={setGoalValue}
/>
<Select
label="기간"
value={selected}
onChange={setSelected}
/>표준을 따르는 것이 가장 예측가능하고, 가장 협업하기 쉬우며, 가장 유지보수하기 쉽다.
입력 컴포넌트의 인터페이스를 '내 임의로' 만드는 순간 그게 오히려 추상화를 깨뜨리고 복잡도를 올리고 있다는 것을 알게 됐다.
4. 커스텀 훅은 useState 인터페이스를 따르기
모의고사 해설에서 아주 명확하게 이야기한 내용이 있다.
가장 강력한 일관성은 useState의 인터페이스를 그대로 따르는 것이다.
예를 들어 탭 상태를 관리하는 훅이 있다면,
const [view, setView] = useView(); // useState와 동일한 형태그리고 내부 구현은 이렇게 간단하다.
export const useView() {
const [view, setView] = useState<"product" | "result">("product");
return [view, setView] as const;
}이 패턴의 강점은 단순하다.
- 나중에 Zustand, Jotai, Redux, recoil 등 어떤 상태관리로 바뀌어도 외부 API는 그대로 유지됨.
- 훅의 사용자(컴포넌트)는 내부 구현을 몰라도 됨.
- 컴포넌트 재사용성이 크게 올라감.
커스텀 훅을 만들 때 "반복되니까 훅으로 추출해야지"가 아니라
이 훅은 useState처럼 사용할 수 있는가?
이 기준으로 바라보면 좋다는 걸 크게 느꼈다.
5. 추출과 추상화는 다르다
이건 정말 충격을 받았던 개념이다.
나는 지금까지 "비즈니스 로직을 숨기면 추상화"라고 생각했다.
하지만 해설에서는 아주 분명하게 경계선을 그린다.
❌ 단순 추출
function useSavingStuff() {
// 단순히 비즈니스 로직을 감쌌다고 추상화가 아님
}✅ 진짜 추상화
- 중요한 것만 드러내고
- 중요하지 않은 부분은 숨기고
- 명확한 인터페이스를 설계하고
- 책임이 어디까지인지 선명하게 나뉘어 있고
- 교체와 확장이 가능해야 함
즉, 추출은 파일 분리 하지만 추상화는 설계 작업이다.
이 차이를 알고 나니 앞으로 "이건 훅으로 빼자"라는 말이 훨씬 조심스러워질 것 같다.
6. 접근성은 의미론적 태그를 쓰는 것부터
의외로 가장 기본적인데 가장 자주 놓쳤던 부분.
나는 예전부터 이렇게 썼다. (지금도 이렇게 쓰는중)
<div onClick={onClick} className="list-row">
...
</div> // ❌ 클릭 가능한 요소가 아님
하지만 해설에서는 단호했다.
div는 클릭 가능한 요소가 아닙니다!!
<button className="list-row-btn" onClick={onClick}>
<SavingItem />
</button>
이렇게 적금 컴포넌트를 클릭커블한 요소로 만들기 위해선 빈 <button> 태그로 감싸주기만 하면 되었다.
그리고 CSS reset으로 기본 스타일만 지우면 된다.
.list-row-btn {
all: unset;
display: block;
width: 100%;
}
이것만으로도
- 스크린 리더
- 키보드 포커스
- 접근성 검증 도구
모두 가져갈 수 있게 되었다.
접근성은 거창한 기술이 아니라 의미론적 태그를 지키는 것에서부터 시작된다는 걸 인지했다
마무리
토스 모의고사를 풀어보면서 왜 이렇게 설계를 했을까? 라는 생각의 과정을 실시간으로 볼 수 있어서 의미있는 시간이었다.
특히 나에게 크게 남은 정리 포인트는 다음과 같다.
- 일반적인 인터페이스를 따르자 (useState, useQuery)
- 도메인 로직은 함수로 분리해서 부모에서 주입시키자
- 마크업은 화면과 1:1로 매칭되게 코드를 배치해보자
- 추출과 추상화의 차이를 명확하게
- 접근성은 의미론적 태그부터
- 구조 → 책임 → 인터페이스 → 구현 순으로 사고하기
실무에서도, 내 프로젝트에서도 이 기준들을 계속 적용하면서 더 예측 가능하고 협업하기 좋은 코드를 만들고 싶다.