~ / posts / developer

URL 인코딩 원리와 한글 URL, encodeURI vs encodeURIComponent 차이

URL 인코딩은 문자를 UTF-8 바이트로 바꾼 뒤 바이트마다 %XX로 쓰는 방식이라 한글 1자가 9자로 늘어요. encodeURI와 encodeURIComponent가 무엇을 남기는지, 공백 %20과 +, 이중 인코딩·EUC-KR 깨짐·URIError까지 실제 실행 결과로 정리했어요.

카톡으로 받은 링크가 https://example.com/%EA%B2%80%EC%83%89?q=%EC%84%9C%EC%9A%B8...처럼 알아볼 수 없는 문자로 가득했던 적, 검색어에 &가 들어가자 API가 엉뚱한 결과를 돌려준 적, 다 같은 주제예요. 결론부터 말하면 URL 인코딩(퍼센트 인코딩)은 URL에 그대로 쓸 수 없는 문자를 UTF-8 바이트로 바꾼 뒤 바이트마다 %와 16진수 두 자리로 적는 규칙이고, 값 하나(쿼리 파라미터, 경로 조각)를 넣을 때는 encodeURIComponent, 이미 완성된 URL 전체를 다듬을 때만 encodeURI를 씁니다. 헷갈리면 값 단위로 encodeURIComponent를 쓰는 게 거의 항상 안전해요.

이 글에서는 한글 한 글자가 왜 9글자가 되는지 바이트 단위로 따라가 보고, 두 함수가 어떤 문자를 남기는지 Node.js에서 실제로 실행한 결과로 비교한 다음, 공백이 %20이 되기도 하고 +가 되기도 하는 이유, 이중 인코딩과 EUC-KR로 깨지는 주소 디버깅법까지 정리했어요.

핵심 요약

URL에 그대로 쓸 수 있는 건 영문자, 숫자, - _ . ~ 정도이고, 나머지는 UTF-8 바이트마다 %XX로 바꿔요. "가"는 EA B0 80 세 바이트라 %EA%B0%80이 됩니다.

encodeURIComponent는 / ? & = # 같은 구분 기호까지 인코딩하고, encodeURI는 URL 구조를 살리려고 이 기호들을 남겨요.

공백은 경로와 encodeURIComponent에서는 %20, HTML 폼과 URLSearchParams에서는 +가 됩니다.

디코딩 후에도 %XX가 남으면 이중 인코딩, URIError가 나면 EUC-KR 같은 다른 인코딩이나 깨진 % 시퀀스를 의심하세요.

URL 인코딩(퍼센트 인코딩)이란

URL 표준(RFC 3986)은 URL에 쓸 수 있는 문자를 아주 좁게 정해 놨어요. 아무 의미 없이 그대로 쓸 수 있는 문자(unreserved)는 영문 대소문자, 숫자, 그리고 - _ . ~ 네 개뿐이에요. / ? # [ ] @ : & = + $ , ; ! ' ( ) * 같은 기호는 URL의 구조를 나누는 예약 문자(reserved)라서, 데이터로 쓰려면 인코딩해야 하고요. 한글, 공백, 이모지처럼 이 목록에 없는 문자는 전부 인코딩 대상입니다.

인코딩 방법은 두 단계예요.

  1. 문자를 UTF-8 바이트로 바꿉니다.
  2. 바이트 하나하나를 % + 16진수 두 자리로 씁니다.

그래서 이름이 퍼센트 인코딩이에요. 영문 a는 그대로 a, 공백은 1바이트 0x20이라 %20, &는 0x26이라 %26이 됩니다.

한글 한 글자가 9글자가 되는 이유

한글 완성형 음절은 UTF-8에서 3바이트예요. "가"(U+AC00)는 EA B0 80이고, 바이트 3개가 각각 %EA %B0 %80이 되니 1글자가 9글자로 늘어납니다.

