프론트엔드 코딩테스트 준비: 5단계 과제 대비 전략

2025.12.24·수정 2026.09.13·약 47분·작성: 해비·블로그 소개

주요 포인트 한눈에 보기

프론트엔드 과제형 코딩테스트는 요구사항을 상태·이벤트·화면으로 나누어 연습하면 준비 범위가 명확해집니다. 이 글은 DOM 토글 → 할 일 목록 → 비동기 검색 → 페이지 추가 → 정렬·파일 미리보기의 기존 5단계를 하나의 실행 가능한 연습 화면으로 묶었습니다. 회사별 출제 빈도나 합격을 보장하는 순위가 아니라, UI 구현 역량을 점검하기 위한 학습 순서입니다.

목표: 상태와 화면의 일치, 안전한 문자열 렌더링, 오래된 응답 무시, 실패 후 재시도, 키보드 대체 조작을 직접 구현하고 설명합니다. 선수 지식: HTML 폼·CSS 선택자·JavaScript 배열/함수·Promise와 async/await 기초.

프론트엔드 코딩테스트 준비: 5단계 과제 대비 전략 핵심 내용을 설명하는 본문 이미지

먼저 실행 환경과 공통 파일을 준비합니다

Node.js 22 이상과 ES modules·fetch를 지원하는 브라우저를 사용합니다. 이 글의 실제 Node 검증 버전은 24.19.0입니다. 별도 npm 설치나 API 키는 필요 없습니다. 아래 전체 파일을 같은 폴더에 저장하거나 실습 ZIP을 내려받아 압축을 풉니다. 각 단계는 같은 HTML 안의 독립된 영역이므로 순서대로 기능을 읽고 확인하세요. 서로 다른 수정 전·후 버전을 제공하는 형식은 아닙니다.

터미널에서 해당 폴더로 이동한 뒤 node server.mjs를 실행하고 http://127.0.0.1:4173에 접속합니다. 종료는 Ctrl+C입니다. 포트가 사용 중이면 기존 서버를 종료합니다. HTML을 더블클릭하는 file:// 방식은 모듈과 API 요청 때문에 사용하지 않습니다.

파일 구성은 index.html, styles.css, app.mjs, shared.mjs, level1.mjs~level5.mjs, server.mjs, logic.test.mjs, README.md입니다. HTML/CSS/JavaScript를 분리했고, 본문의 전체 코드와 ZIP의 해당 파일은 같습니다.

index.html — 전체 파일

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>프론트엔드 5단계 실습</title>
  <link rel="stylesheet" href="./styles.css">
  <script type="module" src="./app.mjs"></script>
</head>
<body>
<main>
  <h1>프론트엔드 5단계 실습</h1>
  <section aria-labelledby="l1">
    <h2 id="l1">1. 토글과 카운트</h2>
    <button id="toggle" type="button" aria-pressed="false">활성 전환</button>
    <p role="status">활성 전환 횟수: <span id="count">0</span></p>
  </section>
  <section aria-labelledby="l2">
    <h2 id="l2">2. 할 일 추가와 삭제</h2>
    <form id="todo-form" novalidate>
      <label for="todo-input">할 일 (1~80자)</label>
      <input id="todo-input" maxlength="80" aria-describedby="todo-status">
      <button type="submit">추가</button>
    </form>
    <p id="todo-status" role="status"></p>
    <ul id="todos"></ul>
  </section>
  <section aria-labelledby="l3">
    <h2 id="l3">3. 검색과 요청 취소</h2>
    <label for="search">검색어 (오류 연습: error, 지연 연습: slow)</label>
    <input id="search" type="search">
    <p id="search-status" role="status">검색어를 입력하세요.</p>
    <ul id="results"></ul>
  </section>
  <section aria-labelledby="l4">
    <h2 id="l4">4. 페이지 추가와 접근성</h2>
    <label><input id="fail-feed" type="checkbox">요청 실패 연습</label>
    <label><input id="auto-feed" type="checkbox">스크롤 자동 불러오기</label>
    <ul id="feed"></ul>
    <div id="sentinel" aria-hidden="true"></div>
    <button id="more" type="button">더 보기</button>
    <p id="feed-status" role="status">더 보기를 누르세요.</p>
  </section>
  <section aria-labelledby="l5">
    <h2 id="l5">5. 정렬과 파일 미리보기</h2>
    <p>드래그하거나 각 항목의 위·아래 버튼으로 순서를 바꾸세요.</p>
    <ul id="sort"></ul>
    <p id="sort-status" role="status"></p>
    <label for="file">이미지 선택 (PNG/JPEG/WebP, 최대 2 MiB)</label>
    <input id="file" type="file" accept="image/png,image/jpeg,image/webp">
    <p id="file-status" role="status">파일은 서버로 전송하지 않습니다.</p>
    <img id="preview" alt="선택한 이미지 미리보기" hidden>
  </section>
