SQL Injection과 Prepared Statement in Java

1. 개요

SQL injection은 OWASP 목록의 최상위 보안 취약점으로, 사용자 입력을 SQL 문자열에 수동으로 연결할 때 발생합니다. 이 글에서는 공격 메커니즘을 설명하고, PreparedStatement가 왜 이를 막는지 보여주며, JDBC, JPA/Hibernate, MyBatis에 적용하는 방법을 안내합니다. 이 글을 읽은 후 안전한 쿼리를 작성할 수 있고, 취약점을 조기에 발견하는 테스트 작성 방법도 알게 될 것입니다.

2. SQL injection은 어떻게 작동하는가

오류는 입력값을 SQL 문자열에 직접 연결할 때 발생합니다. 매개변수화하는 대신입니다.

// 이렇게 하면 안 됩니다
public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
    try (Statement stmt = connection.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next() ? mapRow(rs) : null;
    }
}

username이 "minh"처럼 정상적인 값이면 생성되는 SQL은 다음과 같습니다:

SELECT * FROM customers WHERE username = 'minh'

하지만 공격자가 username = "' OR '1'='1"을 입력하면 SQL은 다음처럼 됩니다:

SELECT * FROM customers WHERE username = '' OR '1'='1'

'1'='1' 조건은 항상 참이므로, 쿼리는 username 검증 로직을 완전히 무시하고 customers 테이블 전체를 반환합니다. 더 나쁜 경우, 공격자는 ; DROP TABLE customers; --를 추가하여 데이터를 삭제하거나, UNION SELECT로 다른 테이블(예: 비밀번호 해시가 있는 users 테이블)을 읽을 수 있습니다.

주의: 입력은 HTML 양식에서만 오지 않습니다. HTTP 헤더, 쿠키, 쿼리 파라미터, 파일 업로드 데이터 모두 잠재적 공격 경로입니다.

3. Prepared Statement가 문제를 해결하는 방법

3.1. 매개변수화 메커니즘

PreparedStatement는 SQL 구조(미리 전송되어 DB에서 파싱되고 실행 계획으로 컴파일됨)와 매개변수 값(나중에 전송되어 순수 데이터로만 취급됨)을 분리합니다. 덕분에 공격자의 입력은 절대 DB에 의해 SQL 구문으로 해석되지 않습니다.

public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, username);
        try (ResultSet rs = stmt.executeQuery()) {
            return rs.next() ? mapRow(rs) : null;
        }
    }
}
  • ?는 플레이스홀더이며, DB는 이 플레이스홀더와 함께 미리 명령을 컴파일합니다.
  • stmt.setString(1, username)은 첫 번째(1부터 시작, 0 아님) 매개변수 위치에 값을 할당합니다.
  • username이 ' OR '1'='1을 포함하더라도, DB는 이를 리터럴 문자열 하나로 이해하지, SQL 구문이 아닙니다. 그런 이상한 이름의 사용자가 없으므로 쿼리는 빈 결과를 반환합니다.

3.2. Spring JdbcTemplate과 함께 Named Parameter

Spring을 사용할 때 NamedParameterJdbcTemplate을 쓰면 순서별 ? 사용보다 코드가 훨씬 읽기 쉬워집니다.

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>
@Repository
public class CustomerRepository {

    private final NamedParameterJdbcTemplate jdbc;

    public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public Optional<Customer> findByUsername(String username) {
        String sql = "SELECT id, username, email FROM customers WHERE username = :username";
        Map<String, Object> params = Map.of("username", username);
        List<Customer> result = jdbc.query(sql, params, this::mapRow);
        return result.stream().findFirst();
    }

    private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
    }
}

:username은 named 플레이스홀더이고, Spring은 내부적으로 이를 ?로 변환한 후 값을 안전하게 바인드합니다. SQL에 여러 조건이 있을 때 매개변수 순서 혼동을 피할 수 있습니다.

4. JPA/Hibernate에서 적용하기

JPA는 기본적으로 prepared statement를 사용하는 SQL을 생성하지만, 잘못된 API를 사용하면 여전히 자신의 발을 쏠 수 있습니다.

// 안전함 — JPQL과 named parameter
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// 위험함 — JPQL에 문자열 연결
public List<Order> findOrdersByStatusUnsafe(String status) {
    String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
    return entityManager.createQuery(jpql, Order.class).getResultList();
}
  • 안전한 예제의 setParameter("status", status)는 Hibernate가 JPQL을 ?를 포함한 SQL로 변환하고, PreparedStatement를 통해 값을 바인드하도록 합니다. 공격자는 명령 구조에 개입할 수 없습니다.
  • 위험한 예제에서도 Hibernate는 JPQL을 파싱하지만, JPQL 문자열이 파싱 전에 이미 “오염”되어 있으므로 공격자는 JPQL injection을 수행할 수 있습니다(SQL injection과 유사하지만 구문 레벨이 다름).