한글 '가'가 유니코드 U+AC00에서 UTF-8 3바이트 EA B0 80으로, 다시 %EA%B0%80으로 바뀌는 퍼센트 인코딩 과정
문자 → UTF-8 바이트 → 바이트마다 %XX
원문 글자 수와 encodeURIComponent 결과 글자 수
원문인코딩 후
0102030405060가 · 원문 1자가 · 인코딩 후 9자가서울 맛집 추천 · 원문 8자서울 맛집 추천 · 인코딩 후 60자서울 맛집 추천카페 & 디저트 · 원문 8자카페 & 디저트 · 인코딩 후 54자카페 & 디저트smith lab · 원문 9자smith lab · 인코딩 후 11자smith laba&b=c · 원문 5자a&b=c · 인코딩 후 9자a&b=c

한글 1자 → 9자, 공백·기호 1자 → 3자, 영문·숫자는 그대로

단위: 자 · 자료: 파이썬 urllib.parse.quote(safe="-_.!~*'()")로 encodeURIComponent와 같은 규칙으로 직접 계산
표로 보기
구분원문인코딩 후
가19
서울 맛집 추천860
카페 & 디저트854
smith lab911
a&b=c59

"서울 맛집 추천"은 한글 6자(54자) + 공백 2개(6자)라 60자가 돼요. 영문은 공백만 바뀌어서 거의 그대로고요. 한글 URL이 복사하면 유독 길어지는 게 이 때문이에요. UTF-8 바이트 구조 자체는 UTF-8과 EUC-KR 한글 바이트 수에 더 자세히 정리했어요.

브라우저 주소창은 보기 좋게 한글로 보여 주지만, 실제로 서버에 전송되는 건 인코딩된 값이에요. 주소창에서 복사하면 브라우저에 따라 인코딩된 형태로 복사되는 것도 그래서입니다.

encodeURI vs encodeURIComponent 차이

자바스크립트에는 인코딩 함수가 두 개 있어요. 같은 문자열 a b+c/d?&=#를 넣어 보면 차이가 확실히 보입니다(Node.js 실행 결과).

encodeURIComponent('a b+c/d?&=#');  // 'a%20b%2Bc%2Fd%3F%26%3D%23'
encodeURI('a b+c/d?&=#');           // 'a%20b+c/d?&=#'
문자encodeURIComponentencodeURI
공백%20%20
한글 "한"%ED%95%9C%ED%95%9C
/ ? : @%2F %3F %3A %40그대로
& = #%26 %3D %23그대로
+ , ; $%2B %2C %3B %24그대로
- _ . ~ ! * ' ( )그대로그대로

encodeURIComponent는 URL 구조를 만드는 기호까지 전부 인코딩해요. 그래서 "URL의 한 조각", 즉 쿼리 파라미터 값이나 경로 세그먼트 하나에 써야 합니다. encodeURI는 https://, /, ?, &, =, #를 남겨서 URL 모양을 유지하고 한글·공백만 바꿔요. 이미 구조가 완성된 URL 전체를 다듬을 때 씁니다.

잘못 쓰면 생기는 일

검색어가 "카페 & 디저트"라고 해 볼게요.

const q = '카페 & 디저트';
'/search?q=' + encodeURI(q);            // '/search?q=%EC%B9%B4%ED%8E%98%20&%20%EB%94%94%EC%A0%80%ED%8A%B8'
'/search?q=' + encodeURIComponent(q);   // '/search?q=%EC%B9%B4%ED%8E%98%20%26%20%EB%94%94%EC%A0%80%ED%8A%B8'

encodeURI를 쓰면 &가 그대로 남아서, 서버는 q=카페 와 디저트라는 이름의 빈 파라미터 두 개로 읽어요. 검색 결과가 "카페"로만 나오는 버그의 정체가 이거예요. 반대로 완성된 URL 전체에 encodeURIComponent를 쓰면 https%3A%2F%2F...처럼 구조까지 망가져서 주소로 쓸 수 없게 됩니다.

가장 안전한 방법은 문자열을 직접 이어 붙이지 않는 거예요. URL 객체와 URLSearchParams를 쓰면 값마다 알아서 인코딩해 줍니다.

const url = new URL('https://example.com/search');
url.searchParams.set('q', '서울 맛집&카페');
url.searchParams.set('page', '1');
url.toString();
// 'https://example.com/search?q=%EC%84%9C%EC%9A%B8+%EB%A7%9B%EC%A7%91%26%EC%B9%B4%ED%8E%98&page=1'

