MyBatis를 처음 배울 때
#{}와${}중 뭘 써야 하냐는 질문에 "#{}쓰세요,${}는 위험해요"라고 배웠다.
맞는 말인데, 왜 위험한지는 몰랐다.
JSP/서블릿 프로젝트를 다시 보고서야 알았다. 이건 MyBatis 문법 이야기가 아니라 JDBC 이야기였다.
#{}는 MyBatis가 새로 만든 게 아니다. JDBC의 PreparedStatement를 이름으로 부를 수 있게 감싼 것이다.
| MyBatis | 실제로 하는 일 (JDBC) |
|---|---|
#{name} | ? 자리 + pstmt.setString(1, name) |
${name} | "... WHERE name = " + name 문자열 결합 |
그래서 #{}가 왜 안전한지는 PreparedStatement가 왜 안전한지와 같은 질문이다. 그리고 그건 이름에 답이 적혀 있다.
PreparedStatement를 "준비된 명령문"이라고 직역해놓고도 뭐가 준비됐다는 건지 몰랐다. 뭘 미리 준비한다는 걸까.
답은 이렇다. SQL 문장을 값보다 먼저 DB에 보내서, DB가 그걸 미리 해석해두게 하는 것이다.
일반적인 SQL 실행은 한 번에 간다.
클라이언트 ──[ SELECT * FROM MEMBER WHERE NAME = '홍길동' ]──> DB
DB가 이 문장을 받아서 파싱하고, 문법을 검사하고, 권한을 확인하고, 실행 계획을 세우고, 실행한다. 전부 한 번에 일어난다.
PreparedStatement는 이걸 두 단계로 쪼갠다.
1단계 PREPARE 클라이언트 ──[ SELECT * FROM MEMBER WHERE NAME = ? ]──> DB
(값 없이 문장만)
DB: 파싱 → 문법 검사 → 실행 계획 수립 → 저장해둠
2단계 EXECUTE 클라이언트 ──[ 아까 그거 실행. 값은 '홍길동' ]──> DB
DB: 준비해둔 계획에 값만 꽂아서 실행
1단계가 "prepare"다. 값이 오기 전에 문장을 미리 준비해둔다는 뜻이다.
실제로 SQL 표준에 PREPARE와 EXECUTE라는 문장이 있다. MySQL 콘솔에서 직접 쳐볼 수 있다.
PREPARE stmt FROM 'SELECT * FROM MEMBER WHERE NAME = ?';
SET @name = '홍길동';
EXECUTE stmt USING @name;
DEALLOCATE PREPARE stmt;
JDBC의 PreparedStatement는 이 두 단계를 자바 API로 감싼 것이다. conn.prepareStatement(sql)이 1단계고, pstmt.executeQuery()가 2단계다.
이걸 알고 나면 코드가 다르게 읽힌다.
pstmt = conn.prepareStatement(sql); // ← 여기서 이미 DB에 SQL이 갔다
pstmt.setString(1, userId); // ← 값은 그 다음에 채운다
rs = pstmt.executeQuery(); // ← 실행
prepareStatement()를 부르는 시점에 이미 SQL 구조가 확정된다. setString()은 그 뒤의 일이다. 이 순서가 전부다.
면접에서 "왜 PreparedStatement를 쓰나요"라고 물으면 대부분 보안 하나만 답한다. 원래 이유는 두 개다.
값이 ' OR '1'='1이라고 해보자.
PreparedStatement에서는 DB가 이미 WHERE NAME = ?로 파싱을 끝냈다. ?는 "값 하나가 들어올 자리"로 확정된 상태다. 여기에 무슨 문자열이 오든 DB는 그걸 값 하나로만 취급한다.
그래서 결과는 이렇게 된다.
NAME이 정확히 "' OR '1'='1" 인 회원을 찾아라
당연히 없다. 작은따옴표가 문법 기호가 아니라 그냥 글자로 취급되기 때문이다.
반면 문자열을 이어붙이면 이런 문장이 만들어져서 DB로 간다.
SELECT * FROM MEMBER WHERE NAME = '' OR '1'='1'
이게 통째로 파싱된다. OR '1'='1'이 조건절로 해석되고, 항상 참이니 전체 행이 나온다.
차이는 하나다. 값이 파싱 전에 들어갔느냐, 후에 들어갔느냐.
이쪽은 잘 안 알려져 있는데 실무에서는 더 중요할 때도 있다.
같은 SQL을 100번 실행한다고 하자. 문자열을 이어붙이면 매번 다른 문장이 된다.
WHERE NAME = '홍길동'
WHERE NAME = '김철수'
WHERE NAME = '이영희'
DB 입장에서는 전부 다른 SQL이다. 매번 새로 파싱하고 실행 계획을 세워야 한다. Oracle에서는 이걸 하드 파싱(hard parse)이라고 부르고, 비용이 꽤 크다.
바인드 변수를 쓰면 SQL 텍스트가 항상 같다.
WHERE NAME = ?
DB가 이미 세워둔 실행 계획을 그대로 재사용한다. 소프트 파싱(soft parse)으로 끝난다. 반복 실행이 많을수록 차이가 커진다.
그래서 대량 INSERT 같은 경우
PreparedStatement를 한 번 만들고setXxx→executeUpdate만 반복하는 게 정석이다. 매번prepareStatement()를 다시 부르면 이 이점이 사라진다.
내 메모 프로젝트의 DAO 코드다.
// MemoDAOImpl.java
String sql = prop.getProperty("userSelect");
pstmt = conn.prepareStatement(sql); // 1단계: 구조 확정
pstmt.setString(1, userId); // 2단계: 값 채우기
rs = pstmt.executeQuery();
SQL은 sql.xml에 이렇게 들어 있다.
<entry key="userSelect">
select user_no, user_pw, user_name
from tb_users
where user_id = ?
</entry>
?가 곧 "여기에 값 하나가 들어온다"는 예약석이다.
메모를 저장할 때는 ?가 세 개라 세 줄이 붙는다.
pstmt.setInt(1, userNo);
pstmt.setString(2, title);
pstmt.setString(3, content);
? 방식의 문제 — 순서 의존이게 JDBC를 직접 쓸 때 가장 짜증나는 부분이었다.
pstmt.setString(1, title);
pstmt.setString(2, content);
pstmt.setString(3, updateDate);
pstmt.setInt(4, memoNo);
SQL에 컬럼 하나만 추가해도 인덱스를 전부 다시 세야 한다. 그리고 틀려도 컴파일 에러가 안 난다. 런타임에 엉뚱한 값이 들어가거나 타입 예외가 난다.
타입도 직접 골라야 한다. setString인지 setInt인지 setDate인지 매번 판단해야 한다.
같은 쿼리를 MyBatis로 쓰면 이렇다.
<select id="login" resultType="Member">
SELECT MEMBER_NO, MEMBER_ID, MEMBER_PW
FROM "MEMBER"
WHERE MEMBER_ID = #{memberId}
</select>
MyBatis가 #{memberId}를 보면 내부에서 ?로 바꾸고 pstmt.setString()을 호출한다. 없어진 게 아니라 감춰진 것이다.
| JDBC 직접 | MyBatis #{} | |
|---|---|---|
| SQL 안 표기 | ? | #{memberId} |
| 값 지정 | pstmt.setString(1, ...) | 자동 |
| 지정 방식 | 순서(인덱스) | 이름 |
| 타입 결정 | setString/setInt 직접 선택 | 자동 추론 |
| 컬럼 추가 시 | 인덱스 전부 재조정 | 그대로 |
순서에서 이름으로 바뀐 게 가장 큰 개선이다. 인덱스를 세지 않아도 되고, 순서를 바꿔도 안 깨진다.
그리고 ${}는 JDBC에서 이렇게 짜는 것과 같다.
// 절대 이렇게 쓰면 안 되는 코드
String sql = "SELECT * FROM MEMBER WHERE NAME = '" + name + "'";
stmt = conn.createStatement();
rs = stmt.executeQuery(sql);
createStatement()에는 ?가 없다. 문장이 이미 완성된 채로 DB에 간다. ${}가 하는 일이 정확히 이것이다.
#{} | ${} | |
|---|---|---|
| 처리 방식 | PreparedStatement 파라미터 | 문자열 그대로 치환 |
| DB에 가는 것 | WHERE name = ? + 값 별도 | WHERE name = 값 완성문 |
| 값이 들어가는 시점 | 파싱 이후 | 파싱 이전 |
| 따옴표 | 자동으로 붙음 | 안 붙음 |
| SQL 인젝션 | 안전 | 위험 |
| 실행 계획 재사용 | O | X |
| JDBC로 치면 | PreparedStatement + ? | Statement + 문자열 결합 |
마지막 줄이 이 표의 요약이다. #{}와 ${}의 차이는 PreparedStatement와 Statement의 차이 그대로다.
${}는 언제 쓰나값이 아니라 SQL 구조 자체를 넣어야 할 때다.
ORDER BY ${sortColumn} ${sortDirection}
?는 값 자리에만 쓸 수 있다. 컬럼명이나 테이블명, ASC/DESC 같은 키워드는 바인딩이 안 된다. 이유는 위에서 설명한 것과 같다. 파싱 시점에 이미 확정돼 있어야 하는 정보이기 때문이다. 실행 계획을 세우려면 어떤 컬럼으로 정렬할지 알아야 하는데, 그게 나중에 값으로 들어오면 계획을 세울 수 없다.
그래서 이때만 ${}를 쓰되, 반드시 허용된 값 목록과 대조해야 한다.
// 서비스 계층에서 화이트리스트 검증
private static final Set<String> ALLOWED_COLUMNS =
Set.of("BOARD_NO", "CREATE_DATE", "READ_COUNT");
if (!ALLOWED_COLUMNS.contains(sortColumn)) {
throw new IllegalArgumentException("허용되지 않은 정렬 컬럼");
}
사용자가 보낸 문자열을 그대로 ${}에 넣으면 인젝션이 그대로 열린다. ${}를 쓴다는 건 "내가 직접 검증하겠다"는 선언이다.
두 프로젝트를 다시 열어봤다.
메모 프로젝트(JDBC) — sql.xml의 모든 쿼리가 ?를 쓰고 있었다. 문자열을 이어붙인 곳은 한 군데도 없었다.
세미 프로젝트(MyBatis) — 매퍼 전체에 ${}가 한 번도 안 나온다. 전부 #{}다.
검색 기능이 이렇게 되어 있다.
<choose>
<when test="searchType == 'title'">
AND B.BOARD_TITLE LIKE '%' || #{searchKeyword} || '%'
</when>
<when test="searchType == 'nickname'">
AND M.MEMBER_NICKNAME LIKE '%' || #{searchKeyword} || '%'
</when>
</choose>
여기서 두 가지가 눈에 띈다.
첫째, LIKE 처리 방식이 정석이다. %를 파라미터 안에 넣지 않고 오라클의 || 연산자로 이어붙였다. '%#{searchKeyword}%'처럼 쓰면 따옴표 안이라 MyBatis가 치환하지 않는다. 문자열 리터럴로 인식해서 #{searchKeyword}라는 글자를 그대로 찾게 된다. ||로 연결하거나, CONCAT('%', #{keyword}, '%')를 쓰거나, 자바에서 %를 붙여 넘겨야 한다.
둘째, 정렬 조건을 ${}로 받지 않았다. <choose>로 미리 정의된 분기를 골랐다. 검색 타입이 사용자 입력에서 오지만, 그 값이 SQL에 직접 들어가지 않고 어느 분기를 탈지 고르는 데만 쓰인다. 결과적으로 화이트리스트와 같은 효과가 난다. 의도했든 안 했든 안전한 구조다.
Q. #{}와 ${}의 차이는?
#{}는 PreparedStatement의 파라미터로 처리되고,${}는 문자열을 그대로 치환합니다.
#{}를 쓰면 DB에WHERE name = ?형태로 먼저 가서 파싱과 실행 계획 수립이 끝난 뒤에 값이 채워집니다. 구조가 이미 확정된 상태라 값에 무엇이 들어와도 쿼리 문법을 바꿀 수 없습니다.
${}는 값이 붙은 완성된 문장이 통째로 파싱되기 때문에 인젝션에 취약합니다. 컬럼명이나 정렬 방향처럼 값이 아니라 SQL 구조인 것을 넣어야 할 때만 쓰고, 그때는 허용 목록으로 검증해야 합니다.
Q. PreparedStatement가 왜 "prepared"인가요?
값보다 SQL 문장을 먼저 DB에 보내서 미리 파싱하고 실행 계획을 세워두기 때문입니다. SQL 표준에도
PREPARE와EXECUTE문장이 따로 있고, JDBC의PreparedStatement는 그 두 단계를 자바 API로 감싼 것입니다.
그래서 이점이 두 가지입니다. 값이 파싱 이후에 들어가니 인젝션을 막을 수 있고, 같은 SQL을 반복 실행할 때 파싱 결과와 실행 계획을 재사용해서 성능에도 이점이 있습니다.
Q. JDBC를 직접 써본 것과 MyBatis를 쓴 것의 차이는?
JDBC에서는
?에setString(1, ...)처럼 순서로 값을 넣었습니다. 컬럼이 추가되면 인덱스를 전부 다시 세야 했고, 틀려도 컴파일 에러가 안 나서 런타임에야 알 수 있었습니다.
MyBatis의#{}는 같은 일을 이름으로 하게 해준 것입니다. 내부적으로는 여전히?로 바꿔서PreparedStatement에 바인딩합니다. 없어진 게 아니라 감춰진 것이라고 이해했습니다.
PreparedStatement의 "prepared"는 값보다 SQL 문장을 먼저 보내 미리 파싱해둔다는 뜻이다.#{}는 MyBatis가 발명한 게 아니라 ?를 이름으로 부르게 한 것이다.${}는 JDBC에서 문자열을 +로 이어붙이는 것과 같다.#{}, SQL 구조에는 ${} + 화이트리스트.?에 값을 하나씩 채워넣어 본 뒤에야 #{}가 뭘 대신하는지 알게 됐다. 프레임워크가 감춘 걸 한 번은 직접 봐야 하는 이유다.
${} 경고가 있다.PreparedStatement SQL Injection으로 검색하면 원리를 더 깊게 볼 수 있다.하드 파싱 소프트 파싱 바인드 변수로 검색. 성능 쪽 이야기가 나온다.