이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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=valid
String 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=valid
public 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=7f9c2b0e
level=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처럼 공격을 분석할 수 있는 필드다.

참고 문서