[번역] URL은 곧 상태입니다
웹이 가진 가장 오래되고 우아한 기능
January 12, 2026원문: https://alfy.blog/2025/10/31/your-url-is-your-state.html
몇 주 전 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을 일급(first-class) 상태 컨테이너로 취급하는 방법에 대해 말이죠.
간과되는 URL의 힘
Scott Hanselman은 “URL은 UI다”라고 말한 것으로 유명한데, 전적으로 옳은 말입니다. URL은 브라우저가 리소스를 가져오기 위해 사용하는 기술적 주소만이 아닙니다. 그것은 인터페이스입니다. 사용자 경험의 일부입니다.
하지만 URL은 UI 그 이상입니다. 그것은 상태 컨테이너입니다. 우리가 URL을 만들 때마다 어떤 정보를 보존할지, 무엇을 공유할 수 있게 할지, 무엇을 북마크할 수 있게 할지에 대한 결정을 내리고 있는 것입니다.
URL이 우리에게 기본으로 제공하는 것들을 생각해보세요.
- 공유 가능성: 누군가에게 링크를 보내면, 그 사람은 당신이 보고 있는 것과 정확히 같은 화면을 보게 됩니다.
- 북마크 가능성: URL을 저장한다는 것은 어떤 시점의 상태를 그대로 저장하는 것입니다.
- 브라우저 히스토리: 뒤로 가기 버튼이 당연하게 작동합니다.
- 딥 링크: 애플리케이션의 특정 상태로 바로 진입할 수 있습니다.
URL은 웹 애플리케이션을 탄탄하고 예측 가능하게 만들어줍니다. URL은 웹의 원조 상태 관리 솔루션이며, 1991년부터 신뢰성 있게 동작해오고 있습니다. 문제는 "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 Segments (
/path/to/myfile.html). 계층적 리소스 네비게이션에 가장 적합합니다.- /users/123/posts — 사용자 123의 게시글
- /docs/api/authentication — 문서 구조
- /dashboard/analytics — 애플리케이션 섹션
- Query Parameters (
?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 — 날짜 범위
- ~~Anchor~~ Fragment (
#SomewhereInTheDocument) - 클라이언트 사이드 네비게이션과 페이지 내 구역 이동에 적합합니다.- #L20-L35 — GitHub에서 특정 라인 하이라이트
- #features — 특정 섹션으로 스크롤
- #/dashboard — 싱글 페이지 앱 라우팅(요즘은 거의 사용되지 않음)
쿼리 파라미터에 잘 맞는 일반적인 패턴들
구분자를 사용한 다중 값
가끔 콤마나 플러스 같은 구분자를 사용해서 여러 값을 하나의 키에 넣는 경우가 있습니다. 이는 간결하고 사람이 읽기 쉽지만, 서버 측에서 수동 파싱이 필요합니다.
?languages=javascript+typescript+python
?tags=frontend,react,hooks
중첩되거나 구조화된 데이터
개발자들은 복잡한 필터나 설정 객체를 단일 쿼리 문자열로 인코딩하는 경우가 많습니다. 간단한 방식은 콤마로 구분된 key–value 쌍을 사용하는 것이고, JSON을 직렬화하거나 Base64로 인코딩해 안전하게 넣는 방식도 있습니다.
?filters=status:active,owner:me,priority:high
?config=eyJyaWNrIjoicm9sbCJ9== (Base64로 인코딩된 JSON)
불리언 플래그
플래그나 토글에는 명시적으로 불리언 값을 전달하거나, 키 자체가 존재하면 true로 간주하는 방식이 흔합니다. URL을 짧게 유지할 수 있고 기능 토글도 간단해집니다.
?debug=true&analytics=false
?mobile (존재하면 = true)
배열 (괄호 표기법)
?tags[]=frontend&tags[]=react&tags[]=hooks
또 다른 오래된 패턴은 괄호 표기법입니다. 쿼리 파라미터에서 배열을 표현하는 방식으로, PHP 같은 초기 웹 프레임워크에서 유래했습니다. 파라미터 이름에 []를 붙이면 여러 값이 하나로 묶여야 한다는 의미가 됩니다.
?tags[]=frontend&tags[]=react&tags[]=hooks
?ids[0]=42&ids[1]=73
Node의 qs 라이브러리나 Express의 미들웨어 같은 많은 현대 프레임워크와 파서도 이 패턴을 자동으로 인식합니다. 다만 URL 사양에서 공식적으로 표준화된 방식은 아니기 때문에, 서버나 클라이언트 구현에 따라 동작이 달라질 수 있습니다. 심지어 제 블로그에서는 이게 문법 하이라이팅을 깨뜨리기도 합니다.
핵심은 일관성입니다. 애플리케이션에 맞는 패턴을 선택하고, 그것을 계속 유지하세요.
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 상태로 적합한 것들
- 검색 쿼리와 필터
- 페이지네이션 및 정렬
- 뷰 모드(리스트/그리드, 어두운 모드/밝은 모드)
- 날짜 범위 및 기간
- 선택된 항목 또는 활성 탭
- 내용에 영향을 주는 UI 설정
- 기능 플래그 및 A/B 테스트 변형
URL 상태로 부적합한 것들
- 민감 정보(비밀번호, 토큰, 개인정보)
- 일시적 UI 상태(모달 열림/닫힘, 드롭다운 펼침 상태)
- 진행 중인 폼 입력(저장되지 않은 변경 사항)
- 매우 크거나 복잡한 중첩 데이터
- 고빈도 일시적 상태(마우스 위치, 스크롤 위치)
어떤 상태가 URL에 들어가야 할지 확신이 서지 않는다면 이렇게 자문해보세요. “이 URL을 누군가 클릭했을 때, 그 사람도 동일한 상태를 봐야 하는가?”
만약 그렇다면 URL에 넣어야 합니다. 아니라면, 다른 상태 관리 방식을 사용하세요.
Plain JavaScript를 사용한 구현
현대의 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 이벤트는 사용자가 브라우저의 뒤로/앞으로 가기 버튼을 사용했을 때 발생합니다. 이를 통해 UI를 URL과 다시 동기화할 수 있으며, 이는 앱의 상태와 히스토리를 일관되게 유지하는 데 필수적입니다. 대부분의 프레임워크 라우터가 이 작업을 대신 수행해 주지만, 내부적으로 어떻게 동작하는지 알아두면 좋습니다.
리액트(React)에서의 구현
리액트 라우터와 Next.js는 훨씬 깔끔하게 URL 상태를 다룰 수 있는 훅을 제공합니다:
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에 넣어 URL을 어지럽히지 마세요.
// 잘못된 예: 기본값 때문에 URL이 지저분해짐
?theme=light&lang=en&page=1&sort=date
// 좋은 예: 기본값이 아닌 값만 URL에 포함
?theme=dark // light은 기본값이므로 생략
기본값 처리는 파라미터를 읽어오는 코드에서 수행하면 됩니다.
function getTheme(params) {
return params.get('theme') || 'light'; // 기본값은 코드에서 처리
}URL 업데이트 디바운싱
검색창 입력처럼 업데이트가 매우 빈번한 경우, 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 vs replaceState
pushState와 replaceState를 선택할 때는 브라우저 히스토리가 어떻게 동작하길 원하는지를 기준으로 판단해야 합니다. pushState는 새로운 히스토리 기록을 추가합니다.
필터 변경, 페이지네이션, 다른 뷰로 이동하기처럼 뒤로 가기 버튼으로 되돌릴 수 있어야 하는 명확한 탐색 동작에 적합합니다. 한편 replaceState는 새로운 항목을 추가하지 않고 현재의 기록 항목을 그대로 교체하므로, 타이핑 중 실시간 검색처럼 입력이 자주 바뀌는 경우처럼, 히스토리를 모든 키 입력마다 채우고 싶지 않은 사소한 UI 조정과 같은 상황에 적합하다.
URL을 ‘계약(Contract)’으로 바라보기
신중하게 설계된 URL은 단순한 상태 컨테이너를 넘어, 애플리케이션과 사용자 간의 계약 역할을 합니다. 좋은 URL은 사람, 개발자, 그리고 기계 모두에게 명확한 기대치를 제공합니다.
명확한 경계 설정
잘 설계된 URL은 공개와 비공개, 클라이언트와 서버, 공유 가능한 상태와 세션 전용 상태 간의 경계를 그어 줍니다. 이는 상태가 어디에 존재하며 어떻게 동작해야 하는지를 명확하게 해줍니다. 개발자는 어떤 상태를 지속해도 안전한지 알 수 있고, 사용자는 어떤 것을 북마크할 수 있는지 알 수 있으며, 기계는 무엇이 인덱싱할 가치가 있는지를 알 수 있습니다.
그런 의미에서 URL은 눈에 보이고, 예측 가능하며, 안정적인 인터페이스 역할을 합니다.
의미를 전달하는 URL
읽기 쉬운 URL은 그 자체로 설명이 됩니다. 다음 두 URL을 비교해 보세요.
https://example.com/p?id=x7f2k&v=3
https://example.com/products/laptop?color=silver&sort=price
첫 번째 URL은 의도를 숨기지만, 두 번째는 이야기를 전달합니다. 사람은 URL만 읽고서도 어떤 내용인지를 이해할 수 있고, 기계는 구조를 파악하여 의미 있는 정보를 추출할 수 있습니다.
Jim Nielsen은 이런 URL을 “스스로를 설명하는 훌륭한 URL의 예”라고 부릅니다.
캐싱과 성능
URL은 곧 **캐시 키(cache key)**입니다. URL을 잘 설계하면 캐싱 전략도 강력해집니다.
- 동일 URL = 동일 리소스 = 캐시 히트
- 쿼리 파라미터가 캐시 변형 조건을 정의
- CDN은 URL 패턴을 기준으로 똑똑하게 캐싱 가능
또한 사용자 여정을 추가 트래킹 코드 없이 시각화할 수도 있습니다.
분석 도구는 추가 기능 없이도 이러한 흐름을 추적할 수 있습니다. 모든 URL 파라미터 하나하나가 분석할 수 있는 하나의 차원이 됩니다.
버전 관리와 진화
URL은 API 버전, 기능 플래그, 실험 정보까지도 표현할 수 있습니다.
?v=2 // API 버전
?beta=true // 베타 기능 활성화
?experiment=new-ui // A/B 테스트 변형
이를 통해 점진적 롤아웃과 하위 호환성 유지가 훨씬 수월해집니다.
피해야 할 안티 패턴(Anti-Patterns to Avoid)
좋은 의도가 있더라도, URL 상태는 오용하기 쉽습니다. 다음은 대표적인 실수들입니다.
“메모리에만 존재하는 상태”를 가진 SPA
고전적인 SPA의 실수입니다.
// 새로고침하면 모든 상태가 사라짐
const [filters, setFilters] = useState({});새로고침 시 앱이 상태를 잃는다면, 이는 웹의 가장 기본적인 원칙을 어기고 있는 것입니다. 사용자는 URL이 맥락을 보존하기를 기대합니다. 몇 년 전 Reddit에서 한 사용자가 이커머스 사이트에 대해 불평하는 영상이 있었는데, "뒤로 가기" 버튼을 누를 때마다 모든 필터가 사라진다고 매우 화를 냈습니다. 그 불만이 모든 걸 설명해줍니다. 사용자가 맥락을 잃으면, 인내심도 잃습니다.
민감한 데이터를 URL에 포함
너무 기본적이지만 반복할 가치가 있습니다.
// 절대 이렇게 하면 안 됩니다
?password=secret123
URL은 브라우저 히스토리, 서버 로그, 분석 도구, Referrer 헤더 등 수많은 곳에 기록됩니다. URL은 공개 정보로 다루세요.
불명확하거나 일관되지 않은 네이밍
// 불명확하고 일관성 없음
?foo=true&bar=2&x=dark
// 명확하고 자기 설명적
?mobile=true&page=2&theme=dark
의미 있는 파라미터 이름을 사용하세요. 미래의 여러분과 팀이 감사하게 될 것입니다.
URL에 과도하게 복잡한 상태를 넣기
이렇게까지 방대한 JSON을 base64로 인코딩해야 할 정도라면, 그 상태는 URL에 두기 적합하지 않습니다.
URL 길이 제한
브라우저와 서버는 URL 길이에 대해 실질적인 제한을 두고 있습니다(일반적으로 2,000~8,000자). 하지만 실제 상황은 이보다 더 미묘합니다. Stack Overflow의 이 상세한 답변에서도 설명하듯, 이러한 제한은 브라우저 동작, 서버 설정, CDN, 심지어 검색 엔진 제약까지 섞여서 결정됩니다. 만약 이러한 한계에 계속 부딪히고 계시다면, 접근 방식을 다시 생각해보아야 한다는 신호입니다.
뒤로 가기 버튼 망가뜨리기
// replaceState를 잘못 사용한 경우
history.replaceState({}, '', newUrl);브라우저 히스토리를 존중해야 합니다. 어떤 사용자 동작이 뒤로 가기 버튼을 통해 “되돌릴 수 있어야” 한다면 pushState를 사용하세요. 반면 단순한 조정이라면 replaceState를 사용하세요.
마무리하며
저는 앞서 얘기한 PrismJS URL 사례를 통해 중요한 사실을 다시 깨달았습니다. 좋은 URL은 단순히 콘텐츠를 가리키는 것에 그치지 않습니다. 사용자와 애플리케이션 사이의 대화를 서술합니다. 의도를 포착하고, 맥락을 보존하며, 다른 어떤 상태 관리 솔루션도 따라올 수 없는 방식으로 공유를 가능하게 합니다.
우리는 Redux, MobX, Zustand, Recoil 등 점점 더 정교한 상태 관리 라이브러리를 만들어 왔습니다. 이 모든 도구는 각자의 쓰임이 있지만 때로 가장 좋은 해결책은 처음부터 존재해 왔던 자연스러운 방식일 때도 있습니다.
제가 이전 글에서 다뤘듯, 나쁜 URL 디자인에는 숨겨진 비용이 있습니다. 오늘은 그 반대의 시각에서 좋은 URL 디자인이 지닌 막대한 가치를 살펴보았습니다. URL은 단순한 주소가 아닙니다. 상태 컨테이너이자, 사용자 인터페이스이며, 계약 그 자체입니다.
만약 여러분의 앱이 새로고침했을 때 상태를 잃어버린다면, 웹이 지닌 가장 오래되고 우아한 기능 중 하나를 놓치고 계신 것입니다.
