개발 slecs

쿼리 매개변수 바인딩의 숨은 함정

목차

mysql2 라이브러리에서 LIMIT ? 플레이스홀더로 페이지네이션을 구현할 때, 잘못된 결과가 나오고 있었다. /debuts/generation 엔드포인트에서 대량의 레코드를 조회할 때마다 offset과 limit 값이 제대로 적용되지 않아, 같은 데이터만 반복되거나 원래 의도와 다른 범위의 결과가 돌아오고 있었다. 조사해 보니 mysql2 의 execute() 함수가 LIMIT 절의 매개변수를 특별하게 다루고 있었다. 이 글에서는 왜 LIMIT에서는 일반적인 매개변수 바인딩이 작동하지 않는지, 그리고 어떻게 해결했는지를 정리했다.

준비된 문(Prepared Statement) 세계의 예외

SQL 매개변수 바인딩은 좋은 관행이다. 사용자 입력을 직접 쿼리 문자열에 연결하면 SQL injection 취약점이 생기기 때문이다. 그래서 보통 이렇게 쓴다:

// 일반적인 매개변수 바인딩 (안전함)
const [rows] = await connection.execute(
  'SELECT * FROM vtubers WHERE status = ? LIMIT ? OFFSET ?',
  ['active', 20, 100]
);

이렇게 하면 데이터베이스 드라이버가 자동으로 값을 escaping 해 주고, SQL 구조와 데이터를 분리해 준다. 그런데 LIMIT와 OFFSET은 이 규칙의 예외다. MySQL 쿼리 파서 입장에서 LIMIT ? 는 "정수 값이 들어올 것"을 기대하지만, 준비된 문 드라이버는 이를 문자열로 바인딩하려 한다. 결국 mysql2 는 literal 정수 대신 문자열을 받게 되고, MySQL이 이를 암묵적으로 0 또는 다른 해석을 하거나, 드라이버 단에서 타입 강제를 실패하게 된다.

실제 코드와 해결책

처음에는 이렇게 짜여 있었다:

// 문제 있는 코드
const limit = request.query.limit as string;
const offset = request.query.offset as string;

const [rows] = await connection.execute(
  'SELECT * FROM vtubers WHERE generation = ? LIMIT ? OFFSET ?',
  [generationId, parseInt(limit), parseInt(offset)]
);

parseInt 를 거쳤음에도 mysql2 의 execute() 메서드가 내부적으로 바인딩 값을 다시 문자열화하는 과정이 있었다. 결국 쿼리가 MySQL에 전달될 때는 제대로 된 정수가 아니었던 것이다.

해결책은 간단하지만 직관적이지 않다. LIMIT과 OFFSET은 쿼리 문자열에 직접 삽입하는 것이다:

// 수정된 코드
const limit = Math.min(parseInt(request.query.limit as string), 100); // 보안: 상한 제약
const offset = Math.max(parseInt(request.query.offset as string), 0);

const [rows] = await connection.execute(
  `SELECT * FROM vtubers WHERE generation = ? LIMIT ${limit} OFFSET ${offset}`,
  [generationId]
);

사용자 입력을 parseInt 로 검증하고 상한/하한을 명시적으로 제약한 후, 쿼리 문자열에 직접 삽입한다. SQL injection이 우려될 수 있지만, 정수만 허용하고 검증하면 안전하다. 숫자가 아닌 입력은 NaN이 되고, 조건문으로 거른다.

왜 이게 함정인가?

이 버그의 핵심은 일관성의 환상이다. 매개변수 바인딩이 "모든 데이터 값에 대해 안전하다"고 배우다 보니, ORDER BY 컬럼명과 마찬가지로 LIMIT도 플레이스홀더로 처리할 수 있을 거라 자연스럽게 생각한다. 하지만 LIMIT은 SQL 문법의 구조 일부다. 데이터가 아니라 명령어의 일부인 것이다.

비슷한 함정은 여러 곳에 있다:

상황 안전한 방법 주의점
WHERE 절 데이터 WHERE id = ? 매개변수 O
ORDER BY 컬럼명 ORDER BY + 화이트리스트 매개변수 X
LIMIT / OFFSET 정수 검증 후 직접 삽입 매개변수 X
GROUP BY / HAVING 컬럼 이름은 화이트리스트 데이터는 O

팀 관점의 배움

이런 일이 발생한 배경을 생각해 보면, 몇 가지 시사점이 있다.

먼저, 라이브러리 문서를 꼼꼼히 읽는 것의 중요성이다. mysql2 공식 문서에는 이런 제한이 명시되어 있다. 하지만 일반적인 ORM(TypeORM, Sequelize)을 쓸 때는 이런 세부사항을 신경 쓸 일이 없다. 로우 쿼리를 직접 건드릴 때야말로 라이브러리 동작을 깊이 있게 이해해야 한다.

둘째, 테스트 커버리지의 맹점이다. 단위 테스트에서는 데이터 값만 검증하고, 실제 페이지네이션이 몇 개 페이지를 조회하는지는 테스트하지 않았을 가능성이 높다. E2E 테스트나 통합 테스트에서 "두 번째 페이지 데이터가 첫 번째와 다른가?"를 확인했다면 빨리 잡혔을 것이다.

셋째, 보안과 편의의 트레이드오프다. 매개변수 바인딩은 SQL injection을 막는 강력한 도구지만, 모든 상황에 적용할 수는 없다. 대신 검증(validation)과 화이트리스트(allowlist) 로 대체해야 한다. 500건의 수정이 필요했던 이유는 여러 엔드포인트에서 같은 실수를 반복했기 때문이다. 코드 리뷰 때 "LIMIT은 매개변수 바인딩으로 안 된다"는 체크리스트 항목이 있었다면 중복을 줄일 수 있었을 거다.

마지막으로, 신규 입사자 온보딩 자료에 이런 "함정" 패턴을 정리해 두면 좋겠다는 생각을 했다. "우리 코드베이스에서 로우 쿼리를 쓸 때 피해야 할 것들"을 한 문서로 만들어 두면, 다음 유사한 실수를 미리 방지할 수 있다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.