로그인과 토큰 검증은 성공했지만 한글 표시 이름을 HTTP 헤더에 그대로 넣으면서 내부 요청 생성이 실패한 원인과 해결 과정을 정리했다.
시작하며
로그인은 정상적으로 완료됐지만 로그인 이후의 API 요청이 실패하는 문제가 있었다. 모든 사용자에게 발생하는 것도 아니었다.
영문 이름 사용자 정상
한글 이름 사용자 실패
Token은 정상적으로 발급됐고 Gateway의 인증도 통과했다. 사용자 정보도 올바르게 조회됐다. 그런데 Gateway가 요청을 내부 서비스로 전달하는 과정에서 한글 사용자에게만 예외가 발생했다.
원인은 인증이나 권한이 아니었다. Gateway가 인증된 사용자의 이름을 Custom Header에 그대로 넣었고, Node.js HTTP Client가 요청을 만들면서 Header Value의 한글 문자를 거부하고 있었다.
로그인 성공
↓
Token 검증 성공
↓
사용자 정보 생성 성공
↓
내부 HTTP 요청 Header 생성
↓
ERR_INVALID_CHAR
나는 먼저 요청이 어느 단계에서 멈췄는지 확인했다. 이후 사용자 식별에는 ID를 사용하고, 내부 서비스에 필요한 표시 이름은 UTF-8 Base64 문자열로 전달하도록 수정했다.
로그인은 성공했지만 다음 요청부터 실패했다
증상만 보면 인증 문제처럼 보였다.
1. 사용자가 로그인한다.
2. 서버가 Token을 발급한다.
3. Client가 Token으로 다른 API를 호출한다.
4. 한글 사용자만 오류를 받는다.
로그인 직후부터 실패해 사용자 권한과 토큰의 사용자 정보를 먼저 살폈다. 요청이 내부 서비스까지 도착하는지도 함께 확인했다.
| 확인한 항목 | 확인 기준 |
|---|---|
| 사용자 권한 | 같은 API에 접근 가능한 사용자끼리 비교한다 |
| 토큰의 사용자 정보 | 검증 후 얻은 ID와 이름을 확인한다 |
| DB 문자열 | 조회한 이름이 이미 깨져 있는지 확인한다 |
| 내부 서비스 호출 | 접근 로그와 응답 상태 코드로 도착 여부를 확인한다 |
하지만 실패한 요청을 단계별로 확인하면 인증은 이미 끝난 상태였다.
Client Request
↓
Gateway Token Verification 성공
↓
Authenticated User Context 정상
↓
Proxy Request Creation 실패
↓
Internal Service 도착하지 않음
내부 서비스의 Access Log와 Controller Log가 모두 없었다. 상대 서버가 4xx나 5xx를 반환한 것이 아니라 Gateway Process 안에서 요청을 전송하기 전에 실패한 것이다.
성공 사용자와 실패 사용자의 입력값을 비교한다
같은 권한과 같은 API를 사용하는 두 사용자를 비교했다.
성공
id = 12
name = Alice
실패
id = 13
name = 홍길동
ID 형식과 권한 구조는 같았고 표시 이름의 문자 범위만 달랐다. 이름을 영문으로 바꾸면 같은 요청이 성공했다.
이 차이를 통해 인증 결과를 내부 서비스로 전달하는 Header를 확인했다.
headers['x-user-id'] = String(user.id);
headers['x-user-name'] = user.name;
숫자 ID를 문자열로 바꾼 값은 문제가 없었다. 사용자 이름을 그대로 넣은 Header만 입력에 따라 달라졌다.

