
티빙에서 3,954만 계정이 빠져나가는 데 이틀이 걸렸습니다. 출발점은 개발환경 접속키 하나였고, 그 키는 소스코드에 노출돼 있거나 평문으로 저장돼 있었습니다(민관합동조사단, 티빙 3,954만 계정). 딥시크의 로그 데이터베이스는 비밀번호 없이 인터넷에 열려 있었다고 위즈(Wiz) 연구팀이 밝혔습니다(딥시크). 두 사고 모두 새로운 기법보다 설정에서 시작했습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 6-1 ~ 6-6

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| 코드·설정 속 시크릿 | application.yml, git 이력, 이미지 레이어 | 시크릿 저장소에 두고 이름만 참조, 기본값이면 기동 거부 | A02 |
| 비밀번호 없이 열린 저장소 | Redis·검색 서버·분석 DB의 기본 설치 | 내부망에만 두고 바깥 포트를 닫고, 저장소마다 인증 | A02 |
| 내부를 보여 주는 오류 응답 | 스택 추적, 쿼리 문장, 관리용 엔드포인트 | 공통 오류 형식과 추적 번호, 상세는 서버 로그에 | A02 |
| 빠진 보안 헤더·넓은 CORS | 프레임워크 기본값, 모든 출처 허용 | 공통 보안 헤더, 출처 허용 목록 | A02 |
시크릿은 비밀번호·API 키·서명키·인증키처럼 코드와 로그 밖에 숨겨야 하는 값입니다.
코드에 적은 키는 저장소를 볼 수 있는 모든 사람에게 보입니다. 파일에서 지워도 커밋 이력에 남습니다. 티빙 사고에서는 키를 사내 메신저로 공유하기도 했고, 2024년 모의해킹에서 지적받은 프로젝트만 고친 채 전사로 넓히지 않았습니다(민관합동조사단).
또 하나 흔한 빈틈은 데모나 개발용 기본값입니다. jwt.secret: changeme 같은 값이 설정 파일에 있으면, 운영 환경 변수가 빠졌을 때 서버는 그 값을 쓰고 아무 경고 없이 뜹니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.
# application.yml: 값은 시크릿 저장소, 코드에는 이름만 (OpenBao는 Vault와 호환)
spring:
config.import: vault://
datasource.password: ${db.password}
// 기본값이나 빈 값이면 서버를 띄우지 않는다
@Component
class SecretStartupCheck implements ApplicationRunner {
private static final Set<String> DEFAULTS = Set.of("", "changeme", "secret", "FAKE-demo-secret");
@Value("${jwt.secret:}") private String jwtSecret;
@Override
public void run(ApplicationArguments args) {
if (DEFAULTS.contains(jwtSecret.trim())) {
throw new IllegalStateException("jwt.secret이 기본값입니다. 시크릿 저장소 설정을 확인하세요.");
}
}
}
강의의 방어 부품(secure-kit)은 같은 검사를 기동 검사기 설정으로 켭니다. 이미 커밋된 키는 이력을 지우는 것보다 먼저 새 키로 교체합니다. 지우는 사이에도 누군가 복사해 갔을 수 있기 때문입니다.
환경 변수 없이 서버를 띄웠을 때 기동이 실패하는지 확인합니다. 커밋 전 시크릿 검사(gitleaks 등)를 CI에도 걸어 결과가 0건인지 봅니다.
Redis, Elasticsearch, ClickHouse 같은 저장소는 비밀번호 없이 띄우기 쉽습니다. "내부용이니 괜찮다"고 두었다가 포트 하나가 밖으로 열리면, 주소를 아는 누구나 조회합니다. 2022년 상하이 경찰 데이터베이스 사건에서 보안 연구자들은 인증 없이 열린 Kibana·Elasticsearch를 출처로 지목했습니다. 규모 10억 명은 판매자의 주장이고 확인되지 않았습니다(상하이 경찰 데이터베이스). 딥시크에서는 서브도메인 2곳의 ClickHouse DB가 외부 포트로 공개돼 있었고, 로그 100만 줄 이상에 평문 대화와 API 키가 들어 있었습니다(위즈).
# docker compose: 저장소는 내부 네트워크에만, 바깥 포트(ports:)는 두지 않는다
services:
redis:
image: redis:7
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
networks: [backend]
app:
networks: [backend, edge]
networks:
backend: { internal: true } # 바깥과 통하지 않는 내부망
edge: {}
# application.yml: 앱은 내부망 이름으로, 비밀번호와 함께 접속
spring.data.redis:
host: redis # 내부망 이름
password: ${REDIS_PASSWORD} # 시크릿 저장소에서 주입
관리용 화면(Kibana 같은 도구)은 별도 입구와 인증 뒤에 둡니다.
서버 바깥의 다른 PC에서 저장소 포트로 접속이 되지 않는지, 내부에서도 비밀번호 없이 접속하면 거부되는지 확인합니다.
개발할 때 켜 둔 상세 오류는 운영에도 그대로 남기 쉽습니다. 스택 추적이 응답으로 나가면 프레임워크와 버전, 파일 경로, 때로는 쿼리 문장과 내부 주소까지 보입니다. 공격하는 쪽에게는 서버 구조를 알려 주는 지도가 됩니다. Spring Boot Actuator 같은 관리용 엔드포인트가 밖에 열려 있으면 설정값이나 환경 정보가 함께 노출될 수 있습니다.
server.error:
include-stacktrace: never
include-message: never
management.endpoints.web.exposure.include: health # 관리용은 health만
@RestControllerAdvice
class CommonErrorHandler {
private static final Logger log = LoggerFactory.getLogger(CommonErrorHandler.class);
@ExceptionHandler(Exception.class)
ResponseEntity<ProblemDetail> handle(Exception e) {
String traceId = UUID.randomUUID().toString();
log.error("unhandled error traceId={}", traceId, e); // 상세는 서버 로그에만
ProblemDetail body = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
body.setDetail("요청을 처리하지 못했습니다.");
body.setProperty("traceId", traceId); // 응답에는 추적 번호만
return ResponseEntity.internalServerError().body(body);
}
}
secure-kit에서는 secure-kit.error-response: { enabled: true } 설정 한 줄로 같은 형식을 켭니다(강의에서 만드는 방어 부품 예시).
일부러 예외를 내는 테스트 요청을 보내 응답 본문에 클래스 이름이나 at com. 같은 줄이 없는지 테스트로 확인합니다. /actuator 아래 경로가 health 말고는 404인지도 봅니다.
보안 헤더는 브라우저에게 "이 페이지는 이렇게 다뤄라"라고 알려 주는 응답 머리입니다. CSP(Content-Security-Policy, 스크립트를 불러올 수 있는 출처 제한), HSTS(HTTPS로만 접속하게 함), frame-ancestors(다른 사이트가 우리 페이지를 틀 안에 넣지 못하게 함)가 대표적입니다. 빠져 있으면 XSS나 클릭재킹 같은 다른 결함의 피해가 커집니다. CORS를 모든 출처 허용으로 열어 두면 다른 사이트의 스크립트가 우리 API 응답을 읽을 수 있는 범위가 넓어집니다.
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.headers(h -> h
.contentSecurityPolicy(c -> c.policyDirectives("default-src 'self'; frame-ancestors 'none'"))
.httpStrictTransportSecurity(t -> t.maxAgeInSeconds(31536000).includeSubDomains(true)))
.cors(c -> c.configurationSource(req -> {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.example.com")); // 허용 목록
cfg.setAllowedMethods(List.of("GET", "POST"));
return cfg;
}));
return http.build();
}
여러 서비스가 있다면 게이트웨이에서 공통 헤더를 한 번에 붙이는 편이 빠뜨림이 적습니다.
응답 머리에 CSP와 HSTS가 있는지, 허용 목록에 없는 Origin으로 보낸 요청에 Access-Control-Allow-Origin이 붙지 않는지 통합 테스트로 확인합니다.
설정의 실수는 코드 리뷰에 잘 보이지 않고, 기능 테스트도 그대로 통과합니다. 시크릿은 저장소에 두고 기본값이면 서버가 뜨지 않게 합니다. 내부 저장소는 밖에 열린 것처럼 잠그고, 오류 응답에는 사용자에게 필요한 말만 담습니다. 보안 헤더와 CORS 허용 목록은 다른 결함이 생겼을 때 피해 범위를 줄여 줍니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다. 상하이 경찰 데이터베이스의 10억 명은 판매자 주장이며 확인되지 않았고, 노출 지점은 보안 연구자의 분석입니다.
이전 글 교체할 수 있는 키: 서명키 회전과 봉투 암호화
다음 글 확인한 것만 배포한다: SBOM, CI 고정, 이미지 서명, 역직렬화
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록