교체할 수 있는 키: 서명키 회전과 봉투 암호화

아프로·2026년 11월 2일

교체할 수 있는 키 표지

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 키·서명키·인증키처럼 코드와 로그 밖에 숨겨야 하는 값을 말합니다.

1. 서명키 회전: kid·JWKS·겹침 기간

무엇이 문제인가

로그인한 사용자에게 서버는 서명한 토큰을 줍니다. 다른 서버는 서명이 맞는지 확인하고 요청을 받습니다. 그래서 서명키를 가진 쪽은 진짜와 구별되지 않는 토큰을 만들 수 있습니다.

키가 새는 날을 완전히 막기는 어렵습니다. 대신 키를 정기적으로 바꾸고, 새면 바로 바꿀 수 있어야 합니다. 그런데 서명값이 설정 파일에 하나로 고정돼 있으면 키를 바꾸는 순간 이미 발급한 토큰이 모두 무효가 됩니다. 로그인이 한꺼번에 풀리니 교체가 계속 미뤄집니다. Storm-0558 사고에서는 키를 자동으로 바꾸는 장치가 없었고, 메일 서버는 서명이 맞는지만 확인했습니다(CSRB). 키의 종류와 발급자는 보지 않았습니다.

해법은 키에 번호를 붙이는 것입니다.

  • kid(키 아이디): 토큰 머리에 "어느 키로 서명했는지"를 적는 칸입니다.
  • JWKS(JSON Web Key Set): 검증할 때 쓰는 공개키를 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을 받는지 테스트로 남깁니다. 키마다 만든 날짜를 기록하고, 정한 나이를 넘기면 경보가 울리게 합니다.

2. 봉투 암호화: 데이터 키와 마스터 키

무엇이 문제인가

SK텔레콤 사고에서는 유심 인증키 Ki가 암호화되지 않은 평문이었고, 다른 서버에 평문으로 저장된 계정정보가 이동 경로가 됐습니다. 같은 인증키를 KT와 LG유플러스는 암호화하고 있었습니다(SK텔레콤 유심 해킹). 반대로 티빙 사고에서는 전화번호와 이메일을 암호화했지만 암호화키가 함께 유출돼 평문과 같은 수준이었습니다(민관합동조사단). 암호화는 키를 데이터와 다른 곳에 둘 때만 의미가 있습니다.

봉투 암호화(envelope encryption)는 키를 두 층으로 나눕니다.

  • 데이터 키: 값을 실제로 암호화하는 키입니다. 칸이나 레코드마다 만들 수 있습니다.
  • 마스터 키: 데이터 키를 감싸는(암호화하는) 키입니다. KMS(키 관리 서비스)나 시크릿 저장소 밖으로 나오지 않습니다.

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의 마스터 키 사용 기록에 어떤 서비스가 언제 복호화했는지 남는지도 봅니다.

3. 개인정보 칸: 검색용 해시, 마스킹, 원문 조회 기록

무엇이 문제인가

전화번호를 암호화하면 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에서 키를 찾으며 발급자와 만료를 함께 확인한다
  • □ 새 키를 먼저 올리고 옛 키를 나중에 내리는 교체 절차를 한 번 이상 실제로 해 봤다
  • □ 인증키·개인정보 칸은 봉투 암호화로 저장하고, 마스터 키는 데이터와 다른 곳에 있다
  • □ 암호화한 칸은 HMAC 칸으로 검색하고, 검색용 평문 칸을 따로 두지 않는다
  • □ 원문 조회와 키 사용이 기록되고, 오래된 키에 경보가 있다

정리

서명키는 숨겨 둘 물건이기도 하지만, 그보다 주기적으로 바꿀 물건입니다. 토큰에 kid를 넣고 공개키 목록과 겹침 기간을 두면 서비스를 멈추지 않고 키를 바꿀 수 있습니다. 중요한 값은 데이터 키와 마스터 키로 나눠 저장해 DB가 새는 날을 가정합니다. 암호화한 칸을 쓰기 위해 평문 칸을 다시 만들지 않도록 검색용 해시와 마스킹을 함께 설계합니다.

함께 보면 좋은 글

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 5-1, 5-2, 5-3, 5-4

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 3-2, 5-2, 5-3, 5-4

출처와 확인 날짜

수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다. 쿠팡의 키 교체 지연은 과기정통부 설명과 언론 보도 기준입니다.


이전 글 비밀번호가 맞아도: 비밀번호 저장, 크리덴셜 스터핑, 토큰 수명
다음 글 코드 밖에 둘 것: 시크릿 저장소, 열린 저장소, 오류 응답과 보안 헤더
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글