지연 로딩과 비동기 로딩 (Lazy Loading and Async Loading)

지연 로딩과 비동기 로딩 (Lazy Loading and Async Loading)

자바스크립트에서 성능을 높이고 초기 로딩을 빠르게 하려면 지연 로딩비동기 로딩이 유용하다. 필요할 때만 리소스를 불러오면 사용자 경험이 더 부드러워진다. 이번에는 이 두 가지 방법을 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


이 방법을 잘 다루면 페이지 로드가 빨라지고 리소스 낭비도 줄일 수 있다. 하나씩 살펴보며 어떤 상황에서 어떻게 적용할지 알아보자.


지연 로딩 기본

지연 로딩은 필요할 때까지 리소스 로드를 미루는 방식이다. 이미지 로딩을 예로 들어보자:

<!-- HTML -->
<img data-src="image.jpg" class="lazy" alt="지연 로딩 이미지">

// JavaScript
function lazyLoadImages() {
    const images = document.querySelectorAll(".lazy");
    images.forEach(img => {
        if (img.getBoundingClientRect().top < window.innerHeight) {
            img.src = img.dataset.src;
            img.classList.remove("lazy");
        }
    });
}

window.addEventListener("scroll", lazyLoadImages);
lazyLoadImages();

화면에 보일 때만 이미지를 로드하니 초기 로딩 속도가 빨라졌다.


1. Intersection Observer로 지연 로딩

IntersectionObserver를 사용하면 더 효율적으로 관리할 수 있다:

const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            obs.unobserve(img);
        }
    });
});

const images = document.querySelectorAll(".lazy");
images.forEach(img => observer.observe(img));

스크롤 이벤트를 직접 듣는 대신 브라우저가 교차 여부를 감지하니 성능 부담이 줄었다.


2. 모듈 비동기 로딩

필요한 모듈을 동적으로 불러오면 초기 부담이 줄어든다:

async function loadModule() {
    const module = await import("./heavyModule.js");
    console.log(module.doSomething());
}

const button = document.querySelector("#loadButton");
button.addEventListener("click", loadModule);

사용자가 버튼을 누를 때만 모듈을 로드하니 페이지 시작이 빨라졌다.


3. 스크립트 비동기 로딩

asyncdefer로 외부 스크립트를 효율적으로 불러오자:

<!-- HTML -->
<script src="script.js" async></script>
<script src="deferScript.js" defer></script>

// script.js
console.log("비동기 로딩 완료");

// deferScript.js
console.log("defer 로딩 완료");

async는 로드가 끝나는 대로 실행되고, defer는 DOM이 준비된 후 순서대로 실행된다. 상황에 맞게 선택하면 된다.


4. 이미지 프리로드와 지연 로딩 조합

중요한 이미지는 미리 로드하고 나머지는 지연 로딩으로 처리할 수 있다:

<!-- HTML -->
<link rel="preload" href="hero.jpg" as="image">
<img src="hero.jpg" alt="메인 이미지">
<img data-src="lazy.jpg" class="lazy" alt="지연 이미지">

// JavaScript
const lazyImages = document.querySelectorAll(".lazy");
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            entry.target.src = entry.target.dataset.src;
        }
    });
});
lazyImages.forEach(img => observer.observe(img));

preload로 핵심 이미지를 먼저 로드하고, 나머지는 지연 로딩으로 균형을 맞췄다.


5. 컴포넌트 지연 로딩

프레임워크에서 컴포넌트를 필요할 때 불러오자 (React 예시):

import React, { lazy, Suspense } from "react";

const HeavyComponent = lazy(() => import("./HeavyComponent"));

function App() {
    return (
        <div>
            <Suspense fallback=<div>로딩 중...</div>>
                <HeavyComponent />
            </Suspense>
        </div>
    );
}

lazySuspense로 컴포넌트를 동적으로 로드하니 초기 번들 크기가 줄었다.


6. 비동기 데이터 로딩

데이터를 비동기로 불러와서 UI를 점진적으로 채울 수 있다:

async function loadData() {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const data = await response.json();
    const list = document.querySelector("#list");
    list.innerHTML = data.map(item => `<li>${item.title}</li>`).join("");
}

window.addEventListener("load", loadData);

페이지가 먼저 렌더링되고 데이터가 나중에 채워지니 사용자 대기 시간이 줄었다.


7. 코드 스플리팅과 비동기 로딩

Webpack으로 코드 스플리팅을 적용해보자:

// app.js
function loadFeature() {
    import("./feature").then(module => {
        module.runFeature();
    });
}

const button = document.querySelector("#featureButton");
button.addEventListener("click", loadFeature);

// webpack.config.js
module.exports = {
    entry: "./app.js",
    output: {
        filename: "[name].[contenthash].js",
        chunkFilename: "[name].[contenthash].chunk.js"
    }
};

동적 임포트로 필요한 코드만 분리해서 로드하니 초기 번들이 가벼워졌다.


8. 비디오 지연 로딩

비디오도 필요할 때만 로드할 수 있다:

<!-- HTML -->
<video data-src="video.mp4" class="lazy-video" controls></video>

// JavaScript
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const video = entry.target;
            video.src = video.dataset.src;
            observer.unobserve(video);
        }
    });
});

const videos = document.querySelectorAll(".lazy-video");
videos.forEach(video => observer.observe(video));

비디오가 뷰포트에 들어올 때만 로드하니 대역폭이 절약됐다.


9. 조건부 비동기 로딩

특정 조건에서만 리소스를 로드할 수 있다:

async function loadIfNeeded(condition) {
    if (condition) {
        const module = await import("./optionalModule.js");
        module.run();
    } else {
        console.log("로드 필요 없음");
    }
}

window.addEventListener("scroll", () => {
    loadIfNeeded(window.scrollY > 500);
});

스크롤이 500px을 넘을 때만 모듈을 로드하니 불필요한 요청이 줄었다.


10. 서비스 워커와 비동기 로딩

서비스 워커로 리소스를 비동기적으로 관리해보자:

// sw.js
self.addEventListener("fetch", event => {
    event.respondWith(
        caches.match(event.request).then(response => {
            if (response) return response;
            return fetch(event.request).then(res => {
                const clone = res.clone();
                caches.open("dynamic").then(cache => 
                    cache.put(event.request, clone)
                );
                return res;
            });
        })
    );
});

// main.js
navigator.serviceWorker.register("/sw.js");

캐시가 없으면 비동기로 가져오고 저장하니 오프라인에서도 동작이 부드러워졌다.


마무리

지연 로딩과 비동기 로딩은 성능 최적화와 사용자 경험 개선에 큰 도움을 준다. 이미지, 모듈, 데이터, 서비스 워커까지 상황에 맞게 적용하면 더 빠르고 효율적인 애플리케이션을 만들 수 있다.


캐싱 전략 (Caching Strategies)

캐싱 전략 (Caching Strategies)

자바스크립트에서 성능을 끌어올리려면 캐싱이 큰 역할을 한다. 반복적인 연산이나 데이터 요청을 줄여서 더 빠르고 효율적인 코드를 만들 수 있다. 이번에는 캐싱 전략을 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


캐싱을 잘 활용하면 리소스 낭비를 줄이고 응답 속도를 높일 수 있다. 어떤 방법들이 있는지 하나씩 살펴보자.


메모리 캐싱 기본

가장 간단한 캐싱은 변수에 값을 저장하는 것이다:

let cachedValue;