결과를 보면 공백이 %20이 아니라 +로 바뀌었죠. 이게 다음 절의 주제예요.

공백은 %20일까 +일까

둘 다 맞고, 쓰는 자리가 달라요.

문제는 섞일 때 생겨요. 쿼리스트링에서는 +가 공백으로 해석되니, 값에 진짜 더하기 기호가 있으면 반드시 %2B로 인코딩해야 해요. "C++"를 검색했는데 서버가 "C "(공백 두 개)로 받는 버그가 대표적이죠. 반대로 경로에서는 +가 그냥 더하기 기호라 공백으로 바뀌지 않아요. 그리고 자바스크립트 decodeURIComponent는 +를 공백으로 바꾸지 않으니, 폼 형식 문자열을 디코딩할 땐 먼저 +를 공백으로 바꾼 뒤 디코딩해야 합니다.

상황공백+ 기호 자체
URL 경로%20+ 또는 %2B
쿼리 (encodeURIComponent)%20%2B
쿼리 (폼·URLSearchParams)+%2B
자바 URLEncoder.encode+%2B

URL 인코더/디코더에는 "공백 ↔ +" 옵션이 있어서, 인코딩할 때 %20을 +로, 디코딩할 때 +를 공백으로 바꿔 줘요. 하단의 URL 분석 표는 쿼리스트링을 폼 규칙대로 해석해서 +를 공백으로 보여 줍니다.

언어별 URL 인코딩 함수 비교

언어값 하나 인코딩공백 처리메모
JavaScriptencodeURIComponent%20쿼리 조립은 URLSearchParams(+) 권장
Pythonurllib.parse.quote(s, safe='')%20기본 safe='/'라 슬래시를 남김에 주의
Pythonurllib.parse.quote_plus / urlencode+폼 형식
JavaURLEncoder.encode(s, UTF_8)+이름과 달리 폼 형식
PHPrawurlencode / urlencode%20 / +raw가 RFC 3986

파이썬 quote의 기본값이 /를 인코딩하지 않는다는 점이 함정이에요. quote('a b+c/d')는 a%20b%2Bc/d가 되니, 경로 조각 하나를 인코딩할 땐 safe=''를 꼭 주세요.

디코딩 문제: 이중 인코딩·EUC-KR·URIError

디코딩했는데 아직 %가 남아 있다면: 이중 인코딩

%EA%B0%80를 한 번 더 인코딩하면 %가 %25로 바뀌어서 %25EA%25B0%2580이 돼요. 한 번 디코딩하면 %EA%B0%80, 두 번 디코딩해야 "가"가 나옵니다. 주로 이런 곳에서 생겨요.

규칙은 하나예요. 인코딩은 값이 URL에 들어가는 마지막 순간에 딱 한 번만. URL 인코더에서 디코딩 결과에 %XX가 남아 있으면 이중 인코딩을 의심하라는 안내가 뜨고, "결과를 입력으로"를 눌러 한 번 더 풀어 볼 수 있어요.

디코딩하면 깨지거나 URIError가 난다면: EUC-KR

오래된 한국 사이트나 게시판 URL은 UTF-8이 아니라 EUC-KR로 인코딩된 경우가 있어요. "가"가 EUC-KR에서는 2바이트 B0 A1이라 %B0%A1이 됩니다. 이걸 decodeURIComponent('%B0%A1')에 넣으면 올바른 UTF-8 바이트열이 아니라서 URIError: URI malformed가 나요. 같은 에러는 100%처럼 뒤에 16진수가 없는 %가 섞여도 납니다.

구분법은 간단해요. 한글 한 글자에 해당하는 %XX가 3개씩 묶이면 UTF-8, 2개씩 묶이고 첫 바이트가 %B0~%C8 범위면 EUC-KR일 가능성이 높아요. URL 인코더는 UTF-8로 실패한 부분을 EUC-KR로 자동 재시도하고 그 사실을 알려 줍니다. 코드에서 디코딩할 땐 try/catch로 URIError를 잡아서 원문을 그대로 쓰도록 처리해 두는 게 안전해요.

한글 URL과 한글 도메인, 쓸까 말까

