들어가며

특정 테이블에서 조회할 컬럼을 클라이언트가 골라 넘기는 API가 있다고 하자. 대략 이런 모양이다.

SELECT ${col} FROM member WHERE status = #{status}

${col} 자리에 "name"이 들어오면 이름을, "email"이 들어오면 이메일을 조회하는 식이다. 정렬 컬럼이나 검색 대상 컬럼을 유연하게 바꿔야 할 때 흔히 나오는 패턴이다.

MyBatis를 쓰면 파라미터 바인딩으로 SQL Injection이 자연스럽게 막힌다고 생각하기 쉽다. 하지만 이 쿼리는 값 조건인 status#{}로 바인딩하고, 정작 컬럼 자리는 ${}를 쓰고 있다. 바로 이 차이가 SQL Injection의 위험 지점이다.


파라미터 바인딩이 이 자리엔 통하지 않는다

MyBatis에서 값을 넘길 땐 보통 #{}를 쓴다. #{status}는 내부적으로 PreparedStatement의 ? 자리에 바인딩되므로, 값에 ' OR 1=1 -- 같은 문자열이 들어와도 그건 하나의 문자열 값으로만 취급되지 SQL 구문으로 해석되지 않는다. 이것이 일반적인 파라미터 바인딩의 방어 원리다.

문제는 위 쿼리에서 컬럼 자리는 #{}가 아니라 ${}를 쓰고 있다는 점이다. ${}는 바인딩이 아니라 문자열을 그대로 이어붙이는 치환이다. 즉 ${col}에 들어온 값이 SQL 문장에 날것 그대로 삽입된다. 여기에 사용자 입력이 통제 없이 흘러 들어가면 injection이 성립한다.

그렇다면 컬럼도 #{}로 바인딩하면 되지 않을까? 하지만 안 된다. 이렇게 써보면 이유가 드러난다.

SELECT #{col} FROM member

#{col}?로 바뀌고 실행 시점에 "name"이라는 문자열 리터럴이 바인딩된다. 그래서 실제로 실행되는 쿼리는 이렇게 된다.

SELECT 'name' FROM member   -- member 행 수만큼 'name' 이라는 문자열이 반복 출력된다

name 컬럼의 값이 아니라 'name'이라는 상수 문자열이 조회된다. 원하는 결과가 전혀 아니다.

이유는 근본적인 데 있다. PreparedStatement의 파라미터 바인딩은 “값(value)“만 채울 수 있고, “식별자(identifier)“는 채우지 못한다. 컬럼명, 테이블명, ORDER BY 대상 같은 식별자는 DB가 쿼리를 파싱하는 시점에 이미 확정되어 있어야 한다. 파싱과 실행 계획 수립이 끝난 뒤에야 값이 바인딩되기 때문에, 쿼리의 구조를 결정하는 식별자를 나중에 끼워 넣는 건 원리상 불가능한 것이다.

정리하면 이렇다.

  • WHERE status = #{status} → 값이므로 바인딩 가능. 안전하다.
  • SELECT ${col} → 식별자라서 바인딩 불가. ${}로 치환할 수밖에 없고, 그래서 위험하다.

컬럼을 동적으로 받는 순간, 파라미터 바인딩이라는 방패는 애초에 이 자리에 쓸 수가 없다는 결론이다. 다른 방어가 필요하다.


어떤 공격이 되는가

${col}이 그대로 이어붙는다는 건, 클라이언트가 컬럼명 대신 이런 문자열을 넣을 수 있다는 뜻이다.

1 FROM member; DROP TABLE member; --

그러면 최종적으로 완성되는 SQL은 다음과 같다.

SELECT 1 FROM member; DROP TABLE member; -- FROM member WHERE status = 'ACTIVE'

DB 계정 권한이나 드라이버의 multi-statement 허용 여부에 따라 실제 실행까지 이어질지는 갈리지만, 서브쿼리로 다른 테이블의 값을 뽑아오거나((SELECT TOP 1 password FROM admin)), 일부러 에러를 유발해 에러 메시지로 스키마 정보를 흘리게 만드는 정도는 어렵지 않게 시도할 수 있다. “설마 컬럼명 자리에 저런 게 먹히겠어"가 아니라, 문자열 이어붙이기가 되는 순간 그 자리는 이미 열려 있는 것으로 봐야 한다.


방어 1 — 화이트리스트

식별자는 바인딩이 안 되니 발상을 바꿔야 한다. 사용자 입력을 SQL에 넣기 전에, 미리 정해둔 안전한 값들 중 하나인지 검사하는 것이다. 이게 화이트리스트(allowlist)이고, 동적 컬럼 문제의 정석 해법이다.

가장 단순하면서 확실한 형태는 코드에 박아두는 정적 화이트리스트다.

