
GS샵 회원 계정에 약 8개월 동안 대량 로그인이 이어졌고, 회사는 알아차리지 못했습니다. 서버가 침해된 사고도 아니었습니다. 다른 곳에서 새어 나간 비밀번호 목록이 출발점이었고, 로그인 서버는 비밀번호만 확인했습니다. 개인정보보호위원회(개인정보위)는 GS리테일에 과징금 128억 3,600만 원을 매겼습니다. 사고 경위는 국내편 GS리테일·티머니·알바몬: 비밀번호가 맞아도 막아야 했던 로그인에 있습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 4-1, 4-2, 4-3, 4-8

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 약한 비밀번호 저장 | 빠른 해시나 평문으로 비밀번호를 저장하는 곳 | 느린 해시로 저장하고, 유출된 비밀번호는 가입 때 거부한다 | A07·A04 |
| 크리덴셜 스터핑 | 시도 횟수를 세지 않는 로그인 | 비밀번호를 보기 전에 계정·IP 축으로 시도를 센다 | A07 |
| 긴 토큰 수명 | 로그아웃해도 만료까지 통하는 토큰 | 접근 토큰은 짧게, 갱신 토큰은 회전, 로그아웃한 토큰은 폐기 목록으로 거절한다 | A07 |
크리덴셜 스터핑(credential stuffing)은 다른 곳에서 새어 나간 아이디·비밀번호 목록을 다른 서비스에 대량으로 넣어 보는 공격입니다. 같은 비밀번호를 여러 곳에 쓰는 사람이 많아서 대부분은 실패하고 일부는 맞습니다. 해외에서도 같은 구조가 반복됐습니다. 스노우플레이크(Snowflake) 고객 계정 침해에서 공격에 쓰인 계정의 79.7%가 과거에 이미 유출된 적이 있었고(맨디언트 조사), 그 계정에는 MFA가 없었습니다.
비밀번호를 평문이나 SHA-256 같은 빠른 해시 한 번으로 저장하면, DB가 유출됐을 때 짧은 시간에 원래 비밀번호를 대량으로 맞혀 볼 수 있습니다. 빠른 해시는 원래 빨리 계산하려고 만든 함수라서, 맞혀 보는 쪽에도 빠릅니다.
저장 방식이 좋아도 비밀번호 자체가 이미 유출 목록에 있다면 소용이 적습니다. 크리덴셜 스터핑은 바로 그 목록으로 시작하기 때문입니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.
// 위험한 모양: 빠른 해시 한 번, 솔트 없음
String stored = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(raw.getBytes(UTF_8)));
// 고친 모양: 느린 해시(기본 bcrypt) + 가입 때 유출 비밀번호 거부
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // 저장값 앞에 {bcrypt} 표시
}
public void signUp(SignUpRequest req) {
if (breachedPasswords.contains(req.password())) // 오프라인 유출 목록과 대조
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "유출된 적 있는 비밀번호입니다");
memberMapper.insert(req.username(), passwordEncoder.encode(req.password()));
}
bcrypt는 해시마다 솔트(같은 비밀번호도 다른 값이 되게 섞는 무작위 값)를 넣고, 일부러 느리게 계산합니다. 저장값 앞의 {bcrypt} 표시 덕분에 나중에 Argon2 같은 다른 방식으로 옮길 때도 기존 회원의 비밀번호를 한꺼번에 바꿀 필요가 없습니다. 로그인에 성공할 때 새 방식으로 다시 저장하면 됩니다.
유출 비밀번호 목록은 외부 서비스로 비밀번호를 보내지 않도록 오프라인 목록으로 둡니다.
흔한 비밀번호로 가입하면 거부되는지 테스트합니다. DB의 비밀번호 칼럼이 {bcrypt}$2a$…처럼 시작하는지, 같은 비밀번호로 가입한 두 계정의 저장값이 다른지 봅니다.
크리덴셜 스터핑에 쓰이는 비밀번호는 맞는 비밀번호입니다. 비밀번호 확인만으로는 정상 로그인과 구별되지 않습니다. 구별할 수 있는 것은 횟수와 분포입니다. 한 IP가 짧은 시간에 수많은 계정을 시도하거나, 한 계정에 여러 IP가 몰리는 모습입니다.
GS 사례에서 개인정보위는 같은 IP에서 짧은 시간에 로그인이 쏟아져도 탐지도, 차단도 하지 못했다고 밝혔습니다. GS25 공격에 쓰인 IP 327개는 GS샵 공격에도 그대로 쓰였습니다.
강의에서 만드는 방어 부품 예시는 설정 한 줄입니다.
# application.yml (강의에서 만드는 방어 부품 secure-kit 예시, 값은 예시)
secure-kit:
rate-limit:
login: { per-account: 5/10m, per-ip: 50/10m }
부품 없이 직접 짜면 Redis 카운터 두 개입니다. 비밀번호를 확인하기 전에 호출합니다.
public void checkLoginAttempts(String username, String ip) {
long byAccount = count("login:acct:" + username);
long byIp = count("login:ip:" + ip);
if (byAccount > 5 || byIp > 50) // 계정·IP 축을 따로 센다
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS);
}
private long count(String key) {
Long n = redis.opsForValue().increment(key);
if (n != null && n == 1) redis.expire(key, Duration.ofMinutes(10));
return n == null ? Long.MAX_VALUE : n; // 셀 수 없으면 막는다
}
Redis에 연결하지 못해 횟수를 셀 수 없을 때는 로그인을 막는 쪽으로 둡니다(fail-closed). 한도를 넘었을 때의 응답과 오류 문구는 계정이 있든 없든 같게 합니다. 응답 시간 차이까지 맞추는 법은 07편에서 다뤘습니다.
정상 사용자의 로그인은 통과하고, 한 IP에서 여러 계정을 빠르게 시도하면 429가 나오는지 테스트합니다. 운영에서는 로그인 실패율이 평소보다 갑자기 오르면 경보를 받습니다.
JWT 같은 토큰 방식 인증은 요청마다 서명과 만료 시각만 확인합니다. 로그아웃은 브라우저에서 토큰을 지울 뿐이고, 서버에 기록이 없으면 그 토큰은 만료될 때까지 통합니다. 데모 프로젝트 기본값처럼 수명이 7일이면, 새어 나간 토큰도 7일 동안 통합니다.
# application.yml (강의에서 만드는 방어 부품 secure-kit 예시, 값은 예시)
secure-kit:
token: { access-ttl: 15m, refresh-rotate: true, revoke-list: redis }
직접 짠다면 갱신과 로그아웃 두 곳을 고칩니다.
// 갱신: 한 번 쓴 갱신 토큰은 바로 폐기하고 새 쌍을 발급한다(회전)
public TokenPair refresh(String presented) {
RefreshToken saved = refreshTokens.findActive(sha256(presented))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
refreshTokens.revoke(saved);
return issue(saved.memberId()); // 15분짜리 접근 토큰 + 새 갱신 토큰
}
// 로그아웃: 접근 토큰 ID(jti)를 남은 수명만큼 폐기 목록에 넣는다
public void logout(Jwt access, String refresh) {
Duration left = Duration.between(Instant.now(), access.getExpiresAt());
redis.opsForValue().set("revoked:" + access.getId(), "1", left);
refreshTokens.revoke(sha256(refresh));
}
요청을 인증하는 필터에서는 서명 확인 뒤 revoked: 키가 있는지 한 번 더 봅니다. 갱신 토큰은 DB에 해시로만 저장합니다. 비밀번호를 바꾸면 그 회원의 갱신 토큰을 모두 폐기합니다.
로그아웃한 직후 같은 접근 토큰으로 요청하면 401이 나오는지, 한 번 쓴 갱신 토큰을 다시 쓰면 거절되는지 테스트합니다.
비밀번호가 맞는다는 사실만으로는 정상 사용자를 가려낼 수 없습니다. 저장은 느린 해시로 하고, 이미 유출된 비밀번호는 가입 때 거부합니다. 로그인은 비밀번호를 보기 전에 누가 몇 번 시도했는지부터 셉니다. 토큰은 짧게 발급하고, 로그아웃은 서버의 폐기 목록에서 끝나게 합니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.
이전 글 입력이 명령이 될 때: SQL 바인딩, 정렬 허용 목록, 저장형 XSS, 파일 업로드
다음 글 교체할 수 있는 키: 서명키 회전과 봉투 암호화
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록