
2016년에 만든 Microsoft 소비자 계정 서명키는 2021년 3월에 폐기될 예정이었지만, 2023년에도 살아 있었습니다. 그 키로 위조한 토큰에 미국 정부 기관의 메일함이 열렸습니다(Storm-0558). 쿠팡 사고에서도 서명키가 코드에 박혀 있어 교체가 번거로웠고, 퇴사자가 생긴 뒤에도 키를 바꾸지 않았다는 설명이 나왔습니다(과기정통부 설명·보도, 쿠팡 3,367만 건). 두 사고에는 키를 제때 바꾸지 못했다는 공통점이 있습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 5-1, 5-2, 5-3, 5-4

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 바꿀 수 없는 서명키 | 설정 파일·코드에 고정된 서명값, 키 번호 없는 토큰 | 토큰에 kid, 공개키 목록(JWKS), 겹침 기간을 두고 교체 | A04 |
| 평문으로 저장한 중요한 값 | 인증키·전화번호 같은 칸을 그대로 저장 | 봉투 암호화(데이터 키 + 마스터 키), 마스터 키는 다른 곳에 | A04 |
| 암호화했지만 쓸 수 없는 개인정보 칸 | 암호문으로는 검색이 안 돼 평문 칸을 따로 둠 | 검색용 해시 칸, 화면 마스킹, 원문 조회 기록 | A04 |
이 글에서 시크릿은 비밀번호·API 키·서명키·인증키처럼 코드와 로그 밖에 숨겨야 하는 값을 말합니다.
로그인한 사용자에게 서버는 서명한 토큰을 줍니다. 다른 서버는 서명이 맞는지 확인하고 요청을 받습니다. 그래서 서명키를 가진 쪽은 진짜와 구별되지 않는 토큰을 만들 수 있습니다.
키가 새는 날을 완전히 막기는 어렵습니다. 대신 키를 정기적으로 바꾸고, 새면 바로 바꿀 수 있어야 합니다. 그런데 서명값이 설정 파일에 하나로 고정돼 있으면 키를 바꾸는 순간 이미 발급한 토큰이 모두 무효가 됩니다. 로그인이 한꺼번에 풀리니 교체가 계속 미뤄집니다. Storm-0558 사고에서는 키를 자동으로 바꾸는 장치가 없었고, 메일 서버는 서명이 맞는지만 확인했습니다(CSRB). 키의 종류와 발급자는 보지 않았습니다.
해법은 키에 번호를 붙이는 것입니다.
kid별로 모아 둔 목록입니다.공개키 방식(비대칭 서명)으로 서명하면 검증하는 서버에는 공개키만 둡니다. 검증 서버가 침해돼도 그 서버에서 토큰을 만들 수는 없습니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다. 라이브러리는 jjwt 0.12를 기준으로 썼습니다.
// 위험한 모양: 설정 파일 속 고정 서명값 하나, 키 번호 없음
@Value("${jwt.secret}") // application.yml: jwt.secret: FAKE-demo-secret
private String secret;
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
.build().parseSignedClaims(jwt).getPayload(); // 서명만 맞으면 통과
// 고친 모양: kid로 공개키 목록에서 키를 찾고, 발급자와 만료를 함께 확인
Claims claims = Jwts.parser()
.keyLocator(jwksKeys) // 토큰 머리의 kid로 JWKS에서 공개키 선택
.requireIssuer("auth.example.com") // 다른 발급자 토큰은 거부
.build().parseSignedClaims(jwt).getPayload(); // exp가 지나면 예외
// 발급 쪽: 현재 키의 kid를 머리에 적는다(개인키는 시크릿 저장소에만)
String token = Jwts.builder()
.header().keyId("FAKE-key-2026-11").and()
.issuer("auth.example.com").subject("alice")
.expiration(Date.from(Instant.now().plus(Duration.ofMinutes(15))))
.signWith(currentPrivateKey).compact();
jwksKeys는 JWKS를 읽어 kid에 맞는 공개키를 돌려주는 객체(jjwt의 Locator)입니다. Spring Security 리소스 서버를 쓴다면 spring.security.oauth2.resourceserver.jwt.jwk-set-uri에 JWKS 주소를 적으면 kid로 키를 고르는 동작이 같은 방식으로 적용됩니다. 교체 순서는 세 단계입니다. ① 새 공개키를 JWKS에 먼저 올리고(아직 서명에는 쓰지 않음), ② 발급 서버가 새 키로 서명을 시작하고, ③ 옛 토큰의 최대 수명이 지나면 옛 키를 JWKS에서 지우고 폐기합니다.
옛 키를 내린 뒤 옛 kid로 서명한 테스트 토큰이 401을 받는지, 발급자가 다른 토큰이 401을 받는지 테스트로 남깁니다. 키마다 만든 날짜를 기록하고, 정한 나이를 넘기면 경보가 울리게 합니다.
SK텔레콤 사고에서는 유심 인증키 Ki가 암호화되지 않은 평문이었고, 다른 서버에 평문으로 저장된 계정정보가 이동 경로가 됐습니다. 같은 인증키를 KT와 LG유플러스는 암호화하고 있었습니다(SK텔레콤 유심 해킹). 반대로 티빙 사고에서는 전화번호와 이메일을 암호화했지만 암호화키가 함께 유출돼 평문과 같은 수준이었습니다(민관합동조사단). 암호화는 키를 데이터와 다른 곳에 둘 때만 의미가 있습니다.
봉투 암호화(envelope encryption)는 키를 두 층으로 나눕니다.
DB에는 암호문과 "감싼 데이터 키"만 남습니다. DB 덤프가 새도 마스터 키 없이는 데이터 키를 풀 수 없습니다. 마스터 키를 바꿀 때도 전체 데이터를 다시 암호화하지 않고 감싼 데이터 키만 다시 감싸면 됩니다. 이 점이 키를 "교체할 수 있게" 만듭니다.
@Encrypted는 강의에서 만드는 방어 부품(secure-kit) 예시입니다. 필드에 한 줄을 붙이면 저장할 때 암호화하고 읽을 때 복호화합니다.
public class UmsMember {
@Encrypted(key = "member-pii") // DB에는 암호문과 감싼 데이터 키만
private String phone;
}
부품 안에서 일어나는 일을 줄이면 이렇습니다. kms는 설명용 이름이고, 실제로는 사용하는 KMS SDK나 OpenBao transit 같은 시크릿 저장소가 같은 역할을 합니다.
// 저장: 데이터 키로 값을 암호화하고, 데이터 키는 감싼 모양으로만 보관
DataKey dk = kms.generateDataKey("member-pii"); // 평문 키 + 마스터 키로 감싼 키
byte[] iv = randomBytes(12);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(dk.plaintext(), "AES"),
new GCMParameterSpec(128, iv));
byte[] sealed = c.doFinal(phone.getBytes(StandardCharsets.UTF_8));
row.save(sealed, iv, dk.wrapped()); // 평문 데이터 키는 저장하지 않음
dk.destroy(); // 메모리에서 지움
DB 덤프에서 해당 칸이 암호문으로만 보이는지 확인합니다. KMS의 마스터 키 사용 기록에 어떤 서비스가 언제 복호화했는지 남는지도 봅니다.
전화번호를 암호화하면 WHERE phone = ?로 찾을 수 없습니다. 같은 값도 암호화할 때마다 다른 암호문이 나오기 때문입니다. 그래서 검색하려고 평문 칸을 하나 더 두는 일이 생깁니다. 이렇게 되면 암호화한 의미가 없습니다.
검색에는 별도의 검색용 해시 칸을 씁니다. 전화번호처럼 경우의 수가 적은 값은 키 없는 해시로 두면 모든 번호를 해시해 대조할 수 있으니, 키를 넣은 HMAC(키가 있어야 만들 수 있는 해시)을 씁니다. HMAC 키도 마스터 키처럼 데이터와 다른 곳에 둡니다. 화면에는 가린 값만 보여 주고, 관리자도 원문은 필요할 때만 열며, 연 기록을 남깁니다.
// 저장할 때: 암호문 칸과 검색용 HMAC 칸을 함께 채운다
member.setPhone(phone); // @Encrypted 칸
member.setPhoneHash(hmac.sha256("phone-search", phone)); // 키는 시크릿 저장소에
// 찾을 때: 같은 HMAC으로 조회
Optional<UmsMember> m = repo.findByPhoneHash(hmac.sha256("phone-search", input));
// 보여 줄 때: 가린 값만
String masked = phone.replaceAll("(\\d{3})-(\\d{2})\\d{2}-(\\d{2})\\d{2}", "$1-$2**-$3**");
검색용 칸만으로 원래 번호를 되돌릴 수 없는지(키 없이 같은 해시를 만들 수 없는지) 테스트하고, 원문 조회 API를 부를 때마다 감사 로그 한 줄이 남는지 확인합니다.
kid가 있고, 검증은 JWKS에서 키를 찾으며 발급자와 만료를 함께 확인한다서명키는 숨겨 둘 물건이기도 하지만, 그보다 주기적으로 바꿀 물건입니다. 토큰에 kid를 넣고 공개키 목록과 겹침 기간을 두면 서비스를 멈추지 않고 키를 바꿀 수 있습니다. 중요한 값은 데이터 키와 마스터 키로 나눠 저장해 DB가 새는 날을 가정합니다. 암호화한 칸을 쓰기 위해 평문 칸을 다시 만들지 않도록 검색용 해시와 마스킹을 함께 설계합니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다. 쿠팡의 키 교체 지연은 과기정통부 설명과 언론 보도 기준입니다.
이전 글 비밀번호가 맞아도: 비밀번호 저장, 크리덴셜 스터핑, 토큰 수명
다음 글 코드 밖에 둘 것: 시크릿 저장소, 열린 저장소, 오류 응답과 보안 헤더
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록