영문 테스트만 사용했다면 이 코드는 정상처럼 보인다. 사용자 입력이 전송 Protocol의 경계를 지날 때는 값의 존재 여부뿐 아니라 문자 Encoding도 확인해야 한다.
내부 서비스가 아니라 요청 생성 단계에서 실패했다
Gateway가 사용하는 HTTP Client는 요청을 보내기 전에 Header 이름과 값을 검증한다. 허용되지 않는 문자가 포함되면 네트워크 연결 전에 예외를 발생시킨다.
대표 오류는 다음과 같은 형태다.
TypeError [ERR_INVALID_CHAR]:
Invalid character in header content
실패 지점은 다음과 같다.
Gateway
├── 사용자 인증 성공
├── 사용자 정보 조회 성공
├── Header 객체 생성 값 할당
├── HTTP 요청 생성 실패
└── Socket 전송 실행되지 않음
따라서 내부 서비스의 Controller, Guard, DB Query를 조사해도 원인을 찾을 수 없다. 요청이 도착하지 않았기 때문이다.
나는 인증이 끝난 지점부터 요청을 전송하는 지점까지 범위를 좁혔다. 내부 서비스의 로그가 없다는 사실에 더해, 예외 코드가 ERR_INVALID_CHAR라는 점이 헤더 생성으로 조사 범위를 좁히는 단서였다.
| 관찰한 증상 | 다음 확인 위치 |
|---|---|
| 내부 서비스가 4xx 또는 5xx를 응답 | 응답 본문과 해당 서비스의 처리 과정 |
ERR_INVALID_CHAR 발생 |
호출 측에서 만드는 헤더 이름과 값 |
| 응답 없이 연결 거부 또는 시간 초과 | DNS, 연결 대상과 네트워크 경로 |
response가 없다는 사실만으로 헤더 오류라고 결론 내릴 수는 없다. DNS 실패나 연결 거부도 응답 없이 끝난다. 이번에는 요청 생성 단계와 오류 코드를 함께 확인했기 때문에 헤더의 문자열을 조사했다.
HTTP Header를 일반적인 Unicode 문자열 저장소처럼 사용하지 않는다
JSON Body는 일반적으로 UTF-8 문자열 전달을 전제로 사용할 수 있다.
{
"displayName": "홍길동"
}
반면 HTTP Header에 사용자 입력을 그대로 넣는 것은 안전하지 않다.
x-user-name: 홍길동
Node.js HTTP Client는 Header Value에 전송할 수 없는 문자가 포함되면 요청을 거부한다. 이 검증은 보안과 Protocol 일관성을 위한 동작이므로 끄는 방식으로 해결해서는 안 된다.
다음과 같은 값도 같은 문제를 만들 수 있다.
- 사용자 표시 이름
- 조직명과 지역명
- 파일명
- Emoji가 포함된 Label
- 사용자 입력으로 만든 추적 정보
여기서 모든 비ASCII 문자가 언제나 거부된다고 일반화하면 안 된다. Node.js가 일부 바이트 범위의 값을 허용하더라도, 한글이나 이모지를 임의의 유니코드 문자열 그대로 헤더에 넣을 수 있다는 뜻은 아니다. 사용자 이름에는 송신과 수신이 합의한 인코딩이 필요하다. Node.js 헤더 값 검증 문서
서버 없이 실패 조건을 확인한다
아래 코드는 네트워크 요청 없이 한글 문자열과 Base64 문자열의 차이를 확인할 수 있다.
const { validateHeaderValue } = require('node:http');
try {
validateHeaderValue('x-user-name', '홍길동');
} catch (error) {
console.log(error.code); // ERR_INVALID_CHAR
}
const encoded = Buffer.from('홍길동', 'utf8').toString('base64');
validateHeaderValue('x-user-name', encoded); // 예외 없음
console.log(Buffer.from(encoded, 'base64').toString('utf8')); // 홍길동
이 예시로 확인하는 것은 헤더 값의 허용 여부와 이름의 복원이다. 실제 프록시 호출의 성공 여부는 Gateway에서 내부 서비스까지 이어지는 경로에서 별도로 확인한다.
사용자 ID와 표시 이름의 역할을 분리한다
처음 구현에서는 사용자 이름이 식별 정보처럼 함께 전달됐다. 하지만 이름은 안정적인 식별자가 아니다.
- 같은 이름을 여러 사람이 사용할 수 있다.
- 사용자가 이름을 변경할 수 있다.
- 언어에 따라 문자 범위가 달라진다.
- 공백과 특수문자가 포함될 수 있다.
- 대소문자와 Unicode 정규화 방식이 다를 수 있다.
사용자 식별은 숫자 ID처럼 안정적인 값으로 처리한다.
if (user.id !== undefined && user.id !== null) {
headers['x-user-id'] = String(user.id);
}
표시 이름은 이력이나 화면 표시처럼 사람이 읽어야 하는 곳에서만 사용한다.
x-user-id
→ 사용자 식별
→ 권한과 소유 관계
x-user-name
→ 변경 이력의 표시 이름
→ 운영 화면의 사용자 표시
내부 서비스도 이름을 기준으로 사용자 권한이나 데이터 소유자를 판단하지 않아야 한다.
UTF-8 문자열을 Base64로 바꿔 전달한다
표시 이름 자체는 내부 서비스에서 필요했다. 그래서 이름을 없애는 대신 ASCII 문자열로 변환해 전달했다.
Gateway에서는 다음 순서로 Encoding한다.
Unicode 문자열
↓ UTF-8 Encoding
Byte 배열
↓ Base64 Encoding
ASCII 문자열
Node.js에서는 Buffer로 구현할 수 있다.
function encodeUserName(name: string): string {
return Buffer
.from(name, 'utf8')
.toString('base64');
}
headers['x-user-name'] = encodeUserName(
user.name,
);
한글 이름은 다음처럼 변환된다.
홍길동
↓ UTF-8
ED 99 8D EA B8 B8 EB 8F 99
↓ Base64
7ZmN6ri464+Z
최종 Header에는 ASCII 문자열만 들어간다.
x-user-name: 7ZmN6ri464+Z
수신 서비스는 같은 계약으로 Decode한다
내부 서비스는 Header Value를 Base64에서 UTF-8 문자열로 복원한다.
function decodeUserNameHeader(
header?: string | string[] | null,
): string | null {
const value = Array.isArray(header)
? header[0]
: header;
if (!value) {
return null;
}
try {
const decoded = Buffer
.from(value, 'base64')
.toString('utf8');
return decoded.trim() || null;
} catch {
return null;
}
}

