사람과 장비는 같은 API Gateway를 호출하지만 Credential의 발급 방식과 수명주기, 접근 범위는 다르다. 사용자 JWT와 장비 Token을 별도의 Guard로 나누고, 인증 결과에서 자원 범위를 파생한 과정을 정리했다.
시작하며
처음에는 외부 요청을 모두 Gateway의 사용자 인증으로 처리하는 구성이 단순해 보였다. 브라우저 요청은 Access Token을 검증하고, 필요하면 Refresh Token으로 재발급한 뒤 내부 서비스에 사용자 정보를 전달하면 된다.
하지만 외부 호출 주체가 사람만 있는 것은 아니었다. 로그인 화면을 사용할 수 없는 무인 장비와 자동화 프로그램도 API를 호출해야 했다.
장비에 사용자 계정을 발급해 JWT를 사용하게 만들 수도 있다. 그러나 사용자 JWT는 로그인, 로그아웃, 권한 변경과 Token 재발급 같은 사용자 Session의 수명주기를 전제로 한다. 장기간 무인으로 동작하는 장비에 이 흐름을 그대로 적용하면 장비의 신원과 특정 사용자 계정이 불필요하게 결합한다.
결국 모든 요청에 같은 인증 수단을 적용하는 대신 호출 주체별로 인증 경계를 나눴다.
사람 → 사용자 JWT
무인 장비 → 장비 Token
자동화 프로그램 → API Key
Gateway 내부 호출 → Internal Secret
이 글에서는 사용자 API와 Machine-to-Machine API를 나눈 이유, UUID4 대신 UUID5를 장비 Token 생성에 사용한 배경, 그리고 현재 방식이 가진 보안상의 한계를 함께 살펴본다.
호출 주체가 다르면 Credential의 수명주기도 다르다
사용자와 장비는 모두 API를 호출하지만 같은 주체는 아니다.
사용자 인증에는 다음 특성이 있다.
- 사용자가 직접 로그인한다.
- Access Token은 비교적 짧은 수명을 가진다.
- Refresh Token으로 Session을 연장한다.
- 로그아웃과 계정 상태 변경을 반영한다.
- 사용자 역할과 권한을 내부 서비스에 전달한다.
반면 장비 인증에는 다른 조건이 필요했다.
- 로그인 UI 없이 시작할 수 있어야 한다.
- 재부팅 후에도 같은 Credential을 다시 만들 수 있어야 한다.
- 장비가 속한 자원 범위를 서버가 결정해야 한다.
- 사용자 Session의 만료와 재발급에 의존하지 않아야 한다.
- 장비 교체와 폐기는 사용자 계정과 독립적으로 처리해야 한다.
인증 수단을 하나로 통일하면 코드 종류는 줄어들지만 서로 다른 수명주기가 한 정책에 섞인다. 그래서 사용자 JWT를 처리하는 Guard와 장비 Token을 처리하는 Guard를 분리했다.