function getData() {
    if (cachedValue) {
        console.log("캐시에서 가져옴");
        return cachedValue;
    }
    cachedValue = Math.random();
    console.log("새로 계산함");
    return cachedValue;
}

console.log(getData());
console.log(getData());
// "새로 계산함"
// 0.12345...
// "캐시에서 가져옴"
// 0.12345...

한 번 계산한 값을 저장해두니 두 번째 호출부터는 새로 계산하지 않았다. 간단하면서도 효과적이다.


1. 메모이제이션 캐싱

입력값에 따라 결과를 저장하면 중복 연산을 피할 수 있다:

function memoize(fn) {
    const cache = {};
    return function(n) {
        if (n in cache) return cache[n];
        return (cache[n] = fn(n));
    };
}

const slowSquare = memoize(n => {
    console.log("계산 중:", n);
    return n * n;
});

console.log(slowSquare(5));
console.log(slowSquare(5));
// "계산 중: 5"
// 25
// 25 (캐시 사용)

같은 입력값으로 호출하면 캐시에서 바로 가져오니 계산 부담이 줄었다.


2. 브라우저 캐싱 활용

HTTP 요청을 줄이려면 localStorage를 사용해보자:

async function fetchWithCache(url) {
    const cachedData = localStorage.getItem(url);
    if (cachedData) {
        console.log("캐시에서 로드");
        return JSON.parse(cachedData);
    }

    const response = await fetch(url);
    const data = await response.json();
    localStorage.setItem(url, JSON.stringify(data));
    console.log("서버에서 로드");
    return data;
}

fetchWithCache("https://jsonplaceholder.typicode.com/posts/1")
    .then(data => console.log(data.title));
// "서버에서 로드"
// "sunt aut facere..."
// 두 번째 호출: "캐시에서 로드"

localStorage에 저장해두니 네트워크 요청이 줄어들었다.


3. TTL 기반 캐싱

캐시에 유효 기간을 추가하면 데이터 신선도를 관리할 수 있다:

const cache = new Map();

function setWithTTL(key, value, ttl) {
    const expiry = Date.now() + ttl;
    cache.set(key, { value, expiry });
}

function getWithTTL(key) {
    const item = cache.get(key);
    if (!item || Date.now() > item.expiry) {
        cache.delete(key);
        return null;
    }
    return item.value;
}

setWithTTL("data", "중요 데이터", 2000);
console.log(getWithTTL("data"));
setTimeout(() => console.log(getWithTTL("data")), 3000);
// "중요 데이터"
// null (2초 후 만료)

TTL(Time To Live)을 설정하니 오래된 데이터가 자동으로 제거됐다.


4. LRU 캐싱

최근 사용된 항목을 우선 유지하는 LRU(Least Recently Used) 캐싱을 구현해보자:

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map();
    }

    get(key) {
        if (!this.cache.has(key)) return null;
        const value = this.cache.get(key);
        this.cache.delete(key);
        this.cache.set(key, value);
        return value;
    }

    set(key, value) {
        if (this.cache.has(key)) this.cache.delete(key);
        else if (this.cache.size >= this.capacity) {
            const firstKey = this.cache.keys().next().value;
            this.cache.delete(firstKey);
        }
        this.cache.set(key, value);
    }
}

const cache = new LRUCache(2);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
console.log(cache.get("a"));
console.log(cache.get("b"));
// null ("a"가 제거됨)
// 2

용량을 초과하면 가장 오래된 항목이 제거되니 메모리 효율이 좋아졌다.


5. 프록시로 캐싱

객체 접근을 가로채서 캐싱할 수 있다:

const handler = {
    get(target, prop) {
        if (!target.cache) target.cache = {};
        if (prop in target.cache) return target.cache[prop];
        const value = target[prop]();
        target.cache[prop] = value;
        return value;
    }
};

const target = {
    getRandom: () => Math.random()
};

const cachedObj = new Proxy(target, handler);
console.log(cachedObj.getRandom);
console.log(cachedObj.getRandom);
// 0.456...
// 0.456... (캐시 사용)

Proxy로 접근을 감지하고 캐싱하니 함수 호출 결과를 재사용할 수 있었다.


6. 서비스 워커로 네트워크 캐싱

서비스 워커를 통해 오프라인 캐싱을 구현해보자:

// sw.js
const CACHE_NAME = "my-cache-v1";

self.addEventListener("install", event => {
    event.waitUntil(
        caches.open(CACHE_NAME).then(cache => {
            return cache.add("/data.json");
        })
    );
});

self.addEventListener("fetch", event => {
    event.respondWith(
        caches.match(event.request).then(response => {
            return response || fetch(event.request);
        })
    );
});

// main.js
if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

서비스 워커로 리소스를 캐싱하니 오프라인에서도 빠르게 로드됐다.


7. 함수 결과 캐싱

비용이 큰 함수의 결과를 캐싱해보자:

const cache = new WeakMap();

function processHeavy(obj) {
    if (cache.has(obj)) return cache.get(obj);
    const result = Array.from({ length: 1000 }, () => obj.value);
    cache.set(obj, result);
    return result;
}

const data = { value: 5 };
console.time("첫 번째");
processHeavy(data);
console.timeEnd("첫 번째");
console.time("두 번째");
processHeavy(data);
console.timeEnd("두 번째");
// 첫 번째: 0.5ms
// 두 번째: 0.01ms

WeakMap으로 캐싱하니 두 번째 호출이 훨씬 빨라졌다.


8. 동적 캐싱 전략

상황에 따라 캐싱 방식을 동적으로 바꿀 수 있다:

function dynamicCache(strategy) {
    const cache = strategy === "memory" ? {} : localStorage;
    return {
        get: key => cache[key] || cache.getItem(key),
        set: (key, value) => strategy === "memory" 
            ? (cache[key] = value) 
            : cache.setItem(key, value)
    };
}

const memoryCache = dynamicCache("memory");
memoryCache.set("key", "값");
console.log(memoryCache.get("key"));
// "값"

메모리와 로컬 스토리지를 상황에 맞게 선택하니 유연성이 높아졌다.


9. 캐시 무효화 관리

데이터가 바뀌면 캐시를 갱신해야 한다:

const cache = new Map();
let version = 1;

function getData(key, currentVersion) {
    const cached = cache.get(key);
    if (cached && cached.version === currentVersion) {
        return cached.value;
    }
    const value = Math.random();
    cache.set(key, { value, version: currentVersion });
    return value;
}

console.log(getData("test", version));
version++;
console.log(getData("test", version));
// 0.789... (첫 계산)
// 0.123... (버전 변경 후 새 계산)

버전을 통해 캐시를 무효화하니 최신 데이터를 유지할 수 있었다.


10. 조건부 캐싱

특정 조건에서만 캐싱하도록 설정할 수 있다:

const cache = {};

function conditionalCache(key, condition, fn) {
    if (condition && key in cache) return cache[key];
    const result = fn();
    if (condition) cache[key] = result;
    return result;
}

const shouldCache = true;
console.log(conditionalCache("rand", shouldCache, () => Math.random()));
console.log(conditionalCache("rand", shouldCache, () => Math.random()));
// 0.234... (첫 계산)
// 0.234... (캐시 사용)

조건에 따라 캐싱 여부를 결정하니 더 유연하게 관리할 수 있었다.


마무리

