Java에서 ORM 없이 데이터베이스에게 실행할 쿼리를 전달할 때 Statement 또는 PreparedStatement를 사용해 본 적이 있을 것이다. 비슷한 이름의 두 객체가 어떤 차이가 있는지, 어떨 때 쓰는 것이 적합한지를 알고 쿼리를 효율적으로 실행시켜 보자.
쿼리 수행 방식
데이터베이스가 하나의 Statement를 받을 때 데이터베이스 엔진은 먼저 parse와 문법 검사를 진행한다. 그 후 Statement를 수행하기 위한 가장 효율적인 방법을 탐색한다. 탐색 과정에서 어떤 인덱스를 사용할지, 모든 row를 탐색할 것인지 등을 결정하여 쿼리 플랜을 세우게 되는데, 이 계산하는 과정에서 비용이 많이 들게 된다. 쿼리 플랜이 세워지면 데이터베이스는 그에 따라 쿼리를 실행한다.
차이
Statement Cache
데이터 접근에 대한 계획을 생성하는 작업은 CPU 자원을 소모하는 일이다. 데이터베이스는 StatementCache를 두어 데이터베이스 엔진이 이전에 실행되었던 Statement들에 대한 플랜을 재사용할 수 있다. 여기서 Statement와 PreparedStatement의 가장 큰 차이점을 알 수 있는데, 바로 캐시를 사용하는지의 여부이다.
Statement를 사용하면 매번 쿼리를 수행할 때마다 앞서 설명했던 모든 단계를 거친다. 즉 쿼리 문장 분석, 컴파일 및 쿼리 플랜 결정, 실행의 과정을 거치게 된다. 반면 PreparedStatement는 처음 한 번만 세 단계를 거친 후 캐시에 담아 재사용한다.
코드로 비교해 보자
1) Statement
String sql = "SELECT name, age FROM USER WHERE id = " + id;
Statement stmt = conn.credateStatement();
ResultSet rs = stmt.executeQuerey(sql);
Statement는 인자를 적용한 SQL 문자열을 executeQuery()의 파라미터로 넘겨준다. 이때 완성문 형태의 SQL이 전달되기 때문에 어떤 쿼리가 전달되었는지 파악이 쉬운 장점이 있다. 반면 인자가 적용된 쿼리를 사용하게 되면서 조건만 달라져도 새로운 쿼리로 인식되기 때문에 캐시를 사용할 수 없게 된다.
2) PreparedStatement
String sql = "SELECT name, age FROM USER WHERE id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
pstmt.setInt(1, id);
ResultSet rs = pstmt.executeQuerey();
PreparedStatement는 SQL 문자열에 인자를 바로 넣지 않고, 그 자리를 물음표로 대체한 채 파라미터로 받고 있다. PreparedStatement라는 이름처럼 미리 SQL문을 컴파일해 두고, 그 뒤에 인자 자리를 채워 실행하는 방식이다. 미리 컴파일 해두기 때문에 실행 속도가 빠르고, 인자만 바꿔주면 되기 때문에 캐시 사용에 적합하며 쿼리 플랜을 새로 계산하지 않아도 되는 장점이 있다. 최종적으로 물음표 자리에 어떤 인자가 적용되었는지 명확하게 알 수 없는 부분은 단점이라고 볼 수 있겠다.
가독성
1) Statement
stmt.executeQuery("UPDATE USER set name = '" + name + "' where " + "id = "' + id + "'";
Statement는 인자가 많아질수록 따옴표가 복잡하게 얽히면서 코드 작성 과정에서 오류가 발생할 수 있다.
2) PreparedStatement
PreparedStatement pstmt = conn.preparedStatement("UPDATE USER set name = ? where id = ?");
pstmt.setString(1, name);
pstmt.setString(2, id);
PreparedStatement를 사용하면 지정할 값이 많아지더라도 깔끔하게 작성할 수 있다.
SQL Injection
SQL Injection은 임의의 SQL문을 주입 후 실행시켜 데이터베이스가 비정상적인 동작을 하도록 조작하는 행위이다.
SELECT * FROM USER WHERE id = 'INPUT1' AND password = 'INPUT2';
INPUT1과 INPUT2 자리에 사용자가 입력한 값을 넣어 전달하는 과정에서 SQL Injection이 이루어질 수 있다.
SELECT * FROM USER WHERE id = '' OR 1=1 --' AND password = 'INPUT2';
위와 같이 ‘ OR 1=1 -- 를 삽입해서 실행하게 되면 SELECT * FROM USER WHERE id = ‘’ OR 1=1 이 되면서 모든 유저의 정보를 얻게 되는 것이다.
이때 PreparedStatement를 이용하면 SQL Injection을 예방할 수 있다. 실제 인자값이 아닌 물음표를 포함한 문자열대로 데이터베이스에서 컴파일하여 실행하지 않고 대기한다. 그 후 인자값의 특수 문자는 escaping(\) 하여 단순 문자열로 삽입하기 때문에 공격자가 원하는 대로 쿼리가 실행되지 않는다.
SELECT * FROM USER WHERE id = '\' OR 1=1 --' AND password = 'INPUT2';
‘ OR 1=1 -- 의 ‘가 escaping 되어 \‘ OR 1=1 -- 의 문자열 형태로 삽입되면서 이와 같은 id인 유저의 정보를 찾게 된다.
정리
PreparedStatement는 Statement와 비교했을 때 장점이 많다. 다시 한번 정리해 보자.
- 인자를 적용하지 않고 쿼리문을 미리 컴파일하는 방식이라 캐시를 사용할 수 있다.
- 인자가 많은 경우 Statement를 사용할 때보다 더 깔끔한 코드를 작성할 수 있다.
- 인자 값에 특수문자가 있을 경우 escaping 하여 파싱하기 때문에 SQL Injection을 예방할 수 있다.
역으로 사용하기 적합한 때를 도출하면 다음과 같다.
- 같은 쿼리를 조건만 변경하여 반복 실행해야 하는 경우
- 전달할 인자가 많을 경우
- 사용자의 입력을 전달해야 할 경우
사용할 때 주의할 점은,
각 DB마다 캐싱할 수 있는 한계가 있기 때문에 정작 성능상 캐싱되어야 할 쿼리가 그렇지 않은 쿼리 때문에 캐싱이 안될 수 있기 때문에 꼭 필요한 쿼리만 PreparedStatement를 쓰는 것을 권고한다.
반면 동적 쿼리를 사용해야 하는 경우에는 반드시 Statement를 사용하는 것이 좋다. 동적 쿼리는 캐싱을 할 수 없어 캐싱의 장점을 잃어버리기 때문이다.