1. 한 줄로 먼저 말하면
HMAC은 초대 링크를 서버가 발급하고 서버가 검증할 때 쓰는 공유 비밀키입니다. 프론트엔드는 서명을 계산하지 않고, 키도 링크마다 다르지 않습니다. 환경 하나에 키 하나이고, 링크마다 달라지는 것은 풀 ID와 만료 시각이 들어 있는 페이로드입니다.
이 글은 인력풀 초대 링크에 유효 기간을 붙이는 일을 하면서 HMAC-SHA256 서명 토큰을 선택한 배경, 처음에 잘못 이해했던 지점, 실제로 코드를 붙이고 배포 설정을 나눈 과정, 그리고 같은 실수를 반복하지 않기 위해 남겨 두는 실무 메모입니다. 이론 정리보다 ‘그때 왜 그렇게 했고, 무엇이 틀렸는가’를 중심으로 적었습니다.
2. 왜 이 기술을 쓰게 되었는가
2.1 제품 요구
반장이 인력풀에 사람을 부를 때 공유 링크를 씁니다. 기존 링크는 풀 ID만 쿼리에 넣는 형태였습니다. 링크를 받은 일꾼이 앱으로 들어오면 그 풀에 대한 초대가 만들어지는 흐름이었습니다. 문제는 유효 기간이 없다는 점이었습니다. 한 번 퍼진 링크는 언제까지고 다시 열 수 있고, URL만 알면 풀 ID를 바꿔 다른 풀을 지정하는 것도 이론상 가능했습니다.
제품 쪽에서는 ‘초대 시트를 열 때마다 새 링크를 만들되, 그 링크는 여러 일꾼이 같이 쓸 수 있게’ 해 달라는 요구가 있었습니다. 만료는 있어야 하고, 만료를 클라이언트에서 숨기면 안 되며, 서버가 검증 시점에 거절해야 했습니다. 목록 화면에서만 만료된 초대를 가리는 것으로는 부족했습니다.
2.2 대안을 먼저 놓고 고른 이유
처음에는 세 가지를 나란히 봤습니다.
-
기존 공유 링크를 그대로 두고, 안내 문구만 ‘며칠 안에 들어와 달라’고 쓰기
-
풀 ID와 만료 시각을 Base64로만 접어 URL에 넣기. 서명은 없음
-
풀 ID·만료를 페이로드로 두고, 서버 비밀키로 HMAC-SHA256 태그를 붙여 토큰 하나로 만들기
첫 번째는 구현이 가장 쉽습니다. 다만 요구가 ‘만료를 검증’하는 것이라 안내만으로는 통과할 수 없었습니다. 두 번째는 URL을 디코딩하면 만료를 늘리거나 풀 ID를 바꿀 수 있습니다. 아는 사람만 고치면 되는 구조입니다. 세 번째는 키가 있는 서버만 같은 태그를 만들 수 있어서, URL을 고치면 검증이 실패합니다. 링크를 여러 사람이 공유하는 모델과도 맞았습니다. 세션 쿠키나 일회용 로그인 코드는 공유 링크에 맞지 않았습니다.
그래서 HMAC 서명 토큰을 골랐습니다. JWT를 안 쓴 이유는, 이 토큰이 로그인 주체가 아니라 ‘이 풀로, 이 시각까지 유효한 초대’라는 짧은 주장만 담으면 되었기 때문입니다. 클레임이 두 개뿐이면 직접 접은 페이로드가 더 읽기 쉬웠습니다.
3. 처음에 어떻게 생각했고, 무엇이 틀렸는가
기술을 이름으로만 알 때와, 배포까지 붙여 볼 때의 간격이 컸습니다. 아래는 당시 제가 갖고 있던 오해입니다.
3.1 공통 HmacUtil을 새로 만들어야 한다
처음에는 표준 Base64를 쓰는 공통 유틸을 하나 더하면 된다고 생각했습니다. 코드를 찾아보니 도메인 서비스 안에 이미 HMAC-SHA256이 있었고, URL에 넣기 위해 URL-safe Base64(패딩 없음)를 쓰고 있었습니다. 표준 Base64의 +, /, = 는 쿼리 문자열을 깨뜨릴 수 있습니다. 유틸을 새로 만들기 전에 이미 쓰는 인코딩부터 맞추는 일이 먼저였습니다.
3.2 Secret에 키 이름만 넣으면 앱이 알아서 읽는다
Kubernetes Secret 오브젝트에 hmac-secret 같은 이름을 만들어 두면 스프링이 자동으로 읽는 줄 알았습니다. 실제로는 애플리케이션 yml이 환경 변수 이름을 읽고, 그 이름이 포드 env로 나와야 합니다. Secret 키가 yml placeholder와 한 글자라도 다르면 앱은 기본값을 쓰거나 빈 값을 봅니다. ‘Secret을 만들었다’와 ‘포드가 그 이름으로 변수를 받았다’는 다른 일입니다.
3.3 프론트가 서명을 계산한다
링크를 만드는 화면이 프론트에 있으니, 프론트가 키로 HMAC을 돌려 API에 보낼 것이라고 생각했습니다. 그렇게 하면 비밀키가 번들에 들어갑니다. 실제 흐름은 반대입니다. 반장이 초대 링크를 요청하면 서버가 토큰 문자열을 돌려주고, 프론트는 쿼리에 그 문자열만 붙입니다. 일꾼이 링크로 들어오면 같은 문자열을 등록 API에 그대로 냅니다. 검증도 서버입니다.
3.4 링크마다 HMAC 키가 다르다
초대마다 키가 바뀌면 운영에서 키를 보관할 방법이 없습니다. 키는 환경(개발·스테이지·운영)당 하나이고, 링크마다 다른 것은 페이로드입니다. 같은 키로 다른 풀 ID와 다른 만료를 서명합니다.
3.5 스테이지에도 ConfigMap이 필수다
yml에 기본값이 있으면 개발·스테이지는 배포만으로 동작할 수 있습니다. 운영에서만 기본값을 덮어쓰기 위해 Secret을 씁니다. 스테이지 ConfigMap을 먼저 요청할 필요는 이 기능만 놓고 보면 없었습니다. 다만 운영과 개발이 같은 키를 쓰면 안 됩니다.
4. HMAC이 여기서 하는 일
HMAC-SHA256은 메시지와 비밀키로 고정 길이 태그를 만듭니다. 키가 있는 쪽만 같은 태그를 재현할 수 있습니다. 그래서 URL에서 만료를 하루 늘리거나 풀 ID를 바꿔도, 태그가 맞지 않으면 서버는 거절합니다. 암호화가 아닙니다. 페이로드는 디코딩하면 보입니다. 보이는 것을 막으려는 기술이 아니라, 보이는 내용을 함부로 고치지 못하게 하는 기술입니다.
이 프로젝트 토큰은 개념적으로 payload.signature 두 부분입니다. payload는 풀 ID와 만료 시각을 접은 뒤 URL-safe Base64로 인코딩한 문자열이고, signature는 그 payload 문자열에 대한 HMAC-SHA256을 같은 방식으로 인코딩한 문자열입니다. 검증할 때는 받은 payload로 서명을 다시 계산해 비교하고, 그다음 만료 시각을 봅니다. 서명부터 보면 위조된 만료를 믿을 일이 없습니다.
5. 적용 과정
5.1 설정이 읽는 이름
스프링 설정은 대략 아래와 같습니다. 콜론 뒤는 포드에 환경 변수가 없을 때 쓰는 기본값입니다. 운영에서는 이 기본값을 쓰지 않는다고 가정하고, 같은 이름의 환경 변수를 포드로 넣습니다.
banjang:
contact-pool:
invite:
hmac-secret: ${BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET:dev-only-not-for-prod}
expire-hours: ${BANJANG_CONTACT_POOL_INVITE_EXPIRE_HOURS:72}
여기 ${이름:기본값} 문법을 자주 헷갈렸습니다. Kubernetes가 yml을 직접 바꿔 주는 것이 아닙니다. 앱이 기동할 때 환경 변수 BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET 가 있으면 그 값이고, 없으면 콜론 뒤입니다. Secret 오브젝트의 키 이름도 이 환경 변수 이름과 같아야 매핑이 단순합니다.
5.2 서버가 토큰을 만드는 쪽 (개념 코드)
아래는 저장소에 없는 상태를 전제로 한 개념 코드입니다. 실제 클래스명·패키지는 팀 코드와 다를 수 있습니다. 중요한 것은 인코딩이 URL-safe이고 패딩이 없다는 점, 그리고 서명 대상이 ‘이미 인코딩된 payload 문자열’이라는 점입니다.
public String issue(String poolId, Instant expiresAt) {
String payloadJson = "{"p":"" + poolId + "","e":" + expiresAt.getEpochSecond() + "}";
String payload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = base64UrlNoPad(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
return payload + "." + signature;
}
private static String base64UrlNoPad(byte[] src) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(src);
}
5.3 서버가 토큰을 검증하는 쪽 (개념 코드)
public InvitePayload verify(String token) {
String[] parts = token.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("malformed token");
}
String payload = parts[0];
String given = parts[1];
String expected = sign(payload); // 발급과 동일한 HMAC
if (!MessageDigest.isEqual(
given.getBytes(StandardCharsets.US_ASCII),
expected.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException("bad signature");
}
InvitePayload body = decode(payload);
if (Instant.now().isAfter(body.expiresAt())) {
throw new IllegalArgumentException("expired");
}
return body;
}
문자열 == 비교 대신 MessageDigest.isEqual을 쓰는 이유는, 길이·내용이 달라도 비교 시간이 한쪽으로 기울지 않게 하려는 습관입니다. 초대 링크 정도에서 실무적으로 큰 차이로 느끼진 못했지만, 서명 비교에는 이쪽이 맞습니다. 만료는 서명 통과 뒤에 봅니다. 만료만 보고 서명을 안 보면, 만료를 고친 토큰을 받아 줄 수 있습니다.
5.4 프론트가 하는 일
프론트는 키를 모릅니다. 반장 화면에서 ‘링크 만들기’를 누르면 서버가 토큰을 주고, 쿼리 파라미터로만 붙입니다. 일꾼이 딥링크로 들어오면 그 문자열을 등록 API body에 다시 넣습니다.
// 반장: 서버가 준 토큰만 붙인다
const shareUrl = `${origin}/job/jobs/pool-invitations?token=${encodeURIComponent(token)}`;
// 일꾼: 받은 토큰 문자열을 그대로 등록에 낸다
await JobWkrFlowApi.registerContactPoolInvitation({ token });
시트를 열 때마다 새 토큰을 만드는 제품 결정을 따랐습니다. 같은 토큰을 여러 일꾼이 쓸 수 있으므로 일회용이 아닙니다. ‘새 토큰’은 만료 시각을 새로 찍는다는 뜻에 가깝고, 이전 토큰이 만료 전이면 둘 다 유효할 수 있습니다. 그 지점은 제품과 한 번 더 맞춰 두는 것이 좋습니다.
5.5 배포에서 키가 실제로 나가는 경로
개발·스테이지는 yml 기본값으로도 기능이 돌아갑니다. 운영은 Secret에 BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET 키로 값을 넣고, Deployment의 env 또는 envFrom으로 같은 이름을 포드에 줍니다. ConfigMap에 비밀키를 넣지 않습니다. ConfigMap은 보통 평문으로 관리되기 쉽습니다.
# 운영 Secret 값 만들기 (실값은 기록하지 않음)
openssl rand -base64 32
# 개념적인 매핑. 키 이름은 yml placeholder와 동일해야 한다
apiVersion: v1
kind: Secret
metadata:
name: banjang-contact-pool-invite
type: Opaque
stringData:
BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET: "<openssl 결과>"
스테이지만 ConfigMap을 먼저 만들어 달라는 요청은, 이 기능만 보면 순서가 앞섰습니다. 운영 배포 전에 Secret으로 기본값을 덮어쓰는 일을 분리하면 됩니다. 개발·스테이지·운영이 같은 값을 쓰면, 개발에서 샌 키가 운영 링크를 위조하는 재료가 됩니다.
6. 적용하면서 막혔던 지점
6.1 서명 대상 문자열을 어디를 기준으로 둘 것인가
JSON을 먼저 HMAC 하고 Base64 할지, Base64 payload 문자열을 HMAC 할지에서 한 번 헤맸습니다. 발급과 검증이 같은 쪽을 보면 됩니다. 저희는 URL에 올라가는 payload 문자열을 서명 대상으로 두었습니다. 디코딩 후 JSON 바이트를 다시 서명하면, JSON 키 순서나 공백이 달라질 때 깨집니다.
6.2 표준 Base64를 그대로 쓰면 링크가 깨진다
동료 스니펫은 표준 Base64였습니다. 쿼리에 넣으면 +가 공백으로 해석되는 환경이 있습니다. 패딩 = 도 거슬립니다. URL-safe, 패딩 없음으로 통일한 뒤에야 톡·문자로 공유해도 토큰이 살아 있었습니다.
6.3 만료를 목록 필터로만 처리하면 우회된다
만료된 초대를 목록에서 안 보여 주는 것과, 등록 API가 만료 토큰을 거절하는 것은 다릅니다. 전자만 하면 예전 링크를 북마크한 사람이 API를 직접 칠 수 있습니다. 검증 시점의 Instant.now()와 페이로드의 만료를 비교하는 쪽이 본 요구입니다.
6.4 프론트 재서명 유혹
만료를 화면에서 보여주고 싶어서 프론트가 페이로드를 열어 보게 할 수는 있습니다. 열어 보는 것과 서명하는 것은 다릅니다. 페이로드는 보이도록 만든 것이고, 고쳐서 다시 서명하는 키는 프론트에 두면 안 됩니다. 만료 시각이 필요하면 서버가 발급 응답에 expiresAt을 같이 주면 됩니다.
7. 결과로 무엇이 달라졌는가
-
초대 링크에 만료가 생겼고, 만료 후 등록은 서버에서 거절됩니다.
-
풀 ID만 고친 URL은 서명이 맞지 않아 통과하지 않습니다.
-
프론트 저장소에 비밀키가 없습니다. 토큰 문자열만 전달합니다.
-
운영 키는 환경 변수 이름과 Secret 키를 맞추는 일로 분리되었습니다.
정량으로 공격이 몇 건 줄었는지는 이 글에 적지 않습니다. 이 작업의 결과는 ‘만료·위조를 서버가 말할 수 있는 링크’가 생긴 것입니다. 이전 링크 형식과 호환을 잠시 둘지는 배포 전략 문제라, 구 링크를 얼마나 받을지는 별도 결정이었습니다.
8. 이 일을 하고 나서 새로 알게 된 것
HMAC을 쓰는 코드와 키를 어디에 둘 것인가는 다른 질문입니다. 앱은 이미 알고리즘을 쓰고 있고, 운영에서 바뀌는 것은 포드의 환경 변수입니다. 코드를 더 짜는 일과 클러스터에 이름을 맞추는 일을 한 번에 생각하면 Secret만 만들고 끝나는 착각이 납니다.
프론트는 서명 계산자가 아닙니다. 토큰 문자열을 전달할 뿐입니다. 키 한 개가 링크 한 개를 의미하지도 않습니다. 키는 환경당 하나, 링크는 페이로드가 다릅니다. 스테이지 ConfigMap을 먼저 요청할 필요는 이 기능만 보면 없었고, 운영에서 기본값을 덮어쓰는 이유가 따로 있습니다.
공통 Util을 만들기 전에 도메인 서비스에 이미 HMAC이 있는지 찾는 버릇이 생겼습니다. 인코딩만 달라도 ‘유틸이 없어서’가 아니라 ‘이미 있는 쪽과 다른 알파벳을 쓰려던 것’인 경우가 많습니다.
9. 실무에서 남기고 싶은 것
-
서명 토큰을 써도 클라이언트에 비밀키를 넣지 않습니다.
-
운영 Secret을 만들 때 yml이 참조하는 환경 변수 이름을 그대로 씁니다. 개발·스테이지 기본값과 다른 값을 넣습니다.
-
링크마다 키를 만들 생각하면 운영이 불가능해집니다. 공유 키와 다른 페이로드로 충분합니다.
-
URL에 넣는 HMAC은 +, /, = 가 링크를 깨뜨릴 수 있으므로 URL-safe·패딩 없음을 먼저 확인합니다.
-
만료는 목록 필터가 아니라 검증 시점에 둡니다.
-
학습 노트와 위키에 실제 키 값을 붙이지 않습니다. 만드는 명령만 남깁니다.
키 생성은 예를 들어 openssl rand -base64 32 결과를 Secret 값으로 넣는 식입니다. 사람이 문장을 길게 쓰는 방식은 쓰지 않았습니다.
10. 같이 보면 좋은 개념
-
URL-safe Base64: 링크·쿼리에 넣어도 문자열이 살아 있게 하는 인코딩
-
환경 변수와 Kubernetes Secret / ConfigMap: 비밀은 Secret, 평문 설정은 ConfigMap
-
토큰 만료: 검증 시점의 거절과 목록 화면의 숨김은 다름
-
공유 링크와 서명 초대 링크: 누가 열어도 되는 주소인지, 위조를 막을 주소인지
11. 마치며
초대 링크에 만료를 붙이는 일은 화면 문구 수정처럼 보였습니다. 막상 하면 ‘누가 서명하는가’, ‘키는 어디에 있는가’, ‘링크마다 키가 다른가’가 한꺼번에 따라왔습니다. HMAC 자체보다, 비밀을 클라이언트에 두지 않는 흐름과 환경 변수 이름을 한 글자도 틀리지 않게 맞추는 일이 더 오래 남았습니다.
같은 요구가 다시 오면 알고리즘부터 고르지 않겠습니다. 먼저 링크가 공유되는지, 만료를 누가 말하는지, 키가 환경에 몇 개여야 하는지를 적고, 그다음에 HMAC이든 JWT든 고르겠습니다. 이 글의 코드는 저장소에 올리지 않은 상태라 개념 수준입니다. 팀 코드의 클래스명과 설정 키는 배포된 브랜치를 기준으로 다시 확인하면 됩니다.
Mina