Java/Spring URI 처리 완벽 가이드: RFC 표준부터 실무 함정까지
웹 개발에서 URI를 다루다 보면 예상치 못한 버그를 만나게 됩니다. 프론트엔드에서 보낸 "hello world"가 백엔드에서 "hello+world"로 찍히거나, user_profile이라는 host가 갑자기 사라지는 문제. 이런 버그들의 공통 원인은 URI 표준의 역사적 변천에 있습니다.
이 글에서는 URI를 제대로 이해하고, Java와 Spring에서 안전하게 다루는 방법을 알아보겠습니다.
📝 목차
- URI 표준의 역사: RFC 2396 vs RFC 3986
- 함정 1: Java vs JavaScript 인코딩 차이
- 함정 2: java.net.URI의 hostname 제약
- 함정 3: UriComponentsBuilder.fromUri()의 정보 누락
- 실무 해결 방법 총정리
- 인코딩/디코딩 시 주의사항
📜 URI 표준의 역사: RFC 2396 vs RFC 3986
URI 처리에서 발생하는 대부분의 혼란은 두 개의 RFC 표준 사이의 차이에서 비롯됩니다.
| 표준 | 연도 | 상태 | 특징 |
|---|---|---|---|
| RFC 2396 | 1998 | ❌ 폐지됨 | 최초의 URI 표준. hostname에 _ 불허, 공백을 +로 인코딩 |
| RFC 3986 | 2005 | ✅ 현재 표준 | RFC 2396을 대체. hostname 규칙 완화, 공백을 %20으로 인코딩 |
문제는 Java의 핵심 클래스들이 아직도 1998년 표준을 기반으로 동작한다는 점입니다.
| 클래스/함수 | 따르는 표준 | 비고 |
|---|---|---|
java.net.URLEncoder | RFC 2396 + Form 스펙 | 공백 → + |
java.net.URI | RFC 2396 | hostname에 _ 불허 |
encodeURIComponent (JS) | RFC 3986 | 공백 → %20 |
UriComponentsBuilder.fromUriString() | RFC 3986 | ✅ 권장 |
UriComponentsBuilder.fromUri(URI) | 혼합 | ⚠️ 주의 필요 |
이제 각 함정을 하나씩 살펴보겠습니다.
⚠️ 함정 1: Java vs JavaScript 인코딩 차이
문제 상황
// Java
String encoded = URLEncoder.encode("hello world", StandardCharsets.UTF_8);
System.out.println(encoded); // 출력: hello+world
// JavaScript
const encoded = encodeURIComponent("hello world");
console.log(encoded); // 출력: hello%20world
같은 문자열인데 결과가 다릅니다:
- Java:
hello+world(공백 →+) - JavaScript:
hello%20world(공백 →%20)
원인: 설계 목적의 차이
Java URLEncoder는 HTML Form 전송용 (application/x-www-form-urlencoded)으로 설계되었습니다:
- 공백을
+로 인코딩 *,-,_,.는 인코딩하지 않음- 그 외 모든 문자는
%XX형태로 인코딩
JavaScript encodeURIComponent는 URI 컴포넌트용 (RFC 3986)으로 설계되었습니다:
- 공백을
%20으로 인코딩 *,-,_,.,!,~,',(,)는 인코딩하지 않음
동작이 달라지는 문자들
| 문자 | Java URLEncoder | JavaScript encodeURIComponent |
|---|---|---|
(공백) | + | %20 |
~ | %7E | ~ |
! | %21 | ! |
' | %27 | ' |
( | %28 | ( |
) | %29 | ) |
실무 문제: 캐시 키 불일치
// Backend - 캐시 키 생성
String cacheKey = "search:" + URLEncoder.encode("안녕 하세요", UTF_8);
// 결과: search:안녕+하세요
// Frontend - 캐시 조회 요청
const cacheKey = "search:" + encodeURIComponent("안녕 하세요");
// 결과: search:안녕%20하세요
같은 검색어인데 캐시 키가 달라 캐시 미스가 발생합니다!
⚠️ 함정 2: java.net.URI의 hostname 제약
문제 상황
URI uri = new URI("myapp://user_profile?version=1.0");
System.out.println(uri.getHost()); // 출력: null ❌
user_profile이 host인데 왜 null이 반환될까요?
원인: RFC 2396의 hostname 규칙
java.net.URI는 RFC 2396 Section 3.2.2를 따릅니다. 이 규칙에 따르면 hostname은:
a sequence of domain labels separated by “.”, each domain label starting and ending with an alphanumeric character and possibly also containing "-" characters.
즉, hostname에 언더스코어(_) 가 허용되지 않습니다!
// ✅ 정상: 언더스코어 없음
new URI("myapp://userprofile?version=1.0").getHost(); // "userprofile"
// ❌ 문제: 언더스코어 포함
new URI("myapp://user_profile?version=1.0").getHost(); // null
[!NOTE] RFC 3986에서는 언더스코어가 허용됩니다.
RFC 3986은
hostname,domainlabel등의 제약 규칙을 삭제했습니다. 따라서user_profile은 현재 표준에서는 완전히 유효한 host입니다.java.net.URI가 폐지된 1998년 표준을 따르고 있는 것이 문제입니다.
java.net.URI의 authority vs host
java.net.URI는 정보를 버리지 않습니다. 단지 다른 변수에 저장할 뿐입니다:
URI uri = new URI("myapp://user_profile?version=1.0");
uri.getHost(); // null
uri.getAuthority(); // "user_profile" ✅
uri.toString(); // "myapp://user_profile?version=1.0" ✅
user_profile 정보는 authority에 저장되어 있습니다. 문제는 이걸 다른 클래스와 함께 사용할 때 발생합니다.
⚠️ 함정 3: UriComponentsBuilder.fromUri()의 정보 누락
문제 상황
java.net.URI와 UriComponentsBuilder를 함께 사용할 때 발생하는 문제입니다:
// URL에서 특정 파라미터를 제거하는 확장함수
private fun String.removeDebugParameter(): String =
UriComponentsBuilder.fromUri(URI(this)) // ⚠️ 여기가 문제!
.replaceQueryParam("debug", null)
.build()
.toUriString()
// 테스트
"myapp://home?debug=true&version=1.0".removeDebugParameter()
// 기대: myapp://home?version=1.0
// 실제: myapp://home?version=1.0 ✅
"myapp://user_profile?debug=true&version=1.0".removeDebugParameter()
// 기대: myapp://user_profile?version=1.0
// 실제: myapp://?version=1.0 ❌ (host가 사라짐!)
원인 분석
URI("myapp://user_profile?...")→host가null,authority에user_profile저장UriComponentsBuilder.fromUri(uri)→uri.getHost()만 참조,authority는 무시- 결과 → host 정보 누락!
// UriComponentsBuilder.uri() 메서드 내부
public UriComponentsBuilder uri(URI uri) {
this.host = uri.getHost(); // null이 반환됨!
// uri.getAuthority()는 참조하지 않음
...
}
UriComponentsBuilder는 RFC 3986을 따르면서도, authority를 별도로 관리하지 않습니다. 그래서 java.net.URI와 함께 사용하면 정보가 누락됩니다.
✅ 실무 해결 방법 총정리
1. JavaScript와 동일한 인코딩 (RFC 3986)
public class URIEncoder {
/**
* JavaScript의 encodeURIComponent와 동일하게 동작하는 인코딩.
*/
public static String encodeURIComponent(String value) {
if (value == null) return null;
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20") // 공백
.replace("%7E", "~") // 틸드
.replace("%21", "!") // 느낌표
.replace("%27", "'") // 작은따옴표
.replace("%28", "(") // 여는 괄호
.replace("%29", ")"); // 닫는 괄호
}
}
[!TIP] 공백 처리만 맞추면 되는 경우라면 간단하게:
URLEncoder.encode(value, UTF_8).replace("+", "%20");
2. Spring UriComponentsBuilder 올바른 사용법
// ❌ 위험: java.net.URI를 경유하면 정보 누락 가능
UriComponentsBuilder.fromUri(new URI(urlString))
// ✅ 권장: 문자열을 직접 파싱 (RFC 3986 정규식 사용)
UriComponentsBuilder.fromUriString(urlString)
fromUriString()은 RFC 3986 기반 정규식으로 직접 파싱하므로, java.net.URI의 제약을 우회합니다.
// 쿼리 파라미터 추가
String url = UriComponentsBuilder.fromUriString("https://api.example.com/search")
.queryParam("q", "hello world") // 자동으로 %20 인코딩
.queryParam("name", "홍길동") // 한글 자동 인코딩
.build()
.toUriString();
// 결과: https://api.example.com/search?q=hello%20world&name=%ED%99%8D%EA%B8%B8%EB%8F%99
3. java.net.URI 사용 시 주의사항
java.net.URI를 반드시 사용해야 한다면, getHost() 대신 getAuthority()를 확인하세요:
URI uri = new URI("myapp://user_profile?version=1.0");
// 방어적 코딩
String host = uri.getHost();
if (host == null) {
host = uri.getAuthority(); // fallback
}
4. JavaScript에서 Form 인코딩이 필요한 경우
// 방법 1: 수동 치환
function encodeFormData(value) {
return encodeURIComponent(value).replace(/%20/g, '+');
}
// 방법 2: URLSearchParams 사용
const params = new URLSearchParams();
params.append('message', 'hello world');
console.log(params.toString()); // message=hello+world
5. 선택 가이드
| 상황 | 권장 방법 |
|---|---|
| 쿼리 파라미터 구성 | UriComponentsBuilder.fromUriString() + queryParam() |
| URL 문자열 직접 구성 | URIEncoder.encodeURIComponent() 유틸리티 |
| Form 데이터 전송 | URLEncoder.encode() 그대로 사용 |
| 프론트-백엔드 일관성 | 양쪽 모두 RFC 3986 방식으로 통일 |
⚠️ 인코딩/디코딩 시 주의사항
더블 인코딩 방지
이미 인코딩된 문자열을 다시 인코딩하면 %가 %25로 변환되어 문제가 발생합니다:
// ❌ 잘못된 예: 이미 인코딩된 문자열을 다시 인코딩
String alreadyEncoded = "hello%20world";
URLEncoder.encode(alreadyEncoded, UTF_8); // "hello%2520world" (% → %25)
예외: 의도된 더블 인코딩 (Nested URL)
URL 안에 URL을 파라미터로 넣는 경우에는 의도적인 더블 인코딩이 필수입니다:
// 딥링크 생성 예시
String innerUrl = "https://site.com/search?q=" + URIEncoder.encodeURIComponent("hello world");
// 결과: "https://site.com/search?q=hello%20world"
String deepLink = "myapp://webview?link=" + URIEncoder.encodeURIComponent(innerUrl);
// 결과: "myapp://webview?link=https%3A%2F%2Fsite.com%2Fsearch%3Fq%3Dhello%2520world"
// ↑ %20 → %2520
| 구분 | 설명 |
|---|---|
| ❌ 실수로 인한 중복 인코딩 | 같은 레벨에서 모르고 다시 인코딩 → 버그 |
| ✅ 구조적 중첩 인코딩 | inner/outer 각 레벨에서 의도적으로 인코딩 → 정상 |
디코딩 동작 차이
인코딩과 마찬가지로 디코딩도 Java와 JavaScript에서 다르게 동작합니다:
// Java URLDecoder: '+'를 공백으로 해석
URLDecoder.decode("hello+world", UTF_8); // "hello world" ✅
URLDecoder.decode("hello%20world", UTF_8); // "hello world" ✅
// JavaScript decodeURIComponent: '+'를 그대로 유지
decodeURIComponent("hello+world"); // "hello+world" ⚠️
decodeURIComponent("hello%20world"); // "hello world" ✅
[!WARNING] JavaScript에서 Form 인코딩된 데이터(
+가 공백인 경우)를 디코딩하려면:function decodeFormData(value) { return decodeURIComponent(value.replace(/\+/g, '%20')); }
🎯 핵심 요약
| 함정 | 원인 | 해결책 |
|---|---|---|
| Java/JS 인코딩 불일치 | URLEncoder는 RFC 2396 + Form 스펙 | URIEncoder.encodeURIComponent() 유틸리티 |
URI.getHost() null 반환 | RFC 2396은 hostname에 _ 불허 | getAuthority() fallback 또는 회피 |
fromUri() 정보 누락 | UriComponentsBuilder가 authority 미참조 | fromUriString() 사용 |
핵심 원칙: Java의 URI 관련 클래스들은 1998년 표준(RFC 2396)을 기반으로 한다는 점을 기억하세요. 현대 웹 개발에서는 가능한 RFC 3986 기반 도구를 사용하는 것이 안전합니다.
// ✅ 이것만 기억하세요
UriComponentsBuilder.fromUriString(url) // java.net.URI를 거치지 않음!
.queryParam("key", "value")
.build()
.toUriString();
참고 자료:
- RFC 3986 - URI 문법 (현재 표준)
- RFC 2396 - URI 문법 (구버전, Obsolete)
- Java URLEncoder 문서
- Spring UriComponentsBuilder 문서
- MDN encodeURIComponent
- 카카오페이 기술 블로그 - URL이 이상해요!
이 글은 AI의 도움을 받아 교정 및 정리되었습니다.