JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기

2026.03.27·수정 2026.09.15·약 29분·작성: 해비·블로그 소개

이 글에서 정리하는 내용

저는 URL 파라미터가 무엇인지부터 시작해, JavaScript에서 값을 읽고 수정하는 방법, 그리고 퍼블리셔 실무에서 바로 쓸 수 있는 활용 패턴까지 한 흐름으로 정리하겠습니다. 이 글을 끝까지 보면 이벤트 페이지의 탭 유지, 필터 상태 보존, UTM 파라미터 확인, 공유 링크 정리 같은 작업을 문자열 분해 대신 표준 API로 안정적으로 처리하는 기준을 잡을 수 있습니다.

URL 파라미터가 무엇인지 먼저 이해하기

image 1557e6f8 68d6 484a aedd d4eb6fa18c55

저는 퍼블리싱 실무에서 URL을 볼 때, 먼저 주소 자체와 상태 정보를 분리해서 봅니다. 예를 들어 example.com/event?tab=review&page=2에서 물음표 뒤 영역은 쿼리 문자열이고, 여기 들어 있는 tab=review, page=2 같은 값이 파라미터입니다. 이 구조를 잘 쓰면 현재 탭 상태, 목록 페이지 번호, 검색 조건, 광고 유입 정보 같은 것을 주소에 남길 수 있습니다. 그래서 새로고침을 해도 상태를 어느 정도 유지할 수 있고, 링크를 복사해 전달해도 같은 화면 문맥을 공유하기 쉬워집니다. 결국 URL 파라미터는 주소 뒤에 붙는 부가 문자가 아니라, 현재 화면 문맥을 설명하는 작은 상태 저장소처럼 볼 수 있습니다.

문자열로 직접 자르기보다 URL 객체를 먼저 떠올리기

const currentUrl = new URL(window.location.href);
const params = currentUrl.searchParams;
console.log(currentUrl.pathname);
console.log(params.toString());

저는 이 코드에서 먼저 window.location.href가 현재 페이지의 전체 주소라는 점부터 이해하면 좋다고 봅니다. 그 값을 new URL()에 넣으면 브라우저가 이해하기 쉬운 URL 객체가 만들어집니다. 그리고 그 안의 searchParams는 URL 파라미터만 다루는 전용 상자처럼 생각하면 편합니다. 마지막 두 줄은 경로와 파라미터를 각각 분리해서 확인하는 예시입니다. 즉, 이 코드는 주소 전체를 한 번 구조화한 뒤 필요한 부분만 안전하게 꺼내 쓰기 위한 출발점입니다.

location.search와 toString() 결과가 다르게 보이는 이유

console.log(window.location.search);
const paramsText = new URLSearchParams(window.location.search).toString();
console.log(paramsText);

저는 여기서 결과가 달라 보여도 놀라지 않는 것이 중요하다고 봅니다. window.location.search는 원래 주소 문자열의 일부라서 물음표를 포함하고, URLSearchParams.toString()은 파라미터 내용만 다시 문자열로 만든 값이라서 물음표가 없습니다. 그래서 둘은 비슷해 보여도 용도가 조금 다릅니다. 화면에 현재 주소 조각을 그대로 보여주고 싶을 때는 전자를, 파라미터를 조합하거나 다른 링크에 붙일 때는 후자를 떠올리면 됩니다.

JavaScript에서 파라미터 읽기

저는 파라미터를 읽을 때 가장 먼저 get(), has(), getAll() 세 가지를 구분합니다. 단일 값이 필요한지, 존재 여부만 확인하면 되는지, 같은 키가 여러 번 들어올 수 있는지에 따라 선택이 달라지기 때문입니다. 이벤트 페이지나 운영 페이지에서는 단일 탭 값처럼 하나만 있으면 되는 경우가 많지만, 필터 조건은 같은 이름의 키가 여러 번 붙는 경우도 있습니다. 이 구분을 알고 있어야 URL 파라미터를 읽을 때 의도와 실제 결과가 어긋나지 않습니다.

가장 자주 쓰는 읽기 패턴

const url = new URL(window.location.href);
const params = url.searchParams;
const tab = params.get('tab');
const page = params.get('page');
const hasUtm = params.has('utm_source');
const categories = params.getAll('category');
console.log(tab);
console.log(page);
console.log(hasUtm);
console.log(categories);

