JavaScript URL 디코딩 — decodeURIComponent()
무료 URL 디코더을 브라우저에서 직접 사용하세요 — 설치 불필요.
URL 디코더 온라인으로 사용하기 →퍼센트 인코딩된 문자열은 JavaScript 코드에서 항상 등장합니다 — 검색 쿼리는 q=standing+desk%26price%3A200로, OAuth 리다이렉트는 next=https%3A%2F%2Fdashboard.internal%2F로, 스토리지 경로는 reports%2F2025%2Fq1.pdf로 도착합니다. JavaScript에서 URL 디코딩하는 방법은 세 가지 내장 함수 중 올바른 것을 선택하는 것으로 귀결됩니다: decodeURIComponent(), decodeURI(), 그리고 URLSearchParams — 이들 간의 선택이 프로덕션 코드베이스에서 제가 본 대부분의 조용한 데이터 손상의 근본 원인이며, 특히 +를 공백으로 처리하는 엣지 케이스와 이중 디코딩 문제입니다. 코드를 작성하지 않고 빠르게 한 번 디코딩하려면, ToolDeck의 URL Decoder 가 브라우저에서 즉시 처리합니다. 이 JavaScript URL 디코딩 튜토리얼은 세 가지 함수를 모두 심층적으로 다룹니다 (ES2015+ / Node.js 10+): 각각을 언제 사용할지, 공백과 예약 문자 처리 방법의 차이, 파일과 HTTP 요청에서의 디코딩, 안전한 오류 처리, 그리고 가장 미묘한 프로덕션 버그를 일으키는 네 가지 실수입니다.
- ✓decodeURIComponent()는 모든 퍼센트 인코딩 시퀀스를 디코딩합니다 — 개별 쿼리 파라미터 값과 경로 세그먼트에 올바른 선택입니다
- ✓decodeURI()는 / ? & = # : 같은 구조적 URI 문자를 보존합니다 — 완전한 URL 문자열을 디코딩할 때만 사용하고, 개별 값에는 절대 사용하지 마세요
- ✓URLSearchParams.get()은 이미 디코딩된 값을 반환합니다 — 그 위에 decodeURIComponent()를 호출하면 이중 디코딩이 발생합니다
- ✓decodeURIComponent()는 +를 공백으로 디코딩하지 않습니다 — 폼 인코딩(application/x-www-form-urlencoded) 데이터에는 URLSearchParams를 사용하거나 디코딩 전에 +를 교체하세요
- ✓입력이 사용자 데이터에서 올 때는 항상 decodeURIComponent()를 try/catch로 감싸세요 — 베어 %나 불완전한 시퀀스는 URIError를 발생시킵니다
URL 디코딩이란?
퍼센트 인코딩(공식적으로 RFC 3986에 정의됨)은 URL에서 안전하지 않거나 구조적으로 중요한 문자를 % 기호와 두 개의 16진수 숫자—— 해당 문자의 UTF-8 바이트 값——로 대체합니다. URL 디코딩은 이 변환을 역으로 수행합니다: 각 %XX 시퀀스가 원래 바이트로 변환되고, 결과 바이트 시퀀스는 UTF-8 텍스트로 해석됩니다. 공백은 %20에서, 슬래시는 %2F에서, 비 ASCII 문자 ü는 %C3%BC(2바이트 UTF-8 표현)에서 디코딩됩니다.
절대 인코딩되지 않는 문자들——비예약 문자라고 함——은 알파벳 A–Z와 a–z, 숫자 0–9, 그리고 - _ . ~입니다. 그 외 모든 것은 URL에서 구조적 역할을 하거나(/는 경로 세그먼트를 구분하고&는 쿼리 파라미터를 구분합니다) 데이터로 사용될 때 인코딩되어야 합니다. 실제 결과:status=active&tier=premium처럼 단일 쿼리 파라미터 값으로 인코딩된 검색 필터는 원래 것과 전혀 다르게 도착합니다.
// 퍼센트 인코딩됨 — HTTP 요청이나 webhook 페이로드에서 수신된 형태 "q=price%3A%5B200+TO+800%5D%20AND%20brand%3ANorthwood%20%26%20status%3Ain-stock"
// URL 디코딩 후 — 원래 Elasticsearch 쿼리 필터 "q=price:[200 TO 800] AND brand:Northwood & status:in-stock"
decodeURIComponent() — 값 디코딩을 위한 표준 함수
decodeURIComponent()는 JavaScript에서 URL 디코딩의 핵심 함수입니다. 입력 문자열의 모든 %XX 시퀀스를 디코딩합니다—— URL에서 구조적 의미를 가진 문자들 포함, 예를 들어 %2F (슬래시), %3F (물음표), %26 (앰퍼샌드), 그리고 %3D (등호). 이것이 개별 쿼리 파라미터 값과 경로 세그먼트를 디코딩하기에 올바른 선택이지만, 완전한 URL을 디코딩하기에는 잘못된 선택입니다——그런 경우 구조적 문자들은 인코딩된 상태로 유지되어야 합니다. 전역 함수이므로 어떤 JavaScript 환경에서도 import가 필요하지 않습니다.
최소 작동 예제
// 개별 쿼리 파라미터 값 디코딩 — 일반적인 경우
const city = decodeURIComponent('S%C3%A3o%20Paulo') // 'São Paulo'
const district = decodeURIComponent('It%C3%A1im%20Bibi') // 'Itáim Bibi'
const category = decodeURIComponent('office%20furniture') // 'office furniture'
const filter = decodeURIComponent('price%3A%5B200+TO+800%5D') // 'price:[200+TO+800]'
// 참고: +는 공백으로 디코딩되지 않습니다 — 비교 표의 + 섹션을 참조하세요
console.log(city) // São Paulo
console.log(district) // Itáim Bibi
console.log(category) // office furniture
console.log(filter) // price:[200+TO+800]쿼리 파라미터에서 추출한 리다이렉트 URL 디코딩
// 리다이렉트 URL은 파라미터 값으로 임베드될 때 퍼센트 인코딩되었습니다
// 수신 측: 추출 후 사용 — URLSearchParams로는 수동 디코딩 불필요
const incomingUrl = 'https://auth.company.com/callback' +
'?next=https%3A%2F%2Fdashboard.internal%2Freports%3Fview%3Dweekly%26team%3Dplatform' +
'&session_id=sid_7x9p2k'
const url = new URL(incomingUrl)
const rawNext = url.searchParams.get('next') // URLSearchParams로 자동 디코딩
const sessionId = url.searchParams.get('session_id') // 'sid_7x9p2k'
// rawNext는 이미 디코딩됨: 'https://dashboard.internal/reports?view=weekly&team=platform'
// decodeURIComponent(rawNext)를 다시 호출하지 마세요 — 이중 디코딩이 됩니다
const nextUrl = new URL(rawNext!)
console.log(nextUrl.hostname) // dashboard.internal
console.log(nextUrl.searchParams.get('view')) // weekly
console.log(nextUrl.searchParams.get('team')) // platform비 ASCII 및 유니코드 경로 세그먼트 디코딩
// 국제화된 경로 세그먼트를 가진 REST API
// 원래 문자의 각 UTF-8 바이트가 개별적으로 퍼센트 인코딩되었습니다
const encodedSegments = [
'%E6%9D%B1%E4%BA%AC', // 東京 (Tokyo) — 문자당 3바이트
'M%C3%BCnchen', // München — ü는 2바이트로 인코딩
'caf%C3%A9', // café — é는 NFC로 합성
'S%C3%A3o%20Paulo', // São Paulo
]
encodedSegments.forEach(seg => {
console.log(decodeURIComponent(seg))
})
// 東京
// München
// café
// São Paulo
// 스토리지 API URL에서 파일 키 추출
// 객체 키에 /가 포함되어 있어 경로 세그먼트 내에서 %2F로 인코딩됨
const storageUrl = 'https://storage.api.example.com/v1/objects/reports%2F2025%2Fq1-financials.pdf'
const rawKey = new URL(storageUrl).pathname.replace('/v1/objects/', '')
// .pathname은 URL 레벨 인코딩을 디코딩하지만 %2F (URL 레벨에서는 %252F)는 유지
// 최종 단계에서 decodeURIComponent 사용:
const fileKey = decodeURIComponent(rawKey) // 'reports/2025/q1-financials.pdf'
console.log(fileKey)decodeURIComponent()는 입력에 %가 두 개의 유효한 16진수가 아닌 다른 문자 뒤에 올 때 URIError를 발생시킵니다——예를 들어 문자열 끝의 베어 %나 %GH 같은 시퀀스입니다. 사용자 제공 입력을 디코딩할 때는 항상 try/catch로 감싸세요. 안전한 디코딩 패턴은 아래 오류 처리 섹션에서 다룹니다.JavaScript URL 디코딩 함수 — 문자 참조
세 가지 내장 디코딩 함수는 정확히 어떤 인코딩 시퀀스를 디코딩하는지에서 차이가 있습니다. 아래 표는 실제에서 가장 중요한 문자들의 동작을 보여줍니다:
| 인코딩됨 | 문자 | decodeURIComponent() | decodeURI() | URLSearchParams |
|---|---|---|---|---|
| %20 | 공백 | 공백 ✅ | 공백 ✅ | 공백 ✅ |
| + | + (폼) | + (유지) | + (유지) | 공백 ✅ |
| %2B | + 리터럴 | + ✅ | + ✅ | + ✅ |
| %26 | & | & ✅ | & (유지) ❌ | & ✅ |
| %3D | = | = ✅ | = (유지) ❌ | = ✅ |
| %3F | ? | ? ✅ | ? (유지) ❌ | ? ✅ |
| %23 | # | # ✅ | # (유지) ❌ | # ✅ |
| %2F | / | / ✅ | / (유지) ❌ | / ✅ |
| %3A | : | : ✅ | : (유지) ❌ | : ✅ |
| %40 | @ | @ ✅ | @ (유지) ❌ | @ ✅ |
| %25 | % | % ✅ | % ✅ | % ✅ |
| %C3%BC | ü | ü ✅ | ü ✅ | ü ✅ |
두 가지 핵심 행은 +와 구조적 문자들인 (%26, %3D, %3F)입니다. URLSearchParams는 +를 공백으로 디코딩합니다. 이는 application/x-www-form-urlencoded 사양을 따르기 때문입니다——HTML 폼 제출에는 올바르지만, decodeURIComponent()가 동일한 문자를 처리하는 방식과는 다릅니다. 그리고 decodeURI()는 %26, %3D, 그리고 %3F를 조용히 건너뜁니다——값이 실제로 인코딩된 앰퍼샌드나 등호를 포함할 때까지는 올바르게 보입니다.
decodeURI() — 구조를 깨지 않고 완전한 URL 디코딩
decodeURI()는 encodeURI()의 대응 함수입니다. URI에서 구조적 의미를 가진 문자들을 보존하면서 완전한 URL 문자열을 디코딩합니다: ; , / ? : @ & = + $ #. 이들은 퍼센트 인코딩된 형태로(또는 입력에서 인코딩되지 않은 상태로 나타났다면 리터럴 문자로) 남겨집니다. 이것이 decodeURI()를 경로나 호스트명에 비 ASCII 문자를 포함할 수 있는 전체 URL을 sanitise하기에 안전하게 만듭니다—— 쿼리 문자열 구조를 우연히 붕괴시키지 않고.
비 ASCII 경로 세그먼트를 가진 URL 정리
// 국제화된 경로 세그먼트와 구조화된 쿼리 문자열을 가진 CDN URL // decodeURI()는 비 ASCII 경로를 디코딩하지만 ? & = 는 그대로 유지 const encodedUrl = 'https://cdn.example.com/assets/%E6%9D%B1%E4%BA%AC%2F2025%2Fq1-report.pdf' + '?token=eyJ0eXAiOiJKV1QiLCJhbGci&expires=1735689600' const readable = decodeURI(encodedUrl) console.log(readable) // https://cdn.example.com/assets/東京/2025/q1-report.pdf?token=eyJ0eXAiOiJKV1QiLCJhbGci&expires=1735689600 // ↑ 비 ASCII 디코딩됨; ? & = 보존됨 — URL은 구조적으로 유효한 상태 유지 // decodeURIComponent는 URL을 파괴합니다 — : / ? & = 모두 한 번에 디코딩 const broken = decodeURIComponent(encodedUrl) // 'https://cdn.example.com/assets/東京/2025/q1-report.pdf?token=eyJ0eXAiOiJKV1QiLCJhbGci&expires=1735689600' // 여기서는 같아 보이지만, 'https%3A%2F%2F...' 같은 URL은 파괴됩니다
decodeURI() 대신 URL 생성자를 사용하는 것이 좋습니다. new URL(str)은 입력을 정규화하고, 구조를 검증하며, .pathname, .searchParams, 그리고 .hostname을 이미 디코딩된 프로퍼티로 노출합니다. 문자열 결과만 필요하고 URL 생성자를 사용할 수 없는 경우(예: 전역 URL 클래스가 없는 매우 오래된 Node.js 6 환경)에만 decodeURI()를 사용하세요.URLSearchParams — 쿼리 문자열 자동 디코딩
URLSearchParams는 현대 JavaScript에서 쿼리 문자열을 파싱하는 관용적인 방법입니다. .get(), .getAll(), 또는 이터레이션으로 반환되는 모든 값은 자동으로 디코딩됩니다——폼 인코딩 데이터의 경우 +도 공백으로 포함합니다. 모든 현대 브라우저와 Node.js 10+에서 전역으로 사용 가능하며, import가 필요 없고, 수동 문자열 분리에서 잘못 처리되는 엣지 케이스들을 처리합니다.
들어오는 쿼리 문자열 파싱
// webhook 콜백이나 OAuth 리다이렉트 쿼리 문자열 파싱
const rawSearch =
'?event_id=evt_9c2f4a1b' +
'&product_name=Standing+Desk+Pro' +
'&filter=price%3A%5B200+TO+800%5D' +
'&tag=ergonomic&tag=adjustable' +
'&redirect=https%3A%2F%2Fdashboard.internal%2Forders%3Fview%3Dpending'
const params = new URLSearchParams(rawSearch)
console.log(params.get('event_id')) // 'evt_9c2f4a1b'
console.log(params.get('product_name')) // 'Standing Desk Pro' ← +가 공백으로 디코딩
console.log(params.get('filter')) // 'price:[200+TO+800]' ← %3A와 %5B 디코딩
console.log(params.getAll('tag')) // ['ergonomic', 'adjustable']
console.log(params.get('redirect')) // 'https://dashboard.internal/orders?view=pending'
// 모든 파라미터 이터레이션
for (const [key, value] of params) {
console.log(`${key}: ${value}`)
}
// event_id: evt_9c2f4a1b
// product_name: Standing Desk Pro
// filter: price:[200+TO+800]
// tag: ergonomic
// tag: adjustable
// redirect: https://dashboard.internal/orders?view=pending현재 브라우저 URL에서 쿼리 파라미터 파싱
// 현재 URL: https://app.example.com/search
// ?q=standing+desk
// &category=office+furniture
// &sort=price_asc
// &page=2
interface SearchFilters {
query: string | null
category: string | null
sort: string
page: number
}
function getSearchFilters(): SearchFilters {
const params = new URLSearchParams(window.location.search)
return {
query: params.get('q'), // 'standing desk'
category: params.get('category'), // 'office furniture'
sort: params.get('sort') ?? 'relevance',
page: Number(params.get('page') ?? '1'),
}
}
const filters = getSearchFilters()
console.log(filters.query) // standing desk (+가 자동으로 디코딩)
console.log(filters.category) // office furniture파일과 API 응답에서 URL 인코딩된 데이터 디코딩
실제 프로젝트에서 항상 나타나는 두 가지 시나리오가 있습니다: 퍼센트 인코딩된 데이터를 포함하는 디스크의 파일을 처리하는 경우(접근 로그, 데이터 내보내기, webhook 캡처 파일)와 Node.js 서버에서 들어오는 HTTP 요청의 URL을 파싱하는 경우입니다. 둘 다 같은 원칙을 따릅니다——수동 문자열 분리 대신 URL 생성자나 URLSearchParams를 사용——하지만 세부 사항은 다릅니다.
파일에서 URL 인코딩된 레코드 읽기 및 디코딩
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
// 파일: orders-export.txt — 한 줄에 URL 인코딩된 레코드 하나씩
// customer_name=%EA%B9%80%EB%AF%BC%EC%A4%80&order_id=ord_9c2f4a&product=Standing+Desk+Pro&total=149900%20KRW
// customer_name=%EC%9D%B4%EC%A7%80%EC%9D%80&order_id=ord_7b3a1c&product=Ergonomic+Chair&total=89000%20KRW
// customer_name=%EC%84%9C%EC%9A%B8%EC%82%AC%EB%9E%8C&order_id=ord_2e8d5f&product=Monitor+Arm&total=59000%20KRW
interface Order {
customerName: string | null
orderId: string | null
product: string | null
total: string | null
}
async function parseOrdersFile(filePath: string): Promise<Order[]> {
const fileStream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: fileStream, crlfDelay: Infinity })
const orders: Order[] = []
for await (const line of rl) {
if (!line.trim()) continue
// URLSearchParams는 +를 공백으로 자동 디코딩하고 %XX 시퀀스도 처리
const params = new URLSearchParams(line)
orders.push({
customerName: params.get('customer_name'), // '김민준'
orderId: params.get('order_id'), // 'ord_9c2f4a'
product: params.get('product'), // 'Standing Desk Pro'
total: params.get('total'), // '149900 KRW'
})
}
return orders
}
const orders = await parseOrdersFile('./orders-export.txt')
console.log(orders[0])
// { customerName: '김민준', orderId: 'ord_9c2f4a', product: 'Standing Desk Pro', total: '149900 KRW' }Nginx 접근 로그 파싱으로 검색 쿼리 디코딩
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
// Nginx 접근 로그 줄은 다음과 같은 형태:
// 192.168.1.42 - - [11/Mar/2026:10:23:01 +0000] "GET /api/search?q=standing%20desk%26brand%3ANorthwood&sort=price_asc HTTP/1.1" 200 1842
async function extractSearchQueries(logFile: string): Promise<string[]> {
const rl = createInterface({ input: createReadStream(logFile), crlfDelay: Infinity })
const queries: string[] = []
for await (const line of rl) {
// 로그 줄에서 요청 경로 추출
const match = line.match(/"GET ([^ ]+) HTTP/)
if (!match) continue
try {
const requestUrl = new URL(match[1], 'http://localhost')
const query = requestUrl.searchParams.get('q')
if (query) queries.push(query) // URLSearchParams가 자동으로 디코딩
} catch {
// 잘못된 형식의 줄 건너뜀 — 접근 로그에는 잘린 항목이 있을 수 있음
}
}
return queries
}
const queries = await extractSearchQueries('/var/log/nginx/access.log')
console.log(queries)
// ['standing desk&brand:Northwood', 'ergonomic chair', 'monitor arm 27 inch']Node.js HTTP 서버에서 쿼리 파라미터 파싱
import http from 'http'
// 들어오는 URL: /api/products?q=standing+desk&warehouse=eu%2Dwest%2D1&minStock=10&cursor=eyJpZCI6MTIzfQ%3D%3D
const server = http.createServer((req, res) => {
// 전체 URL 구성 — URL 생성자에서 필요한 베이스가 두 번째 인자
const requestUrl = new URL(req.url!, 'http://localhost')
const searchQuery = requestUrl.searchParams.get('q') // 'standing desk'
const warehouseId = requestUrl.searchParams.get('warehouse') // 'eu-west-1'
const minStock = Number(requestUrl.searchParams.get('minStock') ?? '0')
const cursor = requestUrl.searchParams.get('cursor') // 'eyJpZCI6MTIzfQ=='
if (!warehouseId) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'warehouse parameter is required' }))
return
}
const cursorData = cursor ? JSON.parse(Buffer.from(cursor, 'base64').toString()) : null
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ searchQuery, warehouseId, minStock, cursorData }))
})
server.listen(3000)개발 중에 인코딩된 URL을 검사해야 할 때——파싱 코드를 작성하기 전에 webhook이 무엇을 보내는지 이해하기 위해——스크립트를 실행하지 않고도 직접 ToolDeck의 URL Decoder 에 붙여넣어 즉시 디코딩된 형태를 확인하세요.
커맨드라인 URL 디코딩
셸 스크립트, CI 파이프라인, 또는 인코딩된 문자열의 빠른 일회성 검사를 위해 완전한 스크립트를 작성하지 않고도 작동하는 몇 가지 방법이 있습니다. Node.js 원라이너는 크로스 플랫폼이며; macOS와 Linux에서는 python3도 항상 사용 가능합니다.
# ── Node.js 원라이너 ──────────────────────────────────────────────────
# 단일 퍼센트 인코딩 값 디코딩
node -e "console.log(decodeURIComponent(process.argv[1]))" "S%C3%A3o%20Paulo%20%26%20Rio"
# São Paulo & Rio
# 쿼리 문자열을 파싱하고 각 키=값 쌍 출력 (디코딩됨)
node -e "
const params = new URLSearchParams(process.argv[1])
for (const [k, v] of params) console.log(`${k} = ${v}`)
" "q=standing+desk&warehouse=eu%2Dwest%2D1&minStock=10"
# q = standing desk
# warehouse = eu-west-1
# minStock = 10
# URL 인코딩된 JSON 본문 디코딩 및 예쁘게 출력 (webhook 디버깅에서 일반적)
node -e "
const raw = decodeURIComponent(process.argv[1])
console.log(JSON.stringify(JSON.parse(raw), null, 2))
" '%7B%22event%22%3A%22purchase%22%2C%22amount%22%3A149.99%2C%22currency%22%3A%22EUR%22%7D'
# {
# "event": "purchase",
# "amount": 149.99,
# "currency": "EUR"
# }
# ── Python 원라이너 (대부분의 macOS/Linux 시스템에서 사용 가능) ──────────
# unquote_plus는 +도 공백으로 디코딩 — 폼 인코딩 데이터에 올바름
python3 -c "
from urllib.parse import unquote_plus
import sys
print(unquote_plus(sys.argv[1]))
" "Standing+Desk+%26+Ergonomic+Chair"
# Standing Desk & Ergonomic Chair
# ── curl — 응답 헤더에서 리다이렉트 URL 로깅 및 디코딩 ─────────────────
curl -sI "https://api.example.com/short/abc123" | grep -i location | node -e "
const line = require('fs').readFileSync('/dev/stdin', 'utf8')
const url = line.replace(/^location:s*/i, '').trim()
console.log(decodeURIComponent(url))
"decode-uri-component를 사용한 안전한 디코딩
내장 decodeURIComponent()는 잘못된 형식의 시퀀스——두 개의 16진수가 뒤따르지 않는 후행 % 포함——에서 URIError를 발생시킵니다. 사용자 제공 또는 서드파티 URL을 처리하는 프로덕션 코드——검색 쿼리 로그, 이메일 캠페인의 클릭스루 URL, 스크래핑된 웹 데이터——에서 잘못된 형식의 시퀀스는 충돌을 일으킬 만큼 흔합니다. decode-uri-component 패키지(주간 npm 다운로드 ~3000만)는 발생시키는 대신 원래 시퀀스를 변경하지 않고 반환하여 잘못된 형식의 시퀀스를 우아하게 처리합니다.
npm install decode-uri-component # or pnpm add decode-uri-component
import decodeUriComponent from 'decode-uri-component'
// 네이티브 함수는 잘못된 입력에서 발생시킴 — 요청 핸들러를 충돌시킬 수 있음
try {
decodeURIComponent('product%name%') // ❌ URIError: URI malformed
} catch (e) {
console.error('네이티브 발생:', (e as Error).message)
}
// decode-uri-component는 발생시키는 대신 원시 시퀀스를 반환
console.log(decodeUriComponent('product%name%'))
// product%name% ← 잘못된 형식의 시퀀스는 그대로 유지, 발생 없음
// 유효한 시퀀스는 올바르게 디코딩
console.log(decodeUriComponent('S%C3%A3o%20Paulo%20%26%20Rio'))
// São Paulo & Rio
// 혼합: 유효한 시퀀스는 디코딩, 유효하지 않은 것은 보존
console.log(decodeUriComponent('Berlin%20Office%20%ZZ%20HQ'))
// Berlin Office %ZZ HQ ← %ZZ는 유효한 16진수가 아님 — 그대로 유지
// 로그 처리 파이프라인에서의 실용적 사용
const rawSearchQueries = [
'standing%20desk%20ergonomic',
'price%3A%5B200+TO+800%5D',
'50%25+off', // ← decodeURIComponent로는 발생 (베어 %)
'wireless%20keyboard%20%26%20mouse',
]
const decoded = rawSearchQueries.map(q => decodeUriComponent(q))
console.log(decoded)
// ['standing desk ergonomic', 'price:[200+TO+800]', '50%25+off', 'wireless keyboard & mouse']예상치 못한 입력이 도착했을 때 즉시 알고 싶은 애플리케이션 코드에는 try/catch와 함께 decodeURIComponent()를 사용하세요. 일부 입력에 유효하지 않은 인코딩이 포함되어 있어도 처리를 계속하고 싶은 데이터 파이프라인, 로그 프로세서, webhook 핸들러에서는 decode-uri-component를 사용하세요.
잘못된 형식의 퍼센트 인코딩 문자열 처리
베어 %, 불완전한 시퀀스 %A, 또는 유효하지 않은 쌍 %GH는 모두 decodeURIComponent()가 URIError: URI malformed를 발생시키게 합니다. 사용자 제어 입력——검색 쿼리, URL 프래그먼트, URL을 포함하는 폼 필드, 이메일 캠페인 링크의 파라미터——은 이런 시퀀스를 포함할 수 있습니다. 외부에 노출된 모든 코드에서 안전한 래퍼는 필수적입니다.
일반적인 시나리오를 위한 안전한 디코딩 래퍼
// ── 1. 기본 안전 디코딩 — 오류 시 원래 문자열 반환 ─────────────────────
function safeDecode(encoded: string): string {
try {
return decodeURIComponent(encoded)
} catch {
return encoded // 디코딩 실패 시 원시 입력 반환 — 절대 충돌 없음
}
}
// ── 2. 안전 디코딩 + + 처리 (폼 인코딩 값용) ─────────────────────────
function safeFormDecode(formEncoded: string): string {
try {
return decodeURIComponent(formEncoded.replace(/+/g, ' '))
} catch {
return formEncoded.replace(/+/g, ' ') // 나머지가 실패해도 최소한 + 교체
}
}
// ── 3. 안전한 전체 쿼리 문자열 파서 → 일반 객체 ──────────────────────
function parseQueryString(queryString: string): Record<string, string> {
const result: Record<string, string> = {}
try {
const params = new URLSearchParams(queryString)
for (const [key, value] of params) {
result[key] = value // URLSearchParams가 내부적으로 모든 디코딩 처리
}
} catch {
// 완전히 잘못된 형식의 입력은 조용히 빈 것 반환
}
return result
}
// 사용법
console.log(safeDecode('S%C3%A3o%20Paulo')) // São Paulo
console.log(safeDecode('search%20for%2050%25+off')) // search for 50%+off
// → 실제로는 괜찮음; 여기서 %는 %25
console.log(safeDecode('malformed%string%')) // malformed%string% (발생 없음)
console.log(safeFormDecode('standing+desk+pro')) // standing desk pro
console.log(parseQueryString('q=hello+world&tag=node%20js&page=2'))
// { q: 'hello world', tag: 'node js', page: '2' }흔한 실수
이 네 가지 패턴이 프로덕션에서 조용한 데이터 손상이나 예상치 못한 충돌을 일으키는 것을 봤습니다——보통 값에 특수 문자가 포함될 때만 나타나는데, 이는 단위 테스트를 통과하고 실제 사용자 데이터에서만 나타나는 그런 버그입니다.
실수 1 — URLSearchParams 값을 이중 디코딩
문제: URLSearchParams.get()은 이미 디코딩된 문자열을 반환합니다. 그 위에 decodeURIComponent()를 호출하면 값이 이중 디코딩됩니다——디코딩된 출력의 나머지 %가 %25가 되어 데이터가 손상됩니다. 수정: URLSearchParams.get()의 값을 직접 사용하세요——추가 디코딩이 필요하지 않습니다.
// ❌ URLSearchParams가 이미 디코딩함 — 다시 디코딩하면 % 가진 값이 손상
const qs = new URLSearchParams('rate=50%25&redirect=https%3A%2F%2Fdashboard.internal%2F')
const rawRate = qs.get('rate') // '50%' ← 이미 디코딩됨
const wrongRate = decodeURIComponent(rawRate)
// '50%25' ← 두 번째 패스에서 %가 %25로 디코딩됨 — 이제 다시 잘못됨// ✅ URLSearchParams.get()을 직접 사용 — 디코딩은 자동
const qs = new URLSearchParams('rate=50%25&redirect=https%3A%2F%2Fdashboard.internal%2F')
const rate = qs.get('rate') // '50%' ← 올바름, 추가 단계 불필요
const redirect = qs.get('redirect') // 'https://dashboard.internal/'
const parsed = new URL(redirect!) // 안전하게 구성 가능 — 이미 디코딩됨
console.log(parsed.hostname) // dashboard.internal실수 2 — 폼 인코딩 데이터에서 +를 공백으로 디코딩하지 않음
문제: 폼 제출과 일부 OAuth 라이브러리는 공백을 +로 인코딩합니다 (application/x-www-form-urlencoded). 이 데이터에 decodeURIComponent()를 호출하면 +가 리터럴 플러스 기호로 남습니다. 수정: 폼 인코딩 데이터 파싱에는 URLSearchParams를 사용하거나, decodeURIComponent()를 호출하기 전에 +를 공백으로 교체하세요.
// ❌ decodeURIComponent는 +를 공백이 아닌 리터럴 플러스로 처리
// OAuth 토큰 엔드포인트 전송: grant_type=authorization_code&code=SplxlOBeZQQYb...
// &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
// 일부 레거시 OAuth 구현은 코드에서도 +를 공백에 사용
const formBody = 'customer_name=Sarah+Chen&product=Standing+Desk+Pro&qty=2'
const [, rawVal] = formBody.split('&')[0].split('=')
const name = decodeURIComponent(rawVal)
console.log(name) // 'Sarah+Chen' ← +가 공백으로 변환되지 않음// ✅ URLSearchParams 사용 — application/x-www-form-urlencoded 사양 따름
const formBody = 'customer_name=Sarah+Chen&product=Standing+Desk+Pro&qty=2'
const params = new URLSearchParams(formBody)
console.log(params.get('customer_name')) // 'Sarah Chen' ← +가 공백으로 올바르게 디코딩
console.log(params.get('product')) // 'Standing Desk Pro'실수 3 — 개별 쿼리 파라미터 값에 decodeURI() 사용
문제: decodeURI()는 %26 (&), %3D (=), 또는 %3F (?)를 디코딩하지 않습니다—— 파라미터 값 내부에서 흔히 인코딩되는 문자들입니다. 단일 값을 디코딩하는 데 사용하면 해당 시퀀스들이 그대로 남아 조용히 잘못된 출력을 생성합니다. 수정: 개별 값에는 decodeURIComponent()를 사용하고, decodeURI()는 완전한 URL 문자열에만 사용하세요.
// ❌ decodeURI는 & 와 = 를 디코딩하지 않음 — 값이 깨진 상태로 유지 const encodedFilter = 'status%3Dactive%26tier%3Dpremium%26region%3Deu-west' const filter = decodeURI(encodedFilter) console.log(filter) // 'status%3Dactive%26tier%3Dpremium%26region%3Deu-west' ← %3D와 %26 디코딩 안됨
// ✅ decodeURIComponent는 & 와 = 포함 모든 시퀀스 디코딩 const encodedFilter = 'status%3Dactive%26tier%3Dpremium%26region%3Deu-west' const filter = decodeURIComponent(encodedFilter) console.log(filter) // 'status=active&tier=premium®ion=eu-west' ← 올바르게 디코딩됨
실수 4 — 사용자 입력에 decodeURIComponent를 try/catch로 감싸지 않음
문제: 두 개의 16진수가 뒤따르지 않는 베어 %——사용자가 입력한 검색 쿼리에서 흔함 ("50% 할인", "100% 면")——은 decodeURIComponent()가 URIError: URI malformed를 발생시키게 하여, 잡히지 않으면 요청 핸들러를 충돌시킵니다. 수정: 입력이 사용자 데이터, URL 프래그먼트, 또는 외부 시스템에서 오는 경우 항상 try/catch로 감싸세요.
// ❌ 사용자가 검색창에 '100% 면'을 입력 — 베어 %가 서버를 충돌시킴
// GET /api/search?q=100%25+cotton ← 이 특정 경우는 괜찮음 (%25 = %)
// GET /api/search?q=100%+cotton ← 이것은 충돌 (% 뒤에 2개 16진수가 없음)
app.get('/api/search', (req, res) => {
const query = decodeURIComponent(req.query.q as string)
// ↑ '100% 면'에 대해 URIError: URI malformed 발생 → 처리되지 않은 500 오류
})// ✅ try/catch로 감싸기 — 디코딩 실패 시 원시 입력으로 폴백
app.get('/api/search', (req, res) => {
let query: string
try {
query = decodeURIComponent(req.query.q as string)
} catch {
query = req.query.q as string // 충돌 대신 원시 값 사용
}
// 안전하게 처리 계속 — query는 디코딩되었거나 원시 상태
})decodeURIComponent vs decodeURI vs URLSearchParams — 빠른 비교
| 메서드 | %XX 디코딩 | +를 공백으로 | 잘못된 형식 시 예외 | & = ? # 디코딩 | 사용 사례 | 설치 필요 |
|---|---|---|---|---|---|---|
| decodeURIComponent() | ✅ 전체 | ❌ 아니오 | ✅ URIError | ✅ 예 | 개별 값과 경로 세그먼트 | No |
| decodeURI() | ✅ 대부분 | ❌ 아니오 | ✅ URIError | ❌ 아니오 | 완전한 URL 문자열 | No |
| URLSearchParams | ✅ 전체 | ✅ 예 | ❌ 조용히 | ✅ 예 | 자동 디코딩이 있는 쿼리 문자열 파싱 | No |
| URL 생성자 | ✅ 전체 | ✅ 예 | ✅ TypeError | ✅ 예 | 전체 URL 파싱 및 정규화 | No |
| decode-uri-component | ✅ 전체 | ❌ 아니오 | ❌ 조용히 | ✅ 예 | 잘못된 입력 허용 배치 디코딩 | npm install |
| querystring.unescape() | ✅ 전체 | ❌ 아니오 | ❌ 조용히 | ✅ 예 | 레거시 Node.js (v16에서 폐기) | No (내장) |
대부분의 경우 선택은 세 가지 시나리오로 좁혀집니다. URLSearchParams를 사용하여 쿼리 문자열을 파싱하세요——디코딩, +를 공백으로 처리하는 규칙, 그리고 반복 키를 자동으로 처리합니다. decodeURIComponent()( try/catch로 감싼)를 단일 값이나 경로 세그먼트에 사용하세요, 특히 세그먼트 내부에 %2F 인코딩된 슬래시가 있는 경우. decodeURI()는 경로에 비 ASCII 문자가 있는 완전한 URL 문자열을 가지고 있고 구조적 문자 (/ ? & =)가 출력에서 인코딩된 상태로 유지되어야 할 때만 사용하세요.
자주 묻는 질문
관련 도구
코드를 작성하지 않고 한 번의 클릭으로 디코딩하려면, 퍼센트 인코딩된 문자열을 직접 ToolDeck의 URL Decoder 에 붙여넣으세요——브라우저에서 즉시 디코딩하며, 결과를 코드나 터미널에 복사할 준비가 됩니다.
Alex is a front-end and Node.js developer with extensive experience building web applications and developer tooling. He is passionate about web standards, browser APIs, and the JavaScript ecosystem. In his spare time he contributes to open-source projects and writes about modern JavaScript patterns, performance optimisation, and everything related to the web platform.
Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.