캐싱 전략은 성능을 높이고 자원을 아끼는 데 큰 도움을 준다. 메모리 캐싱, 브라우저 저장소, TTL, LRU, 서비스 워커까지 상황에 맞는 방법을 선택하면 효율적인 코드를 만들 수 있다.


코드 최적화 기법 (Code Optimization Techniques)

코드 최적화 기법 (Code Optimization Techniques)

자바스크립트에서 성능을 높이려면 코드 최적화가 필수다. 느린 로딩이나 무거운 연산을 줄이고, 더 부드러운 사용자 경험을 만들어보려고 한다. 이번에는 코드 최적화를 위한 여러 방법을 기본부터 심화까지 코드와 함께 자세히 풀어보려고 한다.


최적화를 잘 적용하면 성능이 눈에 띄게 좋아질 뿐 아니라 유지보수도 쉬워진다. 하나씩 살펴보며 어떤 상황에 어떤 방법을 쓸지 알아보자.


불필요한 연산 줄이기

반복문 안에서 매번 계산하는 걸 피하면 성능이 올라간다:

// 최적화 전
function renderItems(items) {
    for (let i = 0; i < items.length; i++) {
        console.log(items[i] * items.length);
    }
}

// 최적화 후
function renderItemsOptimized(items) {
    const len = items.length;
    for (let i = 0; i < len; i++) {
        console.log(items[i] * len);
    }
}

const data = [1, 2, 3, 4, 5];
renderItemsOptimized(data);

items.length를 매번 호출하지 않고 변수에 저장하니 반복마다 접근 비용이 줄었다. 작은 차이 같아도 데이터가 커질수록 효과가 커진다.


1. 반복문 개선하기

forEach 대신 기본 for를 쓰면 속도가 빨라질 수 있다:

// 느린 방식
function sumArrayForEach(arr) {
    let sum = 0;
    arr.forEach(item => sum += item);
    return sum;
}

// 빠른 방식
function sumArrayFor(arr) {
    let sum = 0;
    for (let i = 0, len = arr.length; i < len; i++) {
        sum += arr[i];
    }
    return sum;
}

const numbers = new Array(10000).fill(1);
console.time("forEach");
sumArrayForEach(numbers);
console.timeEnd("forEach");
console.time("for");
sumArrayFor(numbers);
console.timeEnd("for");
// forEach: 0.8ms
// for: 0.3ms

for가 함수 호출 오버헤드를 줄여서 더 빠르게 동작했다. 큰 배열에서 차이가 두드러진다.


2. 객체 속성 접근 최적화

깊은 객체 속성에 자주 접근할 때는 참조를 저장해두자:

// 느린 방식
function logDetails(user) {
    console.log(user.profile.details.age);
    console.log(user.profile.details.name);
}

// 빠른 방식
function logDetailsOptimized(user) {
    const details = user.profile.details;
    console.log(details.age);
    console.log(details.name);
}

const user = { profile: { details: { age: 30, name: "홍길동" } } };
logDetailsOptimized(user);

중첩 속성을 변수에 저장하면 매번 탐색하는 비용이 줄어든다. 코드도 깔끔해진다.


3. 메모이제이션으로 중복 계산 피하기

같은 계산을 반복할 때는 결과를 저장해두자:

function memoize(fn) {
    const cache = {};
    return function(n) {
        if (n in cache) return cache[n];
        return (cache[n] = fn(n));
    };
}

const fib = memoize(function(n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
});

console.time("fib");
console.log(fib(40));
console.timeEnd("fib");
// 102334155
// fib: 0.2ms

피보나치 수열을 메모이제이션으로 계산하니 재귀 호출이 훨씬 빨라졌다.


4. DOM 조작 최소화

DOM에 직접 접근하는 걸 줄이면 렌더링 부담이 줄어든다:

// 느린 방식
function addItems(items) {
    const ul = document.querySelector("ul");
    items.forEach(item => {
        const li = document.createElement("li");
        li.textContent = item;
        ul.appendChild(li);
    });
}

// 빠른 방식
function addItemsOptimized(items) {
    const ul = document.querySelector("ul");
    const fragment = document.createDocumentFragment();
    items.forEach(item => {
        const li = document.createElement("li");
        li.textContent = item;
        fragment.appendChild(li);
    });
    ul.appendChild(fragment);
}

const data = ["항목1", "항목2", "항목3"];
addItemsOptimized(data);

DocumentFragment를 사용하면 DOM에 한 번만 반영돼 리플로우가 줄어든다.


5. 비동기 작업 분할

무거운 작업을 쪼개면 UI가 멈추지 않는다:

function processLargeArray(arr) {
    const chunkSize = 1000;
    let index = 0;

    function processChunk() {
        const end = Math.min(index + chunkSize, arr.length);
        for (index; index < end; index++) {
            arr[index] *= 2;
        }
        if (index < arr.length) {
            setTimeout(processChunk, 0);
        } else {
            console.log("완료");
        }
    }

    processChunk();
}

const largeArray = new Array(10000).fill(1);
processLargeArray(largeArray);

setTimeout으로 작업을 나누니 브라우저가 숨 쉴 틈을 얻었다.


6. 불필요한 함수 호출 제거

조건에 따라 호출을 건너뛰자:

function logIfChanged(newValue, oldValue) {
    if (newValue === oldValue) return;
    console.log(newValue);
}

let value = 5;
logIfChanged(5, value);
logIfChanged(6, value);
// 6만 출력

값이 같을 때는 로그를 건너뛰니 불필요한 실행이 줄었다.


7. 배열 필터링 최적화

필터와 맵을 한 번에 처리하면 연산이 줄어든다:

// 느린 방식
function processData(arr) {
    const filtered = arr.filter(x => x > 0);
    return filtered.map(x => x * 2);
}

// 빠른 방식
function processDataOptimized(arr) {
    return arr.reduce((acc, x) => {
        if (x > 0) acc.push(x * 2);
        return acc;
    }, []);
}

const data = [-1, 2, -3, 4];
console.log(processDataOptimized(data));
// [4, 8]

reduce로 한 번에 처리하니 배열을 두 번 순회할 필요가 없어졌다.


8. 이벤트 핸들러 최적화

이벤트 리스너를 효율적으로 관리하자:

// 비효율적인 방식
document.querySelectorAll(".item").forEach(el => {
    el.addEventListener("click", () => console.log(el.id));
});

// 효율적인 방식 (이벤트 위임)
const container = document.querySelector(".container");
container.addEventListener("click", (e) => {
    if (e.target.matches(".item")) {
        console.log(e.target.id);
    }
});

이벤트 위임으로 리스너를 하나만 등록하니 메모리와 성능이 개선됐다.


9. 불필요한 객체 생성 줄이기

객체를 재사용하면 메모리 부담이 줄어든다:

// 비효율적인 방식
function getPoint(x, y) {
    return { x: x, y: y };
}

// 효율적인 방식
const point = { x: 0, y: 0 };
function updatePoint(x, y) {
    point.x = x;
    point.y = y;
    return point;
}

console.log(updatePoint(5, 10));
// { x: 5, y: 10 }

새 객체를 만들지 않고 기존 객체를 갱신하니 가비지 컬렉션 부담이 줄었다.


10. 비트 연산으로 속도 높이기

수학 연산을 비트 연산으로 바꾸면 더 빨라질 수 있다:

// 일반 연산
function divideByTwo(n) {
    return n / 2;
}

// 비트 연산
function divideByTwoBit(n) {
    return n >> 1;
}

console.log(divideByTwoBit(10));
// 5

