
쿠팡 유출(국내편 03편)에서는 배송지 목록 대량 조회가 약 7개월 이어졌습니다(조사단 기준). 요청 하나하나는 정상 요청과 구별되지 않았고, 계정별 조회량을 세는 곳이 없었습니다. 영국 캐피타(해외편 09편)는 반대였습니다. 고우선 경보가 10분 안에 울렸지만, 감염 PC를 격리하기까지 58시간이 걸렸습니다(ICO). 기록이 없어도, 기록을 보고 움직일 사람이 없어도 유출은 오래 이어집니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 8-1 ~ 8-7

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 대량 조회가 기록에 없음 | 관리자 조회·내보내기 기능 | 누가·무엇을·몇 건·왜를 감사 로그로 남긴다 | A09 |
| 기록은 있는데 아무도 모름 | 건수를 세지 않는 로그, 담당 없는 경보 | 계정별 기준선에 경보, 등급별 처리 기한 | A09 |
| 로그가 새로운 유출 경로 | 토큰·전화번호를 그대로 찍는 로그 | 로그 출력 전에 가린다 | A09·A04 |
| 로그를 고치거나 지움 | 서비스 DB 안에 있는 감사 로그 | 해시 사슬과 쓰기만 되는 별도 저장소 | A09 |
감사 로그(누가 어떤 데이터에 무슨 일을 했는지 남기는 기록)가 없으면 사고가 난 뒤에 범위를 정할 수 없습니다. 접속 로그에는 보통 주소와 응답 코드만 남습니다. "관리자 alice가 회원 1만 건을 내보냈다"는 사실은 남지 않습니다.
필요한 필드는 네 가지입니다. 행위자, 대상, 건수, 사유입니다. 건수가 빠지면 한 건 조회와 1만 건 내보내기가 같은 줄로 보입니다. 반대로 조회한 개인정보 자체를 로그에 담으면 로그가 또 하나의 개인정보 저장소가 됩니다. 대상에는 회원 번호나 범위만 적습니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.
public record AuditEvent(String actor, String action, String target,
int count, String reason, Instant at) {}
@GetMapping("/admin/members/export")
public List<MemberDto> export(@RequestParam String reason, Authentication auth) {
List<MemberDto> rows = memberService.findForExport();
auditSink.send(new AuditEvent(auth.getName(), "member.export",
"members", rows.size(), reason, Instant.now())); // 앱 DB가 아닌 별도 저장소로
return rows;
}
강의에서 만드는 방어 부품 예시에서는 같은 일을 어노테이션 한 줄로 붙입니다. 메서드에 @Audited(action = "member.export")를 달면 AOP가 행위자와 반환 건수를 채워 감사 로그로 보냅니다.
관리자 계정으로 내보내기를 한 번 실행하고, 감사 저장소에 행위자·건수·사유가 담긴 한 줄이 생겼는지 봅니다.
감사 로그를 남겨도 아무도 세지 않으면 7개월은 그대로입니다. 대량 조회는 요청 모양보다 양에서 드러납니다. 그래서 계정마다 평소 조회량(기준선)을 두고, 그 기준을 크게 넘으면 경보를 냅니다. 응답 크기와 밖으로 나가는 트래픽도 같은 방식으로 볼 수 있습니다.
경보가 울린 다음도 설계해야 합니다. 캐피타에서는 즉시 대응 등급(P1)이어야 할 경보가 한 단계 낮은 P2로 분류됐고, P2 처리 기한 준수율은 몇 달째 30% 미만이었습니다(ICO). 경보를 메일로 보내기만 하면 메일함이 찼을 때 그대로 묻힙니다.
계정별로 한 시간 동안의 조회 건수를 세는 가장 단순한 모양입니다.
@Component
public class BulkReadWatcher {
private static final int HOURLY_LIMIT = 2_000; // 예시 값. 평소 기준선을 보고 정한다
private final Map<String, AtomicInteger> perHour = new ConcurrentHashMap<>();
public void record(String actor, int count) {
int total = perHour.computeIfAbsent(actor, k -> new AtomicInteger()).addAndGet(count);
if (total > HOURLY_LIMIT) alerts.raise("P1", "bulk-read", actor, total);
}
@Scheduled(cron = "0 0 * * * *")
void resetEveryHour() { perHour.clear(); }
}
서버가 여러 대면 이 숫자는 한곳에서 세야 합니다. 강의에서는 감사 로그를 Loki로 모으고 Grafana 경보 규칙(기준선, 계정별 한도, 응답 크기)으로 같은 일을 합니다.
경보에는 담당자와 기한을 함께 붙입니다.
private static final Map<String, Duration> DUE = Map.of(
"P1", Duration.ofMinutes(15), "P2", Duration.ofHours(4)); // 예시 값
public void onAlert(Alert alert) {
Ticket t = tickets.open(alert, DUE.get(alert.priority()));
pager.notify(oncall.now(), t); // 당번에게 바로
escalation.at(t.due(), t); // 기한을 넘기면 다음 책임자에게
}
처리 기한 준수율도 지표로 남깁니다. 준수율이 몇 달째 떨어지는 상태가 보고서에 드러나야 인력과 분류 기준을 고칠 수 있습니다.
테스트 계정으로 기준을 넘는 건수를 조회해 경보가 생기는지, 기한이 지나면 다음 책임자에게 올라가는지 모의 경보로 확인합니다.
디버깅하려고 넣은 로그 한 줄이 전화번호와 토큰을 그대로 남깁니다. 로그는 여러 사람이 보고, 수집기와 백업으로 복사됩니다. 감사 로그를 늘릴수록 이 문제도 같이 커집니다.
위험한 모양입니다.
log.info("login ok phone={} auth={}", phone, request.getHeader("Authorization"));
고친 모양은 로그가 출력되기 전에 한 번 가립니다. Logback 메시지 변환기를 바꿔 쓰는 방식입니다.
public class MaskingConverter extends MessageConverter {
private static final Pattern PHONE = Pattern.compile("01\\d-?\\d{3,4}-?(\\d{4})");
private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)\\S+");
@Override
public String convert(ILoggingEvent event) {
String msg = PHONE.matcher(super.convert(event)).replaceAll("***-****-$1");
return BEARER.matcher(msg).replaceAll("$1[MASKED]");
}
}
logback-spring.xml의 conversionRule로 %msg 자리에 이 변환기를 쓰게 합니다. 처음부터 토큰을 로그에 넘기지 않는 것이 먼저이고, 변환기는 놓친 곳을 잡는 두 번째 줄입니다. 강의에서는 로그 수집기에서 한 번 더 가립니다.
테스트에서 가짜 번호 010-0000-1234와 Bearer FAKE-token을 로그로 남긴 뒤, 로그 파일을 검색해 원문이 0건인지 봅니다.
감사 로그가 서비스 DB 안에 있으면 관리자 권한으로 고치거나 지울 수 있습니다. 한 줄이 바뀌어도 알아챌 방법이 없고, 관리자 권한을 얻은 침입자도 마찬가지입니다. 로그가 사라지면 사고 범위를 밝힐 근거도 사라집니다. KT 무단 소액결제(국내편 02편)에서는 로그 삭제가 미신고·허위자료 제출과 함께 개인정보위 고발 사유가 됐습니다. 쿠팡에서는 자료보존 명령 뒤에도 웹 접속로그가 자동으로 삭제돼 수사의뢰로 이어졌습니다.
해시 사슬(로그마다 바로 앞 로그의 해시를 함께 저장해 순서대로 묶는 방식)을 씁니다.
public AuditEntry append(AuditEvent event) {
String prev = auditRepo.lastHash(); // 첫 줄이면 "GENESIS"
String body = json(event);
AuditEntry entry = new AuditEntry(body, prev, sha256Hex(prev + body));
appendOnlyStore.send(entry); // 쓰기만 되는 별도 저장소에 사본
return auditRepo.save(entry);
}
public boolean verify(List<AuditEntry> entries) {
String prev = "GENESIS";
for (AuditEntry e : entries) {
if (!e.prevHash().equals(prev) || !e.hash().equals(sha256Hex(prev + e.body()))) return false;
prev = e.hash();
}
return true;
}
중간 한 줄이 바뀌면 그 뒤의 연결이 모두 어긋납니다. 다만 DB 전체를 쥔 사람은 사슬을 처음부터 다시 계산할 수 있습니다. 그래서 사본은 앱 자격증명으로는 지울 수 없는, 쓰기만 되는 별도 저장소로 보냅니다.
테스트 DB에서 감사 로그 한 줄의 건수를 바꾼 뒤 verify가 false를 돌려주는지 봅니다.
대량 조회는 요청 하나가 아니라 건수로 드러납니다. 그래서 감사 로그에는 건수까지 남기고, 그 수에 경보를 겁니다. 경보에는 담당과 기한을 붙여야 탐지가 대응으로 이어집니다. 로그는 가려서 남기고, 관리자도 지울 수 없는 곳에 사본을 둡니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.
이전 글 확인한 것만 배포한다: SBOM, CI 고정, 이미지 서명, 역직렬화
다음 글 오류가 나면 문이 열린다: 실패하면 막는 예외 처리
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록