웹 개발에서 URI를 다루다 보면 예상치 못한 버그를 만나게 됩니다. 프론트엔드에서 보낸 "hello world"가 백엔드에서 "hello+world"로 찍히거나, user_profile이라는 host가 갑자기 사라지는 문제. 이런 버그들의 공통 원인은 URI 표준의 역사적 변천에 있습니다.

이 글에서는 URI를 제대로 이해하고, Java와 Spring에서 안전하게 다루는 방법을 알아보겠습니다.

📝 목차

  1. URI 표준의 역사: RFC 2396 vs RFC 3986
  2. 함정 1: Java vs JavaScript 인코딩 차이
  3. 함정 2: java.net.URI의 hostname 제약
  4. 함정 3: UriComponentsBuilder.fromUri()의 정보 누락
  5. 실무 해결 방법 총정리
  6. 인코딩/디코딩 시 주의사항

📜 URI 표준의 역사: RFC 2396 vs RFC 3986

URI 처리에서 발생하는 대부분의 혼란은 두 개의 RFC 표준 사이의 차이에서 비롯됩니다.

표준연도상태특징
RFC 23961998❌ 폐지됨최초의 URI 표준. hostname에 _ 불허, 공백을 +로 인코딩
RFC 39862005✅ 현재 표준RFC 2396을 대체. hostname 규칙 완화, 공백을 %20으로 인코딩

문제는 Java의 핵심 클래스들이 아직도 1998년 표준을 기반으로 동작한다는 점입니다.

클래스/함수따르는 표준비고
java.net.URLEncoderRFC 2396 + Form 스펙공백 → +
java.net.URIRFC 2396hostname에 _ 불허
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 URLEncoderHTML Form 전송용 (application/x-www-form-urlencoded)으로 설계되었습니다:

  • 공백을 + 로 인코딩
  • *, -, _, .는 인코딩하지 않음
  • 그 외 모든 문자는 %XX 형태로 인코딩

JavaScript encodeURIComponentURI 컴포넌트용 (RFC 3986)으로 설계되었습니다:

  • 공백을 %20 으로 인코딩
  • *, -, _, ., !, ~, ', (, )는 인코딩하지 않음

동작이 달라지는 문자들

문자Java URLEncoderJavaScript 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.URIRFC 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.URIUriComponentsBuilder를 함께 사용할 때 발생하는 문제입니다:

// 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가 사라짐!)

원인 분석

  1. URI("myapp://user_profile?...")hostnull, authorityuser_profile 저장
  2. UriComponentsBuilder.fromUri(uri)uri.getHost()만 참조, authority는 무시
  3. 결과 → 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() 정보 누락UriComponentsBuilderauthority 미참조fromUriString() 사용

핵심 원칙: Java의 URI 관련 클래스들은 1998년 표준(RFC 2396)을 기반으로 한다는 점을 기억하세요. 현대 웹 개발에서는 가능한 RFC 3986 기반 도구를 사용하는 것이 안전합니다.

// ✅ 이것만 기억하세요
UriComponentsBuilder.fromUriString(url)  // java.net.URI를 거치지 않음!
        .queryParam("key", "value")
        .build()
        .toUriString();

참고 자료:


이 글은 AI의 도움을 받아 교정 및 정리되었습니다.