Spring Data JPA에서도 @Query는 문자열 연결 대신 매개변수를 사용해야 합니다:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
    List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}

5. 매개변수화할 수 없는 경우: 테이블명, 컬럼명

PreparedStatement는 값만 매개변수화할 수 있으며, 식별자(테이블명, 컬럼명, ORDER BY 절)는 매개변수화할 수 없습니다. 많은 개발자가 간과하는 상황입니다.

// 사용자가 선택한 컬럼으로 동적 정렬
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("유효하지 않은 정렬 컬럼: " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // 화이트리스트 때문에 안전함
    // ... PreparedStatement로 정상 실행
    return jdbc.query(sql, this::mapRow);
}
  • ORDER BY ?를 사용할 수 없으므로, 문자열 연결 전에 화이트리스트로 값을 검증해야 합니다.
  • 절대 블랙리스트를 사용하지 마세요(DROP, ; 같은 키워드 차단). 공격자는 항상 인코딩이나 구문 변형을 통해 우회 방법을 찾습니다.

6. 취약점을 조기에 발견하기 위한 테스트 작성

간단한 테스트로 엔드포인트가 injection을 올바르게 방지하는지 확인할 수 있습니다.

@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
        String payload = "' OR '1'='1";

        mockMvc.perform(get("/api/customers").param("username", payload))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
        mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
               .andExpect(status().isBadRequest());
    }
}
class CustomerRepositoryTest {

    @Test
    void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
        CustomerRepository repo = new CustomerRepository(jdbcTemplate);

        Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");

        assertThat(result).isEmpty();
    }
}
  • 첫 번째 테스트는 고전적인 injection 페이로드를 보내고 결과가 비어있음을 기대합니다. 전체 데이터가 반환되지 않아야 합니다.
  • 두 번째 테스트는 엔드포인트가 정렬 컬럼 화이트리스트에 없는 입력을 거부하는지 확인합니다. 위험한 SQL을 실행하는 대신 400 Bad Request를 반환해야 합니다.
  • assertThat(result).isEmpty()는 AssertJ를 사용해 Optional이 비어있음을 확인하고, 쿼리가 우회되지 않았음을 증명합니다.

7. 흔한 실수들

  • 동적 입력에 Statement.executeQuery(String) 사용: 데모 작성 시 편하지만 실제 운영 환경에서는 진정한 보안 구멍입니다.
  • SQL 문을 “짧게” 하려고 String.format 사용: 여전히 문자열 연결이며, +로 더하는 것과 다르지 않습니다.
  • ORM을 100% 신뢰하기: JPQL/HQL/Criteria도 createQuery를 호출하기 전에 문자열을 직접 연결하면 injection이 가능합니다.
  • 수동 escape 대신 매개변수화: 직접 escape 함수(replace("'", "\\'"))를 작성하면 경우를 놓치기 쉽습니다(Unicode 인코딩, 멀티바이트 등). 드라이버의 네이티브 메커니즘만큼 신뢰할 수 없습니다.
  • 동적 IN 절을 잘못 사용: WHERE id IN (?, ?, ?)를 구성할 때 매개변수 개수를 리스트 크기와 정확히 맞춰야 하며, 값을 직접 문자열로 연결하면 안 됩니다.

주의: Prepared statement는 SQL injection을 방지하지만 다른 레이어의 injection은 방지하지 않습니다. command injection, LDAP injection, NoSQL injection 등 각 데이터 레이어는 자체 매개변수화 메커니즘이 필요합니다.

8. PreparedStatement가 불필요한 경우

SQL이 완전히 정적이고 사용자 입력에서 동적 값이 없다면(예: SELECT COUNT(*) FROM customers), 일반 Statement를 사용해도 안전하며 파싱/바인드 단계를 건너뛰므로 약간 더 가벼울 수 있습니다. 하지만 실제로는 대부분의 쿼리에 동적 조건이 있으므로, PreparedStatement 또는 동등한 방법(JPQL with setParameter, MyBatis with #{} not ${})을 기본 습관으로 삼고, “어느 경우가 안전한가”를 일일이 판단하지 않도록 하세요.

9. 결론

SQL injection은 SQL 문자열에서 코드와 데이터를 섞을 때 발생합니다. PreparedStatement는 두 가지를 분리하여 근본적으로 해결합니다. 명령 구조는 미리 컴파일되고, 매개변수 값은 순수 데이터로서만 나중에 바인드됩니다. JDBC, JPQL, MyBatis에서 일관되게 적용하고, 테이블/컬럼명 같은 동적 식별자에는 항상 화이트리스트를 사용하며, 고전적인 injection 페이로드로 테스트를 작성하여 엔드포인트가 절대 공격자가 제어하는 SQL을 실행하지 않도록 보장하세요.

web-securitysql-injectionjdbcspring-bootjava