Categories
웹개발/Mybatis
MyBatis #{} ${} 차이, SQL Injection과 Oracle 동적쿼리 기준 정리
MyBatis에서 #{}와 ${}를 헷갈리면 조회 조건은 맞는 것 같은데 SQL이 깨지거나, 더 심하면 SQL Injection 위험이 생긴다. 먼저 결론부터 잡으면 값은 #{}, SQL 조각은 제한적으로 ${}다.
둘은 비슷한 자리에서 쓰이지만 동작 방식이 완전히 다르다. #{}는 PreparedStatement의 바인딩 변수로 처리되고, ${}는 문자열을 SQL에 그대로 붙인다. 그래서 검색어, 아이디, 날짜, 금액 같은 값에는 거의 항상 #{}를 써야 한다.
핵심 결론:
#{}는 안전한 값 바인딩이고,${}는 SQL 문자열 치환이다.확인 순서: 사용자 입력인가 → 값인가 SQL 문법 조각인가 → 바인딩 가능한가 → 화이트리스트가 있는가 → 최종 SQL 로그가 안전한가.
두 문법의 차이를 먼저 나눈다
MyBatis Mapper XML에서 #{userId}와 ${columnName}은 모두 파라미터를 넣는 것처럼 보인다. 하지만 MyBatis가 SQL을 만드는 단계에서 처리 방식이 다르다.
#{}는 JDBC의 PreparedStatement 파라미터로 바뀐다. SQL에는 물음표가 들어가고, 실제 값은 JDBC 드라이버가 별도로 바인딩한다.
<select id="selectUser" parameterType="string" resultType="User">
SELECT user_id, user_name
FROM users
WHERE user_id = #{userId}
</select>
이 SQL은 내부적으로 아래와 비슷한 형태가 된다.
SELECT user_id, user_name
FROM users
WHERE user_id = ?
반대로 ${}는 값을 SQL 문자열에 그대로 치환한다. 사용자가 넘긴 값이 user_name이면 SQL에 user_name이라는 문자열이 직접 들어간다.
<select id="selectUsers" parameterType="map" resultType="User">
SELECT user_id, user_name
FROM users
ORDER BY ${orderColumn}
</select>
이 방식은 컬럼명이나 정렬 방향처럼 PreparedStatement로 바인딩할 수 없는 SQL 문법 조각을 다룰 때만 제한적으로 쓴다. MyBatis의 기본 역할과 Mapper XML 구조가 아직 헷갈린다면 MyBatis 기본 구조를 먼저 잡고 보는 편이 좋다.
#{}는 값 바인딩이다
WHERE 조건 값에는 #{}를 쓴다
사용자가 입력한 검색어, 회원 ID, 날짜, 상태값, 금액 같은 데이터는 SQL 문법이 아니라 값이다. 이런 값은 #{}로 바인딩해야 한다.
<select id="findOrders" parameterType="OrderSearch" resultType="Order">
SELECT order_id, order_status, created_at
FROM orders
WHERE member_id = #{memberId}
AND order_status = #{orderStatus}
</select>
이렇게 쓰면 MyBatis는 파라미터를 JDBC에 안전하게 전달한다. 문자열에 작은따옴표가 들어가도 SQL 구조가 깨지지 않고, 숫자나 날짜 타입도 TypeHandler를 통해 처리된다.
LIKE 검색도 기본은 #{}다
검색어를 LIKE에 넣을 때도 #{}를 쓰는 것이 기본이다. Oracle에서는 문자열 결합 연산자인 ||를 이용할 수 있다.
<select id="searchUsers" parameterType="string" resultType="User">
SELECT user_id, user_name
FROM users
WHERE user_name LIKE '%' || #{keyword} || '%'
</select>
이 방식이면 검색어가 값으로 바인딩된다. 사용자가 특수문자를 입력해도 SQL 문장 자체가 바뀌지 않는다. 검색어를 ${keyword}로 직접 붙이는 방식은 피해야 한다.
INSERT와 UPDATE도 #{}가 기본이다
저장과 수정 SQL에서도 값은 #{}로 넣는다.
<insert id="insertUser" parameterType="User">
INSERT INTO users (
user_id,
user_name,
created_at
) VALUES (
#{userId},
#{userName},
SYSDATE
)
</insert>
여기서 userId, userName은 모두 데이터다. SQL 키워드나 컬럼명이 아니다. 따라서 문자열 치환이 아니라 값 바인딩으로 처리해야 한다. 결과 매핑까지 같이 정리해야 한다면 매핑 기준에서 resultType과 resultMap 흐름을 같이 보면 된다.
${}는 문자열 치환이다
SQL 문법 조각이 필요할 때만 쓴다
${}는 SQL에 문자열을 그대로 붙인다. 그래서 컬럼명, 테이블명, 정렬 방향처럼 PreparedStatement의 값 바인딩으로 처리할 수 없는 영역에서만 제한적으로 사용한다.
예를 들어 ORDER BY 컬럼은 ORDER BY ?처럼 바인딩한다고 원하는 의미가 되지 않는다. DB는 물음표를 컬럼명이 아니라 값으로 본다. 이런 경우에는 SQL 조각을 만들어야 하므로 ${}가 후보가 된다.
<select id="selectUsers" parameterType="map" resultType="User">
SELECT user_id, user_name, created_at
FROM users
ORDER BY ${orderColumn} ${orderDirection}
</select>
하지만 이 코드는 그대로 쓰면 위험하다. orderColumn과 orderDirection이 사용자 입력 그대로 들어오면 SQL Injection 통로가 된다.
테이블명 동적 처리도 위험도가 높다
월별 테이블이나 로그 테이블처럼 테이블명을 동적으로 바꾸는 경우도 있다.
<select id="selectLog" parameterType="map" resultType="Log">
SELECT log_id, message, created_at
FROM ${logTable}
WHERE created_at >= #{fromDate}
</select>
여기서 fromDate는 값이므로 #{}가 맞다. 반면 logTable은 테이블명이라 #{}로 처리할 수 없다. 그렇다고 사용자 입력을 그대로 넣으면 안 된다. 애플리케이션에서 허용된 테이블명 목록을 검증한 뒤 넣어야 한다.
자주 놓치는 원인:
${}를 쓰는 순간 MyBatis가 값을 안전하게 감싸 주지 않는다. SQL 문자열을 직접 만드는 책임이 개발자에게 넘어온다.
SQL Injection이 생기는 지점
${}에 사용자 입력을 그대로 넣는 경우
가장 위험한 코드는 WHERE 조건에 ${}를 쓰는 방식이다.
<select id="login" parameterType="map" resultType="User">
SELECT user_id, user_name
FROM users
WHERE user_id = '${userId}'
AND password = '${password}'
</select>
이 코드는 값처럼 보이는 자리에 문자열 치환을 쓰고 있다. 사용자가 입력한 내용이 SQL 문장에 그대로 붙기 때문에 조건식을 깨뜨릴 수 있다.
userId = admin' OR '1'='1
위와 같은 값이 들어오면 최종 SQL은 의도하지 않은 조건으로 바뀔 수 있다. 로그인, 검색, 상세 조회, 삭제 조건에 이런 방식이 들어가면 보안 사고로 이어질 수 있다.
#{}로 바꾸면 SQL 구조가 고정된다
같은 SQL은 아래처럼 바꿔야 한다.
<select id="login" parameterType="map" resultType="User">
SELECT user_id, user_name
FROM users
WHERE user_id = #{userId}
AND password = #{password}
</select>
이 경우 입력값은 SQL 문장 일부가 아니라 바인딩 값으로 전달된다. 사용자가 작은따옴표나 OR 조건을 넣어도 SQL 구조 자체는 바뀌지 않는다.
동적 SQL 태그와 같이 쓰면 더 안전하다
조건이 있을 때만 WHERE 절을 추가해야 한다면 ${}로 문자열을 조립하지 말고 MyBatis 동적 SQL 태그를 쓴다.
<select id="searchUsers" parameterType="UserSearch" resultType="User">
SELECT user_id, user_name, status
FROM users
<where>
<if test="userId != null and userId != ''">
AND user_id = #{userId}
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
</where>
</select>
조건 조립은 MyBatis 태그가 맡고, 실제 값은 #{}가 맡는다. 이 조합이 실무에서 가장 안전하고 읽기도 좋다.
Oracle 동적쿼리에서 자주 헷갈리는 부분
ORDER BY 컬럼은 화이트리스트로 제한한다
Oracle에서 검색 목록을 만들 때 정렬 컬럼을 화면에서 선택하게 하는 경우가 많다. 이때 ${orderColumn}을 완전히 없애기 어려운 경우가 있다. 중요한 것은 입력값을 그대로 넘기지 않는 것이다.
public String resolveOrderColumn(String sortKey) {
if ("name".equals(sortKey)) {
return "user_name";
}
if ("date".equals(sortKey)) {
return "created_at";
}
if ("status".equals(sortKey)) {
return "status";
}
return "created_at";
}
그리고 Mapper XML에는 검증된 컬럼명만 전달한다.
<select id="selectUsers" parameterType="map" resultType="User">
SELECT user_id, user_name, status, created_at
FROM users
<where>
<if test="keyword != null and keyword != ''">
AND user_name LIKE '%' || #{keyword} || '%'
</if>
</where>
ORDER BY ${orderColumn}
</select>
여기서 핵심은 orderColumn이 사용자 입력값이 아니라 서버에서 허용 목록을 통과한 결과여야 한다는 점이다.
정렬 방향도 그대로 받지 않는다
ASC, DESC도 문자열 조각이다. 그래서 ${orderDirection}을 쓸 수는 있지만, 허용값은 둘 중 하나로 고정해야 한다.
public String resolveOrderDirection(String direction) {
if ("DESC".equalsIgnoreCase(direction)) {
return "DESC";
}
return "ASC";
}
이렇게 하면 화면에서 이상한 문자열이 넘어와도 SQL에는 ASC 또는 DESC만 들어간다.
Oracle ROWNUM과 페이징 값은 #{}로 처리한다
페이징에서 시작 번호와 끝 번호는 값이다. 따라서 ${}가 아니라 #{}를 쓴다.
<select id="selectPagedUsers" parameterType="UserSearch" resultType="User">
SELECT *
FROM (
SELECT inner_query.*, ROWNUM rn
FROM (
SELECT user_id, user_name, created_at
FROM users
ORDER BY created_at DESC
) inner_query
WHERE ROWNUM <= #{endRow}
)
WHERE rn >= #{startRow}
</select>
startRow, endRow는 숫자 값이므로 바인딩 대상이다. Oracle이라고 해서 숫자를 문자열로 붙일 이유는 없다.
#{}와 ${} 차이 표로 정리하기
| 구분 | #{} | ${} |
|---|---|---|
| 처리 방식 | PreparedStatement 값 바인딩 | SQL 문자열 직접 치환 |
| 주 사용처 | WHERE 값, INSERT 값, UPDATE 값 | 컬럼명, 테이블명, 정렬 방향 |
| SQL Injection 위험 | 낮음 | 높음 |
| 따옴표 처리 | JDBC가 타입에 맞게 처리 | 개발자가 SQL 문자열을 책임짐 |
| 로그 형태 | SQL에는 ?가 보이고 값은 별도 출력 | 최종 SQL에 문자열이 직접 들어감 |
| 실무 기준 | 기본 선택 | 화이트리스트가 있을 때만 제한 사용 |
실무에서 바로 쓰는 판단 기준
값이면 #{}다
사용자 ID, 검색어, 날짜, 상태 코드, 금액, 수량, 게시글 번호는 모두 값이다. 이 경우 고민하지 말고 #{}를 쓴다.
WHERE user_id = #{userId}WHERE created_at >= #{fromDate}AND status = #{status}VALUES (#{title}, #{content}, #{writer})
SQL 문법 조각이면 ${} 후보지만 검증이 먼저다
컬럼명, 테이블명, 정렬 방향은 값 바인딩으로 처리하기 어렵다. 이때는 ${}가 후보가 되지만, 반드시 서버 코드에서 허용 목록을 거쳐야 한다.
- 정렬 컬럼은
name,date,status같은 키를 실제 컬럼명으로 변환한다. - 정렬 방향은
ASC,DESC만 허용한다. - 테이블명은 화면 입력값이 아니라 서버에서 계산한 안전한 이름만 넘긴다.
문자열을 붙이고 싶다는 이유로 ${}를 쓰지 않는다
LIKE 검색이나 IN 조건 때문에 문자열을 붙이고 싶을 때 ${}를 선택하는 경우가 있다. 대부분은 #{}와 동적 SQL 태그로 처리할 수 있다.
<select id="selectByStatuses" parameterType="map" resultType="Order">
SELECT order_id, order_status
FROM orders
WHERE order_status IN
<foreach collection="statuses" item="status" open="(" separator="," close=")">
#{status}
</foreach>
</select>
IN 절도 값을 여러 개 바인딩하는 구조로 만들 수 있다. 쉼표로 이어 붙인 문자열을 ${statusList}로 넣는 방식은 피하는 것이 좋다.
로그를 볼 때 확인할 부분
MyBatis 로그를 보면 Preparing과 Parameters가 나뉘어 찍히는 경우가 많다.
Preparing: SELECT user_id, user_name FROM users WHERE user_id = ?
Parameters: kim(String)
이런 형태라면 #{}로 바인딩된 것이다. SQL 구조와 값이 분리되어 있다.
반대로 최종 SQL에 값이 그대로 들어가면 ${} 치환이거나 직접 문자열을 만든 SQL일 수 있다. 특히 검색어, 로그인 값, 게시글 번호 같은 사용자 입력이 SQL에 그대로 보인다면 Mapper XML을 다시 확인해야 한다. Spring MyBatis에서 Mapper XML 위치와 로딩 흐름이 헷갈리면 연동 설정 흐름도 같이 점검한다.
자주 하는 실수
ORDER BY 때문에 모든 값을 ${}로 바꾸는 경우
ORDER BY 컬럼은 바인딩이 어렵다는 말을 듣고 WHERE 조건까지 ${}로 바꾸는 경우가 있다. 이것은 잘못된 방향이다. ORDER BY 컬럼만 제한적으로 ${}를 쓰고, 나머지 값은 계속 #{}를 써야 한다.
작은따옴표를 직접 붙이는 경우
#{}에는 작은따옴표를 직접 붙이지 않는다.
-- 피할 형태
WHERE user_id = '#{userId}'
#{}는 값 바인딩이므로 JDBC가 타입에 맞게 처리한다. 작은따옴표를 SQL에 직접 넣으면 의도와 다르게 동작하거나 타입 변환 문제가 생길 수 있다.
동적 테이블명을 화면 파라미터로 받는 경우
테이블명을 화면에서 직접 받아 ${tableName}에 넣는 구조는 피해야 한다. 운영에서 월별 테이블을 선택해야 한다면 화면에는 월만 받고, 서버에서 허용된 테이블명으로 변환한다.
public String resolveLogTable(String yyyymm) {
if (!yyyymm.matches("\\d{6}")) {
throw new IllegalArgumentException("invalid month");
}
return "LOG_" + yyyymm;
}
이런 처리도 실제 운영에서는 존재하는 월 범위, 테이블 목록, 권한까지 함께 검증하는 것이 안전하다. Oracle 제약조건과 데이터 오류를 같이 점검해야 하는 경우에는 Oracle 오류 확인처럼 DB 쪽 원인 추적 방식도 같이 익혀 두면 도움이 된다.
실무에서 바로 쓰는 팁
- 기본값은 무조건 #{}로 둔다. ${}가 필요한 이유를 설명할 수 없으면 쓰지 않는다.
- ${}는 화이트리스트와 함께 쓴다. 컬럼명, 정렬 방향, 테이블명은 허용 목록을 통과한 값만 넣는다.
- LIKE 검색도 #{}로 처리한다. Oracle에서는
'%' || #{keyword} || '%'형태로 충분하다. - IN 조건은 foreach를 쓴다. 콤마 문자열을 ${}로 붙이지 않는다.
- 로그에서 ?와 Parameters를 본다. 값이 바인딩되는지, SQL에 직접 붙는지 확인한다.
자주 묻는 질문
- Q. MyBatis #{} ${} 차이는 한 줄로 무엇인가요? A.
#{}는 값을 안전하게 바인딩하고,${}는 문자열을 SQL에 그대로 치환합니다. - Q. WHERE 조건에는 무엇을 써야 하나요? A. 대부분
#{}를 써야 합니다. 사용자 입력값을 WHERE 조건에${}로 넣으면 SQL Injection 위험이 큽니다. - Q. ORDER BY 컬럼은 #{}로 처리할 수 없나요? A. 컬럼명은 값이 아니라 SQL 문법 조각이라 일반적인 값 바인딩으로 처리하기 어렵습니다. 다만
${}를 쓰더라도 허용된 컬럼명만 넣어야 합니다. - Q. LIKE 검색에서는 ${}가 더 편하지 않나요? A. 편해 보여도 위험합니다. Oracle에서는
'%' || #{keyword} || '%'처럼#{}로 안전하게 처리할 수 있습니다. - Q. 테이블명을 동적으로 바꿔야 하면 어떻게 하나요? A. 서버에서 허용된 테이블명으로 변환한 뒤
${}에 넣습니다. 화면 입력값을 그대로 넣지 않습니다. - Q. SQL 로그에서 무엇을 보면 되나요? A.
Preparing에 물음표가 있고Parameters에 값이 따로 찍히면#{}바인딩입니다. 값이 SQL에 직접 들어가면 치환 여부를 의심해야 합니다.
정리
#{}와 ${}는 단순한 표기 차이가 아니다. #{}는 PreparedStatement 값 바인딩이고, ${}는 SQL 문자열 치환이다. 그래서 실무 기준은 단순하다. 값이면 #{}, SQL 문법 조각이면 검증된 경우에만 ${}다.
특히 로그인, 검색, 상세 조회, 삭제 조건처럼 사용자 입력이 들어가는 곳에는 ${}를 쓰지 않아야 한다. ORDER BY 컬럼이나 동적 테이블명처럼 어쩔 수 없이 문자열 치환이 필요한 경우에는 화이트리스트를 먼저 만들고, Mapper XML에는 검증된 값만 전달해야 한다.
MyBatis에서 보안 문제를 줄이는 가장 쉬운 습관은 "일단 #{}로 쓰고, ${}는 이유와 검증 코드가 있을 때만 쓴다"는 기준을 지키는 것이다.
'웹개발 > Mybatis' 카테고리의 다른 글
| MyBatis resultType resultMap 차이, JOIN과 1:N 매핑에서 선택 기준 (0) | 2026.07.09 |
|---|---|
| MyBatis mybatis-config.xml Mapper XML 차이, 무엇을 어디에 써야 할까 (0) | 2026.07.09 |
| MyBatis SqlSessionFactory SqlSession 차이, 역할과 생명주기 정리 (0) | 2026.07.08 |
| @MapperScan 설정과 Mapper 인터페이스가 Bean으로 안 잡힐 때 확인할 부분 (0) | 2026.06.29 |
| Spring MyBatis 연동 설정 완전 정리 (Maven/Gradle) (0) | 2026.06.29 |