경로에 한글을 쓰면 주소창에서는 읽기 좋지만, 메신저·이메일·문서에 붙이면 인코딩된 긴 문자열로 바뀌는 경우가 많아요. 그래서 블로그나 서비스 URL은 영문 소문자 kebab-case(/posts/text/naming-conventions-camel-snake-kebab/)가 무난합니다. 표기 관례는 camelCase·snake_case·kebab-case 네이밍 컨벤션에 정리했어요.

한글 도메인은 퍼센트 인코딩이 아니라 퓨니코드(Punycode)라는 별도 방식으로 바뀌어요. "한국"은 xn--3e0b707e가 됩니다. 브라우저가 주소창에 한글로 보여 줘도 DNS 조회는 퓨니코드로 하니, 경로의 %XX와 섞어서 생각하지 마세요.

마지막으로 꼭 짚을 점이 있어요. URL 인코딩은 누구나 되돌릴 수 있는 표기 변환이지 암호화가 아니에요. 비밀번호나 토큰, 개인정보를 쿼리스트링에 넣으면 인코딩 여부와 상관없이 서버 로그, 브라우저 기록, Referer 헤더에 남습니다. 비슷한 오해는 Base64는 암호화가 아니다와 URL-safe Base64와 패딩에서도 다뤘어요.

자주 묻는 질문

%EA%B0%80 같은 주소를 한글로 보려면 어떻게 하나요?

URL 인코더/디코더에 붙여 넣고 디코딩을 고르면 돼요. 코드에서는 decodeURIComponent를 쓰고, 결과에 %XX가 또 남아 있으면 이중 인코딩이니 한 번 더 디코딩하세요.

encodeURI와 encodeURIComponent 중 뭘 써야 하나요?

쿼리 파라미터 값이나 경로 조각처럼 URL의 일부분을 넣을 때는 encodeURIComponent, 이미 완성된 URL 전체를 다듬을 때만 encodeURI를 써요. 쿼리를 조립할 때는 URLSearchParams를 쓰면 실수할 여지가 가장 적습니다.

공백이 %20으로 바뀔 때와 +로 바뀔 때가 다른 이유는?

%20은 URL 표준의 퍼센트 인코딩이고, +는 HTML 폼 전송 형식(application/x-www-form-urlencoded)의 공백 표기예요. 경로에는 %20을, 폼이나 URLSearchParams로 만든 쿼리에는 +가 쓰입니다. 쿼리 값에 진짜 + 기호가 있으면 %2B로 인코딩해야 해요.

decodeURIComponent에서 URIError가 나요.

UTF-8로 해석되지 않는 바이트열이거나 % 뒤에 16진수 두 자리가 없는 경우예요. 옛 한국 사이트의 EUC-KR 주소(가 = %B0%A1)나 100% 같은 문자열이 흔한 원인이라, try/catch로 감싸고 원문을 확인하세요.

URL 인코딩하면 개인정보가 보호되나요?

아니요. URL 인코딩은 누구나 디코딩할 수 있는 표기 변환일 뿐이에요. 민감한 값은 쿼리스트링에 넣지 말고 POST 본문이나 헤더로 보내고, HTTPS를 쓰세요.

정리

URL 인코딩은 "UTF-8 바이트마다 %XX"라는 단순한 규칙이라, 한글 1자가 9자가 되는 것도, EUC-KR 주소가 깨지는 것도 바이트로 보면 다 설명돼요. 값 하나는 encodeURIComponent, 완성된 URL은 encodeURI, 쿼리 조립은 URLSearchParams. 그리고 인코딩은 마지막에 한 번만. 이 원칙만 지키면 & 때문에 파라미터가 잘리거나 %25가 붙은 이중 인코딩을 만날 일이 거의 없어요.

깨진 주소를 바로 풀어 보거나 쿼리스트링을 표로 확인하려면 URL 인코더/디코더를 써 보세요. 바이트 수가 궁금하면 글자 수 세기에서 UTF-8 바이트도 함께 볼 수 있어요.

#URL 인코딩#퍼센트 인코딩#한글 URL#encodeURIComponent#encodeURI 차이#URL 디코딩#공백 %20 +#이중 인코딩
← 이전 글이모지 글자 수가 2자·11자로 세지는 이유와 grapheme 세는 법