private static final Set<String> ALLOWED_COLUMNS =
        Set.of("name", "email", "created_at", "status");

public List<Member> search(String col, String status) {
    if (!ALLOWED_COLUMNS.contains(col)) {
        throw new IllegalArgumentException("허용되지 않은 컬럼: " + col);
    }
    // 여기 도달한 col은 미리 허용해 둔 값 중 하나임이 보장된다
    return memberMapper.search(col, status);
}

Mapper는 그대로 둔다.

<select id="search" resultType="Member">
    SELECT ${col}
    FROM member
    WHERE status = #{status}
</select>

${col}이 여전히 문자열 치환인 건 맞다. 하지만 이제 col에 들어올 수 있는 값은 ALLOWED_COLUMNS 안의 네 개뿐이고, 그 밖의 값은 서비스 레이어에서 이미 걸러냈다. 위험한 건 ${} 문법 자체가 아니라 ${}에 통제되지 않은 입력이 흘러 들어가는 흐름이고, 화이트리스트는 바로 그 흐름을 끊는다.

한 가지 더 챙기면 좋은 게, 화이트리스트를 API가 노출하는 키와 실제 물리 컬럼명을 매핑하는 형태로 두는 것이다.

private static final Map<String, String> COLUMN_MAP = Map.of(
        "이름",   "name",
        "가입일", "created_at",
        "상태",   "status"
);

이렇게 하면 클라이언트는 실제 테이블 컬럼명을 몰라도 되고(스키마 은닉), 매핑에 없는 키는 자동으로 걸러진다. 사용자가 넘긴 문자열이 SQL에 닿는 게 아니라, 미리 적어둔 컬럼명 상수가 SQL에 닿는다는 점이 핵심이다.


방어 2 — information_schema로 화이트리스트 만들기

여기서 자연스럽게 나오는 질문이 있다. information_schema로 화이트리스트를 만들면 그것만으로 방어가 끝나는가 하는 점이다.

결론부터 말하면 방어는 되지만, 그것만으로 끝나는 게 아니고 주의할 점이 있다.

컬럼이 수십 개고 자주 바뀌는 테이블이라면 매번 손으로 정적 화이트리스트를 적는 게 번거롭다. 이럴 때 실제 스키마에서 컬럼 목록을 읽어와 화이트리스트를 동적으로 구성할 수 있다. MSSQL에서는 이렇게 조회한다.

SELECT COLUMN_NAME
FROM   INFORMATION_SCHEMA.COLUMNS
WHERE  TABLE_NAME = @tableName

MSSQL 네이티브 카탈로그 뷰를 써도 된다.

SELECT c.name
FROM   sys.columns c
WHERE  c.object_id = OBJECT_ID(N'dbo.member')

그리고 사용자가 넘긴 col이 이 결과 집합에 정확히 일치하는지 확인한 뒤에만 쿼리에 넣는다. 여기서 반드시 지켜야 할 것이 세 가지 있다.


첫째, information_schema는 화이트리스트의 “출처"일 뿐, 그 자체가 필터가 아니다. 스키마에서 컬럼 목록을 읽어왔다고 끝난 게 아니라, 사용자 입력을 그 목록과 대조하는 검증 단계가 반드시 있어야 한다. 그리고 이 대조는 **완전 일치(equals)**여야 한다. LIKEcontains, startsWith 처럼 느슨하게 비교하면 우회 여지가 생긴다.

둘째, information_schema를 조회하는 그 쿼리부터 안전해야 한다. 위에서 TABLE_NAME = @tableName 처럼 테이블명을 값으로 바인딩한 점을 눈여겨봐야 한다. 테이블명이 WHERE 절의 값 위치에 오면 그건 정상적으로 파라미터 바인딩된다. 만약 테이블명마저 ${}로 이어붙이면 방어하려다 새 구멍을 뚫는 꼴이 된다.

셋째, information_schema는 그 테이블의 “모든” 컬럼을 돌려준다. 이게 가장 실질적인 함정이다. member 테이블에 password_hash, ssn, internal_memo 같은 컬럼이 있다면 그것들까지 전부 화이트리스트에 올라간다. injection은 막을 수 있어도, 클라이언트가 col=password_hash로 요청해서 원래 노출하면 안 되는 컬럼을 정상 경로로 조회해 가는 과다 노출이 발생한다. injection을 막는 것과 노출 범위를 통제하는 것은 별개의 문제다.


그래서 상황별로 정리하면 이렇게 된다.

  • 조회 가능 컬럼이 정해져 있고 잘 바뀌지 않는다면 → 정적 화이트리스트(방어 1)가 낫다. 노출 범위까지 직접 통제할 수 있다.
  • 컬럼이 많고 동적으로 관리해야 한다면 → information_schema로 목록을 만들되, 민감 컬럼을 제외하는 필터를 한 겹 더 두거나, 애초에 노출 허용 컬럼만 별도 메타 테이블로 관리한다.
  • 어느 쪽이든 information_schema 조회는 매 요청마다 하지 말고 캐싱한다. 스키마는 자주 바뀌지 않는다.

