[ThisVsThat Project] 닉네임 중복 검사 캐싱으로 API 호출 줄이기

킴카·2025년 12월 14일

ThisVsThat Project

목록 보기
4/5

목표와 전제

회원가입 과정에서 닉네임 중복 여부를 입력 즉시 안내하고 싶었다.

하지만 타이핑할 때마다 서버에 요청을 보내는 구조는 불필요한 API 호출 증가로 이어질 수 있다.

그래서 다음 원칙을 세웠다.

  • 입력 중에는 즉각적인 피드백 제공
  • 서버 요청은 최소화
  • 최종 제출 시점에는 항상 서버 기준으로 재검증

1. 문제 상황

닉네임 입력 필드는 input 이벤트를 사용하고 있었고,

아무런 제어 없이 구현하면 한 글자 입력할 때마다 중복 검사 API가 호출된다.

t → te → tes → test

이 과정에서 서버는 같은 목적의 요청을 여러 번 처리하게 되고, 불필요한 API 호출이 반복적으로 발생할 수 있다.


💡 아래에는 설명에 필요한 로직만 발췌해 정리했다.


2. 1차 개선: 디바운싱(300ms)

가장 먼저 적용한 것은 디바운싱이다.

사용자가 타이핑을 멈춘 뒤 일정 시간(300ms)이 지나면 그때만 서버 요청을 보낸다.

let nicknameCheckTimer = null;

nicknameInput.addEventListener("input", () => {
  const nickname = nicknameInput.value.trim();
  clearTimeout(nicknameCheckTimer);

  nicknameCheckTimer = setTimeout(() => {
    // 닉네임 중복 검사 호출
  }, 300);
});

이것만으로도 연속 입력으로 발생하는 요청 대부분을 줄일 수 있다.

하지만 여전히 문제가 하나 남아 있었다.


3. 2차 개선: 캐싱(Map)

사용자가 이런 식으로 입력하는 경우를 생각해보자.

test → test1 → test

디바운싱을 적용해도, 이미 검사했던 "test"에 대해 다시 서버 요청이 발생한다.

그래서 이전 검사 결과를 메모리 캐시(Map)에 저장했다.

const nicknameCache = new Map(); // nickname -> duplicate 여부
async function checkNicknameDuplicate(nickname) {
  // 1) 캐시가 있으면 서버 요청 없이 반환
  if (nicknameCache.has(nickname)) {
    return nicknameCache.get(nickname);
  }

  // 2) 서버 요청
  const response = await fetch(`/auth/check-nickname?nickname=${encodeURIComponent(nickname)}`);
  const { duplicate } = await response.json();

  // 3) 결과 캐싱
  nicknameCache.set(nickname, duplicate);
  return duplicate;
}

이렇게 하면 같은 닉네임을 다시 입력했을 때 API 호출을 생략할 수 있다.


4. 3차 개선: 캐시 유효기간(TTL) + 최종 제출 강제 재검증

1) 캐시 유효기간이 필요한 이유

캐시는 편리하지만, 항상 최신 상태를 보장하지는 않는다.

1. A 사용자가 "test123" 검사 → 사용 가능 (캐싱)
2. B 사용자가 같은 닉네임으로 회원가입 완료
3. A 사용자가 다시 "test123" 입력 → 캐시 사용 → 사용 가능 표시 ⚠️

이를 방지하기 위해 캐시에 유효기간(TTL)을 두었다.

