퍼센트 인코딩(URL Encoding) — 문자를 %XX로 바꾸고 되돌리기

NOTE

URL/쿼리 문자열에 그대로 넣을 수 없는 문자(공백, 한글, 특수문자)를 %XX(16진수 두 자리) 형태로 바꾸는 퍼센트 인코딩과, 그걸 다시 원래 문자로 복원하는 디코딩을 직접 구현하며 원리를 정리한다.

0. 왜 필요한가

URL은 영문·숫자와 일부 기호만 안전하게 담을 수 있다. 공백이나 한글, &/= 같은 URL 문법에서 특별한 의미를 갖는 문자를 값 안에 그대로 넣으면 파싱이 깨진다. 이런 문자를 안전한 문자만으로 표현하는 표준 방법이 퍼센트 인코딩이다 — 안 되는 문자를 % 뒤에 그 문자의 바이트 값을 16진수 두 자리로 붙여서 표현한다(예: 공백 → %20).

1. 인코딩 — 안전한 문자는 그대로, 나머지는 %XX로

static int is_safe_char(char c)
{
    return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
        || c == '-' || c == '.' || c == '_';
}
 
int url_encode(const char *src, char *dst, unsigned int dst_len)
{
    unsigned int out = 0;
 
    for (; *src != '\0'; src++) {
        if (is_safe_char(*src)) {
            out += snprintf(dst + out, dst_len - out, "%c", *src);
        } else {
            out += snprintf(dst + out, dst_len - out, "%%%02X", (unsigned char)*src);
        }
    }
    dst[out] = '\0';
 
    return out;
}

%02X는 “16진수 두 자리로, 자리가 모자라면 앞에 0을 채운다”는 포맷이다. 항상 정확히 두 글자가 나오도록 자리를 고정해야, 디코딩할 때 % 다음 두 글자를 고민 없이 고정 길이로 읽어낼 수 있다.

2. 디코딩 — %를 만나면 다음 두 글자를 16진수로 해석

static int hex_digit_value(char c)
{
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'A' && c <= 'F') return (c - 'A') + 10;
    if (c >= 'a' && c <= 'f') return (c - 'a') + 10;
    return -1;   /* 16진수 문자가 아님 */
}
 
int url_decode(const char *src, char *dst, unsigned int dst_len)
{
    unsigned int out = 0;
 
    for (; *src != '\0' && out < dst_len - 1; src++) {
        if (src[0] == '%' && src[1] != '\0' && src[2] != '\0') {
            int hi = hex_digit_value(src[1]);
            int lo = hex_digit_value(src[2]);
            if (hi < 0 || lo < 0) {
                return -1;   /* %XX 형식이 아님 — 잘못된 입력으로 처리 */
            }
            dst[out++] = (char)((hi << 4) | lo);
            src += 2;
        } else {
            dst[out++] = *src;
        }
    }
    dst[out] = '\0';
 
    return out;
}

핵심은 '0''9', 'A''F', 'a''f'가 아스키 코드 안에서 각각 연속된 값이라는 성질이다. 문자에서 그 구간의 시작 문자를 빼면 015 사이의 숫자 값이 나온다('7' - '0' = 7, 'C' - 'A' + 10 = 12). 16진수 두 자리는 “앞자리 값 × 16 + 뒷자리 값”으로 합치는데, hi << 4(왼쪽으로 4비트 시프트)가 정확히 hi * 16과 같은 값이다 — 16진수 한 자리가 4비트를 표현하므로, 한 자리를 올리는 건 4비트만큼 왼쪽으로 미는 것과 동일하다.

3. 자주 하는 실수

  • dst_len을 넘는지 확인하지 않고 계속 채우기 — url_encode에서 snprintf는 잘려도 실제로 필요했던 길이를 리턴하므로, out이 dst_len을 넘어선 뒤에도 계속 그 값에 더하면 다음 snprintf 호출의 크기 계산(dst_len - out)이 음수가 되어(부호 없는 타입이면 거대한 양수로 뒤집혀서) 버퍼 밖에 쓰게 될 수 있다. 매 반복마다 남은 공간을 확인해야 한다.
  • 소문자 16진수(%2f 등)를 처리 안 하기 — 표준(RFC 3986)은 %XX의 대소문자를 구분하지 않는다. 디코딩 쪽에서 대문자 A~F만 처리하고 소문자를 빼먹으면, 다른 시스템이 소문자로 인코딩해서 보낸 값을 못 알아본다.
  • % 뒤에 문자가 두 개 안 남은 경우(문자열이 중간에 끝난 경우)를 검사 안 하기 — src[1], src[2]가 이미 문자열의 널 종단을 지나쳤는데 그대로 읽으면 범위 밖 메모리를 읽는다. 2번 예제처럼 %를 만났을 때 그 뒤 두 글자가 실제로 존재하는지부터 확인해야 한다.
  • 안전 문자 집합을 표준과 다르게 잡기 — 어떤 문자를 “안전하다”고 볼지는 URL의 어느 부분(경로, 쿼리 값 등)이냐에 따라 미묘하게 다르다. 너무 좁게 잡으면 불필요하게 인코딩되는 문자가 늘고, 너무 넓게 잡으면 실제로 예약된 의미를 가진 문자(&, = 등)가 인코딩 없이 값 안에 섞여 들어가 파싱이 깨질 수 있다.

관련 문서