이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
금융권 SI 현장에서 JDK 8 / 11짜리 레거시를 JDK 21로 올리는 일을 맡았다. 로직은 일절 건드리지 않는다는 조건이었는데, 빌드 통과시키고 나니 SonarQube SQL Injection 룰 S3649만 87건이 떴다. 한 줄도 안 고쳤는데 말이다.
이유는 단순했다. JDK 업그레이드와 함께 사내 SonarQube Quality Profile도 최신으로 갈렸고, 그동안 비활성화돼 있던 보안 룰들이 한꺼번에 켜졌다. 그중 제일 먼저 손볼 수밖에 없었던 게 SQL Injection이다. 이 글은 SonarQube SQL Injection 룰 S3649를 실제로 통과시키기까지 시도했던 3가지 방법과 최종 해결 코드를 정리한 기록이다.

레거시에서 발견된 취약 패턴
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)에서 확인할 수 있다.