저는 이 코드가 처음에는 길어 보여도, 사실 네 가지 질문을 순서대로 던지는 구조라고 설명하고 싶습니다. 첫째, 현재 주소에서 URL 파라미터를 읽을 준비를 합니다. 둘째, tabpage처럼 한 개만 들어올 값을 읽습니다. 셋째, utm_source가 아예 붙어 있는지 여부만 확인합니다. 넷째, category처럼 같은 이름이 여러 번 들어올 수 있는 값은 배열로 한꺼번에 받습니다. 즉 get()은 값 하나, has()는 존재 여부, getAll()은 같은 이름의 여러 값을 읽는 용도라고 정리하면 됩니다.

메서드 언제 쓰면 좋은가
get() 탭, 페이지 번호, 정렬값처럼 하나만 읽을 때
getAll() 복수 필터처럼 같은 키가 여러 번 붙을 때

파라미터 수정하고 주소에 반영하기

const url = new URL(window.location.href);
const params = url.searchParams;
params.set('tab', 'notice');
params.set('page', '1');
params.delete('popup');
params.append('category', 'ring');
history.replaceState(history.state, '', url.toString());

저는 이 코드를 볼 때 수정 동작을 세 갈래로 나눠서 이해하면 쉽다고 봅니다. set()은 값을 새로 지정하는 동작이고, 이미 같은 이름이 있으면 덮어씁니다. delete()는 필요 없는 URL 파라미터를 지우는 동작입니다. append()는 같은 이름의 값을 하나 더 붙이는 동작입니다. 마지막의 history.replaceState()는 바뀐 URL 파라미터를 브라우저 주소줄에 반영하지만, 페이지 자체를 새로 열지는 않습니다. 그래서 탭, 정렬, 필터 같은 상태를 조용히 바꾸고 싶을 때 유용합니다.

기존 URL 파라미터를 유지한 채 일부 값만 바꾸기

const url = new URL(window.location.href);
url.searchParams.set('sort', 'latest');
url.searchParams.set('page', '1');
const nextLink = url.toString();
console.log(nextLink);

저는 이 패턴을 실무에서 특히 자주 씁니다. 이미 주소에 utm_sourcetab 같은 URL 파라미터가 붙어 있는 상태에서 정렬값 하나만 바꾸고 싶을 때, 기존 URL 파라미터를 통째로 날리지 않고 필요한 값만 수정할 수 있기 때문입니다. 문자열을 다시 조립하는 방식은 빠르게 보이지만, 실제로는 기존 값 누락이 자주 생깁니다. 그래서 운영 페이지에서는 전체를 새로 쓰기보다 기존 URL 파라미터를 유지한 채 일부만 수정하는 방식이 더 안전합니다. 특히 링크 추적값이 있는 페이지에서는 이 차이가 더 크게 느껴집니다.

링크를 새로 만들 때는 URL 객체를 끝까지 활용하기

const targetUrl = new URL('/event/detail', window.location.origin);
targetUrl.searchParams.set('tab', 'review');
targetUrl.searchParams.set('page', '2');
targetUrl.searchParams.set('utm_source', 'newsletter');
const finalLink = targetUrl.toString();
console.log(finalLink);

저는 링크를 조합할 때도 문자열을 이어 붙이기보다 new URL()로 기준 주소를 만든 뒤 searchParams를 채워 넣는 편을 선호합니다. 이렇게 하면 기존 주소에 물음표가 있는지 없는지, 앰퍼샌드를 붙여야 하는지 같은 사소하지만 자주 발생하는 실수를 줄일 수 있습니다. 운영 배너 링크를 여러 개 관리할 때 특히 안정적입니다. 이 과정을 코드로 만드는 흐름이 일정해지면, 다음 작업자도 규칙을 빠르게 파악할 수 있습니다. 주니어 개발자 관점에서도 읽는 순서가 자연스럽기 때문에 디버깅이 쉬운 편입니다.

실무에 적용하기

URLSearchParams 사용법: JavaScript로 URL 파라미터 다루기 적용 흐름을 설명하는 두 번째 본문 이미지

저는 퍼블리셔가 URL 파라미터를 단순 개발 문법이 아니라, 운영 효율을 높이는 도구로 보면 좋다고 생각합니다. 실제로는 화면 분기, 링크 보존, 유입 추적, 임시 상태 유지 같은 작업이 훨씬 자주 등장합니다. 특히 마케팅 페이지와 운영 목록 화면은 URL 파라미터를 어떻게 다루느냐에 따라 사용 경험과 분석 정확도가 함께 달라질 수 있습니다.

UTM 유지, 탭 상태 유지, 공유 링크 정리

