주요 포인트 한눈에 보기
프론트엔드 코딩테스트는 ‘입출력 파싱’보다 ‘UI를 요구사항대로 구현하는 능력’을 더 자주 봅니다. DOM 조작, 이벤트 처리, 비동기 통신(fetch), 성능(무한 스크롤/디바운스), 접근성(키보드 조작/ARIA)까지, 실무에서 바로 쓰는 패턴을 5단계로 쪼개서 문제 풀이 형태로 정리했습니다.
- Level 1 — DOM 선택·렌더링·기본 이벤트
- Level 2 — 이벤트 위임·상태 관리·폼 검증
- Level 3 — fetch·에러 처리·요청 취소(AbortController)
- Level 4 — 성능·무한 스크롤(IntersectionObserver)·접근성
- Level 5 — 미니 과제(드래그 앤 드롭·파일 업로드·코드 품질)
- 많이 찾는 질문(FAQ)
Level 1 — DOM 선택·렌더링·기본 이벤트
프론트엔드 코딩테스트의 시작은 DOM을 정확히 선택하고, 요구사항대로 화면을 바꾸는 능력입니다. 이 단계에서는 querySelector, textContent, classList, addEventListener를 안정적으로 쓰는 것이 목표입니다.
대표 과제 — 버튼 토글 + 카운트
문제(연습용): 버튼을 누르면 활성 상태가 토글되고, 클릭 횟수가 화면에 표시됩니다. 활성 상태일 때만 카운트가 증가해야 합니다.
<div class="demo">
<button type="button" class="btn js-toggle" aria-pressed="false">Start</button>
<p class="txt">count: <span class="js-count">0</span></p>
</div>
.btn.is-active { font-weight: 700; }
.demo { display: inline-flex; gap: 12px; align-items: center; }
const $btn = document.querySelector('.js-toggle');
const $count = document.querySelector('.js-count');
let active = false;
let count = 0;
$btn.addEventListener('click', () => {
active = !active;
$btn.classList.toggle('is-active', active);
$btn.setAttribute('aria-pressed', String(active));
if (active) {
count += 1;
$count.textContent = String(count);
}
});
동작 설명은 단순하지만, 평가 포인트는 명확합니다. 상태(활성/비활성)를 변수로 관리하고, DOM 업데이트를 최소화해서 요구사항을 정확히 만족시키는지 확인합니다.
실수 포인트는 “상태와 UI가 서로 어긋나는 것”입니다. 예를 들어 classList만 토글하고 변수는 갱신하지 않으면 이후 로직이 틀어질 수 있습니다.
개선 방법은 ‘상태 1개 → 렌더 1번’ 규칙을 지키는 것입니다. 작은 과제일수록 이 습관이 코드 품질을 크게 올려줍니다.
Level 2 — 이벤트 위임·상태 관리·폼 검증
이 레벨부터는 클릭 이벤트를 요소마다 붙이는 방식이 금방 한계를 드러냅니다. 리스트가 동적으로 늘어나는 과제에서는 이벤트 위임이 사실상 필수이며, 상태(state)와 화면(render)을 분리할수록 실수가 줄어듭니다.
대표 과제 — 투두 리스트(추가/삭제) + 이벤트 위임
문제(연습용): 입력창에 텍스트를 입력하고 추가하면 리스트에 항목이 생깁니다. 각 항목의 삭제 버튼을 누르면 해당 항목이 제거됩니다. 삭제 이벤트는 컨테이너에 한 번만 등록해야 합니다.
<div class="todo">
<form class="todo-form js-form">
<input class="todo-input js-input" name="text" autocomplete="off" />
<button type="submit" class="todo-add">Add</button>
</form>
<ul class="todo-list js-list"></ul>
</div>
const $form = document.querySelector('.js-form');
const $input = document.querySelector('.js-input');
const $list = document.querySelector('.js-list');
let items = [];
function render() {
$list.innerHTML = items
.map((it) => (
`<li class="todo-item" data-id="${it.id}">` +
`<span class="todo-text">${escapeHtml(it.text)}</span>` +
`<button type="button" class="todo-del js-del">Delete</button>` +
`</li>`
))
.join('');
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (ch) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[ch]));
}
$form.addEventListener('submit', (e) => {
e.preventDefault();
const text = $input.value.trim();
if (!text) return;
items = [{ id: String(Date.now()), text }, ...items];
$input.value = '';
render();
});
$list.addEventListener('click', (e) => {
const $btn = e.target.closest('.js-del');
if (!$btn) return;
const $li = $btn.closest('.todo-item');
if (!$li) return;
const id = $li.dataset.id;
items = items.filter((it) => it.id !== id);
render();
});
render();
이 과제의 핵심은 “이벤트 리스너는 1개, 삭제는 정확히 1개 항목만”입니다. 동적으로 생성된 버튼에도 정상 동작하는지를 보면서, 이벤트 버블링을 이해하고 있는지 확인합니다.
실수 포인트는 innerHTML로 렌더링할 때 XSS 위험이 생길 수 있다는 점입니다. 과제 환경에서는 통과할 수 있어도, 리뷰 단계에서는 불리해질 수 있습니다.
개선 방법으로 위 예시는 최소한의 escapeHtml을 추가했습니다. 실무형 과제일수록 이런 디테일이 평가에 반영되는 경우가 많습니다.
Level 3 — fetch·에러 처리·요청 취소(AbortController)
프론트엔드 과제에서 가장 흔한 시나리오는 “검색창에 입력하면 서버에서 결과를 받아 리스트를 갱신”입니다. 이때 사용자가 빠르게 타이핑하면 이전 요청이 뒤늦게 도착해 UI가 뒤집히는 문제가 자주 생기며, 이를 막으려면 요청 취소 또는 응답 최신성 보장이 필요합니다.
대표 과제 — 검색 자동완성(디바운스 + 요청 취소)
문제(연습용): 입력값이 바뀔 때 300ms 디바운스 후 API를 호출하고, 이전 요청은 취소합니다. 성공/실패/로딩 상태가 UI에 반영되어야 합니다.
const $input = document.querySelector('.js-search');
const $list = document.querySelector('.js-result');
const $status = document.querySelector('.js-status');
let timer = null;
let controller = null;
function setStatus(text) {
$status.textContent = text;
}
function render(items) {
$list.innerHTML = items.map((x) => `<li class="item">${x}</li>`).join('');
}
async function request(q) {
if (controller) controller.abort();
controller = new AbortController();
setStatus('loading');
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
headers: { 'Accept': 'application/json' }
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
render(Array.isArray(data.items) ? data.items : []);
setStatus('done');
} catch (err) {
if (err.name === 'AbortError') return;
render([]);
setStatus('error');
}
}
$input.addEventListener('input', () => {
const q = $input.value.trim();
clearTimeout(timer);
if (!q) {
if (controller) controller.abort();
render([]);
setStatus('idle');
return;
}
timer = setTimeout(() => request(q), 300);
});
이 레벨에서 보는 포인트는 “비동기 안정성”입니다. 사용자가 빠르게 입력해도 최신 결과만 화면에 남는지, 취소/에러 상태가 무너지지 않는지, 그리고 로딩 UI를 과하게 흔들지 않는지 등을 확인합니다.
실수 포인트는 ‘요청 경쟁’과 ‘에러 분기’입니다. 요청을 취소하지 않으면 뒤늦게 도착한 응답이 UI를 덮어쓸 수 있습니다. 또한 네트워크 오류와 취소를 같은 에러로 처리하면 UX가 나빠집니다.
개선 방법은 위 코드처럼 AbortError를 분리하고, 입력이 비었을 때는 즉시 UI를 초기화하는 것입니다.
Level 4 — 성능·무한 스크롤(IntersectionObserver)·접근성
Level 4는 기능을 “되게 만드는 것”을 넘어, 많은 데이터/빈번한 이벤트에서도 안정적으로 동작하는지까지 봅니다. 특히 무한 스크롤, 스크롤 이벤트 최적화, 키보드 접근성은 프론트엔드 과제에서 자주 등장합니다.
대표 과제 — 무한 스크롤(IntersectionObserver로 페이지 추가 로딩)
문제(연습용): 리스트 하단 sentinel이 화면에 보이면 다음 페이지를 로드합니다. 중복 요청을 막고, 더 이상 데이터가 없으면 관찰을 중단합니다.
const $list = document.querySelector('.js-feed');
const $sentinel = document.querySelector('.js-sentinel');
let page = 1;
let loading = false;
let ended = false;
function append(items) {
const html = items.map((x) => `<li class="feed-item">${x.title}</li>`).join('');
$list.insertAdjacentHTML('beforeend', html);
}
async function loadNext() {
if (loading || ended) return;
loading = true;
try {
const res = await fetch(`/api/feed?page=${page}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const items = Array.isArray(data.items) ? data.items : [];
if (items.length === 0) {
ended = true;
observer.disconnect();
return;
}
append(items);
page += 1;
} finally {
loading = false;
}
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) loadNext();
}, { root: null, threshold: 0.1 });
observer.observe($sentinel);
loadNext();
IntersectionObserver는 스크롤 이벤트를 직접 계산하는 방식보다 관리가 단순해지고, 불필요한 연산을 줄일 수 있습니다. 과제에서는 “중복 요청 방지(loading 플래그)”와 “끝났을 때 중단(disconnect)”이 자주 채점 포인트로 들어갑니다.
실수 포인트는 관찰 대상을 여러 번 observe하거나, 요청이 느릴 때 isIntersecting이 여러 번 발생해 중복 로딩이 걸리는 경우입니다. 따라서 loading 플래그는 사실상 필수입니다.
접근성 측면에서는, 무한 로딩 시에도 키보드 사용자가 현재 위치를 잃지 않도록(포커스 이동/ARIA live 영역 등) 설계를 고민해보시면 Level 4에서 차별점이 됩니다.
Level 5 — 미니 과제(드래그 앤 드롭·파일 업로드·코드 품질)
Level 5는 단일 알고리즘 문제가 아니라 “작은 제품”을 만드는 느낌의 과제가 많습니다. 예를 들어 드래그 앤 드롭 정렬, 파일 업로드 미리보기, 모달/토스트, 라우팅 레이아웃 같은 요구가 결합됩니다. 이 단계에서는 구조화(모듈 분리), 상태-렌더 분리, 예외 처리, 테스트 가능성이 평가에 들어가기 쉽습니다.
대표 과제 — 드래그 앤 드롭으로 리스트 정렬
문제(연습용): 리스트 아이템을 드래그해서 순서를 바꿀 수 있어야 합니다. 드롭 후에는 새로운 순서가 상태에 반영되어 다시 렌더해도 유지되어야 합니다.
const $list = document.querySelector('.js-sort');
let items = ['A', 'B', 'C', 'D'];
let dragIndex = -1;
function render() {
$list.innerHTML = items
.map((t, idx) => `<li class="sort-item" draggable="true" data-idx="${idx}">${t}</li>`)
.join('');
}
$list.addEventListener('dragstart', (e) => {
const $it = e.target.closest('.sort-item');
if (!$it) return;
dragIndex = Number($it.dataset.idx);
e.dataTransfer.effectAllowed = 'move';
});
$list.addEventListener('dragover', (e) => {
if (dragIndex < 0) return;
e.preventDefault();
});
$list.addEventListener('drop', (e) => {
e.preventDefault();
const $it = e.target.closest('.sort-item');
if (!$it) return;
const dropIndex = Number($it.dataset.idx);
if (dropIndex === dragIndex) return;
const next = items.slice();
const [moved] = next.splice(dragIndex, 1);
next.splice(dropIndex, 0, moved);
items = next;
dragIndex = -1;
render();
});
render();
이 과제에서 중요한 것은 “DOM만 바꾸지 말고 상태(items)를 바꾼다”는 점입니다. 상태가 바뀌어야 새로 렌더해도 결과가 유지됩니다.
실수 포인트는 드래그 인덱스를 DOM에서만 믿는 경우입니다. 드롭 시점에 이미 렌더가 바뀌면 dataset 인덱스가 꼬일 수 있으므로, 과제가 커지면 id 기반으로 바꾸는 편이 안전합니다.
개선 방법은 아이템에 고유 id를 두고, 드래그 대상도 id로 추적하는 것입니다. 실전 과제에서는 이 정도 설계가 “실무형”으로 평가됩니다.
많이 찾는 질문(FAQ)
Q. 알고리즘 코딩테스트와 프론트엔드 코딩테스트는 무엇이 다른가요?
A. 알고리즘형은 입력을 받아 정답을 출력하는 구조가 많고, 프론트엔드형은 DOM/이벤트/비동기/접근성처럼 “사용자 상호작용이 있는 기능”을 요구사항대로 구현하는 비중이 큽니다. 지원하는 회사가 어떤 형태를 보는지 먼저 확인하고, 그에 맞춰 루트를 분리하시는 편이 효율적입니다.
Q. 프론트엔드 과제에서 가장 많이 나오는 주제는 무엇인가요?
A. 리스트 렌더링 + CRUD(추가/삭제/수정), 검색/필터, 무한 스크롤, 폼 검증, 모달/토스트, 파일 업로드, 드래그 앤 드롭이 자주 묶여 나옵니다. 특히 이벤트 위임과 비동기 안정성은 거의 매번 등장합니다.
Q. fetch 요청이 꼬이거나 결과가 뒤집히는 문제는 어떻게 막나요?
A. 빠른 입력이나 연속 클릭으로 요청이 겹치면 “뒤늦게 온 응답이 UI를 덮는” 문제가 생깁니다. 해결책은 (1) 이전 요청을 취소하거나 (2) 요청에 시퀀스 번호를 붙여 최신 응답만 반영하는 방식입니다.
Q. 면접에서 이벤트 위임을 설명하라고 하면 어떻게 말해야 하나요?
A. 여러 자식 요소에 리스너를 붙이는 대신, 공통 부모에 1개의 리스너를 붙이고 버블링으로 올라오는 이벤트의 target을 기준으로 분기 처리한다고 설명하시면 됩니다. 동적으로 추가되는 항목에도 동작하고, 리스너 개수를 줄여 성능과 관리가 좋아진다는 점까지 연결하시면 좋습니다.
Q. 무한 스크롤을 스크롤 이벤트로 구현해도 되나요?
A. 가능하지만, 스크롤 이벤트는 호출 빈도가 높아 최적화가 필요합니다. 과제에서는 IntersectionObserver로 sentinel을 관찰하는 방식이 더 간결하고 안정적인 경우가 많습니다.
Q. Level 5에서 코드 품질은 무엇을 보나요?
A. 함수/모듈 분리, 상태-렌더 분리, 예외 처리, 재사용성, 그리고 “요구사항 변경에 견디는 구조”를 봅니다. 동일 기능이라도 구조가 깔끔하면 리뷰 단계에서 평가가 확실히 올라갑니다.