#{} vs ${} MyBatis가 감춘 PreparedStatement

lee·2026년 8월 15일

Servlet/JSP

목록 보기
1/7

MyBatis를 처음 배울 때 #{}와 ${} 중 뭘 써야 하냐는 질문에 "#{} 쓰세요, ${}는 위험해요"라고 배웠다.
맞는 말인데, 왜 위험한지는 몰랐다.
JSP/서블릿 프로젝트를 다시 보고서야 알았다. 이건 MyBatis 문법 이야기가 아니라 JDBC 이야기였다.


0. 결론부터

#{}는 MyBatis가 새로 만든 게 아니다. JDBC의 PreparedStatement를 이름으로 부를 수 있게 감싼 것이다.

MyBatis실제로 하는 일 (JDBC)
#{name}? 자리 + pstmt.setString(1, name)
${name}"... WHERE name = " + name 문자열 결합

그래서 #{}가 왜 안전한지는 PreparedStatement가 왜 안전한지와 같은 질문이다. 그리고 그건 이름에 답이 적혀 있다.


1. "Prepared"가 무슨 뜻인가

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 개념이다

실제로 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()은 그 뒤의 일이다. 이 순서가 전부다.


2. 준비해두면 뭐가 좋은가 — 두 가지

면접에서 "왜 PreparedStatement를 쓰나요"라고 물으면 대부분 보안 하나만 답한다. 원래 이유는 두 개다.

(1) 보안 — 구조가 먼저 굳는다

값이 ' OR '1'='1이라고 해보자.

PreparedStatement에서는 DB가 이미 WHERE NAME = ?로 파싱을 끝냈다. ?는 "값 하나가 들어올 자리"로 확정된 상태다. 여기에 무슨 문자열이 오든 DB는 그걸 값 하나로만 취급한다.

그래서 결과는 이렇게 된다.

NAME이 정확히 "' OR '1'='1" 인 회원을 찾아라

당연히 없다. 작은따옴표가 문법 기호가 아니라 그냥 글자로 취급되기 때문이다.

반면 문자열을 이어붙이면 이런 문장이 만들어져서 DB로 간다.

SELECT * FROM MEMBER WHERE NAME = '' OR '1'='1'

이게 통째로 파싱된다. OR '1'='1'이 조건절로 해석되고, 항상 참이니 전체 행이 나온다.

차이는 하나다. 값이 파싱 전에 들어갔느냐, 후에 들어갔느냐.

(2) 성능 — 파싱 결과를 재사용한다

이쪽은 잘 안 알려져 있는데 실무에서는 더 중요할 때도 있다.

같은 SQL을 100번 실행한다고 하자. 문자열을 이어붙이면 매번 다른 문장이 된다.

WHERE NAME = '홍길동'
WHERE NAME = '김철수'
WHERE NAME = '이영희'

DB 입장에서는 전부 다른 SQL이다. 매번 새로 파싱하고 실행 계획을 세워야 한다. Oracle에서는 이걸 하드 파싱(hard parse)이라고 부르고, 비용이 꽤 크다.

바인드 변수를 쓰면 SQL 텍스트가 항상 같다.

WHERE NAME = ?

DB가 이미 세워둔 실행 계획을 그대로 재사용한다. 소프트 파싱(soft parse)으로 끝난다. 반복 실행이 많을수록 차이가 커진다.

그래서 대량 INSERT 같은 경우 PreparedStatement를 한 번 만들고 setXxx → executeUpdate만 반복하는 게 정석이다. 매번 prepareStatement()를 다시 부르면 이 이점이 사라진다.


3. 서블릿에서는 이렇게 썼다

내 메모 프로젝트의 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인지 매번 판단해야 한다.


4. MyBatis는 그걸 이름으로 부르게 했다

같은 쿼리를 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에 간다. ${}가 하는 일이 정확히 이것이다.


5. 핵심 차이

#{}${}
처리 방식PreparedStatement 파라미터문자열 그대로 치환
DB에 가는 것WHERE name = ? + 값 별도WHERE name = 값 완성문
값이 들어가는 시점파싱 이후파싱 이전
따옴표자동으로 붙음안 붙음
SQL 인젝션안전위험
실행 계획 재사용OX
JDBC로 치면PreparedStatement + ?Statement + 문자열 결합

마지막 줄이 이 표의 요약이다. #{}와 ${}의 차이는 PreparedStatement와 Statement의 차이 그대로다.


6. 그럼 ${}는 언제 쓰나

값이 아니라 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("허용되지 않은 정렬 컬럼");
}

사용자가 보낸 문자열을 그대로 ${}에 넣으면 인젝션이 그대로 열린다. ${}를 쓴다는 건 "내가 직접 검증하겠다"는 선언이다.


7. 내 코드 확인

두 프로젝트를 다시 열어봤다.

메모 프로젝트(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에 직접 들어가지 않고 어느 분기를 탈지 고르는 데만 쓰인다. 결과적으로 화이트리스트와 같은 효과가 난다. 의도했든 안 했든 안전한 구조다.


8. 면접 답변

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 구조에는 ${} + 화이트리스트.

?에 값을 하나씩 채워넣어 본 뒤에야 #{}가 뭘 대신하는지 알게 됐다. 프레임워크가 감춘 걸 한 번은 직접 봐야 하는 이유다.


참고

  • MyBatis 공식 문서 — 매퍼 XML — 한국어. "문자열 대체" 절에 ${} 경고가 있다.
  • PreparedStatement SQL Injection으로 검색하면 원리를 더 깊게 볼 수 있다.
  • Oracle을 쓴다면 하드 파싱 소프트 파싱 바인드 변수로 검색. 성능 쪽 이야기가 나온다.
profile
반갑습니다

0개의 댓글