경로 Prefix는 외부 계약이지 인증 수단이 아니다
장비와 자동화 프로그램이 호출하는 외부 API에는 사용자 API와 구분되는 경로 접두사를 적용했다. /machine/resource처럼 장비용 경로를 따로 두는 방식이다.
Machine Client → Gateway: /machine/<resource>
Gateway → Internal Service: /<resource>
외부 Prefix는 소비자가 의존하는 API 계약이다. Gateway 뒤의 서비스 이름과 내부 경로가 바뀌더라도 장비가 사용하는 외부 경로는 유지할 수 있다.
하지만 /machine이라는 문자열 자체에는 아무런 보안 기능이 없다. 경로를 알고 있다고 인증된 장비가 되는 것도 아니다.
/machine 경로 사용 ≠ 인증 완료
모든 Machine API에는 호출 주체에 맞는 별도 인증이 필요하다. Prefix는 Routing과 Version Contract를 나타내고, Guard가 실제 신원을 검증한다.
Public은 인증 생략이 아니라 사용자 인증의 우회다
Gateway에는 사용자 JWT를 확인하는 Guard가 전역으로 등록돼 있다. 장비 요청에는 사용자 JWT가 없으므로 해당 Guard를 우회해야 한다.
@Public()
@UseGuards(DeviceAuthGuard)
@Get('resource')
getResource() {
// 장비용 응답
}
내가 구현한 사용자 Guard에서 @Public()은 누구나 호출할 수 있다는 최종 정책이 아니다. 전역 사용자 Guard를 적용하지 말라는 Metadata다. 그 자리를 DeviceAuthGuard가 대신한다.
Machine Request
↓
@Public(): 사용자 JWT Guard 우회
↓
DeviceAuthGuard: 장비 Token 검증
↓
Controller 실행
이 구성을 사용할 때 가장 위험한 실수는 @Public()만 붙이고 대체 Guard를 누락하는 것이다. 이름만 보면 의도를 구분하기 어렵기 때문에 다음과 같이 하나의 Decorator로 묶는 방법도 고려할 수 있다.
export function MachineAuthenticated() {
return applyDecorators(
Public(),
UseGuards(DeviceAuthGuard),
);
}
사용자 인증을 우회하는 설정과 장비 인증을 적용하는 설정이 항상 함께 움직이게 만드는 개선안이다.
실제로는 두 데코레이터를 따로 사용하고 있어서, 장비 업로드 API에 두 설정이 모두 남아 있는지 확인하는 테스트를 작성했다. 컨트롤러의 @Public() 메타데이터와 업로드 메서드의 DeviceAuthGuard 등록을 각각 검사한다. 어느 하나를 지워도 문법 오류가 나지 않는 만큼, 정상 요청이 성공하는지만 확인해서는 부족했다.
| 빠진 설정 | 생기는 문제 | 고정한 검사 |
|---|---|---|
| 사용자 Guard 우회 | 장비 Token이 사용자 JWT 검증 단계에서 거절됨 | 컨트롤러에 Public 메타데이터가 있는지 검사 |
| 장비 Guard | 사용자 인증만 우회하고 장비 인증 없이 실행될 수 있음 | 메서드의 Guard 목록에 DeviceAuthGuard가 포함되는지 검사 |
이 테스트는 설정 누락을 잡는 용도다. 실제 Token 검증과 접근 범위 제한까지 증명하는 테스트는 아니므로 별도로 확인해야 한다.
UUID4로는 같은 Token을 다시 만들 수 없었다
장비 Token을 정할 때 UUID4도 검토했다. UUID4는 무작위 값이므로 호출할 때마다 새로운 값이 나온다.
uuid4() → 매번 다른 값
UUID4를 장비 Credential로 사용하려면 처음 발급한 값을 어딘가에 저장하고 장비에 안전하게 전달해야 한다. 서버도 원문 또는 검증 가능한 Hash를 저장해야 하며, 재설치 시 Credential을 복구하거나 다시 발급하는 절차가 필요하다.
당시 구현에서 필요했던 것은 장비와 서버가 같은 장비 식별자를 이용해 별도 Token 저장 없이 동일한 값을 계산하는 것이었다. 이 조건에는 이름 기반 UUID인 UUID5가 맞았다.
UUID4
같은 입력 개념 없음 → 호출할 때마다 다른 값
UUID5
같은 Namespace + 같은 Name → 항상 같은 값
UUID5는 Namespace와 Name을 입력으로 받아 결정적인 UUID를 만든다.
function deviceToken(deviceId: string): string {
return uuid5(
TOKEN_PREFIX + deviceId.trim().toLowerCase(),
DEVICE_NAMESPACE,
);
}
장비의 Python 구현과 Gateway의 Node.js 구현이 같은 Namespace와 정규화 규칙을 사용하면 동일한 결과가 나온다. 위 uuid5는 이름을 첫 번째 인자로 받도록 만든 공통 함수다.
이 과정에서 실제로 어긋났던 부분은 함수의 인자 순서였다. Python의 uuid.uuid5(namespace, name)과 Node.js uuid 패키지의 v5(name, namespace)는 순서가 반대다. 같은 UUID5라는 이름만 보고 호출 형태까지 같다고 생각하면 잘못된 값을 만들거나 입력 오류가 발생할 수 있다.
그래서 Node.js에서는 Namespace 바이트와 UTF-8 이름으로 SHA-1을 계산하고, UUID 버전과 variant 비트를 설정하는 구현을 공통 함수로 모았다. 장비 식별자도 양쪽에서 앞뒤 공백을 제거하고 소문자로 맞췄다. 비교할 것은 함수 이름이 아니라 같은 입력을 넣었을 때의 최종 결과였다.
| 비교 항목 | 맞춰야 할 내용 |
|---|---|
| Namespace | 같은 UUID의 바이트 표현을 사용 |
| Name | 접두사와 장비 식별자의 결합 순서를 통일 |
| 식별자 정규화 | 앞뒤 공백 제거와 소문자 변환을 양쪽에 적용 |
| 문자열 인코딩 | UTF-8로 계산 |
| 결과 | 고정된 입력으로 Python과 Node.js 결과가 같은지 비교 |
UUID5의 생성 규칙은 RFC 9562의 UUID Version 5에 정리돼 있다.