</main>
</body>
</html>

styles.css — 전체 파일

* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #172435; background: #f3f6fa; }
main { max-width: 760px; margin: auto; padding: 24px 16px; }
section { padding: 20px; margin-block: 20px; background: white; border: 1px solid #c8d2df; border-radius: 10px; }
input, button { font: inherit; max-width: 100%; }
button { padding: 8px 12px; margin: 4px; cursor: pointer; }
button:disabled { cursor: default; }
label { display: block; margin-block: 8px; }
input:not([type="checkbox"]) { padding: 8px; }
:focus-visible { outline: 3px solid #145ac7; outline-offset: 3px; }
[aria-pressed="true"] { background: #163d75; color: white; }
li { padding-block: 8px; overflow-wrap: anywhere; }
#feed li { min-height: 100px; border-bottom: 1px solid #ddd; }
#sentinel { height: 2px; }
#preview { max-width: 100%; max-height: 240px; object-fit: contain; }
[role="status"] { min-height: 1.5em; }

app.mjs — 전체 파일

import './level1.mjs';
import './level2.mjs';
import './level3.mjs';
import './level4.mjs';
import './level5.mjs';

shared.mjs — 전체 파일

export function moveItem(items, from, to) {
  const a = items.indexOf(from);
  const b = items.indexOf(to);
  if (a < 0 || b < 0 || a === b) return items.slice();
  const next = items.slice();
  next.splice(a, 1);
  next.splice(b, 0, from);
  return next;
}
export function validImage(file) {
  return Boolean(file && ['image/png', 'image/jpeg', 'image/webp'].includes(file.type)
    && file.size > 0 && file.size <= 2 * 1024 * 1024);
}
export async function getItems(url, options = {}, fetcher = fetch) {
  const response = await fetcher(url, options);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (!Array.isArray(data.items) || !data.items.every(x => typeof x === 'string')) {
    throw new Error('응답 형식 오류');
  }
  return data.items;
}
export function listNodes(values) {
  return values.map(value => {
    const li = document.createElement('li');
    li.textContent = value;
    return li;
  });
}

Level 1 — DOM 선택·렌더링·기본 이벤트

과제: 버튼을 누르면 활성 상태가 바뀌고, 비활성에서 활성으로 전환될 때만 횟수를 1 더합니다. 모든 클릭 횟수와 구분해야 합니다. 세 번 누른 뒤 활성 상태는 true, 횟수는 2입니다.

level1.mjs에서 active·count를 상태로 두고 이벤트가 끝날 때 aria-pressedtextContent를 함께 갱신합니다. CSS는 이 속성을 읽어 활성 색상을 표시하므로 별도의 classList 상태가 어긋날 여지가 없습니다. classList 방식으로 확장할 때도 동일한 active 값에서 클래스와 ARIA를 파생하세요. 기본 button을 사용해 Enter·Space 조작을 유지합니다.

level1.mjs — 전체 파일

const button = document.querySelector('#toggle');
const output = document.querySelector('#count');
let active = false;
let count = 0;
button.addEventListener('click', () => {
  active = !active;
  if (active) count += 1;
  button.setAttribute('aria-pressed', String(active));
  output.textContent = String(count);
});

Level 2 — 이벤트 위임·상태 관리·폼 검증

과제: 1~80자 할 일을 추가하고, 동적으로 생긴 삭제 버튼도 목록에 등록한 하나의 click 리스너로 처리합니다. 공백만 입력하면 추가하지 않고 설명을 보여줍니다. 이벤트 위임은 동적 목록에 유용한 선택이며 모든 UI에 반드시 필요한 방식은 아닙니다.

level2.mjs는 고유 id로 상태에서 항목을 삭제한 후 렌더합니다. 사용자 문자열은 textContent에 넣으므로 태그 모양의 입력도 문자로 보입니다. 기존 escapeHtml 조각의 깨진 따옴표와 백슬래시를 제거했고, HTML 문자열 조립 자체를 사용하지 않습니다. 삭제로 초점 대상이 사라지면 입력으로 초점을 돌립니다.

확인: 공백 제출은 0개, <img src=x> 제출은 이미지가 아닌 문자 1개, 삭제하면 0개가 되어야 합니다.

level2.mjs — 전체 파일

const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
const list = document.querySelector('#todos');
const status = document.querySelector('#todo-status');
let items = [];
let nextId = 0;
function render() {
  list.replaceChildren(...items.map(item => {
    const li = document.createElement('li');
    const text = document.createElement('span');
    const button = document.createElement('button');
    text.textContent = item.text;
    button.type = 'button';
    button.textContent = '삭제';
    button.dataset.id = item.id;
    button.setAttribute('aria-label', `${item.text} 삭제`);
    li.append(text, button);
    return li;
  }));
}
form.addEventListener('submit', event => {
  event.preventDefault();
  const text = input.value.trim();
  if (!text || text.length > 80) {
    input.setAttribute('aria-invalid', 'true');
    status.textContent = '1~80자의 할 일을 입력하세요.';
    input.focus();
    return;
  }
  input.removeAttribute('aria-invalid');
  items.push({ id: String(++nextId), text });
  input.value = '';
  render();
  status.textContent = `${items.length}개 할 일`;
  input.focus();
});
list.addEventListener('click', event => {
  const button = event.target.closest('button[data-id]');
  if (!button || !list.contains(button)) return;
  items = items.filter(item => item.id !== button.dataset.id);
  render();
  status.textContent = `${items.length}개 할 일. 삭제했습니다.`;
  input.focus();
});

Level 3 — fetch·에러 처리·요청 취소(AbortController)

과제: 입력이 멈춘 뒤 300ms에 검색하고, 이전 요청을 취소하며, 현재 입력에 해당하는 응답만 표시합니다. 이 실습은 검색 결과 목록이며 ARIA combobox 자동완성 위젯 구현은 포함하지 않습니다.

level3.mjs는 입력이 바뀌는 즉시 sequence를 증가시킵니다. 취소가 늦거나 fetch 대체 구현이 취소를 무시해도 오래된 응답은 sequence 검사에서 제외됩니다. 빈 검색어는 타이머·요청을 취소하고 화면을 초기화합니다. 로딩·빈 결과·HTTP 오류를 별도 문구로 표시합니다. fetch는 HTTP 404/503만으로 reject하지 않으므로 공통 함수에서 response.ok와 JSON 배열 형식을 검사합니다.

확인: DOM 검색은 1개, 없는 단어는 0개, error는 HTTP 503 안내입니다. slow 입력 후 요청이 시작되도록 300ms 이상 기다렸다가 DOM을 입력하면 늦은 slow 응답이 DOM 결과를 덮지 않아야 합니다.

level3.mjs — 전체 파일

import { getItems, listNodes } from './shared.mjs';
const input = document.querySelector('#search');
const list = document.querySelector('#results');
const status = document.querySelector('#search-status');
let timer;
let controller;
let sequence = 0;
input.addEventListener('input', () => {
  const current = ++sequence;
  const query = input.value.trim();
  clearTimeout(timer);
  controller?.abort();
  list.replaceChildren();
  if (!query) {
    status.textContent = '검색어를 입력하세요.';
    return;
  }
  status.textContent = '입력 대기 중…';
  timer = setTimeout(async () => {
    controller = new AbortController();
    status.textContent = '검색 중…';
    try {
      const items = await getItems(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal
      });
      if (current !== sequence) return;
      list.replaceChildren(...listNodes(items));
      status.textContent = `${items.length}개 결과`;
    } catch (error) {
      if (current !== sequence || error.name === 'AbortError') return;
      status.textContent = `검색 실패: ${error.message}. 다시 입력하세요.`;
    }
  }, 300);
});

Level 4 — 성능·무한 스크롤(IntersectionObserver)·접근성

과제: 한 번에 한 페이지만 불러오고, 실패하면 같은 페이지를 재시도하며, 빈 페이지에 도달하면 관찰을 중단합니다. 실습 서버는 세 페이지·총 9개 항목으로 끝을 재현합니다.

level4.mjs의 loading은 중복 진입을 차단하고 page는 성공한 뒤에만 증가합니다. catch가 네트워크·HTTP·JSON 오류를 화면에 처리하므로 이벤트 콜백의 Promise 거절이 방치되지 않습니다. 실패하면 자동 관찰을 중단해 반복 요청을 막고 더 보기 버튼으로 재시도합니다. 목록을 추가해도 초점을 강제로 옮기지 않습니다.

IntersectionObserver는 감시 요소의 교차 변화를 비동기로 알려줍니다. 계속 화면 안에 있는 요소가 매번 새 콜백을 보장하지는 않으므로 더 보기 버튼을 유지했습니다. 자동 불러오기는 명시적으로 선택하며, 미지원 환경에서도 버튼으로 진행할 수 있습니다. 이 예시는 가상 목록이나 대규모 성능 최적화까지 구현하지 않습니다.

확인: 실패 연습 → 더 보기 → 오류, 실패 해제 → 더 보기 → 항목 1~3, 계속 불러오기 → 9개, 다음 요청 → 마지막 페이지. 오류 때문에 첫 페이지를 건너뛰면 안 됩니다.

level4.mjs — 전체 파일

import { getItems, listNodes } from './shared.mjs';
const list = document.querySelector('#feed');
const more = document.querySelector('#more');
const status = document.querySelector('#feed-status');
const auto = document.querySelector('#auto-feed');
const sentinel = document.querySelector('#sentinel');
let page = 1;
let loading = false;
let ended = false;
let observer;
async function loadNext() {
  if (loading || ended) return;
  loading = true;
  more.setAttribute('aria-disabled', 'true');
  status.textContent = '불러오는 중…';
  try {
    const fail = document.querySelector('#fail-feed').checked ? '1' : '0';
    const items = await getItems(`/api/feed?page=${page}&fail=${fail}`);
    if (!items.length) {
      ended = true;
      observer?.disconnect();
      status.textContent = '마지막 페이지입니다.';
      return;
    }
    list.append(...listNodes(items));
    page += 1;
    status.textContent = `${list.children.length}개 표시. 더 보기로 계속할 수 있습니다.`;
  } catch (error) {
    auto.checked = false;
    observer?.disconnect();
    status.textContent = `불러오기 실패: ${error.message}. 실패 연습을 해제하고 더 보기를 누르세요.`;
  } finally {
    loading = false;
    more.setAttribute('aria-disabled', String(ended));
  }
}
more.addEventListener('click', loadNext);
if ('IntersectionObserver' in globalThis) {
  observer = new IntersectionObserver(entries => {
    if (entries.some(entry => entry.isIntersecting)) loadNext();
  });
  auto.addEventListener('change', () => {
    observer.disconnect();
    if (auto.checked && !ended) observer.observe(sentinel);
  });
} else {
  auto.disabled = true;
}

Level 5 — 미니 과제(드래그 앤 드롭·파일 업로드·코드 품질)

프론트엔드 코딩테스트 과제 대비 단계별 점검 흐름

과제: A~D의 순서를 상태에 저장하고 다시 렌더해도 같은 순서를 보여줍니다. 드래그 인덱스 대신 고유 id를 추적하며, 키보드·터치 사용자를 위해 위·아래 버튼도 제공합니다. A를 D에 놓으면 최종 순서는 B,C,D,A입니다.

level5.mjs는 공통 moveItem 함수로 새 배열을 만든 뒤 DOM을 갱신합니다. 버튼 조작 후 이동한 항목의 첫 버튼으로 초점을 복구하고 현재 순서를 알립니다. 드래그는 점진적 부가 기능이며 버튼으로도 같은 상태 변경이 가능합니다.

파일 부분은 업로드 전 로컬 미리보기만 구현합니다. PNG/JPEG/WebP, 0바이트 초과·2 MiB 이하를 안내용으로 검사하고 이미지 디코딩 실패도 표시합니다. 새 선택·페이지 이탈 시 이전 object URL을 해제합니다. MIME·크기 검사는 실제 업로드의 보안 검증을 대체하지 않습니다. 서버 업로드·저장·인증 기능과 새로고침 후 상태 보존은 제공하지 않습니다.

level5.mjs — 전체 파일

import { moveItem, validImage } from './shared.mjs';
const list = document.querySelector('#sort');
const status = document.querySelector('#sort-status');
let items = ['A', 'B', 'C', 'D'];
let dragged = null;
function render(focusId) {
  list.replaceChildren(...items.map((id, index) => {
    const li = document.createElement('li');
    li.dataset.id = id;
    li.draggable = true;
    li.textContent = id;
    for (const [label, delta] of [['위', -1], ['아래', 1]]) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = label;
      button.dataset.delta = String(delta);
      button.setAttribute('aria-label', `${id} ${label}로 이동`);
      button.setAttribute('aria-disabled', String(index + delta < 0 || index + delta >= items.length));
      li.append(button);
    }
    return li;
  }));
  if (focusId) list.querySelector(`[data-id="${focusId}"] button`).focus();
}
function move(from, to) {
  items = moveItem(items, from, to);
  render(from);
  status.textContent = `현재 순서: ${items.join(', ')}`;
}
list.addEventListener('click', event => {
  const button = event.target.closest('button[data-delta]');
  if (!button || button.getAttribute('aria-disabled') === 'true') return;
  const id = button.closest('li').dataset.id;
  move(id, items[items.indexOf(id) + Number(button.dataset.delta)]);
});
list.addEventListener('dragstart', event => {
  const li = event.target.closest('li[data-id]');
  if (!li || !event.dataTransfer) return;
  dragged = li.dataset.id;
  event.dataTransfer.setData('text/plain', dragged);
  event.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', event => {
  if (dragged) event.preventDefault();
});
list.addEventListener('drop', event => {
  event.preventDefault();
  const target = event.target.closest('li[data-id]');
  if (dragged && target) move(dragged, target.dataset.id);
  dragged = null;
});
list.addEventListener('dragend', () => { dragged = null; });
render();
const fileInput = document.querySelector('#file');
const preview = document.querySelector('#preview');
const fileStatus = document.querySelector('#file-status');
let objectUrl;
function clearPreview() {
  if (objectUrl) URL.revokeObjectURL(objectUrl);
  objectUrl = undefined;
  preview.removeAttribute('src');
  preview.hidden = true;
}
preview.addEventListener('error', () => {
  clearPreview();
  fileStatus.textContent = '이미지를 읽을 수 없습니다.';
});
fileInput.addEventListener('change', () => {
  clearPreview();
  const file = fileInput.files[0];
  if (!file) {
    fileStatus.textContent = '선택한 파일이 없습니다.';
    return;
  }
  if (!validImage(file)) {
    fileStatus.textContent = '2 MiB 이하 PNG/JPEG/WebP 이미지를 선택하세요.';
    return;
  }
  objectUrl = URL.createObjectURL(file);
  preview.src = objectUrl;
  preview.hidden = false;
  fileStatus.textContent = `${file.name} 미리보기. 서버 전송 없음.`;
});
window.addEventListener('pagehide', clearPreview);

검색·목록 API를 제공하는 로컬 서버

GET /api/search?q=DOM{"items":["DOM"]}, error는 503, slow는 900ms 지연을 반환합니다. GET /api/feed?page=1은 문자열 3개, page=4 이상은 빈 배열, fail=1은 503입니다. 잘못된 페이지 번호는 400입니다. 외부 API나 운영 서버에 의존하지 않는 학습용 fixture입니다. 실제 서비스용 인증·저장·배포 서버로 사용하기 위한 예제는 아닙니다.

server.mjs — 전체 파일

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const files = new Set(['index.html', 'styles.css', 'app.mjs', 'shared.mjs',
  'level1.mjs', 'level2.mjs', 'level3.mjs', 'level4.mjs', 'level5.mjs']);
const words = ['JavaScript', 'DOM', 'CSS', 'HTML', 'fetch', 'slow'];
const server = createServer(async (request, response) => {
  const url = new URL(request.url, 'http://localhost');
  const json = (status, data) => {
    response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
    response.end(JSON.stringify(data));
  };
  try {
    if (url.pathname === '/api/search') {
      const query = url.searchParams.get('q') || '';
      await new Promise(resolve => setTimeout(resolve, query === 'slow' ? 900 : 100));
      if (query === 'error') return json(503, { error: 'practice failure' });
      return json(200, { items: words.filter(word => word.toLowerCase().includes(query.toLowerCase())) });
    }
    if (url.pathname === '/api/feed') {
      if (url.searchParams.get('fail') === '1') return json(503, { error: 'practice failure' });
      const page = Number(url.searchParams.get('page'));
      if (!Number.isInteger(page) || page < 1) return json(400, { error: 'invalid page' });
      const items = page > 3 ? [] : Array.from({ length: 3 }, (_, i) => `항목 ${(page - 1) * 3 + i + 1}`);
      return json(200, { items });
    }
    const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
    if (!files.has(name)) return json(404, { error: 'not found' });
    const type = name.endsWith('.css') ? 'text/css' : name.endsWith('.mjs') ? 'text/javascript' : 'text/html';
    const body = await readFile(new URL(name, import.meta.url));
    response.writeHead(200, { 'Content-Type': `${type}; charset=utf-8` });
    response.end(body);
  } catch {
    if (!response.headersSent) json(500, { error: 'server error' });
    else response.end();
  }
});
server.listen(4173, '127.0.0.1', () => console.log('http://127.0.0.1:4173'));

실행 확인·테스트·ZIP

각 단계의 확인 절차를 실행한 뒤 공통 로직 검사는 node --test logic.test.mjs로 실행합니다. 정상 결과는 테스트 3개 통과입니다. 아래 테스트도 ZIP에 포함되어 있습니다.

logic.test.mjs — 전체 파일

import test from 'node:test';
import assert from 'node:assert/strict';
import { moveItem, validImage, getItems } from './shared.mjs';
test('reorder by stable ID, boundaries and immutability', () => {
  const input = ['A', 'B', 'C'];
  assert.deepEqual(moveItem(input, 'A', 'C'), ['B', 'C', 'A']);
  assert.deepEqual(input, ['A', 'B', 'C']);
  assert.deepEqual(moveItem(input, 'X', 'A'), input);
  assert.deepEqual(moveItem(input, 'A', 'A'), input);
});
test('image validation rejects oversized and non-images', () => {
  assert.equal(validImage({ type: 'image/png', size: 100 }), true);
  assert.equal(validImage({ type: 'image/png', size: 2097153 }), false);
  assert.equal(validImage({ type: 'text/html', size: 100 }), false);
  assert.equal(validImage(null), false);
});
test('HTTP, invalid shape, invalid JSON and abort are rejected', async () => {
  assert.deepEqual(await getItems('/x', {}, async () => new Response('{"items":["A"]}')), ['A']);
  await assert.rejects(getItems('/x', {}, async () => new Response('', {status:503})), /503/);
  await assert.rejects(getItems('/x', {}, async () => new Response('{"items":null}')), /형식/);
  await assert.rejects(getItems('/x', {}, async () => new Response('bad')));
  await assert.rejects(getItems('/x', {}, async () => { throw new DOMException('aborted','AbortError'); }), {name:'AbortError'});
});

codingtest-five-stage-lab.zip 다운로드 — 전체 소스·실습 README·단위 테스트 포함. 외부 라이브러리 설치 없이 실행합니다.

실제로 확인한 범위 (2026-09-13): Node 24.19.0에서 위 단위 테스트 3개, 별도의 jsdom 검사에서 토글·공백 검증·태그 문자열 렌더링·이벤트 위임 삭제·초점 복구·오래된 응답 무시·요청 취소 신호·HTTP 오류·검색 초기화·목록 중복 방지/재시도/끝·버튼 정렬을 확인했습니다. 로컬 HTTP 서버의 9개 화면 자원과 검색·목록 성공/오류 응답도 요청해 확인했습니다.

검증 한계: jsdom은 실제 브라우저가 아닙니다. 실제 스크롤 교차 감지, 네이티브 드래그, 스크린리더 낭독, 이미지 디코딩, 모바일 터치는 이번 검증에서 실행하지 않았습니다. 위 수동 확인 항목을 대상 브라우저에서도 수행하세요. 실습의 단위 테스트만으로 접근성이나 전체 UI가 검증되었다고 해석하면 안 됩니다.

많이 찾는 질문(FAQ)

알고리즘형과 UI 과제형 중 무엇을 먼저 준비하나요?

지원 공고와 과제 안내에서 평가 형식을 먼저 확인합니다. 알고리즘형은 입출력·자료구조·복잡도를, UI 과제형은 요구사항·상태·이벤트·비동기·접근성을 별도로 연습합니다. 이 글은 후자를 위한 연습안이며 기업별 출제 비율 자료는 아닙니다.

가장 많이 나오는 주제는 무엇인가요?

이 글만으로 출제 빈도 순위를 말할 수는 없습니다. 목록·폼·검색·페이지 추가·정렬을 골라 연습한 이유는 서로 다른 UI 문제를 다루기 때문입니다. 실제 과제에서는 필수 요구사항부터 동작하게 하고 오류·빈 상태·키보드 조작을 확인하세요.

이벤트 위임과 요청 경쟁을 어떻게 설명하나요?

이벤트 위임은 부모에서 버블링된 이벤트의 target을 검사해 동적 자식의 동작을 처리하는 방식입니다. 요청 경쟁은 완료 순서가 시작 순서와 달라 발생하며, 취소와 최신 요청 번호 검사를 함께 적용하면 이전 응답이 현재 결과를 바꾸지 못하게 할 수 있습니다.

무한 스크롤과 코드 품질은 무엇으로 확인하나요?

요청 중 중복 진입, 실패 후 페이지 유지, 마지막 페이지 종료를 확인합니다. 정렬에서는 DOM만 바꾸지 않고 상태를 바꾸는지, 버튼 대체 조작이 있는지 봅니다. 코드 품질은 추상적인 평가 보장보다 요구사항을 설명할 수 있는 함수·모듈 경계와 재현 가능한 검사로 보여주세요.

관련 학습

공식 문서와 확인일

대상은 표준 Web API와 Node.js 내장 HTTP 서버입니다. 아래 MDN 원문을 2026-09-13에 확인했습니다. API 동작 근거와 이 글의 교육용 설계 선택을 구분했습니다.

이 글이 도움이 되었나요?

조회 중

커리어·포트폴리오 학습 순서

필수 5개 · 전체 7개

읽음 기록 관리

전체 과정 목차 (7개)
  1. 필수 길잡이 · 웹퍼블리셔 2026 로드맵: 포트폴리오와 AI 활용 기준
  2. 필수 길잡이 · 2026 프론트엔드 개발자 로드맵: React, Next.js, TypeScript
  3. 선택 참고 · 프론트엔드 개발자 취업 준비: 포트폴리오와 연봉 기준
  4. 필수 학습 · 2026 개발자 취업 포트폴리오: AI 시대에 보여줘야 할 프로젝트 기준
  5. 필수 학습 · 프론트엔드 이력서 작성법: 프로젝트 경험을 성과로 쓰는 기준
  6. 필수 길잡이 · 프론트엔드 코딩테스트 준비: 5단계 과제 대비 전략 현재 글
  7. 선택 참고 · 개발자 기술 블로그 상위 노출 전략: 검색 유입과 글 홍보 기준

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기