다만 이 함수의 try/catch가 잘못된 Base64를 모두 걸러 주는 것은 아니다. Buffer.from(value, 'base64')는 일부 비정상 입력을 허용하고, 잘못된 UTF-8 바이트는 문자열 변환 과정에서 대체 문자로 나타날 수 있다. 따라서 이 코드는 신뢰된 Gateway가 만든 값을 복원하는 용도이며 엄격한 형식 검증기는 아니다. 엄격한 검증이 필요하면 Base64 형식과 UTF-8 유효성을 별도로 검사해야 한다. Node.js Buffer 문서
인코딩과 디코딩은 한 쌍의 내부 계약이다.
Gateway
UTF-8 → Base64
Internal Service
Base64 → UTF-8
각 Controller에서 직접 Decode하면 누락되거나 다른 방식이 섞일 수 있다. 공통 유틸리티나 Decorator에서 처리해 내부 서비스의 사용 코드는 복원된 문자열만 받도록 했다.
URL Encoding도 가능하지만 의미를 명확히 해야 한다
encodeURIComponent()를 사용해 ASCII 문자열을 만들 수도 있다.
headers['x-user-name'] =
encodeURIComponent(user.name);
그러나 이 값은 URL의 Path나 Query가 아니라 Custom Header에 담긴 문자열이다. URL Encoding을 사용하면 다음 규칙도 함께 관리해야 한다.
- 송신 시 한 번 인코딩하고 수신 시 한 번 디코딩한다.
- 원본의
%도 인코딩 대상에 포함한다. decodeURIComponent()로 복원하고 폼 인코딩의+처리 규칙을 섞지 않는다.
Base64도 계약은 필요하지만 UTF-8 Byte를 ASCII로 운반한다는 목적이 명확하다.
나는 UTF-8 바이트를 ASCII 문자열로 옮기기 위해 Base64를 선택했다. 다른 인코딩을 선택하더라도 송신자와 수신자가 같은 규칙으로 변환하고 복원해야 한다.
Base64는 암호화가 아니다
Base64 결과는 사람이 바로 읽기 어렵지만 누구나 쉽게 원본으로 되돌릴 수 있다.
const original = Buffer
.from('7ZmN6ri464+Z', 'base64')
.toString('utf8');
console.log(original); // 홍길동
Base64가 제공하는 것은 Binary-to-text Encoding이다. 다음 기능은 제공하지 않는다.
- 기밀성
- 사용자 인증
- 요청 위조 방지
- 데이터 무결성
- 개인정보 보호
이번 문제에서 Base64를 사용한 이유는 한글을 숨기기 위해서가 아니라 UTF-8 Byte를 Header에 넣을 수 있는 ASCII 문자열로 바꾸기 위해서다.
Base64
→ 운반 형식
Encryption
→ 허가되지 않은 사람이 내용을 읽지 못하게 보호
Signature
→ 데이터가 변조되지 않았는지 검증
민감정보 보호나 요청 위조 방지는 TLS, 내부 인증, 서명과 접근 제어 같은 별도 수단으로 해결해야 한다.
Client가 보낸 사용자 Header를 신뢰하지 않는다
클라이언트도 같은 이름의 Custom Header를 보낼 수 있다.
x-user-id: 42
x-user-name: 7ZmN6ri464+Z
이 값을 그대로 내부 서비스에 전달하면 다른 사용자의 ID나 이름을 위조할 수 있다.
사용자 Context는 Gateway가 검증한 Token에서 새로 만든다.
Client가 보낸 사용자 Context Header
→ 신뢰하지 않음
검증된 Token의 사용자 정보
→ Gateway가 ID와 이름 추출
→ 내부 Header 생성
// 전달용 헤더를 복사했다면 외부 사용자 정보는 먼저 제거한다.
for (const key of Object.keys(headers)) {
if (['x-user-id', 'x-user-name'].includes(key.toLowerCase())) {
delete headers[key];
}
}
const user = request.user;
if (user?.id !== undefined && user?.id !== null) {
headers['x-user-id'] = String(user.id);
}
if (user?.name) {
headers['x-user-name'] =
encodeUserName(user.name);
}
Base64 Decode에 성공했다는 사실도 인증을 의미하지 않는다. 내부 서비스는 요청이 신뢰할 수 있는 Gateway에서 왔는지 별도의 내부 인증 경계로 확인해야 한다.
사용자 정보가 없으면 Header를 만들지 않는다
로그인 전 요청이나 사용자 Context가 없는 시스템 호출에는 ID와 이름이 없을 수 있다.
이때 빈 문자열이나 임의의 사용자 값을 만들어 넣지 않는다.
if (user?.name) {
headers['x-user-name'] =
encodeUserName(user.name);
}
수신 서비스도 값이 없거나 정상적인 문자열을 얻지 못하면 null 또는 unknown으로 처리한다.
확인된 이름 있음
→ Decode한 이름 사용
Header 없음
→ unknown
빈 값
→ unknown
복원 결과 없음
→ unknown
감사 이력에서는 잘못된 사용자를 추측해 기록하는 것보다 확인할 수 없다는 사실을 남기는 편이 안전하다.
한글 Round Trip을 회귀 테스트로 고정한다
문제를 수정한 뒤에는 한글 이름을 헤더로 만드는 과정과 수신 측에서 복원하는 과정을 회귀 테스트에 넣었다. 헤더 생성에 성공하더라도 이름이 잘못 복원되면 변경 이력에 엉뚱한 값이 남을 수 있기 때문이다.
it('한글 이름을 Base64로 전달한다', () => {
const headers = buildForwardHeaders({
id: 12,
name: '홍길동',
});
const decoded = Buffer
.from(headers['x-user-name'], 'base64')
.toString('utf8');
expect(decoded).toBe('홍길동');
});
수신 유틸리티에도 같은 한글 이름을 넣어 복원 결과를 확인했다.
it('Base64 한글 이름을 복원한다', () => {
const encoded = Buffer
.from('홍길동', 'utf8')
.toString('base64');
expect(decodeUserNameHeader(encoded))
.toBe('홍길동');
});
Header Parser에 따라 값이 배열로 전달될 가능성도 확인한다.
expect(decodeUserNameHeader([encoded]))
.toBe('홍길동');
모든 전달 Header가 안전한 값인지 검사한다
한글 이름 하나만 테스트하면 다른 Header에 Unicode 값이 추가됐을 때 같은 문제가 반복될 수 있다.
Gateway가 내부 요청에 붙이는 Header 전체를 검사한다.
const isSendableHeaderValue = (value: unknown) =>
typeof value === 'string' &&
!/[^\t\x20-\x7e\x80-\xff]/.test(value);
it('내부 요청의 모든 Header 값을 전송할 수 있다', () => {
const headers = buildForwardHeaders({
id: 12,
name: '홍길동',
});
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue;
}
expect([key, isSendableHeaderValue(value)])
.toEqual([key, true]);
}
});
회귀 테스트에는 다음 조건을 넣었다.
- 사용자 ID가 문자열 Header로 전달된다.
- 한글 이름이 Base64로 전달되고 원래 값으로 복원된다.
- Gateway가 추가하는 모든 Header Value가 전송 가능한 형태다.
- 로그인 전에는 사용자 ID와 이름 Header를 추가하지 않는다.
- Header가 배열 형태여도 첫 값을 Decode한다.
- 값이 없으면
null을 반환한다.
공백과 Emoji까지 회귀 범위를 넓힌다
먼저 한글 이름의 왕복을 회귀 테스트로 고정했다. 하지만 Unicode 문제를 한글 하나의 예외로만 보면 다른 입력에서 다시 실패할 수 있다.
추가로 다음 문자열을 테스트할 수 있다.
describe.each([
['ASCII', 'Alice'],
['한글', '홍길동'],
['공백 포함', '김 개발자'],
['Emoji', '사용자🙂'],
['악센트 문자', 'Renée'],
['특수문자', `O'Connor`],
])('%s 표시 이름', (_, original) => {
it('Base64 Header로 왕복된다', () => {
const encoded = Buffer
.from(original, 'utf8')
.toString('base64');
const decoded = Buffer
.from(encoded, 'base64')
.toString('utf8');
expect(decoded).toBe(original);
});
});
한글 왕복을 확인한 뒤에는 이 목록으로 문자 범위를 넓힐 수 있다. 실제 전달 함수와 수신 유틸리티를 함께 호출해야 어느 한쪽의 인코딩 누락도 잡을 수 있다.
공백에는 별도의 정책도 필요하다. Decode 함수가 trim()을 적용하면 앞뒤 공백은 제거된다.
" 김 개발자 "
↓ trim
"김 개발자"
이름 정책상 앞뒤 공백을 허용하지 않는다면 정상화가 맞다. 공백도 원본 데이터의 일부라면 Decode 단계에서 임의로 제거하면 안 된다. Encoding 테스트와 데이터 정책 테스트를 구분해야 한다.
Header에는 최소한의 사용자 Context만 전달한다
Base64는 원본 Byte보다 문자열 길이가 늘어난다. 전체 사용자 Profile이나 큰 권한 목록을 Header로 전달하는 것은 적합하지 않다.
Header로 전달
├── 안정적인 사용자 ID
├── 필요한 경우 표시 이름
└── 요청 처리에 필요한 최소 Context
Header로 전달하지 않음
├── 전체 사용자 Profile
├── 대용량 권한 목록
├── 불필요한 개인정보
└── Body에 적합한 구조화 데이터
Header는 Gateway, Proxy와 내부 서비스의 Log에 남을 수 있다. Encoding 가능 여부와 별개로 민감한 정보는 최소화해야 한다.
사용자 이름이 반드시 필요하지 않다면 ID만 전달하고 내부 서비스가 필요할 때 조회하는 방식도 검토할 수 있다. 다만 요청마다 사용자 조회가 추가되는 비용과 서비스 간 결합을 함께 비교해야 한다.
수정 결과는 요청 전달과 이름 복원까지 확인한다
헤더 생성에서 예외가 사라지는 것만으로는 충분하지 않다. 수신 측에서 Base64 문자열을 그대로 표시하면 요청은 성공해도 사용자 이름이 잘못 남는다. 아래처럼 전달과 복원을 나눠 확인해야 한다.
| 확인 지점 | 수정 전 문제 | 수정 후 확인 기준 |
|---|---|---|
| Gateway 요청 생성 | 한글을 그대로 넣어 ERR_INVALID_CHAR 발생 |
인코딩된 이름으로 요청 생성 |
| 내부 서비스 | 요청이 도착하지 않음 | 같은 API 요청이 도착하고 정상 응답 |
| 표시 이름 사용 | 이름을 전달하지 못함 | 수신 유틸리티에서 한글 이름 복원 |
| 사용자 없는 요청 | 외부 헤더가 남으면 잘못된 사용자로 오인 가능 | 사용자 헤더를 만들지 않고 외부 값도 제거 |
문제를 다시 만났을 때의 점검 순서
특정 언어와 문자열에서만 Proxy 요청이 실패한다면 다음 순서로 확인할 수 있다.
- 로그인과 Token 검증이 실제로 성공했는지 확인한다.
- 내부 서비스에 요청이 도착했는지 확인한다.
- HTTP Client 예외에
response가 있는지 확인한다. - 성공 사용자와 실패 사용자의 입력값을 비교한다.
- Gateway가 사용자 입력으로 Header를 만드는지 찾는다.
- Header Value를 안전한 운반 형식으로 변환한다.
- 송신과 수신의 Encoding 계약을 공통화한다.
- 한글과 Emoji를 포함한 회귀 테스트를 추가한다.
- Base64를 인증이나 암호화로 오해하지 않는다.
- Header에 전달하는 사용자 정보를 최소화한다.
내부 서비스의 오류 로그가 없다는 사실도 중요한 단서다. 요청이 도착하지 않았다면 상대 서비스의 비즈니스 로직보다 요청을 만드는 Gateway와 HTTP Client부터 확인해야 한다.
마치며
로그인 직후 오류가 나면 인증부터 의심하기 쉽다. 이번에는 토큰 검증 이후 내부 요청을 만드는 단계까지 나누어 보면서, 한글 사용자 정보가 헤더에 들어가는 지점을 찾았다.
사용자 식별은 ID로 처리하고, 표시 이름은 UTF-8 Base64로 전달한 뒤 공통 유틸리티에서 복원하도록 정리했다. 이때 Base64는 운반 형식일 뿐이므로, 사용자 정보의 신뢰는 Gateway의 인증과 내부 서비스의 접근 경계에서 보장해야 한다.
이후 비슷한 문제를 조사할 때는 상대 서비스에 요청이 도착했는지, 실제 응답을 받았는지, 어떤 예외 코드가 남았는지부터 확인한다. 문자열의 내용과 그 문자열을 전달하는 형식을 함께 보는 것이 이 장애에서 얻은 기준이다.
'Backend > Troubleshooting' 카테고리의 다른 글
| [Trino] 같은 서버의 MySQL인데 왜 연결이 타임아웃됐을까 (0) | 2026.09.04 |
|---|---|
| [Trino] MySQL은 연결되는데 Trino Worker는 연결되지 않았던 이유 (0) | 2026.08.30 |
| [트러블슈팅] 젠킨스 디스크 용량 부족 (0) | 2024.05.19 |
| [트러블슈팅] ConcurrentModificationException (0) | 2024.05.18 |
| [트러블슈팅] Could not resolve org.springframework.boot:spring-boot-gradle-plugin:3.1.2" (0) | 2024.05.17 |
| [JPA] OneToOne 조회 오류 - More than one row with the given identifier was found (0) | 2024.05.12 |