UUID5를 선택한 이유는 UUID5가 UUID4보다 항상 우수해서가 아니다. 당시 요구사항이 “발급한 난수를 저장한다”가 아니라 “같은 입력으로 같은 값을 재현한다”였기 때문이다.
결정적이라는 특성과 안전하다는 특성은 다르다
UUID5를 사용하면 저장 없이 같은 값을 재현할 수 있지만 비밀성이 생기는 것은 아니다. UUID5는 암호나 서명 Token이 아니다.
Namespace, Prefix와 장비 식별자를 알면 누구든 같은 UUID를 계산할 수 있다. SHA-1을 사용한다는 사실보다 더 근본적인 문제는 Token 생성에 서버만 아는 Secret이 포함되지 않는다는 점이다.
따라서 현재 방식의 성격은 다음과 같이 표현해야 정확하다.
UUID5 장비 Token
├── 장점: 결정적, 구현 간 재현 가능, 별도 저장 불필요
└── 한계: 입력을 알면 재생성 가능, 폐기와 교체 어려움
이 방식은 기존 장비와 같은 값을 재현하기 위한 호환 선택이지, 새 장비 인증을 설계할 때 권장할 보안 모델은 아니다. 내부망에 있다고 계산 가능한 값이 비밀이 되는 것도 아니다. UUID를 보유했다는 사실만으로 접근 권한을 부여하지 말라는 주의는 RFC 9562의 보안 고려사항에서도 확인할 수 있다.
보안을 강화하려면 다음 구조가 더 적합하다.
- 장비별 무작위 Secret을 발급한다.
- 서버에는 원문이 아닌 Token Hash를 저장한다.
- Credential별 발급, 폐기와 Rotation 시각을 관리한다.
- 장비 식별자와 Secret을 분리한다.
- 더 높은 신뢰가 필요하면 mTLS를 사용한다.
UUID4는 이 구조에서 무작위 Credential을 만드는 용도로 사용할 수 있다. 즉 UUID4를 사용할 수 없었던 것은 UUID4 자체의 결함이 아니라, 당시 선택한 “저장 없이 재현” 방식과 맞지 않았기 때문이다.
인증 결과에서 자원 범위를 파생한다
장비 Guard는 Token의 형식만 검사하지 않는다. 활성 장비에 같은 UUID5 규칙을 적용해 일치하는 장비를 찾고, 장비 ID와 연결된 자원 범위를 Request에 주입한다.
request.deviceId = matched.id;
request.scopeId = matched.scopeId;
Controller는 클라이언트가 전달한 Scope를 그대로 신뢰하지 않는다.
Bearer Device Token
↓
DeviceAuthGuard
↓
인증된 장비 정보
├── deviceId
└── scopeId
↓
해당 범위의 데이터만 요청
Path에 장비 ID가 포함되는 API에서는 Path 값과 인증 결과도 비교한다.
if (requestedDeviceId !== request.deviceId) {
throw new ForbiddenException();
}
Token이 유효한 장비라는 사실만으로 다른 장비의 데이터까지 접근할 수 있게 해서는 안 된다.
- 인증은 어떤 장비인지 확인한다.
- 인가는 그 장비가 어느 범위에 접근할 수 있는지 결정한다.
장비가 요청 Body나 Query에 자신의 Scope를 직접 싣지 않게 한 것도 같은 이유다. 권한 범위는 입력값이 아니라 검증된 신원에서 파생해야 한다.
Gateway 이후에는 내부 신뢰 경계로 전환한다
장비 Token은 Gateway에서 검증한다. Gateway가 내부 서비스로 요청을 전달할 때는 장비 Token 대신 내부 호출을 증명하는 Header를 사용한다.
Machine Client
│ Device Token
▼
Gateway
│ Gateway Marker + Internal Secret
▼
Internal Service
내부 서비스는 운영 환경에서 다음 내용을 확인한다.
- Gateway 호출을 나타내는 헤더를 확인한다.
- 전달받은 내부 Secret을 서버 설정값과 비교한다.
Gateway 표시 헤더만으로는 호출자를 증명할 수 없다. 클라이언트도 같은 이름의 헤더를 보낼 수 있으므로, 내부 Secret 검증과 직접 접근 차단이 함께 필요하다.
외부 장비 Credential 검증을 모든 내부 서비스에 복제하지 않고 Gateway가 검증한 장비 정보만 전달하는 구조다. 내부 서비스는 외부에서 직접 호출되지 않도록 Loopback 또는 사설 Network에 묶는다.
다만 하나의 공유 Secret은 유출 시 영향 범위가 크고 서비스별 권한을 표현하기 어렵다. 서비스가 늘어나면 서비스별 Credential, Secret Rotation, 짧은 수명의 Service Token이나 mTLS를 고려해야 한다.
Credential을 소유한 서비스에서 검증할 수도 있다
모든 Machine Credential을 Gateway가 검증하는 것은 아니다. 일부 자동화 API의 API Key는 해당 업무 서비스의 DB에서 발급과 폐기 상태를 관리한다.
Gateway가 그 DB Connection까지 가지게 만들면 인증을 중앙화하는 대신 서비스의 데이터 경계가 무너진다. 이 경우 Gateway는 사용자 JWT만 우회해 요청을 전달하고, 실제 API Key 검증은 Credential을 소유한 서비스가 수행한다.
Automation Client
│ API Key
▼
Gateway: Route 선택
▼
Owning Service: Key 검증, 접근 범위 결정
나는 검증 위치를 정할 때 발급과 폐기, 상태 조회, 접근 범위 결정을 묶어서 봤다. 이 정보를 업무 서비스가 소유한다면 그 서비스에서 Key를 검증하도록 두고, Gateway에는 라우팅에 필요한 책임만 남기는 편이 데이터 의존성을 줄일 수 있었다.
API Key가 Query String으로 전달되면 Proxy와 Access Log에 남을 수 있다는 문제도 있다. 가능하면 Authorization Header나 전용 Header를 사용하고, 기존 계약을 유지해야 한다면 로그 마스킹과 주기적인 Rotation이 필요하다.
Machine API는 응답 계약도 다르다
사용자 API는 Gateway에서 성공 응답을 공통 Wrapper로 감싼다.
{
"success": true,
"statusCode": 200,
"data": {}
}
반면 장비 프로그램은 이미 합의된 JSON의 최상위 Field를 직접 사용할 수 있다. Gateway가 같은 응답을 공통 Wrapper로 감싸면 인증은 성공해도 장비가 응답을 해석하지 못한다.
그래서 장비용으로 지정한 경로는 성공 응답을 공통 형식으로 감싸지 않도록 예외 처리했다. /machine/* 같은 경로 이름이 자동으로 이런 동작을 만드는 것은 아니다. 응답 Interceptor에서 해당 경로를 구분해야 한다.
예를 들어 장비가 response.items를 읽고 있다면 응답을 감싼 뒤에는 response.data.items로 접근해야 한다. HTTP 200이 돌아와도 소비자에게는 계약이 깨진 것이다. 인증 코드를 분리할 때 응답 형태까지 함께 확인한 이유다.
인증 경계를 분리하면서 계약도 다음처럼 나뉜다.
- 사용자 API: 사용자 Session과 공통 응답 형식
- Machine API: 장비 Credential과 기계 소비자 전용 응답 형식
- 내부 API: Gateway 전용 Credential과 내부 서비스 계약
경로와 인증, 응답은 각각 다른 문제지만 하나의 외부 API 계약 안에서는 함께 검토해야 한다.
현재 구현의 한계
인증할 때마다 활성 장비 전체를 순회한다
현재 Guard는 활성 장비 목록을 조회하고 각 식별자로 UUID5를 계산해 제시된 Token과 비교한다. 장비 수가 늘어나면 인증 비용이 선형으로 증가한다.
결정적 Token을 유지하면서 조회 비용을 줄이려면 검색 가능한 Token 해시를 저장하는 방법을 고려할 수 있다. 다만 이는 전체 순회를 줄일 뿐, 입력을 아는 사람이 Token을 재생성할 수 있다는 보안 문제를 해결하지 않는다. 서명된 Token으로 전환한다면 별도의 비밀키 관리와 만료 정책까지 설계해야 한다. 장기적으로는 장비별 Credential 테이블로 전환하는 편이 폐기와 Rotation에도 유리하다.
Public과 대체 Guard의 결합이 코드 규칙에 의존한다
사용자 Guard 우회와 장비 Guard 적용이 별도 Decorator이므로 하나를 빠뜨릴 수 있다. 장비 업로드 API에는 두 설정을 검사하는 테스트를 두었지만, 이것만으로 모든 장비용 API가 보호된다고 할 수는 없다. 같은 검사를 다른 장비용 API로 확대하고 통합 데코레이터를 도입하는 작업이 남아 있다.
개발 환경에서는 내부 접근 제한이 완화된다
내부 서비스의 Gateway 전용 제한은 개발 환경에서 적용되지 않는다. 로컬 개발에는 편리하지만 배포 환경 변수가 잘못 설정되면 직접 접근이 열릴 수 있다. 운영 배포 과정에서 환경과 Network Binding을 함께 검증해야 한다.
공유 Secret의 권한 범위가 넓다
모든 내부 호출이 같은 Secret을 사용하면 어느 서비스가 호출했는지 구분하기 어렵다. Secret 유출 시 여러 내부 API가 함께 영향을 받는다. 서비스 단위 Identity와 최소 권한이 필요하다.
배포 전에 확인할 인증 경계
정상 장비의 요청 한 번이 성공하는 것으로 검증을 끝내면 다른 장비의 자원에 접근하는 경우나 Guard가 빠진 경로를 놓치기 쉽다. 인증 성공 여부, 접근 범위, 응답 형태를 나눠 다음 기준으로 확인한다.
| 요청 조건 | 확인할 동작 | 주의점 |
|---|---|---|
| Authorization 헤더가 없거나 Bearer 값이 비어 있음 | 장비 Guard가 401로 거절 | 사용자 로그인 흐름으로 넘기지 않음 |
| 활성 장비와 일치하지 않는 Token | 401로 거절 | UUID 형식이 맞는 것만으로 통과시키지 않음 |
| 유효한 Token으로 자기 자원 요청 | 인증 결과에서 얻은 범위로 조회 | Body나 Query의 범위를 권한 근거로 삼지 않음 |
| 경로의 장비 ID가 인증된 장비와 다름 | 해당 API에서 403으로 거절 | 인증 성공과 다른 장비에 대한 접근 허용을 구분 |
| 장비용 API의 Guard 설정 변경 | 우회 메타데이터와 대체 Guard를 모두 검사 | 컴파일 성공만으로 설정 누락을 발견할 수 없음 |
| 장비용 API가 성공 응답 반환 | 합의한 최상위 JSON 필드 유지 | HTTP 상태뿐 아니라 소비자가 읽는 위치까지 확인 |
| 내부 서비스에 직접 요청 | 운영 환경에서 내부 인증과 네트워크 제한 확인 | Gateway 표시 헤더만으로 통과시키지 않음 |
인증을 조사할 때도 Token 원문은 로그에 남기지 않는다. 인증 실패 단계와 요청 경로, 조회한 장비의 상태를 중심으로 확인하고, 헤더나 URL에 자격증명이 섞여 저장되는지도 함께 점검한다.
마치며
사용자 JWT와 장비 Token을 분리한 이유는 단순히 Header 값을 다르게 받기 위해서가 아니다. 사람과 장비의 Credential 수명주기, 인증 시점과 접근 범위가 서로 달랐기 때문이다.
이번 구조에서 얻은 결론은 다음과 같다.
- 외부 경로 Prefix는 API 계약이며 인증 수단이 아니다.
- 사용자 Guard를 우회한 Endpoint에는 반드시 대체 인증이 있어야 한다.
- 장비 인증 결과에서 장비 ID와 자원 Scope를 파생한다.
- UUID4는 무작위라 저장 없이 같은 Token을 재현하는 요구에 맞지 않았다.
- UUID5는 같은 입력으로 같은 값을 만들기 위해 선택했다.
- UUID5의 결정성은 비밀성이나 강한 인증을 의미하지 않는다.
- 외부 장비 인증과 Gateway 이후의 내부 서비스 인증을 분리한다.
- API Key는 Credential과 Scope를 소유한 서비스에서 검증할 수 있다.
- Machine Client의 응답 계약은 사용자 API의 공통 응답과 별도로 관리한다.
인증 방식을 하나로 통일하는 것보다 중요한 것은 누가 호출하고, Credential을 누가 관리하며, 인증 결과로 어떤 범위를 허용할지 명확하게 만드는 일이다. 그리고 결정적 Token이 필요했던 초기 조건과 운영 환경에서 요구되는 폐기, 교체, 비밀성 사이의 차이도 계속 점검해야 한다.
'Backend > Architecture' 카테고리의 다른 글
| [Architecture] 엣지 서버의 로컬 ID와 전역 ID 충돌을 피하는 방법 (0) | 2026.09.20 |
|---|---|
| [Refactoring] 레거시 API를 이식할 때 계약과 버그를 구분하는 방법 (0) | 2026.09.13 |
| [Server] 대용량 ZIP을 생성하면서 API Gateway 너머로 스트리밍하기 (0) | 2026.09.12 |
| [Design Pattern] 서로 다른 데이터 스키마를 하나의 API로 다루기 (0) | 2026.08.18 |
| [Server] 보상 작업으로 다단계 생성 흐름의 실패 범위 줄이기 (0) | 2026.08.15 |
| [Server] 여러 API 서비스의 인증을 Gateway에 모은 이유 (0) | 2026.08.08 |