[백엔드 입문 #9] 레거시 MyBatis 코드로 본 SQL 매핑과 SQL 인젝션
지난 글까지는 신규 코드만 봤다. JPA에서는 repository.findById(1L)만 부르면 SQL이 알아서 만들어졌다. 그런데 오래된 레거시 코드를 열어 보니 XML 파일에 SQL이 그대로 적혀 있었다. 이번 글은 그 방식인 MyBatis 이야기다.
MyBatis는 문법이 많아서, 꼭 기억할 세 가지를 먼저 정리해 두고 시작한다.
- MyBatis는 SQL을 사람이 직접 쓰고, 결과만 객체에 담아 준다.
- 값은 반드시
#{}로 넣는다.${}는 SQL 인젝션이 된다.- 쿼리 두 개가 같은 DB 연결을 써야 할 때가 있고, 그걸 보장하는 게 트랜잭션이다.
글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.
① JPA와 무엇이 다른가
| JPA | MyBatis | |
|---|---|---|
| SQL은 누가 쓰나 | JPA가 만들어 준다 | 사람이 XML에 직접 쓴다 |
| 결과물 | Entity (규칙을 가진 객체) | DTO (데이터만 담는 그릇) |
| 값을 바꾸면 | 변경 감지로 UPDATE가 자동으로 나간다 | UPDATE문을 직접 호출해야 한다 |
| 프론트 비유 | Prisma 같은 ORM | 쿼리 문자열을 직접 짜서 보내기 |
어느 쪽이 더 나은 건 아니다. 테이블 7개를 조인하는 복잡한 조회 화면은 SQL을 직접 쓰는 게 훨씬 편하다. 대신 규칙이 SQL 곳곳에 흩어지기 쉽다는 단점이 있다. 이 이야기는 ⑤에서 다시 한다.
② 구조: 인터페이스 하나, XML 하나
카트 목록 조회를 예로 따라가 봤다. Java 쪽에는 인터페이스만 있고 구현이 없다.
@Mapper
public interface CartMapper {
List<CartSearchOutDTO> selectCartList(CartSearchReqDTO reqDTO);
}
SQL은 XML에 있다.
<mapper namespace="com.example.wms.cart.mapper.CartMapper"> <!-- 어느 인터페이스? -->
<select id="selectCartList" resultType="CartSearchOutDTO"> <!-- 어느 메서드? 결과는 어디에 담나? -->
SELECT cm.cart_id
,cm.cart_type_cd
,cm.cart_barcode_no
FROM cart_mst cm
WHERE cm.delete_yn = 'N'
AND cm.sub_account_id = #{subAccountId}
<if test="cartTypeCd != null">
AND cm.cart_type_cd = #{cartTypeCd}
</if>
<if test="searchData != null and searchData != ''">
AND cm.cart_barcode_no LIKE CONCAT('%', #{searchData}, '%')
</if>
ORDER BY cm.cart_id DESC
LIMIT #{start}, #{size}
</select>
</mapper>
둘은 이름으로 연결된다. namespace는 인터페이스 이름, id는 메서드 이름이다. cartMapper.selectCartList(reqDTO)를 부르면 MyBatis가 같은 이름의 SQL을 찾아 실행하고, 결과 한 줄 한 줄을 CartSearchOutDTO에 담아 준다.
SQL 컬럼은 cart_barcode_no인데 DTO 필드는 cartBarcodeNo다. map-underscore-to-camel-case: true 설정이 자동으로 바꿔 준다. 프론트에서 API 응답에 camelcaseKeys()를 쓰는 것과 같다.
③ 조건이 있을 때만 붙이기: 동적 SQL
검색 조건은 있을 수도 있고 없을 수도 있다. 프론트에서는 이렇게 처리한다.
const params = { subAccountId };
if (cartTypeCd) params.cartTypeCd = cartTypeCd;
if (searchData) params.searchData = searchData;
XML에서는 <if test="...">가 같은 일을 한다. 조건이 참일 때만 그 줄이 SQL에 들어간다.
IN (1, 2, 3)처럼 목록을 넣을 때는 <foreach>를 쓴다.
AND cm.cart_id IN
<foreach collection="cartIds" item="cartId" separator="," open="(" close=")">
#{cartId}
</foreach>
`IN (${cartIds.map(() => '?').join(',')})` // 하는 일은 같다
④ #{}와 ${}: 이것만은 꼭 기억
#{searchData}는 값을 SQL에 문자열로 이어 붙이지 않는다. SQL 모양을 먼저 확정해 두고, 값은 ? 자리에 데이터로만 따로 넘긴다.
WHERE cm.cart_barcode_no LIKE CONCAT('%', ?, '%') -- 값은 따로 전달
그래서 사용자가 무엇을 입력해도 SQL 문법으로 해석되지 않는다.
${}는 반대로 입력을 그대로 이어 붙인다. 프론트로 치면 이런 차이다.
el.textContent = userInput; // #{} : 글자로만 들어간다
el.innerHTML = userInput; // ${} : 입력이 코드로 실행될 수 있다 (XSS)
이 레포의 매퍼 XML 65개를 전부 검색해 봤더니 ${}는 0개였다. 일부러 쓰지 않는다는 뜻이다.
⑤ 페이징의 함정: 같은 연결이어야 한다
목록 화면에는 “전체 N건”도 필요하다. 레거시는 MySQL 기능으로 이걸 구한다.
SELECT SQL_CALC_FOUND_ROWS ... LIMIT 0, 20 -- 20건만 가져오되, LIMIT이 없었다면 몇 건인지 기억해 둬
SELECT FOUND_ROWS() -- 방금 기억한 숫자를 줘
@Service
@Transactional(readOnly = true)
public class CartService {
public PageDTO<List<CartSearchOutDTO>> searchCartList(CartSearchReqDTO reqDTO) {
List<CartSearchOutDTO> dataList = cartMapper.selectCartList(reqDTO); // 쿼리 1
pageDTO.setTotCnt(commMapper.getTotCnt()); // 쿼리 2: FOUND_ROWS()
FOUND_ROWS()는 같은 DB 연결에서 바로 전에 실행한 쿼리의 결과를 기억한다. 그래서 쿼리 1과 쿼리 2가 같은 연결을 써야 맞는 숫자가 나온다.
그걸 보장하는 게 5편에서 배운 @Transactional이다. 트랜잭션 안에서는 메서드가 끝날 때까지 연결 하나를 붙잡고 쓴다. 트랜잭션이 롤백 말고도 하는 일이 하나 더 있었다.
⑥ 레거시에서 보이는 것
6편에서 본 “빈약한 모델”이 여기 그대로 있다.
- DTO에는 필드와 getter, setter만 있다.
delete_yn = 'N'(삭제 안 된 것만 보기) 같은 규칙을 쿼리마다 직접 적어야 한다. 하나만 빠뜨려도 삭제된 카트가 화면에 나온다.- 상태를 바꾸려면 UPDATE문을 직접 써서 호출해야 한다.
<update id="updateDeleteYnCartContainerDtl">
UPDATE cart_container_dtl
SET delete_yn = 'Y', upd_dt = now(), upd_id = #{userId}
WHERE cart_id = #{cartId}
</update>
신규 기능을 JPA와 도메인 모델로 만드는 이유가 여기서 이해됐다. MyBatis가 나빠서가 아니라 규칙이 SQL에 흩어지면 테스트하기 어렵기 때문이다. 8편에서 본 것처럼 Entity 안의 규칙은 순수 Java로 바로 테스트할 수 있다. 반면 XML 속 WHERE 조건은 DB를 띄워야만 확인할 수 있다.
퀴즈 풀어 보기
문제 1: ${}로 바꾸고 이상한 값을 넣으면?
AND cm.cart_barcode_no LIKE CONCAT('%', '${searchData}', '%')
검증되지 않은 값이 들어가서 오류가 날 거라고 답했다. 위험하다는 건 맞혔지만 결과는 틀렸다. 오류가 나지 않는다. 그래서 더 위험하다.
검색창에 ') OR 1=1 -- 를 넣으면 SQL이 이렇게 바뀐다.
WHERE cm.delete_yn = 'N'
AND cm.sub_account_id = 5
AND cm.cart_barcode_no LIKE CONCAT('%', '') OR 1=1 -- ', '%')
↑ 이 뒤는 전부 주석 처리
AND가 OR보다 먼저 계산되니까 결국 (앞의 조건들) OR 1=1이 된다. 1=1은 항상 참이라서 다른 창고의 카트까지 전부 조회된다. 화면에는 에러 없이 데이터가 잘 나오니 아무도 눈치채지 못한다. 이게 SQL 인젝션이다. 차라리 오류가 났으면 다행이었을 것이다.
문제 2: @Transactional을 지우면 전체 건수는?
“조회만 하는데 트랜잭션이 왜 필요해?” 하고 지웠을 때를 묻는 문제였다. 트랜잭션이 없으면 같은 트랜잭션이라는 걸 몰라서 안 된다고 답했다. 방향은 맞았지만, 정확한 원인은 트랜잭션이 아니라 같은 연결이다.
트랜잭션이 없으면 쿼리마다 커넥션 풀에서 연결을 빌렸다가 돌려준다. 쿼리 1과 쿼리 2가 서로 다른 연결을 쓰게 될 수 있고, 그러면 FOUND_ROWS()가 엉뚱한 숫자를 준다.
더 무서운 건 항상 틀리지는 않는다는 점이다. 풀이 우연히 같은 연결을 다시 주면 숫자가 맞는다. 로컬에서 혼자 테스트하면 멀쩡하고, 사용자가 몰리는 운영 환경에서만 가끔 전체 건수가 이상하게 나온다. 이렇게 가끔만 틀리는 버그가 가장 찾기 어렵다.
문제 3: 검색 조건이 비어 있으면 어떤 SQL이 나갈까?
cartTypeCd는 null이고 searchData는 ""인 요청이다. 처음엔 질문을 잘못 읽어서 SELECT *를 떠올렸는데, 물은 건 WHERE 절에 무엇이 남느냐였다.
두 <if>가 모두 거짓이라서 두 줄 다 빠진다.
WHERE cm.delete_yn = 'N'
AND cm.sub_account_id = #{subAccountId}
“이 창고의 삭제 안 된 카트 전부”다. 프론트에서 필터를 하나도 고르지 않고 조회 버튼을 누른 상황과 같다.
searchData != ''까지 확인하는 이유도 알게 됐다. 프론트에서 검색창을 비워 두고 보내면 null이 아니라 빈 문자열이 오는 경우가 많다. 프론트에서 if (searchData)로 빈 문자열까지 거르는 것과 같은 습관이다.
정리
| 개념 | 한 줄 요약 | 프론트 비유 |
|---|---|---|
| Mapper 인터페이스 + XML | namespace와 id로 메서드와 SQL을 연결한다 |
API 함수 선언과 실제 쿼리 |
resultType |
결과 한 줄을 DTO에 담는다 | camelcaseKeys(res) |
<if>, <foreach> |
조건부 SQL, 목록 SQL | 조건부 파라미터, .map().join() |
#{} vs ${} |
값으로 넘길지, 문자열로 이어 붙일지 | textContent vs innerHTML |
FOUND_ROWS() |
같은 연결이 필요해서 트랜잭션이 필수 | — |
정리하고 나니 남는 건 결국 세 가지였다. SQL을 직접 쓴다, 값은 #{}로 넣는다, 쿼리 두 개가 이어지면 같은 연결이 필요하다. 나머지 문법은 필요할 때 다시 찾아보면 된다.
다음 글은 이 시리즈의 마지막이다. 지금까지 배운 Controller, UseCase, Port, Entity, 예외, 테스트를 모아서 작은 API를 직접 만들어 볼 예정이다.