>>로 오른쪽 시프트하면 나누기보다 빠르게 동작한다.


11. 불변 데이터 활용

불변성을 유지하면 복사가 줄어든다:

const state = Object.freeze({ count: 0 });

function updateState(value) {
    return { ...state, count: value };
}

console.log(updateState(1));
// { count: 1 }

freeze로 불변성을 보장하면 의도치 않은 변경을 막고 예측 가능한 흐름을 유지할 수 있다.


12. 웹 워커로 무거운 작업 분리

메인 스레드를 막지 않으려면 웹 워커를 활용하자:

// worker.js
self.onmessage = (e) => {
    let sum = 0;
    for (let i = 0; i < e.data; i++) {
        sum += i;
    }
    self.postMessage(sum);
};

// main.js
const worker = new Worker("worker.js");
worker.onmessage = (e) => console.log(e.data);
worker.postMessage(1000000);

무거운 연산을 별도 스레드로 옮기니 UI가 멈추지 않았다.


마무리

코드 최적화는 성능을 높이는 동시에 깔끔한 코드를 유지하는 데 큰 역할을 한다. 연산 줄이기, DOM 관리, 비동기 분할, 웹 워커까지 상황에 맞는 기법을 적용하면 더 나은 결과를 얻을 수 있다.


디버깅 팁 (Debugging Tips)

디버깅 팁 (Debugging Tips)

자바스크립트 코드에서 문제가 생겼을 때, 빠르고 효과적으로 원인을 찾아내는 건 디버깅의 핵심이다. 단순히 에러 메시지를 읽는 데서 끝나는 게 아니라, 문제를 깊이 파고들어 해결하는 과정이 필요하다. 이번에는 디버깅을 더 잘할 수 있도록 기본부터 심화까지 다양한 접근법을 코드와 함께 풀어보려고 한다.


디버깅을 잘 다루면 복잡한 상황에서도 침착하게 문제를 해결할 수 있다. 하나씩 단계별로 살펴보자 개발자라면 꼭 알아두면 좋을 내용들을 정리해봤다.


콘솔로 빠르게 확인하기

가장 먼저 할 수 있는 건 console.log로 값을 찍어보는 것이다. 어디서 값이 잘못됐는지 바로 확인할 수 있다:

function calculateTotal(items) {
    let total = 0;
    items.forEach(item => {
        console.log("현재 아이템 가격:", item.price);
        total += item.price;
    });
    return total;
}

const cart = [{ price: 10 }, { price: "20" }, { price: 30 }];
console.log(calculateTotal(cart));
// "현재 아이템 가격:" 10
// "현재 아이템 가격:" "20"
// "현재 아이템 가격:" 30
// 1020 (문자열 때문에 이상한 결과)

console.log로 각 단계의 값을 확인하니 문자열이 섞여서 계산이 틀린 걸 알 수 있었다. 이런 식으로 간단하게 흐름을 파악할 수 있다.


1. 타입 확인으로 실수 줄이기

값이 예상과 다를 때 타입을 확인하면 문제를 빠르게 좁힐 수 있다:

function addNumbers(a, b) {
    console.log("a 타입:", typeof a, "b 타입:", typeof b);
    return a + b;
}

console.log(addNumbers(5, "10"));
// "a 타입:" "number" "b 타입:" "string"
// "510"

typeof로 타입을 찍어보니 문자열 때문에 더하기가 연결로 바뀌었다는 걸 알 수 있다. 이런 상황을 미리 잡아내면 수정이 훨씬 쉬워진다.


2. 조건문으로 문제 좁히기

특정 조건에서만 문제가 생긴다면 조건문을 추가해서 범위를 줄여보자:

function processData(data) {
    if (!data) {
        console.log("data가 undefined거나 null입니다.");
        return;
    }
    console.log("data 처리 중:", data);
    return data.map(item => item * 2);
}

processData();
// "data가 undefined거나 null입니다."

값이 없는 경우를 미리 걸러내니 어디서 문제가 시작됐는지 바로 눈에 들어왔다.


3. 브라우저 DevTools 활용하기

브라우저의 개발자 도구에서 debugger를 넣으면 코드 실행을 멈추고 값을 확인할 수 있다:

function renderList(items) {
    debugger;
    const result = items.map(item => item.name);
    return result;
}

const data = [{ name: "항목1" }, { id: 2 }];
renderList(data);

debugger에서 멈추면 DevTools에서 items를 확인할 수 있다. 두 번째 객체에 name이 없어서 undefined가 나오는 걸 발견할 수 있다.


4. 에러 스택 읽기

에러가 발생했을 때 스택을 보면 어디서 문제가 시작됐는지 알 수 있다:

function innerFunc() {
    throw new Error("문제 발생");
}

function outerFunc() {
    innerFunc();
}

outerFunc();
// Error: 문제 발생
//     at innerFunc (script.js:2:11)
//     at outerFunc (script.js:6:5)
//     at script.js:9:1

스택을 따라가면 innerFunc에서 에러가 발생했고, 호출 경로를 역추적할 수 있다.


5. 비동기 코드 디버깅

비동기 작업에서 문제가 생겼을 때는 호출 순서를 확인해야 한다:

async function fetchData() {
    console.log("데이터 가져오기 시작");
    const response = await fetch("https://invalid-url");
    console.log("데이터 가져오기 완료");
    return response.json();
}

fetchData()
    .then(data => console.log(data))
    .catch(error => console.log("에러:", error.message));
// "데이터 가져오기 시작"
// "에러: Failed to fetch"

로그로 순서를 확인하니 fetch에서 문제가 생겼다는 걸 알 수 있다. URL이 잘못된 걸 바로 잡아낼 수 있었다.


6. 조건부 브레이크포인트 사용

DevTools에서 특정 조건에서만 멈추게 설정할 수 있다:

function processItems(items) {
    items.forEach(item => {
        let value = item.count * 2; // 여기서 조건부 브레이크포인트 (item.count === undefined일 때)
        console.log(value);
    });
}

const list = [{ count: 1 }, {}, { count: 3 }];
processItems(list);

DevTools에서 해당 줄에 브레이크포인트를 추가하고 조건을 item.count === undefined로 설정하면 두 번째 반복에서 멈춘다. 값을 확인하며 수정할 수 있다.


7. 객체 깊이 들여다보기

복잡한 객체를 다룰 때는 console.dir로 구조를 확인할 수 있다:

const user = {
    profile: {
        name: "홍길동",
        details: { age: undefined }
    }
};

console.dir(user, { depth: null });
// { profile: { name: "홍길동", details: { age: undefined } } }

depth: null 옵션으로 모든 중첩 구조를 펼쳐서 볼 수 있다. age가 undefined인 걸 바로 확인할 수 있었다.


8. 이벤트 리스너 확인

이벤트가 예상대로 작동하지 않을 때는 연결된 리스너를 점검해보자:

const button = document.querySelector("#myButton");
button.addEventListener("click", () => {
    console.log("클릭됨");
});

// DevTools에서 확인하려면:
console.log(getEventListeners(button));

Chrome DevTools 콘솔에서 getEventListeners를 사용하면 버튼에 붙은 리스너를 확인할 수 있다. 이벤트가 안 먹히면 여기서부터 점검하면 된다.


9. 성능 문제 추적

코드가 느려질 때는 실행 시간을 측정해보자:

function heavyTask() {
    console.time("heavyTask");
    let sum = 0;
    for (let i = 0; i < 1000000; i++) {
        sum += i;
    }
    console.timeEnd("heavyTask");
    return sum;
}

heavyTask();
// heavyTask: 2.345ms

console.timeconsole.timeEnd로 걸린 시간을 측정해서 병목 지점을 찾을 수 있다.


10. 네트워크 요청 점검

API 호출이 실패할 때는 요청과 응답을 확인해야 한다:

async function getUser() {
    try {
        const response = await fetch("https://api.example.com/user");
        console.log("응답 상태:", response.status);
        if (!response.ok) {
            throw new Error("네트워크 응답 실패");
        }
        return response.json();
    } catch (error) {
        console.log("에러:", error.message);
    }
}

getUser();

상태 코드를 확인하고, DevTools의 Network 탭에서 헤더와 응답 본문을 보면 문제를 더 명확히 파악할 수 있다.


11. 복잡한 상태 관리 디버깅

상태가 꼬였을 때는 변화를 추적해보자:

let state = { count: 0 };

function updateState(newValue) {
    console.log("이전 상태:", state);
    state = { ...state, count: newValue };
    console.log("새 상태:", state);
}

updateState(1);
updateState("2");
// "이전 상태:" { count: 0 }
// "새 상태:" { count: 1 }
// "이전 상태:" { count: 1 }
// "새 상태:" { count: "2" }

상태 변화를 로그로 남기니 문자열이 섞인 걸 바로 알 수 있었다.


12. 타임아웃과 경쟁 조건 확인

비동기 순서가 엉킬 때는 시간 차이를 점검하자:

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

let value = 0;
delay(1000).then(() => {
    value = 1;
    console.log("1초 후:", value);
});
delay(500).then(() => {
    console.log("0.5초 후:", value);
});
// "0.5초 후:" 0
// "1초 후:" 1

시간 차이를 로그로 확인하니 값이 덮어씌워지는 순서를 파악할 수 있었다.


13. 서드파티 라이브러리 문제 파악

외부 라이브러리가 이상할 때는 입력과 출력을 점검하자:

import thirdPartyFunc from "some-library";

function useLibrary(input) {
    console.log("입력:", input);
    const result = thirdPartyFunc(input);
    console.log("출력:", result);
    return result;
}

useLibrary({ key: null });

입출력을 확인하면 라이브러리가 null을 어떻게 처리하는지 알 수 있다.


14. 메모리 누수 확인

메모리 문제가 의심될 때는 참조를 추적해보자:

function createHandler() {
    let bigArray = new Array(1000000).fill(0);
    return () => console.log(bigArray.length);
}

const handler = createHandler();
setInterval(handler, 1000);

DevTools의 Memory 탭에서 힙 스냅샷을 찍으면 bigArray가 해제되지 않고 남아있는 걸 볼 수 있다.


마무리

디버깅은 단순히 문제를 찾는 데 그치지 않고, 코드를 더 깊이 이해하고 개선하는 과정이다. 콘솔 로그부터 DevTools, 비동기 흐름, 메모리 점검까지 다양한 도구와 접근법을 활용하면 어떤 상황에서도 침착하게 대처할 수 있다.


테스트 커버리지 (Test Coverage)

테스트 커버리지 (Test Coverage)

자바스크립트에서 코드를 테스트할 때, 얼마나 많은 부분이 테스트되었는지 확인하려면 테스트 커버리지가 중요한 지표가 된다. 테스트 커버리지는 코드의 품질을 높이고, 놓친 부분을 찾아내는 데 도움을 준다. 이번에는 테스트 커버리지의 기본 개념부터 심화된 활용까지 코드와 함께 자세히 알아보려고 한다.


테스트 커버리지를 잘 이해하고 활용하면 코드의 신뢰성을 한층 더 끌어올릴 수 있다. 하나씩 단계별로 풀어보자.


테스트 커버리지가 무엇인가

테스트 커버리지는 테스트가 코드의 어느 부분을 다루고 있는지 비율로 나타낸 것이다. 주로 라인 커버리지(Line Coverage), 분기 커버리지(Branch Coverage), 함수 커버리지(Function Coverage) 같은 지표로 측정된다:

function add(a, b) {
    return a + b;
}

console.log(add(2, 3));

위 코드를 테스트하면 몇 줄이 실행되었는지, 어떤 조건이 빠졌는지 커버리지로 확인할 수 있다.


1. Jest로 커버리지 측정 시작

Jest를 사용하면 테스트 커버리지를 쉽게 확인할 수 있다:

// sum.js
function sum(a, b) {
    if (a > 0) {
        return a + b;
    }
    return 0;
}

module.exports = sum;

// sum.test.js
const sum = require("./sum");

test("양수일 때 더한다", () => {
    expect(sum(2, 3)).toBe(5);
});

// package.json
{
    "scripts": {
        "test": "jest --coverage"
    }
}

npm test를 실행하면 커버리지 보고서가 생성된다. 여기서는 if 조건의 양수 케이스만 테스트되었고, 음수 케이스는 빠졌다.


2. 커버리지 보고서 해석

Jest의 커버리지 보고서는 네 가지 주요 열을 보여준다:

- % Stmts: 실행된 문장의 비율

- % Branch: 분기(조건)의 커버리지

- % Funcs: 함수 호출 커버리지

- % Lines: 라인 커버리지

위 코드의 보고서를 보면 return 0이 실행되지 않아 커버리지가 100% 미만일 것이다.


3. 커버리지 100%로 개선

테스트를 추가해 커버리지를 높여보자:

// sum.test.js
const sum = require("./sum");

test("양수일 때 더한다", () => {
    expect(sum(2, 3)).toBe(5);
});

test("음수일 때 0을 반환한다", () => {
    expect(sum(-1, 3)).toBe(0);
});

음수 조건을 추가했더니 모든 분기와 라인이 테스트되었다. 이제 커버리지가 100%에 가까워졌다.


4. 복잡한 로직의 커버리지

조건이 많은 코드를 테스트해보자:

// logic.js
function checkValue(x) {
    if (x > 0) {
        if (x < 10) {
            return "작은 양수";
        } else {
            return "큰 양수";
        }
    } else if (x === 0) {
        return "제로";
    } else {
        return "음수";
    }
}

module.exports = checkValue;

// logic.test.js
const checkValue = require("./logic");

test("작은 양수를 확인한다", () => {
    expect(checkValue(5)).toBe("작은 양수");
});

test("제로를 확인한다", () => {
    expect(checkValue(0)).toBe("제로");
});

이 상태로 커버리지를 확인하면 "큰 양수"와 "음수" 분기가 빠져서 100%가 안 된다. 모든 경우를 테스트해야 한다:

// logic.test.js (개선)
const checkValue = require("./logic");

test("작은 양수를 확인한다", () => {
    expect(checkValue(5)).toBe("작은 양수");
});

test("큰 양수를 확인한다", () => {
    expect(checkValue(15)).toBe("큰 양수");
});

test("제로를 확인한다", () => {
    expect(checkValue(0)).toBe("제로");
});

test("음수를 확인한다", () => {
    expect(checkValue(-5)).toBe("음수");
});

이제 모든 분기가 커버되었다.


5. 비동기 코드 커버리지

비동기 함수도 커버리지를 측정할 수 있다:

// async.js
async function fetchData(success) {
    if (success) {
        return await Promise.resolve("성공");
    }
    throw new Error("실패");
}

module.exports = fetchData;