const url = new URL(window.location.href);
const params = url.searchParams;
const utmSource = params.get('utm_source');
const utmCampaign = params.get('utm_campaign');
const activeTab = params.get('tab') || 'info';
if (utmSource === 'newsletter') { document.body.dataset.inflow = 'newsletter';
} // 버튼들 중에서 현재 URL 파라미터와 같은 탭만 활성화합니다.
document.querySelectorAll('[data-tab]').forEach((button) => { if (button.dataset.tab === activeTab) { button.classList.add('is-active'); }
});

저는 마케팅 랜딩 페이지에서는 UTM 파라미터를 확인해 문구를 바꾸거나, 유입 채널별 배너를 다르게 노출하는 식으로 활용할 수 있다고 봅니다. 또 탭 UI에서는 ?tab=info, ?tab=review처럼 현재 위치를 URL에 남겨두면 새로고침 뒤에도 같은 탭을 다시 보여주기 쉽습니다. 공유 링크를 만들 때는 반대로 불필요한 테스트용 파라미터를 제거하고, 필요한 값만 남겨서 더 깔끔한 주소로 정리할 수도 있습니다. 결국 URL 파라미터는 퍼블리셔가 화면 상태와 유입 맥락을 동시에 관리하는 데 유용한 수단입니다. 위 코드도 순서대로 보면, 읽기 → 기본값 처리 → 화면 반영이라는 흐름으로 이해할 수 있습니다.

정리

저는 URL 파라미터를 단순히 주소 뒤에 붙는 옵션이 아니라, 페이지 상태와 유입 정보를 다루는 실무 도구로 이해하는 것이 중요하다고 봅니다. 퍼블리셔가 자주 만나는 탭 유지, 필터 복원, 페이지 번호 보존, 광고 유입 확인 같은 작업은 대부분 쿼리 문자열과 연결됩니다. 이때 핵심은 문자열을 억지로 자르지 않고 URLURLSearchParams를 기준으로 읽고 수정하는 것입니다. 실무에서는 빠르게 처리하는 것도 중요하지만, 다음 수정자도 바로 이해할 수 있는 방식으로 남기는 것이 더 중요합니다. 저는 그래서 get, set, append, delete, replaceState 정도만 정확히 익혀도 운영 페이지 작업 안정성이 꽤 올라간다고 정리하고 싶습니다.

많이 받는 질문

Q. location.search만 써도 되는데 굳이 URL 객체까지 만들어야 하나요?
저는 단순 확인만 할 때는 가능하다고 보지만, 값을 수정하거나 여러 URL 파라미터를 함께 다뤄야 할 때는 URL 객체가 훨씬 안전하다고 봅니다. 읽기와 수정 흐름이 한 번에 이어지고, 유지보수할 때도 의도가 더 잘 보입니다.

Q. set()과 append()는 실무에서 어떻게 구분하면 되나요?
저는 값이 하나만 존재해야 하는 탭, 페이지, 정렬 상태에는 set()을 쓰고, 같은 이름의 값이 여러 개 들어갈 수 있는 복수 필터에는 append()를 씁니다. 이 구분이 안 되면 필터 복원 로직이 어긋날 수 있습니다.

Q. UTM 파라미터는 퍼블리셔도 알아야 하나요?
저는 그렇다고 봅니다. 직접 분석 리포트를 보지 않더라도, 랜딩 페이지를 수정하면서 기존 유입 파라미터를 날리지 않도록 유지해야 하는 경우가 많기 때문입니다. 특히 배너 이동, 버튼 링크 연결, 페이지 내부 이동에서 놓치기 쉬운 부분입니다.

같이 읽으면 좋은 글

같이 읽으면 좋은 글

입력 규칙을 정하고 같은 주소로 재현하기

먼저 읽기: DOM 폼. 아래 실행 예제는 tab은 info/review만, page는 1 이상의 안전한 정수만 허용합니다. 누락·빈 값·0·음수·소수·문자가 섞인 값은 page=1로 해석합니다. parseInt(“2x”)처럼 일부만 읽는 변환을 피하려고 정규식과 Number.isSafeInteger를 함께 씁니다. 정규식의 ^와 $는 문자열 전체, [1-9]는 첫 자리, \d*는 뒤 숫자 0개 이상을 뜻합니다.

