SonarQube SQL Injection 해결 — 레거시 JDK 21에서 만난 S3649 시도 3가지

이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.

금융권 SI 현장에서 JDK 8 / 11짜리 레거시를 JDK 21로 올리는 일을 맡았다. 로직은 일절 건드리지 않는다는 조건이었는데, 빌드 통과시키고 나니 SonarQube SQL Injection 룰 S3649만 87건이 떴다. 한 줄도 안 고쳤는데 말이다.

이유는 단순했다. JDK 업그레이드와 함께 사내 SonarQube Quality Profile도 최신으로 갈렸고, 그동안 비활성화돼 있던 보안 룰들이 한꺼번에 켜졌다. 그중 제일 먼저 손볼 수밖에 없었던 게 SQL Injection이다. 이 글은 SonarQube SQL Injection 룰 S3649를 실제로 통과시키기까지 시도했던 3가지 방법과 최종 해결 코드를 정리한 기록이다.

SonarQube SQL Injection — IDE 정적 분석 경고 화면

레거시에서 발견된 취약 패턴

87건을 분류해 보니 두 패턴이 거의 전부였다.

패턴 1. MyBatis ${}

<!-- AccountMapper.xml -->
<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = '${acctNo}'
   ORDER BY ${sortCol} ${sortDir}
</select>

패턴 2. JDBC Statement + 문자열 연결

String sql =
    "SELECT CUST_ID, CUST_NM FROM CUSTOMER " +
    "WHERE CUST_NM LIKE '%" + name + "%'";
ResultSet rs = stmt.executeQuery(sql);

${}는 MyBatis가 값을 그대로 SQL에 박아넣는 방식이고, Statement + 문자열 연결도 똑같이 위험하다. 외부 입력이 그대로 SQL이 되니 SonarQube SQL Injection 룰 입장에서는 잡지 않을 이유가 없다.

시도 1. 작은따옴표 escape — 실패

제일 먼저 떠올린 게 입력값에서 '''로 바꿔주는 sanitize였다.

String safeName = name == null ? "" : name.replace("'", "''");
String sql = "... WHERE CUST_NM LIKE '%" + safeName + "%'";

S3649는 그대로 떠 있다. SonarQube SQL Injection 분석기는 입력값이 SQL 문자열에 직접 합쳐지는 데이터 플로우 자체를 추적한다. 중간에 replaceAll이 끼어들어도 알려진 sanitizer가 아니면 인정해주지 않는다. 게다가 숫자 컨텍스트(WHERE ID = ${id})에서는 작은따옴표 escape 자체가 의미가 없다.

시도 2. 정규식 화이트리스트 — 부분 실패

다음 시도는 입력값 패턴 검증이다.

private static final Pattern ACCT = Pattern.compile("^[0-9-]{10,20}$");

public List<TxVO> findTx(String acctNo, String sortCol) {
    if (!ACCT.matcher(acctNo).matches()) {
        throw new IllegalArgumentException("invalid account");
    }
    return mapper.findTxByAcct(acctNo, sortCol);
}

값 자체를 검증하니 일부 케이스는 사라졌다. 그러나 동적 정렬 컬럼(ORDER BY ${sortCol})은 여전히 잡힌다. SonarQube는 ${}로 들어가는 흐름이 있으면 의심을 거두지 않는다. 룰이 보는 건 결국 PreparedStatement 또는 그에 준하는 바인딩이다.

시도 3. PreparedStatement 부분 적용 — 부분 통과

WHERE 절만 바인딩하고 동적 정렬은 그대로 둔 케이스다.

<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = #{acctNo}
   ORDER BY ${sortCol} ${sortDir}
</select>

WHERE는 통과했다. 그런데 ORDER BY 줄에서 또 S3649가 뜬다. ${}로 컬럼명을 받으면 SonarQube는 무조건 잡는다. 동적 식별자라는 사실을 코드에서 증명해줘야 한다.

최종 해결 — #{} + enum 화이트리스트

세 단계 조합으로 87건을 전부 해소했다. 핵심은 외부 입력이 절대 SQL 문자열로 직접 들어가지 않게 하는 것이다.

1) 모든 단순 값은 #{} 또는 PreparedStatement

<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = #{acctNo}
   ORDER BY ${sortColumn} ${sortDirection}
</select>

JDBC 쪽도 동일하게 처리한다.

String sql =
    "SELECT CUST_ID, CUST_NM FROM CUSTOMER " +
    "WHERE CUST_NM LIKE CONCAT('%', ?, '%')";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);
    ResultSet rs = ps.executeQuery();
}

2) 동적 컬럼/정렬은 enum으로 매핑

식별자는 외부 입력을 그대로 받지 않고 enum으로 한 번 가둔다.

public enum TxSortColumn {
    TX_DT("TX_DT"),
    AMT("AMT"),
    REMARK("REMARK");

    private final String column;
    TxSortColumn(String column) { this.column = column; }
    public String column() { return column; }
}

public enum SortDir {
    ASC, DESC;
    public static SortDir from(String s) {
        return "DESC".equalsIgnoreCase(s) ? DESC : ASC;
    }
}
// Service
public List<TxVO> findTx(String acctNo, String sortColParam, String sortDirParam) {
    TxSortColumn col = TxSortColumn.valueOf(sortColParam); // 미허용 값이면 IllegalArgumentException
    SortDir dir = SortDir.from(sortDirParam);
    return mapper.findTxByAcct(acctNo, col.column(), dir.name());
}

Mapper에 들어가는 sortColumn, sortDirection은 enum에서 꺼낸 값이라 외부 입력이 닿지 않는다. SonarQube는 데이터 플로우를 보기 때문에 enum 상수에서 출발한 값이면 ${}여도 통과시킨다.

3) LIKE 검색은 CONCAT 안에서 #{}

<select id="searchByName" resultType="CustVO">
  SELECT CUST_ID, CUST_NM
    FROM CUSTOMER
   WHERE CUST_NM LIKE CONCAT('%', #{name}, '%')
</select>

레거시에서 '%${name}%' 패턴을 자주 보는데, CONCAT 안에 #{}를 넣으면 바인딩 변수로 처리되면서도 LIKE 검색이 정상 동작한다.

정리

SonarQube SQL Injection 대응의 본질은 결국 “값”과 “식별자”를 구분하는 거였다. 값은 #{} 또는 PreparedStatement, 식별자(컬럼명, 정렬 방향)는 enum 매핑. 어설픈 escape나 정규식만 추가하는 건 시간 낭비고, S3649는 데이터 플로우를 보기 때문에 sanitize 한 줄 추가한다고 사라지지 않는다.

다음 편에서는 같은 입력 검증 계열인 SonarQube Command Injection (S2076) 케이스를 다룬다. 외부 시스템 연동 배치에서 자주 터지는 룰이다.

SonarQube SQL Injection 룰 본문은 SonarSource 공식 룰 페이지(S3649)에서 확인할 수 있다.