URL은 단순한 주소가 아니라 애플리케이션의 상태를 담는 1급 컨테이너다. 좋은 URL 설계가 공유, 북마크, 히스토리, 딥링킹을 어떻게 가능하게 하는지와 프론트엔드에서의 실전 패턴 및 안티패턴을 살펴본다.
URL: https://alfy.blog/2025/10/31/your-url-is-your-state.html
Title: Your URL Is Your State
몇 주 전 The Hidden Cost of URL Design을 발행할 때 SQL 문법 하이라이팅을 추가해야 했습니다. PrismJS 웹사이트에 가서 플러그인으로 추가해야 하는지 뭐였는지 기억을 되살리려 했죠. 다운로드 페이지에 옵션이 너무 많아 압도당해서, 다시 코드로 돌아갔습니다. PrismJS 파일을 열어보니 파일 맨 위에 URL이 들어 있는 주석이 있었습니다:
/* https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+css-extras+markdown+scss+sql&plugins=line-highlight+line-numbers+autolinker */
이걸 완전히 잊고 있었습니다. URL을 클릭하자 PrismJS 다운로드 페이지가 열렸는데, 모든 체크박스, 드롭다운, 옵션이 제 설정과 정확히 일치하도록 미리 선택되어 있었습니다. 테마 선택. 언어 선택. 플러그인 활성화. 모든 것이 단 하나의 URL로 완벽하게 복원되어 있었죠.
예전에 알고 있던 사실이 새롭게 ‘딱’ 맞아떨어지는 순간이었습니다. 여기서 URL은 단지 페이지를 가리키는 것 이상을 하고 있었습니다. 상태를 저장하고, 의도를 인코딩하고, 제 전체 설정을 공유 가능하고 복구 가능하게 만들고 있었죠. 데이터베이스도 없고. 쿠키도 없고. localStorage도 없습니다. 그냥 URL 하나뿐.
이 일을 계기로 생각했습니다. 프론트엔드 엔지니어로서 우리는 URL을 상태 관리 도구로 얼마나 자주 간과할까요? 전역 스토어, 컨텍스트, 캐시 같은 다양한 추상화를 꺼내 들면서도, 웹에서 가장 우아하고 오래된 기능 중 하나인 ‘소박한 URL’을 무시하곤 합니다.
이전 글에서는 좋지 않은 URL 설계의 숨은 비용을 다뤘습니다. 오늘은 관점을 뒤집어, 좋은 URL 설계가 가져다주는 엄청난 가치에 대해 이야기해보려 합니다. 특히, 현대 웹 애플리케이션에서 URL을 1급 상태 컨테이너로 다루는 방법에 대해요.
Scott Hanselman이 “ URLs are UI”라고 유명하게 말했는데, 전적으로 맞는 말입니다. URL은 브라우저가 리소스를 가져오기 위해 사용하는 기술적 주소만이 아닙니다. URL은 인터페이스입니다. 사용자 경험의 일부죠.
하지만 URL은 UI를 넘어섭니다. URL은 상태 컨테이너입니다. URL을 설계할 때마다, 어떤 정보를 보존할지, 무엇을 공유 가능하게 할지, 무엇을 북마크 가능하게 할지 결정하는 셈입니다.
URL이 ‘공짜로’ 제공하는 것들을 생각해보세요:
URL은 웹 애플리케이션을 더 견고하고 예측 가능하게 만듭니다. URL은 웹의 ‘원조’ 상태 관리 솔루션이고, 1991년부터 믿음직하게 작동해왔죠. 질문은 URL이 상태를 저장할 수 있느냐가 아닙니다. 우리가 URL의 잠재력을 끝까지 활용하고 있느냐입니다.
예시로 들어가기 전에, URL이 어떻게 상태를 인코딩하는지 나눠서 봅시다. 아래는 전형적인 ‘상태를 담은’ URL입니다:

