#{}와 ${}, 한 글자 차이가 만드는 보안 사고MyBatis로 개발하다 보면 파라미터를 바인딩할 때 #{}와 ${} 두 가지 문법을 만나게 됩니다. 생긴 건 비슷하지만, 이 둘의 차이를 모르고 쓰면 SQL Injection으로 직행하는 지름길이 됩니다. 오늘은 두 문법이 내부적으로 어떻게 다르게 동작하는지, 그리고 왜 이 차이가 보안 사고로 이어지는지 정리해보겠습니다.
#{} → PreparedStatement 바인딩 (안전)${} → 문자열 치환 (위험)#{}는 내부적으로 무슨 일이 일어날까#{}로 작성한 쿼리는 JDBC의 PreparedStatement로 변환됩니다. 파라미터 자리는 ? 플레이스홀더로 바뀌고, 값은 별도로 바인딩됩니다.
<select id="getUser" parameterType="String">
SELECT * FROM users WHERE id = #{userId}
</select>
실제 DB로 전달되는 모습:
SELECT * FROM users WHERE id = ?
-- 파라미터: 'admin'
여기서 핵심은, 파라미터 값이 SQL 구문이 아니라 데이터로 취급된다는 점입니다. 사용자가 무엇을 넣든 그 값은 "찾을 데이터"일 뿐, 쿼리의 구조를 바꿀 수 없습니다.
${}는 그냥 문자열 붙이기반면 ${}는 자바에서 문자열 더하기("... " + userId + " ...")와 다를 게 없습니다. 전달된 값이 SQL 문자열에 그대로 박혀버립니다.
<select id="getUser" parameterType="String">
SELECT * FROM users WHERE id = '${userId}'
</select>
사용자가 admin을 넣으면:
SELECT * FROM users WHERE id = 'admin'
문제는 사용자가 평범한 값만 넣지 않는다는 거죠.
로그인 화면에서 ID 입력란에 다음을 넣었다고 해봅시다.
' OR '1'='1
${}로 작성된 쿼리는 이렇게 완성됩니다.
SELECT * FROM users WHERE id = '' OR '1'='1'
'1'='1'은 항상 참이므로 전체 사용자 데이터가 조회됩니다. 인증 로직이 "결과가 1건 이상이면 로그인 성공"이라면 그대로 뚫립니다.
1'; DROP TABLE users; --
이 값이 ${}로 들어가면:
SELECT * FROM users WHERE id = '1'; DROP TABLE users; --'
뒤의 --는 주석 처리라 무시되고, DROP TABLE이 실행됩니다.
같은 입력값을 #{}로 받았다면? DB는 "1'; DROP TABLE users; --"라는 ID를 가진 유저를 찾으려다 결과 없음으로 끝납니다. 구문이 아니라 데이터니까요.
${}는 절대 쓰면 안 되나요?쓸 수밖에 없는 경우가 있습니다. PreparedStatement는 값(value)만 바인딩할 수 있고, 식별자(identifier)는 바인딩할 수 없기 때문입니다.
대표적으로:
SELECT * FROM ${tableName}SELECT ${columnName} FROM usersORDER BY ${sortColumn} ${sortOrder}이런 곳은 #{}로 처리할 수 없습니다. DB가 ORDER BY ?를 허용하지 않거든요.
원칙은 단순합니다. 사용자 입력을 절대 그대로 ${}에 넣지 않습니다. 대신 화이트리스트로 걸러낸 값만 사용합니다.
// 나쁜 예
String sortColumn = request.getParameter("sort"); // 그대로 전달
// 좋은 예
Set<String> allowed = Set.of("name", "created_at", "email");
String sortColumn = allowed.contains(input) ? input : "created_at";
정규식 검증(^[a-zA-Z_]+$)도 한 방법이지만, 화이트리스트가 가장 확실합니다.
코드 리뷰할 때 다음 항목만 확인해도 SQL Injection의 90%는 막을 수 있습니다.
${}가 쓰인 모든 곳을 찾는다 (전체 검색 한 번이면 됩니다)#{}로 변경)LIKE 검색 시 '%${keyword}%'처럼 쓰지 않았는지 본다 — LIKE CONCAT('%', #{keyword}, '%')로 바꿀 것#과 $는 키보드에서 한 칸 차이지만, 보안적으로는 "안전한 바인딩"과 "문자열 더하기"라는 완전히 다른 세계입니다. 편해서, 또는 동작이 안 돼서 ${}를 쓰고 싶어질 때마다, 그 자리가 정말 식별자 자리인지 한 번만 의심해보세요. 대부분의 경우 #{}로 해결됩니다.
기본기 같은 이야기지만, 실제 사고는 늘 이 기본기에서 터집니다.