LiteSpeed .htaccess로 한글 URL 리라이트하기: 실측으로 확인한 것
패턴은 인코딩 전일까 후일까, 공백은 어떻게 쓸까, 301은 쿼리스트링을 남길까. 운영 중인 LiteSpeed에 요청 23번을 보내 확인한 결과와 안전한 실험 방법.
한글이 들어간 URL의 리라이트 규칙에는 확인되지 않은 정보가 많습니다. "인코딩해서 써라"는 글도 있고 "그대로 써라"는 글도 있는데, 대부분 Apache에서 시험한 것이고 많은 공유 호스팅이 쓰는 LiteSpeed에서 확인한 글은 드뭅니다. 저희는 정확히 알아야 했습니다. App Store 등록 정보가 한글과 일본어가 들어간 WordPress URL을 가리키고 있었기 때문입니다. 틀리면 지원 페이지 링크가 소리 없이 404가 됩니다.
그래서 직접 측정했습니다. 아래 내용은 모두 운영 중인 LiteSpeed 서버(Hostinger 공유 호스팅, 앞단에 Hostinger CDN)에 2026년 11월 8일과 9일에 보낸 요청 23번의 결과입니다.
운영 서버에 영향 없이 운영 서버에서 시험하는 법
공유 호스팅에는 스테이징이 없습니다. 하지만 하위 폴더의 .htaccess는 그 폴더 아래로 오는 요청에만 적용되므로, 일회용 폴더가 안전한 실험실이 됩니다.
/_rwtest_YYYYMMDD/를 만든다. 이미 있으면 중단해서 남의 폴더를 덮어쓰지 않는다RewriteBase /_rwtest_YYYYMMDD/를 넣은.htaccess와, 표식 문자열(OK-KO등)만 담은 작은 파일을 올린다- 리다이렉트를 따라가지 않고, 매번 고유한 쿼리(
?cb=<나노초>)를 붙여 요청한다. CDN 캐시가 대신 응답하지 못하게 하기 위해서다 - 결과와 상관없이
finally에서 폴더를 지운다
try:
ftp.mkd(target)
for rel, body in FILES.items():
ftp.storbinary(f"STOR {target}/{rel}", io.BytesIO(body.encode()))
for desc, method, path, want_status, want_body, want_loc in CASES:
status, loc, body = request(method, path) # 리다이렉트는 따라가지 않음
...
finally:
rmtree(ftp, target) # 반드시 삭제
이 방법은 나중에 규칙 207개가 든 루트 .htaccess 전체를 격리 폴더 기준으로 바꿔 시험하는 데도 썼고, 운영 루트에 넣기 전에 문법 오류가 없다는 것을 확인했습니다.
결과 1: 패턴은 디코딩된 경로와 비교된다
규칙 RewriteRule ^개인정보\-처리방침/?$ ok-ko.html [L]은 파일 안에 한글을 UTF-8 그대로 썼습니다.
| 요청 | 상태 |
|---|---|
/%ea%b0%9c%ec%9d%b8...%b9%a8/ (소문자 인코딩) |
200 |
/%EA%B0%9C%EC%9D%B8...%B9%A8/ (대문자 인코딩) |
200 |
/개인정보-처리방침 (끝 슬래시 없음) |
200 |
퍼센트 인코딩으로 쓴 규칙 ^%EB%A7%81%ED%81%AClinq/?$ |
한 번도 매칭 안 됨 |
클라이언트가 대문자로 인코딩하든 소문자로 하든 차이가 없습니다. LiteSpeed가 먼저 디코딩한 다음 비교하기 때문입니다. 퍼센트 인코딩으로 쓴 패턴은 사실상 죽은 코드입니다. .htaccess는 BOM 없는 UTF-8로 저장하고 글자를 그대로 쓰면 됩니다.
결과 2: 공백 표기는 세 가지 모두 동작한다
어떤 스토어 등록은 US%20Lottery%20Infomation이었습니다. 같은 규칙을 네 가지로 써서 비교했습니다.
| 패턴 | /x-US%20Lottery/ 요청 |
|---|---|
^a\-US\ Lottery/?$ (백슬래시+공백) |
200 |
^b\-US\sLottery/?$ |
200 |
"^c-US Lottery/?$" (따옴표로 감쌈) |
200 |
^d\-US%20Lottery/?$ |
404 |
즉 LiteSpeed는 흔히 쓰는 표기를 모두 받아들입니다. 실제로 저희가 걸린 문제는 다른 곳에 있었습니다. Python 생성 스크립트가 re.escape()로 공백을 이스케이프한 뒤 이어지는 문자열 치환에서 한 번 더 이스케이프해서, \\(백슬래시의 이스케이프+공백)를 출력하고 있었던 것입니다. 규칙을 생성한다면 \s를 쓰세요. 공백이 들어 있지 않으니 뒤에 오는 이스케이프 처리에 쪼개질 일이 없습니다.
결과 3: 대소문자 무시와 "공백 무시"는 다르다
같은 페이지가 US Lottery Infomation(스토어 등록)과 us-lottery-infomation(WordPress 슬러그) 두 형태로 존재했습니다. 대소문자는 [NC]로 해결되지만, 공백과 하이픈은 둘 다 명시적으로 허용해야 합니다.
RewriteRule ^technical\-support\-of\-the\-「US[\s-]Lottery[\s-]Infomation」/?$ app/us_lottery/support/index.html [L,NC]
두 형태 모두 200이었습니다. 낫표 「」는 이스케이프할 필요가 없었습니다.
결과 4: 301은 쿼리스트링을 넘긴다 (지우지 않는 한)
| 규칙 대상 | GET /keepqs/?utm=x의 Location |
|---|---|
/ok.html [R=301,L] |
/ok.html?utm=x&cb=… |
/ok.html? [R=301,L] |
/ok.html |
/ok.html [R=301,L,QSD] |
/ok.html |
옮긴 블로그 글이라면 쿼리가 남는 것은 대개 괜찮습니다(UTM이 유지됩니다). 하지만 WordPress의 /?p=1219처럼 쿼리 자체를 조건으로 쓰는 규칙에서는 반드시 지워야 합니다. 그러지 않으면 새 URL에 ?p=1219가 계속 붙어 다닙니다.
RewriteCond %{QUERY_STRING} (^|&)p=1219(&|$)
RewriteRule ^$ /ko/csharp-linq-basics/? [R=301,L]
결과 5: 내부 리라이트로 끝 슬래시 리다이렉트를 피한다
새 사이트에서 /privacy는 실제 디렉토리입니다. 보통은 슬래시 없이 요청하면 /privacy/로 301이 돌아옵니다. 슬래시 없는 경로를 파일로 바로 연결하는 규칙을 쓰면 이를 피할 수 있습니다.
RewriteRule ^privacy/?$ privacy/index.html [L]
실측 결과 /privacy는 리다이렉트 없이 바로 200을 돌려줬습니다. 같은 방법으로 모바일 앱의 API 호출 32건(디렉토리인 /Lotto/json/latest 등)이 "301 뒤에 200"에서 "한 번에 200"으로 바뀌었습니다. POST에서는 이게 더 중요합니다. 모바일 HTTP 클라이언트 대부분은 301을 받은 POST를 다시 보내지 않습니다.
데이터로 규칙을 생성하고, 올리기 전에 확인하기
옛 URL이 100개가 넘는다면 규칙을 손으로 쓰면 안 됩니다. 저희는 대응표를 CSV(old_path, new_path, kind)로 관리하고, 거기서 .htaccess 블록을 생성했습니다.
def esc(path: str) -> str:
# re.escape는 한글·일본어를 그대로 두고 정규식 특수 문자만 이스케이프한다.
# WordPress는 공백을 하이픈으로 바꾸므로 둘 다 허용한다.
return re.escape(path.strip("/")).replace("\\ ", "[\\s-]")
for r in rows:
if r["kind"] == "serve": # 스토어 등록 URL: 같은 주소에서 200
out.append(f"RewriteRule ^{esc(r['old_path'])}/?$ {r['new_path'].lstrip('/')}index.html [L,NC]")
else: # 일반 이전: 301
out.append(f"RewriteRule ^{esc(r['old_path'])}/?$ {r['new_path']} [R=301,L]")
올리기 전에는 알려진 옛 URL을 모두 생성된 패턴에 Python 정규식 엔진으로 대 보는 짧은 스크립트를 돌렸습니다. [L]이면 처음 매칭된 규칙만 적용되므로, URL마다 처음 매칭되는 규칙이 올바른 대상으로 가는지 확인합니다. 대상은 WordPress URL 196개(끝 슬래시 있음·없음)와 스토어 등록 URL 16개 전부입니다. 이 검사로 하이픈 표기 누락과 이중 이스케이프된 공백을 찾았고, 둘 다 서버에 닿기 전에 고쳤습니다. Python 정규식 엔진은 LiteSpeed의 것과 다르니 이건 빠른 1차 필터일 뿐이고, 진짜 증명은 앞에서 설명한 격리 폴더 실측입니다.
한글 URL 리라이트 체크리스트
- 글자는 UTF-8 그대로 쓴다. 패턴에 퍼센트 인코딩을 쓰지 않는다
- 클라이언트의 인코딩이 대문자든 소문자든 규칙을 따로 만들 필요는 없다
- 대소문자는
[NC], 공백과 하이픈이 섞이면[\s-], 생성하는 규칙에는\s - 쿼리스트링이 조건이었다면 대상 끝에
?(또는QSD) - 리다이렉트가 곤란한 곳에서는 슬래시 없는 디렉토리 URL을
index.html/index.php로 직접 연결한다 - 운영 서버의 일회용 하위 폴더에서 캐시 우회 쿼리를 붙여 시험하고, 끝나면 지운다
이 실험은 스토어에 등록된 URL을 끊지 않고 WordPress를 정적 사이트로 옮기는 작업과, 설치된 앱을 멈추지 않고 옛 API를 옮기는 작업을 위해 한 것입니다. 두 작업은 이 블로그의 다른 글에 각각 정리했습니다.
자주 묻는 질문
RewriteRule 패턴에 퍼센트 인코딩을 써야 하나요?
아닙니다. LiteSpeed는 디코딩된 경로와 비교합니다. %20이나 %EB%A7%81 같은 패턴은 한 번도 매칭되지 않았고, 같은 규칙을 한글과 공백 그대로 쓰면 어떤 인코딩 형태의 요청에도 매칭됐습니다.
공백은 어떻게 써야 하나요?
백슬래시+공백, \s, 따옴표로 감싼 패턴 모두 200이었습니다. 스크립트로 규칙을 생성한다면 \s가 안전합니다. 이스케이프가 두 번 걸려도 깨지지 않습니다.
301 리다이렉트에서 원래 쿼리스트링이 유지되나요?
기본적으로 유지됩니다. 대상 끝에 ?를 붙이거나 QSD 플래그를 쓰면 사라지며, 두 방법의 결과는 같았습니다.