URL의 구성 요소 - 출처: What is a URL - MDN Web Docs
수년 동안 이것들이 URL의 유일한 구성 요소로 여겨졌습니다. 하지만 Text Fragments가 도입되면서 바뀌었습니다. 이 기능은 페이지 내 특정 텍스트 조각으로 직접 링크할 수 있게 해줍니다. 더 자세한 내용은 제 글 Smarter than ‘Ctrl+F’: Linking Directly to Web Page Content에서 읽을 수 있습니다.
URL의 서로 다른 부분은 서로 다른 종류의 상태를 인코딩합니다:
경로 세그먼트 (/path/to/myfile.html). 계층적 리소스 내비게이션에 가장 적합합니다:
/users/123/posts - 사용자 123의 게시글/docs/api/authentication - 문서 구조/dashboard/analytics - 애플리케이션 섹션쿼리 파라미터 (?key1=value1&key2=value2). 필터, 옵션, 설정에 딱 맞습니다:
?theme=dark&lang=en - UI 선호 설정?page=2&limit=20 - 페이지네이션?status=active&sort=date - 데이터 필터링?from=2025-01-01&to=2025-12-31 - 날짜 범위앵커 프래그먼트 (#SomewhereInTheDocument). 클라이언트 사이드 내비게이션과 페이지 섹션에 이상적입니다:
#L20-L35 - GitHub 라인 하이라이팅#features - 섹션으로 스크롤#/dashboard - 싱글 페이지 앱 라우팅(요즘은 거의 쓰이지 않지만)때로는 콤마나 플러스(+) 같은 구분자를 사용해 하나의 키에 여러 값을 묶어 넣는 것을 봅니다. 간결하고 사람이 읽기 쉽지만, 서버 측에서 수동 파싱이 필요합니다.
?languages=javascript+typescript+python
?tags=frontend,react,hooks
개발자는 종종 복잡한 필터나 설정 객체를 하나의 쿼리 문자열로 인코딩합니다. 간단한 관례로는 쉼표로 구분된 키–값 쌍을 쓰고, 다른 방식으로는 JSON을 직렬화하거나 안전을 위해 Base64로 인코딩하기도 합니다.
?filters=status:active,owner:me,priority:high
?config=eyJyaWNrIjoicm9sbCJ9== (base64-encoded JSON)
플래그나 토글에는 불리언을 명시적으로 전달하거나, 키의 존재 자체를 truthy로 보는 방식이 흔합니다. URL을 더 짧게 유지할 수 있고 기능 토글도 쉽습니다.
?debug=true&analytics=false
?mobile (존재 = true)
?tags[]=frontend&tags[]=react&tags[]=hooks
또 다른 오래된 패턴은 **브래킷 표기(bracket notation)**로, 쿼리 파라미터에서 배열을 표현합니다. 이는 PHP 같은 초기 웹 프레임워크에서 시작됐는데, 파라미터 이름에 []를 붙이면 여러 값이 하나로 묶여야 한다는 신호였죠.
?tags[]=frontend&tags[]=react&tags[]=hooks
?ids[0]=42&ids[1]=73
Node의 qs 라이브러리나 Express 미들웨어 같은 많은 현대 프레임워크/파서도 여전히 이 패턴을 자동으로 인식합니다. 다만 URL 스펙에서 공식 표준은 아니어서 서버/클라이언트 구현에 따라 동작이 달라질 수 있습니다. 게다가 제 웹사이트에서는 이 표기 때문에 문법 하이라이팅이 깨지기까지 하네요.
핵심은 일관성입니다. 애플리케이션에 맞는 패턴을 선택하고 끝까지 유지하세요.
URL을 상태 컨테이너로 쓰는 현실 세계의 예시를 봅시다:
PrismJS 설정
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript&plugins=line-numbers
URL에 문법 하이라이터 설정 전체가 인코딩되어 있습니다. UI에서 무엇이든 바꾸면 URL이 업데이트됩니다. URL을 공유하면 상대도 동일한 설정을 그대로 받습니다. 이 예시는 쿼리 파라미터가 아니라 앵커(프래그먼트)를 쓰지만, 개념은 같습니다.
GitHub 라인 하이라이팅
https://github.com/zepouet/Xee-xCode-4.5/blob/master/XeePhotoshopLoader.m#L108-L136
특정 파일로 링크하면서 108~136줄을 하이라이트합니다. 이 링크를 어디서 클릭하든, 논의 중인 정확한 코드 섹션으로 도착하죠.
Google Maps
https://www.google.com/maps/@22.443842,-74.220744,19z
좌표, 줌 레벨, 지도 타입이 URL에 들어 있습니다. 이 링크를 공유하면 누구나 동일한 지도 뷰를 볼 수 있습니다.
Figma 및 디자인 도구
https://www.figma.com/file/abc123/MyDesign?node-id=123:456&viewport=100,200,0.5
공유 가능한 디자인 링크가 없던 시절에는 큰 파일에서 최신 화면이나 컴포넌트를 찾는 일이 고역이었습니다. 누군가가 레이어를 가로질러 스크롤하고 줌하면서, 말 그대로 어디에 있는지 보여줘야 했죠. 오늘날 Figma 링크에는 캔버스 위치, 줌 레벨, 선택된 요소 등 모든 컨텍스트가 담깁니다. 작업 공간으로 사용자를 즉시 떨궈 넣는 데 필요한 모든 것이요.
이커머스 필터
https://store.com/laptops?brand=dell+hp&price=500-1500&rating=4&sort=price-asc
현실에서 가장 흔히 보는 패턴 중 하나입니다. 모든 필터, 정렬 옵션, 가격 범위가 보존됩니다. 사용자는 정확한 검색 조건을 북마크해 언제든 돌아올 수 있죠. 무엇보다도, 다른 곳으로 이동했거나 페이지를 새로고침한 뒤에도 다시 돌아올 수 있습니다.
구현 디테일을 논하기 전에, 무엇을 URL에 넣어야 하는지에 대한 명확한 가이드라인이 필요합니다. 모든 상태가 URL에 속하는 것은 아닙니다. 간단한 휴리스틱을 하나 제시하죠:
URL 상태로 좋은 후보:
URL 상태로 나쁜 후보:
어떤 상태가 URL에 들어가야 할지 확신이 없다면 이렇게 자문해보세요: 누군가 이 URL을 클릭했을 때, 동일한 상태를 봐야 하는가? 그렇다면 URL에 있어야 합니다. 아니라면 다른 상태 관리 접근을 쓰세요.
현대적인 URLSearchParams API는 URL 상태 관리를 단순하게 만듭니다:
// URL 파라미터 읽기
const params = new URLSearchParams(window.location.search);
const view = params.get('view') || 'grid';
const page = params.get('page') || 1;
// URL 파라미터 업데이트
function updateFilters(filters) {
const params = new URLSearchParams(window.location.search);
// 개별 파라미터 업데이트
params.set('status', filters.status);
params.set('sort', filters.sort);
// 페이지 리로드 없이 URL 업데이트
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.pushState({}, '', newUrl);
// 이제 새 필터에 맞춰 UI 업데이트
renderContent(filters);
}
// 뒤로/앞으로 버튼 처리
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
const filters = {
status: params.get('status') || 'all',
sort: params.get('sort') || 'date'
};
renderContent(filters);
});
popstate 이벤트는 사용자가 브라우저의 뒤로/앞으로 버튼으로 이동할 때 발생합니다. URL과 일치하도록 UI를 복원할 수 있게 해주며, 앱의 상태와 히스토리를 동기화하는 데 필수입니다. 보통은 프레임워크 라우터가 이를 처리해주지만, 내부 동작을 이해해두면 좋습니다.
React Router와 Next.js는 이를 더 깔끔하게 만드는 훅을 제공합니다:
import { useSearchParams } from 'react-router-dom';
// 또는 Next.js 13+: import { useSearchParams } from 'next/navigation';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
// URL에서 읽기(기본값 포함)
const color = searchParams.get('color') || 'all';
const sort = searchParams.get('sort') || 'price';
// URL 업데이트
const handleColorChange = (newColor) => {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
params.set('color', newColor);
return params;
});
};
return (
<div>
<select value={color} onChange={e => handleColorChange(e.target.value)}>
<option value="all">All Colors</option>
<option value="silver">Silver</option>
<option value="black">Black</option>
</select>
{/* 필터링된 상품 목록이 여기 렌더링됨 */}
</div>
);
}
이제 URL이 애플리케이션 상태를 담을 수 있음을 봤으니, URL을 깔끔하고 예측 가능하며 사용자 친화적으로 유지하는 모범 사례 몇 가지를 살펴봅시다.
기본값을 URL에 쓸데없이 넣지 마세요:
// 나쁨: 기본값 때문에 URL이 지저분해짐
?theme=light&lang=en&page=1&sort=date
// 좋음: 기본값이 아닌 값만 URL에 포함
?theme=dark // light가 기본값이므로 생략
파라미터를 읽을 때 코드에서 기본값을 처리하세요:
function getTheme(params) {
return params.get('theme') || 'light'; // 기본값은 코드에서 처리
}
검색어 자동완성처럼 고빈도 업데이트에는 URL 변경을 디바운스하세요:
import { debounce } from 'lodash';
const updateSearchParam = debounce((value) => {
const params = new URLSearchParams(window.location.search);
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
window.history.replaceState({}, '', `?${params.toString()}`);
}, 300);
// 히스토리가 매 키 입력마다 쌓이지 않도록 pushState 대신 replaceState 사용
pushState와 replaceState 중 무엇을 쓸지 결정할 때는, 브라우저 히스토리가 어떻게 동작하길 원하는지 생각하세요. pushState는 새 히스토리 엔트리를 만들기 때문에, 필터 변경, 페이지네이션, 새로운 뷰로 이동 같은 명확한 내비게이션 액션에 적합합니다. 사용자는 뒤로 가기 버튼으로 이전 상태로 돌아갈 수 있죠. 반대로 replaceState는 새 엔트리를 추가하지 않고 현재 엔트리를 갱신하므로, 검색어 입력처럼 **미세한 개선(refinement)**이나 작은 UI 조정처럼 매번 히스토리를 쌓고 싶지 않은 경우에 이상적입니다.
URL을 신중하게 설계하면, URL은 단순한 상태 컨테이너를 넘어섭니다. URL은 애플리케이션과 소비자 사이의 **계약(contract)**이 됩니다. 좋은 URL은 사람, 개발자, 기계 모두에게 기대치를 정의하죠.
잘 구조화된 URL은 공개/비공개, 클라이언트/서버, 공유 가능/세션 종속의 경계를 그어줍니다. 상태가 어디에 살고 어떻게 동작해야 하는지 명확히 합니다. 개발자는 무엇이 안전하게 지속될 수 있는지 알고, 사용자는 무엇을 북마크할 수 있는지 알고, 기계는 무엇이 인덱싱할 가치가 있는지 알게 됩니다.
그런 의미에서 URL은 인터페이스로 작동합니다: 눈에 보이고, 예측 가능하며, 안정적입니다.
읽기 쉬운 URL은 스스로를 설명합니다. 아래 두 URL의 차이를 보세요.
https://example.com/p?id=x7f2k&v=3
https://example.com/products/laptop?color=silver&sort=price
첫 번째는 의도를 숨깁니다. 두 번째는 이야기를 들려줍니다. 사람은 읽고 무엇을 보고 있는지 이해할 수 있고, 기계는 파싱해 의미 있는 구조를 추출할 수 있습니다.
Jim Nielsen은 이런 것들을 “examples of great URLs”라고 부릅니다. 스스로를 설명하는 URL이죠.
URL은 캐시 키입니다. 잘 설계된 URL은 더 나은 캐싱 전략을 가능하게 합니다:
추가 트래킹 코드 없이도 사용자의 여정을 시각화할 수도 있습니다:
graph LR A["/products"] --> |selects category| B["/products?category=laptops"] B --> |adds price filter| C["/products?category=laptops&price=500-1000"]
style A fill:#e9edf7,stroke:#455d8d,stroke-width:2px; style B fill:#e9edf7,stroke:#455d8d,stroke-width:2px; style C fill:#e9edf7,stroke:#455d8d,stroke-width:2px;
분석 도구는 추가 계측 없이도 이 흐름을 추적할 수 있습니다. URL 파라미터 하나하나가 분석 가능한 차원(dimension)이 됩니다.
URL은 API 버전, 기능 플래그, 실험을 전달할 수 있습니다:
?v=2 // API 버전
?beta=true // 베타 기능
?experiment=new-ui // A/B 테스트 변형
이는 점진적 롤아웃과 하위 호환성을 훨씬 더 관리하기 쉽게 만듭니다.
최선의 의도가 있어도 URL 상태를 오용하기 쉽습니다. 흔한 함정을 살펴봅시다:
전형적인 싱글 페이지 앱의 실수:
// 사용자가 새로고침하면 모든 것을 잃음
const [filters, setFilters] = useState({});
새로고침 시 앱이 상태를 잊어버리면, 웹의 가장 기본적인 기능 중 하나를 깨뜨리는 셈입니다. 사용자는 URL이 컨텍스트를 보존하길 기대합니다. 몇 년 전 Reddit 사용자가 이커머스 사이트에 대해 분노하는 바이럴 영상을 본 기억이 납니다. “뒤로 가기”를 누를 때마다 모든 필터가 사라졌거든요. 그 좌절감이 모든 걸 말해줬죠. 사용자가 컨텍스트를 잃으면, 인내심도 잃습니다.
뻔한 얘기지만, 반복할 가치가 있습니다:
// 절대 이렇게 하지 마세요
?password=secret123
URL은 어디에나 기록됩니다: 브라우저 히스토리, 서버 로그, 애널리틱스, 리퍼러 헤더. URL은 공개 정보로 취급하세요.
// 불명확하고 일관성 없음
?foo=true&bar=2&x=dark
// 자기 설명적이고 일관적
?mobile=true&page=2&theme=dark
의미 있는 파라미터 이름을 고르세요. 미래의 나(그리고 팀)가 고마워할 겁니다.
?config=eyJtZXNzYWdlIjoiZGlkIHlvdSByZWFsbHkgdHJpZWQgdG8gZGVjb2RlIHRoYXQ_IiwiZmlsdGVycyI6eyJzdGF0dXMiOlsiYWN0aXZlIiwicGVuZGluZyJdLCJwcmlvcml0eSI6WyJoaWdoIiwibWVkaXVtIl0sInRhZ3MiOlsiZnJvbnRlbmQiLCJyZWFjdCIsImhvb2tzIl0sInJhbmdlIjp7ImZyb20iOiIyMDI0LTAxLTAxIiwidG8iOiIyMDI0LTEyLTMxIn19LCJzb3J0Ijp7ImZpZWxkIjoiY3JlYXRlZEF0Iiwib3JkZXIiOiJkZXNjIn0sInBhZ2luYXRpb24iOnsicGFnZSI6MSwibGltaXQiOjIwfX0==
거대한 JSON 객체를 Base64로 인코딩해야 한다면, 그 상태는 아마 URL에 둘 자리가 아닐 가능성이 큽니다.
브라우저와 서버는 실용적인 URL 길이 제한을 갖고 있습니다(보통 2,000~8,000자 사이). 하지만 현실은 더 미묘합니다. 이 자세한 Stack Overflow 답변이 설명하듯, 제한은 브라우저 동작, 서버 설정, CDN, 심지어 검색 엔진 제약이 섞여서 발생합니다. 이 한계에 부딪힌다면 접근 방식을 재고해야 한다는 신호입니다.
// 상태를 잘못 대체
history.replaceState({}, '', newUrl); // pushState가 필요할 때 사용됨
브라우저 히스토리를 존중하세요. 사용자 행동이 뒤로 가기 버튼으로 “되돌릴 수” 있어야 한다면 pushState를 쓰세요. 단순한 미세 조정이라면 replaceState를 쓰면 됩니다.
그 PrismJS URL은 중요한 사실을 다시 떠올리게 했습니다. 좋은 URL은 콘텐츠를 가리키기만 하지 않습니다. URL은 사용자와 애플리케이션 사이의 대화를 설명합니다. 의도를 포착하고, 컨텍스트를 보존하며, 다른 어떤 상태 관리 솔루션도 따라올 수 없는 방식으로 공유를 가능하게 하죠.
우리는 Redux, MobX, Zustand, Recoil 등 점점 더 정교한 상태 관리 라이브러리를 만들어 왔습니다. 모두 각자의 자리가 있습니다. 하지만 때로는 최고의 해결책이란, 처음부터 거기 있었던 것일 수도 있습니다.
이전 글에서는 나쁜 URL 설계의 숨은 비용을 썼습니다. 오늘은 그 반대편을 살펴봤죠: 좋은 URL 설계의 엄청난 가치. URL은 단순한 주소가 아닙니다. 상태 컨테이너이자 사용자 인터페이스이며 계약까지 한꺼번에 품고 있습니다.
새로고침을 했을 때 앱이 상태를 잊어버린다면, 웹에서 가장 오래되고 가장 우아한 기능 중 하나를 놓치고 있는 것입니다.