#{} 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 표준에 PREPAREEXECUTE라는 문장이 있다. 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를 한 번 만들고 setXxxexecuteUpdate만 반복하는 게 정석이다. 매번 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 + 문자열 결합

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


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 표준에도 PREPAREEXECUTE 문장이 따로 있고, 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개의 댓글