get은 같은 키의 첫 값을 반환하고 없으면 null입니다. ?flag=는 빈 문자열이므로 누락과 다릅니다. has는 값이 비어 있어도 true입니다. getAll은 중복 값을 배열로 보존합니다. set은 같은 키의 중복을 하나로 정리하고 append는 추가합니다. 쿼리 문자열에서 +는 공백으로 읽히므로 C++의 더하기 기호는 %2B로 인코딩되어야 합니다. searchParams.set에 원래 문자열을 전달하면 직렬화가 처리하므로 미리 encodeURIComponent를 적용하지 않습니다.

URL 교체와 화면 갱신은 별도 단계

replaceState는 현재 기록을 교체하고 pushState는 기록을 추가합니다. 둘 다 이 예제의 render를 자동 실행하지 않으므로 변경 직후 직접 호출합니다. 뒤로·앞으로 탐색은 popstate 리스너에서 URL을 다시 읽습니다. 같은 출처의 URL만 적용할 수 있으므로 실습은 로컬 HTTP 서버에서 실행합니다. 기존 URL에서 시작해 UTM과 #demo 해시를 유지하며 popup만 제거합니다.

이전 탭 조각은 클래스 추가만 보여준 최소 예입니다. 여러 번 실행하는 실제 화면에서는 이전 활성 상태도 제거하고 내용을 갱신해야 합니다. 아래 예제는 tab 값을 select와 결과 객체에 매번 다시 반영하므로 알 수 없는 tab을 숨겨진 상태로 남기지 않습니다. URL은 사용자가 수정할 수 있고 공유·기록에 남으므로 비밀번호나 비밀 토큰을 이 실습 값으로 넣지 않습니다.

주소별 예상 결과

쿼리 해석
없음 info, page 1, rawFlag null
?flag=&page=0&tab=unknown hasFlag true, rawFlag 빈 문자열, info, page 1
?category=ring&category=watch&q=C%2B%2B+입문 category 두 개, q는 C++ 입문
?page=2&page=9&tab=review&popup=1 review, page 2; 적용하면 page 중복 정리 및 popup 삭제

index.html

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

app.js

const form = document.querySelector('#query-form');
const output = document.querySelector('#output');
function render() {
  const state = readQuery(location.href);
  form.elements.tab.value = state.tab;
  form.elements.page.value = String(state.page);
  form.elements.q.value = state.q;
  output.textContent = JSON.stringify({ ...state, address: location.href }, null, 2);
}
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const candidate = updateQuery(location.href, {
    tab: form.elements.tab.value,
    page: form.elements.page.value,
    q: form.elements.q.value,
  });
  const next = updateQuery(candidate, readQuery(candidate));
  history.replaceState(history.state, '', next);
  render();
});
document.querySelector('#cases').addEventListener('click', (event) => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest('button[data-query]');
  if (!button) return;
  const url = new URL(location.href);
  url.search = button.dataset.query;
  url.hash = 'demo';
  history.pushState(null, '', url);
  render();
});
window.addEventListener('popstate', render);
render();

checks.cjs

const assert = require('node:assert/strict');
const { readQuery, updateQuery } = require('./logic.js');
const base = 'https://example.com/event';
assert.equal(readQuery(base).rawFlag, null);
assert.equal(readQuery(base + '?flag=').rawFlag, '');
for (const page of ['0', '-1', '2x', '1.5', '', '9007199254740992'])
  assert.equal(readQuery(base + '?page=' + page).page, 1);
assert.equal(readQuery(base + '?page=2&page=9').page, 2);
assert.equal(readQuery(base + '?q=C%2B%2B+입문').q, 'C++ 입문');
assert.deepEqual(readQuery(base + '?category=ring&category=watch').categories, [
  'ring',
  'watch',
]);
const next = new URL(
  updateQuery(base + '?utm_source=lesson&popup=1&tab=info&tab=review#demo', {
    tab: 'review',
    page: 2,
    q: 'a+b c',
  }),
);
assert.equal(next.searchParams.get('utm_source'), 'lesson');
assert.equal(next.searchParams.has('popup'), false);
assert.equal(next.hash, '#demo');
assert.equal(next.searchParams.getAll('tab').length, 1);
assert.equal(next.searchParams.get('q'), 'a+b c');
console.log('16 URL assertions passed');

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>URL 상태 읽기와 재현</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <h1>URL 상태 읽기와 재현</h1>
      <p>
        아래 사례를 누른 뒤 주소와 해석 결과를 비교하세요. 사례 이동은 기록을 추가하고,
        폼 적용은 현재 기록을 교체합니다.
      </p>
      <div id="cases">
        <button type="button" data-query="">누락</button
        ><button type="button" data-query="?flag=&amp;page=0&amp;tab=unknown">
          빈 값과 잘못된 값</button
        ><button
          type="button"
          data-query="?category=ring&amp;category=watch&amp;q=C%2B%2B+입문&amp;utm_source=lesson"
        >
          중복과 인코딩</button
        ><button
          type="button"
          data-query="?page=2&amp;page=9&amp;tab=review&amp;popup=1"
        >
          첫 값과 삭제
        </button>
      </div>
      <form id="query-form">
        <label for="tab">보기</label
        ><select id="tab" name="tab">
          <option value="info">안내</option>
          <option value="review">후기</option></select
        ><label for="page">페이지</label
        ><input id="page" name="page" inputmode="numeric" /><label for="q">검색어</label
        ><input id="q" name="q" /><button type="submit">주소에 적용</button>
      </form>
      <pre id="output" aria-live="polite"></pre>
      <p id="demo">해시는 적용 후에도 남습니다.</p>
    </main>
    <script src="logic.js"></script>
    <script src="app.js"></script>
  </body>
