이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Prepared Statement가 막는 SQL Injection과 막지 못하는 입력은 무엇인가?
- 검색어, 정렬, 필터가 섞인 API에서 어떤 값을 bind parameter로 두고 어떤 값을 allowlist로 제한해야 하는가?
- SQL 오류, 차단 로그, DB 권한은 Injection 대응에서 어떤 역할을 하는가?
개요
SQL Injection은 공격자가 입력값을 이용해 서버가 의도한 SQL의 구조를 바꾸는 공격이다. 웹 API에서는 로그인 폼뿐 아니라 검색, 관리자 필터, report export, batch 조건, native query가 모두 진입점이 된다.
Prepared Statement는 사용자 값을 SQL 코드가 아니라 데이터로 전달하게 해 SQL 의도 변경을 막는다. 이 방어는 강력하지만 모든 문자열 결합을 없애 주지는 않는다.
특히 컬럼명, 정렬 방향, 테이블명, where fragment 같은 SQL 구조는 bind parameter로 대체할 수 없다. 이 값들은 사용자가 보낸 문자열을 그대로 붙이지 말고 서버가 아는 선택지 중 하나로 매핑해야 한다.
이 문서의 핵심은 “입력값을 escape할 것인가”가 아니라 “값과 구조를 분리했는가”다.
공격 시나리오
- 공격자는 먼저 검색, 목록, 관리자 필터처럼 조건을 많이 받는 endpoint를 찾는다.
- 정상 요청의
keyword,status,sort,direction,pageSize를 바꿔 보며 오류 메시지와 응답 건수 변화를 관찰한다. - 서버가 검색어를 문자열로 이어 붙이면
' OR '1'='1같은 payload로 조건을 무력화한다. - 서버가 정렬 컬럼을 그대로 붙이면
created_at desc, (select ...)처럼 쿼리 구조를 바꾸려 한다. - 오류 응답에 SQL 조각이나 DB 제품명이 나오면 공격자는 DB별 syntax에 맞춰 payload를 더 빠르게 조정한다.
- Injection이 성공하면 데이터 조회, 데이터 변조, 인증 우회, DB 계정 권한 범위 내 명령 실행으로 피해가 커질 수 있다.
취약한 요청/응답 예시
GET /api/orders?keyword=' OR '1'='1&sort=created_at desc HTTP/1.1
Host: api.example.com
Cookie: SESSION=validString sql = "select * from orders where memo like '%" + keyword + "%' " +
"order by " + sort;
return jdbcTemplate.query(sql, orderRowMapper);HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{"message":"You have an error in your SQL syntax near '' OR '1'='1' order by created_at desc"}문제는 검색어와 정렬 조건이 모두 문자열 결합으로 SQL에 들어간다는 점이다. 검색어는 값이고 정렬 컬럼은 구조이므로 방어 방식도 달라야 한다.
주제별 핵심 판단
- 값은 Prepared Statement, JPQL named parameter, Querydsl binding처럼 parameterized API로 넘긴다.
- 구조는
createdAt,price,id같은 서버 내부 allowlist에서만 선택한다. like검색에서도%문자를 SQL 문자열에 붙이지 말고 parameter binding 함수나 prepared value로 처리한다.- ORM을 사용해도
@Query(nativeQuery = true), 문자열 JPQL 조립, report SQL builder는 별도로 확인한다. - 입력 검증은 보조 방어다. SQL Injection의 1차 방어를 blacklist 필터나 quote escape에 맡기지 않는다.
- DB 계정은 애플리케이션 기능에 필요한 최소 권한만 가져야 한다.
- 오류 응답은 SQL syntax를 숨기고 서버 로그에 trace id와 query template 식별자만 남긴다.
개선된 요청/응답 예시
GET /api/orders?keyword=desk&sort=createdAt&direction=desc HTTP/1.1
Host: api.example.com
Cookie: SESSION=validpublic enum OrderSortKey {
CREATED_AT("createdAt"),
TOTAL_PRICE("totalPrice"),
ID("id");
private final String property;
OrderSortKey(String property) {
this.property = property;
}
public Sort toSort(Sort.Direction direction) {
return Sort.by(direction, property);
}
}
public Page<Order> search(OrderSearchRequest request, Pageable pageable) {
Sort.Direction direction = request.directionOrDefault();
Sort sort = request.sortKeyOrDefault().toSort(direction);
Pageable safePage = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
return orderRepository.searchByMemo(request.keyword(), safePage);
}정렬 이름은 enum으로 제한하고 검색어는 repository parameter로만 전달한다. 공격자가 sort=created_at desc; drop table orders를 보내도 enum 변환 단계에서 실패해야 한다.
방어 설계
- API 계약에서 값 입력과 구조 선택을 분리한다.
- Controller는 길이, 타입, enum 같은 형식 검증을 먼저 수행한다.
- Service는 사용자의 권한과 조회 범위를 제한한다.
- Repository는 parameter binding이 깨지지 않는 query API를 사용한다.
- 동적 SQL builder를 써야 한다면 fragment를 문자열로 받지 않고 조건 객체로 받는다.
- DB 계정은 schema 변경, 다른 schema 조회, 파일 접근 같은 권한을 갖지 않게 한다.
- 에러 핸들러는
BAD_REQUEST,INVALID_SORT,INTERNAL_ERROR같은 안정된 코드만 응답한다. - 보안 테스트는 정상 검색어, quote, comment marker, union keyword, 매우 긴 입력, 잘못된 sort key를 모두 포함한다.
Spring/HTTP 예시
public record OrderSearchRequest(
@Size(max = 80) String keyword,
OrderSortKey sort,
Sort.Direction direction
) {
public OrderSortKey sortKeyOrDefault() {
return sort == null ? OrderSortKey.CREATED_AT : sort;
}
public Sort.Direction directionOrDefault() {
return direction == null ? Sort.Direction.DESC : direction;
}
}public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o
from Order o
where (:keyword is null or o.memo like concat('%', :keyword, '%'))
""")
Page<Order> searchByMemo(@Param("keyword") String keyword, Pageable pageable);
}@GetMapping("/api/orders")
public Page<OrderResponse> search(@Valid OrderSearchRequest request, Pageable pageable) {
return orderService.search(request, pageable).map(OrderResponse::from);
}Bean Validation은 입력의 길이와 enum 변환을 확인한다. SQL Injection 방어의 본체는 repository의 parameter binding과 sort allowlist다.
운영 로그와 감사 지점
level=WARN event=sql_input_rejected result=blocked principal=member:1004 endpoint=GET /api/orders field=sort valueHash=2fd8 reason=unknown_sort_key traceId=7f9c2b0elevel=ERROR event=sql_query_failed result=error queryId=order.searchByMemo principal=member:1004 endpoint=GET /api/orders sqlState=42000 traceId=7f9c2b0e- payload 원문을 길게 남기지 말고 hash, field, reason, queryId를 남긴다.
- SQL syntax 오류가 반복되면 공격 탐지 신호로 본다.
- DB 오류 로그와 API access log가 같은 trace id로 연결되어야 한다.
- 정상 사용자의 validation 실패와 SQL probe를 구분할 수 있게 event 이름을 나눈다.
- DB 계정 권한 오류도 공격 결과일 수 있으므로 무시하지 않는다.
실전 판단 기준
- 코드 리뷰에서는
+ keyword +,String.format(sql,order by " +,${}형태를 먼저 찾는다. PreparedStatement를 쓰더라도 table name이나 column name을 문자열로 붙이면 취약할 수 있다.JpaRepository기본 메서드는 안전한 편이지만 custom native query는 별도 리뷰가 필요하다.- 검색 API의 sort key는 클라이언트 표시명과 DB 컬럼명을 직접 연결하지 않는다.
- SQL 오류가 클라이언트에 보이면 방어와 탐지 모두 실패한 신호다.
- Injection 대응은 애플리케이션 코드, DB 권한, 오류 처리, 로그 분석을 함께 본다.
테스트 포인트
keyword=' OR '1'='1요청이 결과 전체 조회로 이어지지 않는지 확인한다.keyword=%' union select ...같은 payload가 데이터로만 처리되는지 확인한다.sort=id desc, version()같은 값이 400으로 거부되는지 확인한다.- 잘못된 enum 값이 내부 stack trace 없이 안정된 오류 코드로 응답되는지 확인한다.
- native query와 report query에도 같은 테스트를 적용한다.
- SQL 오류가 발생해도 응답에 SQL 조각, DB 제품명, class name이 없는지 확인한다.
위험 신호!
- repository 코드에 사용자 입력이 문자열 결합으로 들어간다.
- 정렬 컬럼, direction, table name을 요청 문자열 그대로 사용한다.
- SQL 오류가
500응답 본문에 그대로 노출된다. - ORM을 사용한다는 이유로 native query 리뷰를 생략한다.
- DB 계정이 DDL, 다른 schema 조회, 관리자 함수를 실행할 수 있다.
- 로그에 전체 SQL과 개인정보 payload가 함께 남는다.
확인 질문
확인 질문
- Prepared Statement가 직접 보호하는 입력은 무엇인가?
- SQL의 값 위치에 들어가는 사용자 입력이다. 공격 payload가 들어와도 SQL 코드가 아니라 데이터로 처리된다.
- Prepared Statement만으로 보호되지 않는 입력은 무엇인가?
- 컬럼명, 정렬 방향, 테이블명, SQL fragment처럼 쿼리 구조를 결정하는 입력이다.
- SQL Injection 차단 로그에는 무엇을 남겨야 하는가?
- endpoint, field, reason, queryId, principal, traceId, value hash처럼 공격을 분석할 수 있는 필드다.