// async.test.js
const fetchData = require("./async");

test("성공 시 데이터를 반환한다", async () => {
    const result = await fetchData(true);
    expect(result).toBe("성공");
});

test("실패 시 에러를 던진다", async () => {
    await expect(fetchData(false)).rejects.toThrow("실패");
});

성공과 실패 케이스를 모두 테스트해서 비동기 로직의 커버리지를 확보했다.


6. 모킹과 커버리지

외부 의존성을 모킹한 경우에도 커버리지를 확인할 수 있다:

// api.js
const axios = require("axios");

async function getData() {
    const response = await axios.get("https://api.example.com");
    if (response.data) {
        return response.data;
    }
    return "빈 데이터";
}

module.exports = getData;

// api.test.js
const getData = require("./api");
const axios = require("axios");
jest.mock("axios");

test("데이터를 반환한다", async () => {
    axios.get.mockResolvedValue({ data: "테스트 데이터" });
    const result = await getData();
    expect(result).toBe("테스트 데이터");
});

test("빈 데이터를 반환한다", async () => {
    axios.get.mockResolvedValue({ data: null });
    const result = await getData();
    expect(result).toBe("빈 데이터");
});

모킹을 통해 두 가지 분기를 모두 커버했다.


7. 커버리지 임계값 설정

Jest에서 커버리지 목표를 설정할 수 있다:

// package.json
{
    "jest": {
        "coverageThreshold": {
            "global": {
                "branches": 80,
                "functions": 90,
                "lines": 85,
                "statements": 85
            }
        }
    }
}

임계값을 넘지 못하면 테스트가 실패한다. 목표를 정해서 품질을 관리할 수 있다.


8. 커버리지와 코드 품질의 관계

커버리지가 높다고 반드시 코드가 완벽한 건 아니다:

function divide(a, b) {
    return a / b; // 0으로 나누는 경우 처리 없음
}

test("나눗셈을 한다", () => {
    expect(divide(6, 2)).toBe(3);
});

커버리지는 100%지만, b가 0일 때의 예외를 다루지 않았다. 커버리지는 도구일 뿐, 테스트의 질을 보장하려면 로직을 꼼꼼히 설계해야 한다.


9. 커버리지와 유지보수성에 미치는 영향

테스트 커버리지가 코드에 어떤 영향을 주는지 보자:

- 신뢰성: 높은 커버리지는 코드 변경 시 안정성을 높여준다.

- 유지보수성: 테스트가 덮지 못한 부분을 찾아 수정할 수 있다.

분기라인을 모두 고려해 테스트를 설계하는 점이 핵심이다.


마무리

테스트 커버리지는 코드의 테스트된 범위를 알려주며, 품질을 높이는 데 중요한 역할을 한다. 기본적인 측정부터 복잡한 로직, 비동기, 모킹까지 다루며 커버리지를 높이면 코드에 대한 자신감이 생긴다.


DevTools 심화 (Advanced DevTools Usage)

DevTools 심화 (Advanced DevTools Usage)

브라우저의 DevTools는 단순한 디버깅을 넘어 코드 분석, 성능 최적화, 네트워크 모니터링까지 가능한 강력한 도구다. 자바스크립트 개발에서 DevTools를 깊이 활용하면 문제를 빠르게 파악하고 해결할 수 있다. 이번에는 DevTools의 기본 기능부터 심화된 활용까지 자세히 풀어보려고 한다.


DevTools를 잘 다루면 개발 흐름이 한결 매끄러워진다. 하나씩 단계별로 살펴보자.


DevTools란 무엇인가

DevTools는 Chrome, Firefox, Edge 같은 현대 브라우저에 내장된 개발자 도구다. 콘솔 로그 출력부터 DOM 조작, 성능 프로파일링까지 다양한 기능을 제공한다. 기본적으로 F12나 오른쪽 클릭 후 "검사"로 열 수 있다:

console.log("DevTools 열기");

이 간단한 로그도 DevTools 콘솔에서 바로 확인할 수 있다. 이제부터 더 깊이 들어가 보자.


1. 콘솔로 코드 디버깅

콘솔은 단순 로그를 넘어 변수 상태를 확인하거나 즉석에서 코드를 실행할 수 있다:

let count = 0;
function increment() {
    count += 1;
    console.log("현재 값:", count);
}

increment();
increment();
// 콘솔에서 직접 입력
count // 2
count = 10;
increment();
// "현재 값: 11"

콘솔에서 변수 값을 확인하고 수정하며 함수를 호출해보았다. 실시간으로 변화를 볼 수 있다.


2. 소스 탭으로 브레이크포인트 설정

소스 탭에서 브레이크포인트를 설정하면 코드 실행을 멈추고 상태를 분석할 수 있다:

function calculateSum(n) {
    let sum = 0;
    for (let i = 0; i < n; i++) {
        sum += i;
    }
    return sum;
}

console.log(calculateSum(5));

DevTools에서 소스 탭을 열고, sum += i 줄에 브레이크포인트를 추가한다. 코드를 실행하면 해당 줄에서 멈추고, 변수 sumi의 값을 실시간으로 확인할 수 있다.


3. 네트워크 탭으로 요청 분석

네트워크 탭은 API 호출이나 리소스 로딩 상태를 확인하는 데 유용하다:

fetch("https://jsonplaceholder.typicode.com/posts/1")
    .then(response => response.json())
    .then(data => console.log(data.title));

네트워크 탭에서 요청 상태(200 OK), 응답 시간, 헤더, 반환된 JSON 데이터를 모두 볼 수 있다. 느린 요청을 찾아 최적화 포인트를 잡아내는 데도 활용된다.


4. 성능 탭으로 병목 지점 찾기

성능 탭은 코드 실행 속도를 분석한다:

function heavyTask() {
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
        result += i;
    }
    console.log(result);
}

heavyTask();

성능 탭에서 "Record"를 누르고 실행하면, 함수 호출 시간과 CPU 사용량을 그래프로 확인할 수 있다. 긴 실행 시간을 줄일 방법을 고민해볼 수 있다.


5. 요소 탭으로 DOM 조작

요소 탭은 HTML 구조와 스타일을 실시간으로 수정할 수 있다:

<div id="box" style="width: 100px; height: 100px; background: red;"></div>

<script>
    const box = document.getElementById("box");
    box.style.backgroundColor = "blue";
</script>

요소 탭에서 #box를 선택하고, 스타일 패널에서 색상을 변경하거나 속성을 추가해보면 실시간으로 반영된다.


6. 메모리 탭으로 누수 탐지

메모리 탭은 메모리 사용량과 누수를 분석한다:

function leakMemory() {
    let arr = [];
    for (let i = 0; i < 100000; i++) {
        arr.push(new Array(1000));
    }
    // arr이 참조 해제되지 않음
}

leakMemory();

메모리 탭에서 힙 스냅샷을 찍으면, arr이 메모리를 계속 잡고 있는 것을 확인할 수 있다. 참조를 해제하는 방법을 찾는 데 유용하다.


7. 애플리케이션 탭으로 저장소 관리

애플리케이션 탭은 로컬 스토리지, 세션 스토리지, 쿠키를 확인하고 수정할 수 있다:

localStorage.setItem("user", "홍길동");
sessionStorage.setItem("temp", "임시 데이터");
document.cookie = "id=123; expires=Fri, 31 Dec 2025 23:59:59 GMT";

console.log(localStorage.getItem("user"));

