
8개월. GS샵 회원 계정에 대량 로그인이 이어진 기간입니다. 그동안 회사는 이상을 알아차리지 못했습니다. 서버가 침해된 사고도 아니었습니다. 유출로 이어진 로그인은 비밀번호가 맞는 로그인이었는데, 개인정보보호위원회(이하 개인정보위)는 GS리테일에 과징금 128억 3,600만 원을 매겼습니다.
사고 원인: 로그인 이상탐지 부재
이번 편은 크리덴셜 스터핑(credential stuffing, 다른 곳에서 새어 나간 아이디·비밀번호 목록을 다른 서비스에 대량으로 넣어 보는 공격)입니다. 2024년 말부터 2025년까지 GS샵·GS25·티머니가 같은 방식으로 피해를 입었고, 알바몬도 같은 시기에 이력서 유출을 감지했습니다. 다만 알바몬은 공격 방식이 공식 발표되지 않아 이 글에서 크리덴셜 스터핑으로 단정하지 않습니다.
| 회사 | 공격 기간 | 피해 | 제재 |
|---|---|---|---|
| GS샵 | 2024-06-21 ~ 2025-02-13 (약 8개월) | 1,581,025명 | GS리테일 합산 과징금 128억 3,600만 원 + 과태료 300만 원 (2026-08) |
| GS25 | 2024-12-26 ~ 2025-01-04 | 79,128명 | (위와 합산) |
| 티머니 | 2025-03 | 51,691명, 일부 마일리지 탈취 | 과징금 5억 3,400만 원 (2026-01-28) |
| 알바몬 | 2025-04-30 감지 | 이력서 22,473건(이름·휴대폰·이메일) | 처분 결과 미확인, 공격 방식 공식 미발표 |
GS샵·GS25·티머니 피해를 합하면 약 171만 명입니다. GS 두 곳에서 나간 항목은 이름·성별·생년월일·연락처·주소·이메일 여섯 가지입니다.

출발점은 이 회사들이 아닙니다. 다른 사이트에서 이미 유출된 아이디·비밀번호 목록입니다. 같은 비밀번호를 여러 곳에 쓰는 사람이 많기 때문에, 목록을 자동으로 넣어 보면 대부분은 실패하고 일부는 맞습니다. 로그인 서버는 비밀번호만 확인했고, 맞으면 그대로 들여보냈습니다. 로그인에 성공하면 회원정보 페이지가 열렸고, 거기에 여섯 가지 개인정보가 있었습니다.
원인은 개인정보위가 GS리테일을 조사해 확인한 것입니다. 티머니는 공개된 원인 상세가 없어 여기서 다루지 않습니다.
개인정보위의 판단은 분명합니다. 서버가 침해되지 않았더라도, 로그인 이상을 잡지 못하면 기업 책임이라는 것입니다.
사고 회사의 코드가 아니라 같은 실수를 보여 주는 일반 예시입니다. 값은 모두 가짜입니다.
먼저 로그인입니다. 왼쪽 모양은 비밀번호만 확인합니다. 한 IP가 수많은 계정을 시도해도, 맞는 비밀번호가 나오면 세션을 내줍니다.
# 위험한 모양: 비밀번호만 확인
@app.post("/login")
def login(req):
user = db.find_user(req.email)
if not user:
return fail()
if not verify(req.pw, user.pw_hash):
return fail()
session["uid"] = user.id
return ok()
# IP당 시도 횟수, 실패율: 보지 않음
# 처음 보는 기기: 알리지 않음
고친 모양은 IP와 계정별 시도 횟수를 먼저 셉니다. 한도를 넘으면 비밀번호를 보기도 전에 429(요청이 너무 많음)로 돌려보냅니다. 실패는 따로 집계해 실패율이 갑자기 오르면 알림을 보내고, 처음 보는 기기에서 로그인하면 본인에게 알립니다.
# 고친 모양
@app.post("/login")
def login(req):
if limiter.over(req.ip, req.email):
return too_many() # 429
user = check_password(req)
if not user:
fail_rate.add(req.ip) # 알림 규칙
return fail()
if new_device(user, req):
notify(user, "새 기기 로그인")
session["uid"] = user.id
return ok()
다음은 회원정보 페이지입니다. 로그인만으로 이름·주소·연락처 전체를 그대로 내보내면, 로그인 한 번이 곧 개인정보 전체가 됩니다.
# 위험한 모양: 로그인만으로 전체 공개
@app.get("/me/profile")
@login_required
def profile():
u = db.profile(session["uid"])
return u
# 이름·주소·연락처 전체가 그대로
고친 모양은 최근에 다시 인증한 경우에만 보여 주고, 연락처는 가려서 내보냅니다. 알림 규칙의 숫자는 예시 값이므로 서비스 트래픽에 맞춰 정합니다.
# 고친 모양: 재인증 + 마스킹 + 알림 규칙
@app.get("/me/profile")
@login_required
@recent_auth(max_age_min=5)
def profile():
u = db.profile(session["uid"])
return mask(u) # 010-****-0000
# 알림 규칙 (예시 값)
ALERTS = [
("login_fail_rate", 0.3, "5m"),
("accounts_per_ip", 20, "10m"),
]
로그인 기록은 이미 서버에 쌓이고 있습니다. 위 코드에서 새로 생긴 것은 그 기록을 세는 규칙과, 기준을 넘었을 때 누가 알림을 받는지 정한 것뿐입니다.
| 회사 | 처분 | 시점 |
|---|---|---|
| GS리테일(GS샵·GS25 합산) | 과징금 128억 3,600만 원 + 과태료 300만 원 | 2026년 8월 |
| 티머니 | 과징금 5억 3,400만 원 | 2026년 1월 28일 |
| 알바몬 | 처분 결과 미확인 | 2026년 10월 말 기준 |
모두 개인정보위 처분 기준입니다. 알바몬은 2026년 10월 말 기준으로 처분 결과도, 공격 방식도 공식 발표를 확인하지 못했습니다. 이 글은 법률 자문이 아닙니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치는 2026년 10월 말 기준 공개 보도·발표입니다. 진행 중인 사건은 바뀔 수 있고, 이 글은 법률 자문이 아닙니다.
이전 글 랜섬웨어 2연타, 예스24·SGI서울보증: 백업이 같이 잠기면
다음 글 AI가 바꾼 공격: 2026 금융권 연쇄 해킹과 AI 활용 해커
시리즈 전체 목록 해킹 사고 해부 전체 목록