URL은 어떻게 읽어야 할까? 주소 구조와 상대 경로를 이해하는 실습
Soshy··조회수 —

React 코드에서 다음 두 요청을 만났다고 해보자.
fetch("/api/posts")와 fetch("api/posts").
슬래시 하나만 다르지만, 현재 페이지 주소에 따라 서로 다른 곳으로 요청할 수 있다. 코드에 적힌 문자열만 보고 같은 API라고 판단하면, 특정 화면에서만 요청이 실패하는 이유를 놓치기 쉽다.
URL을 읽는다는 것은 도메인 이름을 알아보는 데서 끝나지 않는다. 어느 서버의 어떤 리소스를 가리키는지, 상대 경로가 어떤 주소로 해석되는지, 서버에 전달되는 부분은 어디까지인지 구분하는 일이다.
이 글에서는 URL을 구성 요소로 나누고, 브라우저의 URL API와 Network 탭으로 실제 해석 결과를 확인한다. 별도의 프로젝트 설치 없이 진행할 수 있다.
학습 목표와 실습 준비
코드를 실행하기 전에 결과를 예상하고, 실제 출력과 비교하면서 진행한다.
| 학습 내용 | 확인할 결과 |
|---|---|
| URL의 구성 요소 이해 | 스킴·호스트·포트·경로·쿼리·프래그먼트 구분 |
| URL 객체로 주소 분석 | host와 hostname, origin의 차이 |
| 상대 경로 해석 실험 | 슬래시와 기준 URL에 따른 결과 |
| 쿼리 문자열 만들기 | 검색어와 특수문자를 보존하는 방법 |
| Network에서 요청 확인 | 쿼리 전송과 프래그먼트 제외 여부 |
| React 코드 적용과 복습 | 요청 주소가 달라지는 원인 설명 |
준비물은 Chrome 브라우저와 개발자 도구다. 개발자 도구는 macOS에서 Command + Option + I, Windows/Linux에서 Ctrl + Shift + I로 연다.
먼저 JSONPlaceholder 홈페이지를 연다. 이 사이트는 실습용 데이터를 제공하며, 회원가입이나 API 키 없이 조회 요청을 보낼 수 있다. Console은 이 페이지의 개발자 도구에서 사용한다.
example.com이 등장하는 코드는 주소 해석만을 위한 예시다. 해당 주소를 방문하거나 요청할 필요는 없다. 실제 통신 실습은 JSONPlaceholder에서 진행한다.
URL을 여섯 부분으로 나누어 읽기
스킴, 호스트, 포트, 경로
URL은 Uniform Resource Locator의 약자다. 웹에서 페이지, 이미지, API 데이터 같은 리소스의 위치를 나타낸다. 다음은 구조를 설명하기 위한 가상의 주소다.
https://api.example.com:8443/posts/42?lang=ko&sort=recent#comments
| 부분 | 예시 | 의미 |
|---|---|---|
| 스킴 | https | 주소를 처리할 방식. 여기서는 HTTPS |
| 호스트 이름 | api.example.com | 요청 대상 호스트 |
| 포트 | 8443 | 해당 호스트에서 접속할 포트 번호 |
| 경로 | /posts/42 | 요청할 리소스를 식별하는 경로 |
| 쿼리 문자열 | ?lang=ko&sort=recent | 키와 값으로 표현한 추가 정보 |
| 프래그먼트 | #comments | 리소스 내부의 위치나 클라이언트 측 상태를 나타내는 부분 |
api는 이 예시에서 호스트 이름의 일부다. 이름에 api가 들어 있다는 이유만으로 API 서버가 되는 것은 아니다.
포트가 생략된 HTTP 주소는 기본적으로 80, HTTPS 주소는 443을 사용한다. 포트가 주소에 보이지 않는다고 연결에 포트가 없는 것은 아니다.
경로는 서버 디스크의 실제 폴더와 같다고 단정할 수 없다. /posts/42가 어떤 데이터를 가리키는지는 서버의 라우팅 규칙에 따라 정해진다. MDN: URL의 구조
쿼리와 프래그먼트는 역할이 다르다
쿼리는 ?부터 시작한다. 흔히 키=값 형태를 사용하고, 여러 항목은 &로 구분한다.
?lang=ko&sort=recent
이 예시에는 lang과 sort 두 항목이 있다. 다만 lang이 언어 선택을 뜻하고 sort가 정렬을 뜻하는지는 API가 정하는 약속이다. URL에 조건을 붙였다고 서버가 자동으로 그 기능을 제공하는 것은 아니다.
프래그먼트는 #부터 시작한다. HTML 문서의 특정 요소로 이동할 때 자주 쓰이며, 클라이언트 라우터가 화면 상태로 사용하기도 한다.
프래그먼트는 해당 URL로 HTTP 요청을 보낼 때 서버에 전달되지 않는다. 반면 쿼리는 요청 대상에 포함된다. 서버가 프래그먼트를 활용하려면 JavaScript 등이 그 값을 별도로 읽어 요청 데이터로 보내야 한다. MDN: URI fragment
실습: URL 객체로 구성 요소 확인하기
문자열을 객체로 분석하기
Console에 다음 코드를 실행한다. 이 코드는 네트워크 요청을 보내지 않고 URL 문자열을 분석한다.
{
const address = new URL(
"https://api.example.com:8443/posts/42?lang=ko&sort=recent#comments"
);
console.table({
href: address.href,
protocol: address.protocol,
hostname: address.hostname,
host: address.host,
port: address.port,
pathname: address.pathname,
search: address.search,
hash: address.hash,
origin: address.origin,
});
}
코드 바깥의 중괄호는 변수의 범위를 묶는다. 이후 예제를 같은 Console에서 실행할 때 변수 이름이 겹치는 일을 줄이기 위한 것이다.
출력에서 다음 차이를 찾는다.
| 속성 | 예상 값 | 관찰할 점 |
|---|---|---|
| protocol | https: | 콜론이 포함된다 |
| hostname | api.example.com | 포트가 없다 |
| host | api.example.com:8443 | 포트가 포함된다 |
| pathname | /posts/42 | 쿼리와 프래그먼트가 없다 |
| search | ?lang=ko&sort=recent | 물음표가 포함된다 |
| hash | #comments | 샵 기호가 포함된다 |
| origin | https://api.example.com:8443 | 경로·쿼리·프래그먼트가 없다 |
이어서 기본 포트를 명시한 주소를 비교한다.
{
const address = new URL("https://example.com:443/posts");
console.log(address.href); // https://example.com/posts
console.log(address.port); // 빈 문자열
}
URL 객체는 HTTPS의 기본 포트인 443을 정규화 과정에서 생략한다. port가 빈 문자열이어도 HTTPS의 기본 포트를 사용하는 주소다.
origin으로 같은 출처인지 비교하기
일반적인 HTTP·HTTPS URL의 origin(출처)은 스킴, 호스트 이름, 유효 포트의 조합으로 결정된다. 경로가 달라도 출처는 같을 수 있다. MDN: URL.origin
다음 기준 주소와 같은 출처인지 먼저 예상한 뒤 코드로 확인한다.
{
const base = new URL("https://example.com/posts");
const candidates = [
"https://example.com/users",
"https://example.com:443/users",
"http://example.com/users",
"https://api.example.com/users",
"https://example.com:8443/users",
];
console.table(candidates.map((value) => ({
url: value,
sameOrigin: new URL(value).origin === base.origin,
})));
}
결과는 위에서부터 true, true, false, false, false다.
출처는 브라우저의 보안 정책을 이해하는 기준이기도 하다. 다만 서로 다른 출처라는 사실만으로 요청이 반드시 실패한다고 결론 내릴 수는 없다. 응답 데이터를 읽을 수 있는지는 요청 방식과 서버의 CORS 설정 등도 관련된다.
실습: 상대 경로가 실제 URL로 바뀌는 과정
루트 기준과 현재 디렉터리 기준
절대 URL은 https://example.com/api/posts처럼 주소 자체에 스킴과 호스트가 들어 있다. 상대 참조는 빠진 부분을 기준 URL에서 가져와 해석한다.
여기서는 기준 URL을 https://example.com/docs/guide/로 고정한다. 실제 페이지를 이동할 필요는 없다.
{
const base = "https://example.com/docs/guide/";
const paths = [
"/api/posts",
"api/posts",
"./api/posts",
"../api/posts",
"?page=2",
"#examples",
];
console.table(paths.map((path) => ({
input: path,
resolved: new URL(path, base).href,
})));
}
실행 전에 아래 표의 결과를 가리고 각 주소를 예상해본다.
| 입력 | 해석 결과 |
|---|---|
| /api/posts | https://example.com/api/posts |
| api/posts | https://example.com/docs/guide/api/posts |
| ./api/posts | https://example.com/docs/guide/api/posts |
| ../api/posts | https://example.com/docs/api/posts |
| ?page=2 | https://example.com/docs/guide/?page=2 |
| #examples | https://example.com/docs/guide/#examples |
/로 시작하면 기준 URL의 루트부터 경로를 잡는다. ./ 또는 일반 경로로 시작하면 현재 디렉터리를 기준으로 해석한다. ../는 상위 디렉터리로 올라간다.
URL 해석은 문자열 두 개를 단순히 이어 붙이는 연산이 아니다. 경로의 형태와 기준 URL의 마지막 슬래시까지 결과에 영향을 준다. MDN: 상대 참조 해석
마지막 슬래시 하나로 달라지는 결과
다음 두 결과를 비교한다.
console.log(new URL("api/posts", "https://example.com/docs/guide/").href);
// https://example.com/docs/guide/api/posts
console.log(new URL("api/posts", "https://example.com/docs/guide").href);
// https://example.com/docs/api/posts
두 번째 기준 URL은 마지막에 /가 없다. 상대 경로를 해석할 때 마지막 구간인 guide를 제외한 /docs/가 현재 디렉터리가 된다.
이 규칙은 guide가 실제 파일인지 디렉터리인지 서버에 물어서 결정하는 것이 아니다. URL 문자열의 경로 구조를 기준으로 해석한다.
기준 URL을 https://example.com/team/members로 바꾸고, avatar.png, /avatar.png, ../avatar.png의 결과를 각각 예상해본다.
확인할 결과는 순서대로 /team/avatar.png, /avatar.png, /avatar.png이며, 모두 https://example.com 뒤에 이어진다.
브라우저가 사용하는 기준 URL 확인하기
일반적인 문서에서 상대 URL의 기준은 문서 주소다. HTML에 <base href="…">가 있으면 기준이 달라질 수 있다. 실제 기준은 document.baseURI로 확인한다. MDN: baseURI
JSONPlaceholder 홈페이지의 Console에서 실행한다.
console.log(location.href);
console.log(document.baseURI);
console.log(new URL("/todos/1", document.baseURI).href);
홈페이지를 연 상태라면 마지막 결과는 https://jsonplaceholder.typicode.com/todos/1이다. 예상과 다르면 먼저 현재 탭의 주소와 기준 URL을 확인한다.
이 글에서 다루는 브라우저 문서의 fetch는 상대 주소를 문서의 기준 URL로 해석한다. 서버에서 실행하는 코드나 API 라이브러리의 baseURL 설정은 별도의 실행 환경과 규칙을 확인해야 한다. MDN: fetch의 상대 URL 기준
실습: 쿼리 문자열을 안전하게 만들기
문자열 연결이 검색어를 바꾸는 경우
검색어가 React & URL일 때 다음 코드를 실행한다.
{
const keyword = "React & URL";
const address = new URL("https://example.com/search?q=" + keyword);
console.log(address.searchParams.get("q"));
console.log([...address.searchParams.entries()]);
}
q의 값은 원래 검색어 전체가 아니다. 검색어 안의 &가 쿼리 항목을 구분하는 기호로 해석되기 때문이다. 공백만 눈으로 확인해서는 이 문제를 놓칠 수 있다.
다음은 URLSearchParams를 사용한 코드다.
{
const address = new URL("https://example.com/search");
address.searchParams.set("q", "React & URL");
address.searchParams.set("page", "2");
console.log(address.href);
// https://example.com/search?q=React+%26+URL&page=2
console.log(address.searchParams.get("q"));
// React & URL
}
URLSearchParams는 값을 쿼리 문자열로 직렬화할 때 필요한 인코딩을 처리한다. 이 방식에서는 공백이 +, 값에 포함된 &가 %26으로 표현된다. MDN: URLSearchParams
set에는 encodeURIComponent로 미리 인코딩한 값 대신 원래 문자열을 전달한다. 미리 인코딩하면 퍼센트 기호까지 다시 인코딩되어 의도와 다른 값이 될 수 있다.
같은 키가 여러 번 등장하는 경우
반복된 키를 사용하는 주소도 있다.
{
const params = new URLSearchParams();
params.append("tag", "react");
params.append("tag", "network");
console.log(params.toString()); // tag=react&tag=network
console.log(params.get("tag")); // react
console.log(params.getAll("tag")); // ["react", "network"]
params.set("tag", "javascript");
console.log(params.toString()); // tag=javascript
}
append는 같은 키에 값을 추가하고, getAll은 해당 키의 모든 값을 읽는다. set은 그 키의 값을 하나로 정리한다.
검색어를 한글 & React+URL로 바꾸어 같은 실험을 해본다. 인코딩된 주소의 겉모습보다 다시 읽은 값이 원래 입력과 같은지를 확인한다. 반복 키를 서버가 배열로 처리하는지는 해당 API의 규칙에 달려 있다.
실습: Network에서 쿼리와 프래그먼트 비교하기
요청 보내고 실제 주소 확인하기
JSONPlaceholder 홈페이지에서 개발자 도구의 Network 탭을 연다. 기록을 켜고, 필터를 All로 둔 뒤 Clear로 기존 기록을 비운다. 다른 탭의 Console에서 실행하지 않도록 현재 페이지 주소를 확인한다.
Console에서 다음 코드를 실행한다. 이 코드는 실습용 API에 GET 요청 한 번을 보낸다.
fetch("/todos?userId=1#practice");
Network로 돌아가 Fetch/XHR 필터를 선택하고 새로 발생한 todos 요청을 연다.
- Headers → General → Request URL을 확인한다.
- Payload → Query String Parameters에서
userId와 값1을 찾는다. - Response에서 JSON 배열과 항목의
userId를 확인한다.
Chrome 버전에 따라 항목 배치나 이름이 다르면 요청 상세에서 Query String Parameters를 찾는다. Chrome: Network 사용 안내
실제 전송 대상은 다음과 같으며, #practice는 포함되지 않는다.
https://jsonplaceholder.typicode.com/todos?userId=1
이 서비스의 필터링 예시처럼 userId=1은 사용자별 할 일을 조회하는 조건으로 사용된다. 실제 응답의 상태 코드와 데이터를 함께 확인한다. 서비스 상태에 따라 요청이 실패했다면 그 결과만으로 URL 해석 규칙이 달라졌다고 판단하지 않는다.
객체에 남는 주소와 전송되는 주소 구분하기
Console에서 URL 객체의 결과도 확인한다.
{
const address = new URL("/todos?userId=1#practice", document.baseURI);
console.log(address.href);
console.log(address.search);
console.log(address.hash);
}
href에는 #practice가 남아 있고, hash는 #practice다. URL 객체가 표현하는 전체 주소와 HTTP로 전송하는 요청 대상은 구분해야 한다.
다이어그램을 불러오는 중…
flowchart TD
A[URL 문자열] --> B[기준 URL로 절대 주소 해석]
B --> C[전체 URL에 쿼리와 프래그먼트 포함]
C --> D[HTTP 요청 전송 시 프래그먼트 제외]
D --> E[서버는 경로와 쿼리로 요청 처리]
같은 페이지에서 Console 코드를 fetch("/todos?userId=2#practice")로 바꾸어 한 번 더 실행한다. 새 요청의 쿼리 값과 응답 항목의 userId를 비교한다.
쿼리를 바꾸면 서버에 전달하는 조건이 달라지고, 프래그먼트는 HTTP 요청 대상에서 제외된다. 단, 애플리케이션의 JavaScript가 프래그먼트를 읽어 별도 요청을 만드는 경우는 별개의 동작이다.
React 코드에서 URL 문제를 추적하는 방법
다음은 API 요청을 만드는 함수의 예시다. 현재 프론트엔드와 같은 출처의 /api/posts를 사용한다는 가정이며, 실제 서버 구현이 필요한 코드다. JSONPlaceholder 실습 페이지에서 실행하는 코드는 아니다.
function buildPostsUrl(keyword, page) {
const address = new URL("/api/posts", window.location.origin);
address.searchParams.set("q", keyword);
address.searchParams.set("page", String(page));
return address.href;
}
브라우저에서 실행되는 이벤트 핸들러 등에서 이 함수의 결과를 fetch에 전달할 수 있다. window를 사용하므로 서버 렌더링 중 실행되는 코드에는 그대로 적용할 수 없다.
| 증상 | URL 관점에서 확인할 것 |
|---|---|
| 특정 하위 화면에서만 404 발생 | api/posts처럼 루트 슬래시가 빠졌는가? |
| 프론트엔드 주소로 API 요청이 나감 | /api가 실제로 어느 출처를 기준으로 하는가? |
| 개발 환경에서는 되고 배포 후 실패 | API 주소 설정이나 프록시 규칙이 달라졌는가? |
| 검색어 일부가 사라짐 | &, #, + 등을 문자열로 직접 연결했는가? |
| 서버가 # 뒤 값을 받지 못함 | 프래그먼트를 서버 요청 값으로 착각했는가? |
/api로 시작한다는 사실만으로 별도의 백엔드 서버에 요청되는 것은 아니다. 브라우저가 해석한 출처로 요청하고, 그 뒤 프록시나 서버의 라우팅 규칙이 요청을 처리할 수 있다.
코드가 React 컴포넌트의 어느 폴더에 있는지도 상대 요청 URL의 기준을 정하지 않는다. 브라우저의 기준 URL, 요청을 만드는 코드, 라이브러리 설정, Network의 실제 Request URL을 연결해서 확인해야 한다.
복습과 정리
다음 질문에 먼저 답하고, 아래 확인 기준과 비교한다.
- host와 hostname은 어떻게 다른가?
- HTTPS 주소의 port가 빈 문자열이면 포트를 사용하지 않는 것인가?
- /api/posts와 api/posts는 언제 다른 주소가 되는가?
- 기준 URL 끝의 슬래시는 상대 경로에 어떤 영향을 주는가?
- 검색어에 &가 포함될 때 URLSearchParams를 쓰는 이유는 무엇인가?
- #comments는 HTTP 요청으로 서버에 전달되는가?
확인 기준: host는 포트를 포함할 수 있지만 hostname은 포함하지 않는다. 기본 포트는 URL 객체에서 생략될 수 있다. 루트 기준 경로와 현재 디렉터리 기준 경로는 기준 URL에 따라 다른 결과를 만든다.
마지막 슬래시가 없는 기준 URL에서는 마지막 경로 구간을 제외한 디렉터리가 상대 경로의 기준이 된다. URLSearchParams는 검색어 안의 특수문자가 구분자로 해석되지 않도록 인코딩한다. 프래그먼트는 해당 URL의 HTTP 요청 대상에 포함되지 않는다.
실습을 마무리하며 실제 요청 하나를 다음 세 문장으로 설명해본다.
- 코드에 적힌 주소는 무엇이며, 어떤 기준 URL로 해석되었는가?
- 최종 출처와 경로, 서버에 전달된 쿼리는 무엇인가?
- 전체 URL 중 HTTP 요청에 포함되지 않은 부분은 무엇인가?
URL을 구성 요소와 해석 규칙으로 나누어 보면, 요청 주소가 예상과 달라진 이유를 확인할 수 있다. 코드의 문자열을 읽는 것과 Network에서 실제 요청을 확인하는 것을 함께 사용하는 것이 핵심이다.