애플리케이션 탭에서 저장된 값을 보고, 필요하면 직접 삭제하거나 수정할 수 있다.


8. 커서와 명령어 팔레트 활용

DevTools의 숨겨진 기능 중 하나는 명령어 팔레트다. Ctrl+Shift+P(또는 Cmd+Shift+P)를 누르면 열린다:

// 콘솔에서 실행
console.time("loop");
for (let i = 0; i < 100000; i++) {}
console.timeEnd("loop");

명령어 팔레트에서 "Show Performance"를 입력해 성능 탭으로 바로 이동하거나, "Disable JavaScript"로 JS를 비활성화해볼 수 있다.


9. 사용자 정의 스니펫 추가

소스 탭의 Snippets로 자주 쓰는 코드를 저장하고 실행할 수 있다:

// Snippets에 저장
function logAllElements() {
    document.querySelectorAll("*").forEach(el => {
        console.log(el.tagName);
    });
}

logAllElements();

Snippets에 추가한 후, 페이지에서 실행하면 모든 요소의 태그 이름을 출력한다.


10. 반응형 디자인 테스트

DevTools의 장치 도구 모음으로 다양한 화면 크기를 시뮬레이션할 수 있다:

<style>
    @media (max-width: 600px) {
        .container { font-size: 14px; }
    }
</style>

<div class="container">테스트</div>

장치 도구 모음에서 "iPhone X"나 "iPad"를 선택하면, 미디어 쿼리가 적용된 모습을 바로 확인할 수 있다.


11. 성능과 생산성에 미치는 영향

DevTools가 개발에 어떤 영향을 주는지 보자:

- 성능: 병목 지점을 찾아 최적화하면 페이지 로딩 속도가 빨라진다.

- 생산성: 실시간 디버깅과 분석으로 문제 해결 시간이 단축된다.

브레이크포인트와 성능 분석으로 코드 흐름을 깊이 이해하는 점이 핵심이다.


마무리

DevTools는 단순한 도구를 넘어 개발의 모든 단계를 지원한다. 콘솔, 소스, 네트워크, 성능, 메모리 등 다양한 탭을 활용하면 코드의 품질과 효율성을 크게 높일 수 있다.


Jest 사용법 (Using Jest)

Jest 사용법 (Using Jest)

자바스크립트에서 테스트를 쉽게 작성하고 관리하려면 Jest가 훌륭한 도구로 자리잡고 있다. Jest는 간단한 설정과 강력한 기능으로 코드의 신뢰성을 높여준다. 이번에는 Jest의 기본 사용법부터 심화된 기능까지 코드와 함께 자세히 풀어보려고 한다.


Jest를 잘 활용하면 테스트 과정을 단순화하고, 코드 품질을 한층 끌어올릴 수 있다. 단계별로 하나씩 알아보자.


Jest란 무엇인가

Jest는 Facebook에서 만든 오픈소스 테스트 프레임워크로, 설정이 거의 필요 없고 빠르게 시작할 수 있다. 기본적으로 단위 테스트를 지원하며, 스냅샷 테스트, 모킹 같은 기능도 제공한다:

// 설치: npm install --save-dev jest
function sum(a, b) {
    return a + b;
}

module.exports = sum;

간단한 함수를 작성한 후, 이를 테스트해보자.


1. 첫 번째 Jest 테스트 작성

Jest로 테스트 파일을 만들고 실행해보자:

// sum.test.js
const sum = require("./sum");

test("2와 3을 더하면 5가 된다", () => {
    expect(sum(2, 3)).toBe(5);
});

test("0과 0을 더하면 0이 된다", () => {
    expect(sum(0, 0)).toBe(0);
});

package.json에 스크립트를 추가하고 실행한다:

// package.json
{
    "scripts": {
        "test": "jest"
    }
}
// 실행: npm test

expecttoBe로 기대값과 실제 값을 비교했다. 테스트가 통과하면 결과를 콘솔에서 확인할 수 있다.


2. 비동기 코드 테스트

Jest는 비동기 함수도 쉽게 다룰 수 있다:

function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => resolve("데이터"), 1000);
    });
}

test("1초 후 데이터를 반환한다", async () => {
    const data = await fetchData();
    expect(data).toBe("데이터");
});

async/await를 사용해 비동기 결과를 기다린 후 검증했다.


3. 모킹(Mocking)으로 의존성 제어

Jest의 모킹 기능을 활용해 외부 의존성을 대체해보자:

// user.js
const axios = require("axios");

async function getUser() {
    const response = await axios.get("https://api.example.com/user");
    return response.data;
}

module.exports = getUser;

// user.test.js
const getUser = require("./user");
const axios = require("axios");
jest.mock("axios");

test("사용자 데이터를 가져온다", async () => {
    axios.get.mockResolvedValue({ data: "가짜 유저" });
    const result = await getUser();
    expect(result).toBe("가짜 유저");
});

jest.mockmockResolvedValue로 실제 네트워크 요청 없이 테스트를 진행했다.


4. 스냅샷 테스트

Jest의 스냅샷 테스트로 UI나 데이터 구조를 확인해보자:

function renderUser(name, age) {
    return { name, age };
}

test("유저 객체가 예상대로 렌더링된다", () => {
    const user = renderUser("홍길동", 30);
    expect(user).toMatchSnapshot();
});

첫 실행 시 스냅샷이 생성되고, 이후 변경 사항을 감지한다.


5. 에러 처리 테스트

에러가 발생하는 상황을 테스트해보자:

function divide(a, b) {
    if (b === 0) throw new Error("0으로 나눌 수 없다");
    return a / b;
}

test("0으로 나누면 에러가 발생한다", () => {
    expect(() => divide(5, 0)).toThrow("0으로 나눌 수 없다");
});

test("정상적인 나눗셈은 결과를 반환한다", () => {
    expect(divide(6, 2)).toBe(3);
});

toThrow로 에러를 검증하고, 정상 동작도 확인했다.


6. 매처(Matchers) 활용

Jest는 다양한 매처를 제공한다:

function getItems() {
    return [1, 2, 3];
}

test("배열과 객체를 테스트한다", () => {
    expect(getItems()).toContain(2);
    expect(getItems()).toHaveLength(3);
    expect(5).toBeGreaterThan(3);
    expect("hello").toMatch(/ell/);
});

toContain, toHaveLength 등으로 조건을 다양하게 검증했다.


7. 설정 커스터마이징

Jest 설정을 변경해 테스트 환경을 조정해보자:

// jest.config.js
module.exports = {
    testEnvironment: "node",
    verbose: true,
    testMatch: ["**/*.test.js"]
};

환경을 Node로 설정하고, 자세한 로그를 출력하도록 했다.


8. 커버리지 확인

Jest로 코드 커버리지를 측정해보자:

// package.json
{
    "scripts": {
        "test": "jest --coverage"
    }
}

// sum.js
function sum(a, b) {
    if (a > 0) {
        return a + b;
    }
    return 0;
}

// sum.test.js
const sum = require("./sum");

test("양수일 때 더한다", () => {
    expect(sum(2, 3)).toBe(5);
});

--coverage 옵션으로 실행하면, 테스트가 덮지 못한 분기(여기서는 음수 조건)를 알려준다.


9. 성능과 편의성에 미치는 영향

Jest가 테스트에 어떤 영향을 주는지 보자:

- 성능: 병렬 실행으로 빠르게 처리되며, 캐싱으로 반복 실행 속도가 빨라진다.