</html>

logic.js

function readQuery(href) {
  const url = new URL(href);
  const params = url.searchParams;
  const rawPage = params.get('page');
  const numeric = Number(rawPage);
  const page =
    /^[1-9]\d*$/.test(rawPage ?? '') && Number.isSafeInteger(numeric) ? numeric : 1;
  const rawTab = params.get('tab');
  return {
    tab: ['info', 'review'].includes(rawTab) ? rawTab : 'info',
    page,
    q: params.get('q') ?? '',
    categories: params.getAll('category'),
    hasFlag: params.has('flag'),
    rawFlag: params.get('flag'),
  };
}
function updateQuery(href, values) {
  const url = new URL(href);
  url.searchParams.set('tab', values.tab);
  url.searchParams.set('page', String(values.page));
  url.searchParams.set('q', values.q);
  url.searchParams.delete('popup');
  return url.href;
}
if (typeof module !== 'undefined') module.exports = { readQuery, updateQuery };

style.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 2rem 1rem;
  color: #222;
  background: #fff;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
main {
  max-width: 48rem;
  margin: auto;
}
form,
section {
  margin-block: 1.5rem;
}
input,
select,
button {
  font: inherit;
  padding: 0.5rem;
  border: 1px solid #777;
  color: #222;
  background: #fff;
}
button {
  cursor: pointer;
}
:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
label {
  display: block;
}
li {
  margin-block: 0.7rem;
  overflow-wrap: anywhere;
}
li button {
  margin-inline-start: 0.5rem;
}
.done span {
  text-decoration: line-through;
}
pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  padding: 1rem;
  background: #eee;
}
[aria-invalid='true'] {
  border: 2px solid #222;
}

index.html 전체 코드 보기

style.css

style.css 전체 코드 보기

logic.js

logic.js 전체 코드 보기

app.js

app.js 전체 코드 보기

참고: MDN URLSearchParams · MDN replaceState.

실습 파일 내려받기

HTML·CSS·JavaScript 실습 ZIP 다운로드

이 폴더에서 python -m http.server 8000을 실행하고 http://localhost:8000/index.html을 엽니다. History API의 동일 출처 조건을 확인하기 위해 file://로 실행하지 않습니다. 서버 종료는 Ctrl+C입니다.

완료 기준

  1. 로컬 서버에서 누락 사례: page 1, tab info, rawFlag null입니다.
  2. 빈 값 사례: hasFlag true, rawFlag 빈 문자열이며 page와 tab은 기본값입니다.
  3. 중복 사례: categories 두 개와 C++ 입문이 보입니다. 검색어를 a+b c로 바꾸고 적용·새로고침하면 그대로 복원됩니다.
  4. 첫 값 사례: page 2입니다. 적용 뒤 page 키는 한 개이고 popup은 사라집니다.
  5. UTM 사례에서 폼 적용: utm_source와 #demo가 유지됩니다.
  6. 사례 두 개를 차례로 선택 후 뒤로/앞으로: 결과가 해당 주소와 일치합니다. 폼 적용은 새 기록을 추가하지 않습니다.

이 글이 도움이 되었나요?

조회 중

JavaScript 학습 순서

필수 13개 · 전체 14개

읽음 기록 관리

전체 과정 목차 (14개)
  1. 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
  2. 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
  3. 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
  4. 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
  5. 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
  6. 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
  7. 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
  8. 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기 현재 글
  9. 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
  10. 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
  11. 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
  12. 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
  13. 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
  14. 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기