즉 “information_schema 화이트리스트만으로 방어가 되는가"에 대한 답은, injection 차단이라는 목적에는 충분히 유효하지만, (1) 완전 일치 검증과 (2) 민감 컬럼 과다 노출 통제를 함께 하지 않으면 반쪽짜리라는 것이다.


방어 3 — QUOTENAME (MSSQL의 심층 방어)

화이트리스트를 통과했더라도, DB 안에서 동적 SQL을 조립하는 경우라면 MSSQL이 제공하는 QUOTENAME()을 한 겹 더 씌우는 편이 좋다. 식별자를 대괄호 [ ]로 안전하게 감싸주는 내장 함수다.

DECLARE @sql NVARCHAR(MAX);
SET @sql = N'SELECT ' + QUOTENAME(@col) + N' FROM dbo.member WHERE status = @status';

EXEC sp_executesql
     @sql,
     N'@status NVARCHAR(50)',
     @status = @status;

QUOTENAME은 식별자 안에 섞인 닫는 대괄호 ]]]로 이스케이프해서, 대괄호 밖으로 빠져나가는 것을 원천 차단한다.

SELECT QUOTENAME('name]; DROP TABLE member --');
-- 결과: [name]]; DROP TABLE member --]

공격 문자열이 통째로 하나의 (존재하지 않는) 식별자 이름으로 갇혀버린다. SQL 구문으로 탈출하지 못하는 것이다. 그리고 값 조건인 statussp_executesql의 파라미터로 넘겨 끝까지 바인딩으로 처리했다는 점도 함께 봐야 한다. 동적 SQL을 쓰더라도 값은 값대로 바인딩, 식별자는 QUOTENAME이라는 원칙은 그대로 간다.

다만 오해하면 안 되는 게 있다. QUOTENAME은 “구문 탈출"을 막아줄 뿐, “허용된 컬럼인지"는 판단하지 못한다. QUOTENAME('password_hash')는 아무 불평 없이 [password_hash]를 돌려준다. injection은 막아도 과다 노출은 막지 못한다는 얘기다. 그래서 QUOTENAME은 화이트리스트를 대체하는 게 아니라 보완하는 심층 방어 레이어로 두어야 한다.


값 조건은 끝까지 파라미터 바인딩

여기까지 컬럼(식별자) 이야기만 했는데, 하나 강조하고 넘어가야 한다. 동적 컬럼을 방어하느라 신경이 그쪽에 쏠려서, 정작 WHERE 절의 값 조건을 문자열로 이어붙이는 실수를 하면 지금까지의 방어가 무의미해진다.

<!-- 나쁨: 값인데 ${}로 이어붙임 -->
WHERE status = '${status}'

<!-- 좋음: 값은 #{}로 바인딩 -->
WHERE status = #{status}

식별자는 화이트리스트, 값은 파라미터 바인딩. 이 둘의 역할이 섞이지 않게 하는 게 전부다. ${}는 바인딩이 불가능한, 통제된 식별자에만 최소한으로 쓴다.


마치며

파라미터 바인딩은 SQL Injection 방어의 기본이지만, 컬럼명 같은 식별자 자리에는 애초에 적용할 수 없다. #{}${}를 습관적으로 나눠 쓰면서도 그 경계를 정확히 의식하지 않으면 이 지점을 놓치기 쉽다.

정리해 보면 방어는 한 겹으로 끝내지 말고 층을 쌓아야 한다.

  1. 화이트리스트로 입력을 검증한다. 정적 Set/Map이 가장 확실하다. 사용자 문자열이 아니라 미리 적어둔 컬럼명이 SQL에 닿게 한다.
  2. information_schema는 화이트리스트의 출처로만 쓴다. 완전 일치 검증은 필수이고, 민감 컬럼 과다 노출은 별도로 통제한다. “그것만으로 충분"은 아니다.
  3. QUOTENAME으로 구문 탈출을 막는다. MSSQL의 심층 방어이지만 화이트리스트를 대체하지는 못한다.
  4. 값 조건은 끝까지 파라미터 바인딩한다. 식별자와 값의 방어를 섞지 않는다.
  5. DB 계정은 최소 권한으로 둔다. 혹시 뚫려도 피해 범위를 줄인다.

한 문장으로 줄이면, 바인딩할 수 없는 자리는 화이트리스트로 검증한다는 것이다. 편의를 위해 무심코 열어둔 ${} 한 줄이 어디까지 위험해질 수 있고, 그 방어가 결국 “입력을 신뢰하지 않는다"는 기본으로 돌아온다는 점만 기억하면 된다.