- 편의성: 설정이 간단하고, 풍부한 API로 테스트 작성이 직관적이다.

expect와 모킹으로 복잡한 로직도 쉽게 검증할 수 있는 점이 핵심이다.


마무리

Jest는 테스트 작성을 단순화하면서도 강력한 기능을 제공한다. 기본 테스트부터 비동기, 모킹, 스냅샷, 커버리지까지 다채롭게 활용할 수 있다.


유닛 테스트 기초 (Unit Testing Basics)

유닛 테스트 기초 (Unit Testing Basics)

자바스크립트에서 코드를 작성할 때, 제대로 작동하는지 확인하는 방법 중 하나로 유닛 테스트가 있다. 유닛 테스트는 코드의 작은 단위를 독립적으로 검증하며, 전체 애플리케이션의 안정성을 높여준다. 이번에는 유닛 테스트의 기본 개념부터 심화된 활용까지 코드와 함께 자세히 알아보려고 한다.


유닛 테스트를 잘 다루면 코드의 신뢰성을 높이고, 수정할 때도 안심할 수 있다. 하나씩 단계별로 풀어보자.


유닛 테스트란 무엇인가

유닛 테스트는 함수나 모듈 같은 코드의 개별 단위를 테스트하는 방법이다. 전체 시스템을 한 번에 확인하는 대신, 작은 조각들을 따로 검증한다:

function add(a, b) {
    return a + b;
}

console.log(add(2, 3)); // 5

위 함수를 테스트하려면 입력값을 넣고 출력값이 예상과 같은지 확인하면 된다. 이 과정을 자동화하는 도구를 사용하면 편리하다.


1. 간단한 유닛 테스트 작성

자바스크립트에서 유닛 테스트를 위해 assert 모듈을 활용해보자:

const assert = require("assert");

function add(a, b) {
    return a + b;
}

assert.strictEqual(add(2, 3), 5, "2 + 3은 5여야 한다");
assert.strictEqual(add(0, 0), 0, "0 + 0은 0이어야 한다");
console.log("모든 테스트 통과!");

assert.strictEqual로 결과값과 기대값을 비교했다. 오류가 없으면 메시지가 출력된다.


2. 테스트 프레임워크 사용: Mocha

수동으로 assert를 쓰는 대신, Mocha 같은 테스트 프레임워크를 사용하면 더 체계적으로 관리할 수 있다:

// 설치: npm install mocha
const assert = require("assert");

describe("add 함수 테스트", () => {
    it("2와 3을 더하면 5가 된다", () => {
        function add(a, b) {
            return a + b;
        }
        assert.strictEqual(add(2, 3), 5);
    });

    it("0과 0을 더하면 0이 된다", () => {
        function add(a, b) {
            return a + b;
        }
        assert.strictEqual(add(0, 0), 0);
    });
});

describe로 테스트 그룹을 만들고, it으로 개별 테스트를 정의했다. npm test로 실행하면 결과를 볼 수 있다.


3. 비동기 함수 테스트

비동기 코드를 테스트하려면 약간의 추가 작업이 필요하다:

const assert = require("assert");

function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => resolve("데이터"), 1000);
    });
}

describe("비동기 함수 테스트", () => {
    it("1초 후 데이터를 반환한다", async () => {
        const result = await fetchData();
        assert.strictEqual(result, "데이터");
    });
});

asyncawait를 사용해서 비동기 결과를 기다린 후 검증했다.


4. 모킹(Mocking)으로 의존성 관리

외부 의존성이 있는 코드를 테스트할 때, sinon 같은 라이브러리로 모킹을 해보자:

// 설치: npm install sinon mocha --save-dev
const assert = require("assert");
const sinon = require("sinon");

function getUserData(fetch) {
    return fetch("https://api.example.com/user");
}

describe("getUserData 테스트", () => {
    it("API 호출 결과를 반환한다", async () => {
        const fakeFetch = sinon.stub().resolves("유저 데이터");
        const result = await getUserData(fakeFetch);
        assert.strictEqual(result, "유저 데이터");
    });
});

sinon.stub으로 가짜 함수를 만들어 실제 API 호출 없이 테스트했다.


5. 에러 상황 테스트

함수가 에러를 잘 처리하는지 확인해보자:

const assert = require("assert");

function divide(a, b) {
    if (b === 0) throw new Error("0으로 나눌 수 없다");
    return a / b;
}

describe("divide 함수 테스트", () => {
    it("0으로 나누면 에러가 발생한다", () => {
        assert.throws(() => divide(5, 0), {
            message: "0으로 나눌 수 없다"
        });
    });

    it("정상적인 나눗셈은 결과를 반환한다", () => {
        assert.strictEqual(divide(6, 2), 3);
    });
});

assert.throws로 에러를 검증하고, 정상 동작도 확인했다.


6. 복잡한 객체 테스트

객체를 다룰 때는 깊은 비교가 필요하다:

const assert = require("assert");

function createUser(name, age) {
    return { name, age };
}

describe("createUser 테스트", () => {
    it("이름과 나이를 포함한 객체를 반환한다", () => {
        const expected = { name: "홍길동", age: 30 };
        const result = createUser("홍길동", 30);
        assert.deepStrictEqual(result, expected);
    });
});

deepStrictEqual로 객체의 속성을 모두 비교했다.


7. 테스트 더블(Test Double) 활용

테스트 더블로 복잡한 의존성을 대체해보자:

const assert = require("assert");

class Database {
    getData() {
        return "실제 데이터";
    }
}

class Service {
    constructor(db) {
        this.db = db;
    }
    fetch() {
        return this.db.getData();
    }
}

describe("Service 테스트", () => {
    it("가짜 DB로 데이터 가져오기", () => {
        const fakeDb = { getData: () => "가짜 데이터" };
        const service = new Service(fakeDb);
        assert.strictEqual(service.fetch(), "가짜 데이터");
    });
});

가짜 객체를 만들어 실제 데이터베이스 없이 테스트를 진행했다.


8. 반복 테스트 자동화

여러 입력값을 한 번에 테스트하려면 반복문을 활용한다:

const assert = require("assert");

function multiply(a, b) {
    return a * b;
}

describe("multiply 함수 테스트", () => {
    const cases = [
        [2, 3, 6],
        [0, 5, 0],
        [1, 1, 1]
    ];
    cases.forEach(([a, b, expected]) => {
        it(`${a}와 ${b}를 곱하면 ${expected}가 된다`, () => {
            assert.strictEqual(multiply(a, b), expected);
        });
    });
});

배열을 순회하며 여러 경우를 자동으로 테스트했다.


9. 성능과 유지보수성에 미치는 영향

유닛 테스트가 코드에 어떤 영향을 주는지 살펴보자:

- 성능: 테스트 실행에 시간이 걸릴 수 있지만, 버그를 줄여 전체 개발 속도를 높인다.

- 유지보수성: 코드 변경 후에도 테스트로 바로 확인할 수 있어 안정성이 유지된다.

작은 단위를 테스트하며 전체 시스템의 신뢰성을 확보하는 점이 핵심이다.


마무리

유닛 테스트는 코드의 작은 부분을 검증하며 전체 품질을 높여준다. 기본적인 함수 테스트부터 비동기, 모킹, 에러 처리까지 다양한 상황에서 활용할 수 있다. 꾸준히 작성하면 코드에 대한 자신감이 생길 것이다.


+ Recent posts