const nicknameCache = new Map(); // nickname -> { duplicate, timestamp }
const CACHE_EXPIRY_TIME = 30_000; // 30초
async function checkNicknameDuplicate(nickname, forceRefresh = false) {
  // 캐시 확인 (강제 재검증이 아닐 때만)
  if (!forceRefresh && nicknameCache.has(nickname)) {
    const cached = nicknameCache.get(nickname);
    
    // TTL 유효하면 캐시 반환
    if (Date.now() - cached.timestamp < CACHE_EXPIRY_TIME) {
      return cached.duplicate;
    }
    
    // TTL 만료 → 명시적으로 캐시 무효화
    nicknameCache.delete(nickname);
  }

  // 서버 요청
  try {
    const res = await fetch(`/auth/check-nickname?nickname=${encodeURIComponent(nickname)}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    
    const { duplicate } = await res.json();

    nicknameCache.set(nickname, {
      duplicate,
      timestamp: Date.now(),
    });

    return duplicate;
  } catch {
    return null; // 검사 실패
  }
}

2) 최종 제출 시에는 반드시 서버 재검증

입력 중 검사는 UX를 위한 참고 정보일 뿐이고, 데이터가 확정되는 순간은 폼 제출 시점이다.

그래서 최종 제출 시에는 캐시를 무시하고 서버를 다시 확인한다.

form.addEventListener("submit", async (e) => {
  e.preventDefault();

  const nickname = nicknameInput.value.trim();
  const isDuplicate = await checkNicknameDuplicate(nickname, true); // 강제 재검증

  if (isDuplicate === null) {
    alert("닉네임 확인 중 오류가 발생했습니다.");
    return;
  }

  if (isDuplicate) {
    showError("이미 사용 중인 닉네임입니다.");
    return;
  }

  // 여기서부터 실제 회원가입 로직 (생략)
});

5. 에러 처리

fetch는 네트워크 오류에만 reject되고, 4xx/5xx 응답은 정상적으로 resolve된다.

그래서 res.ok로 HTTP 상태를 명시적으로 확인하고,

예외 발생 시 catch 블록에서 null을 반환하도록 했다.

try {
  const res = await fetch(...);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // ...
} catch {
  return null; // 검사 실패
}

이렇게 하면 사용 가능(false) / 중복(true) / 확인 불가(null) 세 가지 상태를 구분할 수 있다.

만약 에러를 false로 처리하면, 네트워크 장애 상황에서도 중복이 아닌 것으로 판단되어 잘못된 안내가 발생할 수 있다.


6. 추가로 고려할 수 있는 점: Race Condition

입력이 빠르게 변경되는 상황에서는 여러 중복 검사 요청이 동시에 진행될 수 있다.

이때 이전 요청이 늦게 도착하면, 최신 입력 상태를 과거 결과로 덮는 race condition 문제가 발생할 수 있다.

예를 들어 다음과 같은 방식으로 보완하는 것도 가능하다.

① 요청 ID 비교 방식 (논리적 필터링)

  • 요청은 모두 전송하되
  • 응답이 도착했을 때, 최신 요청이 아니라면 UI 반영을 무시한다
let currentRequestId = 0;

// input 이벤트 리스너 내부에서
nicknameCheckTimer = setTimeout(async () => {
  const requestId = ++currentRequestId;

  const isDuplicate = await checkNicknameDuplicate(nickname);

  if (requestId !== currentRequestId) return; // 오래된 응답 무시

  // UI 업데이트
}, 300);

② AbortController 방식 (요청 취소)

  • 새로운 요청이 발생하면
  • 이전 요청을 중간에 취소해 불필요한 처리를 줄인다
let abortController = null;

// input 이벤트 리스너 내부에서
nicknameCheckTimer = setTimeout(async () => {
  abortController?.abort();
  abortController = new AbortController();

  const isDuplicate = await checkNicknameDuplicate(
    nickname,
    false,
    abortController.signal
  );

  if (isDuplicate === null) return;

  // UI 업데이트
}, 300);

7. 정리

닉네임 중복 검사는 단순해 보이지만, 입력 빈도가 높고 서버 확인이 필요한 기능이라 생각보다 고려할 부분이 많았다.

디바운싱을 적용해 입력 중 발생하는 불필요한 API 호출을 줄일 수 있었고, 캐싱을 추가하면서 같은 값을 다시 입력하는 경우에는 서버 요청을 생략할 수 있었다.

다만 캐시를 사용하면 결과가 최신 상태와 어긋날 수 있어 유효기간을 두고 최종 제출 시에는 서버 기준으로 다시 확인하도록 했다.

또한 서버 응답을 신뢰할 수 없는 상황에서는 중복 여부를 곧바로 확정하지 않도록 하여 잘못된 안내가 발생할 가능성을 줄일 수 있었다.

입력이 빠르게 변경되는 상황에서 발생할 수 있는 race condition 문제도 살펴보면서, 입력 기반 API 호출에서는 응답 순서나 예외 상황까지 고려해야 한다는 점을 정리할 수 있었다.

profile
고민하고 공부하며 기록하자🔥

0개의 댓글