SQL 인젝션 방어: ORM에서 다시 뚫리는 4곳
ORM을 써도 SQL 인젝션 방어가 끝나지 않는 이유를 설명합니다. JPA·Prisma·Sequelize에서 raw query, 동적 정렬, LIKE 검색, JSON 필터가 뚫리는 지점을 위험한 코드와 안전한 코드로 비교하고, 코드 리뷰 체크리스트도 정리했습니다.
결론부터 말씀드리면, ORM을 써도 SQL 인젝션 걱정이 끝나지는 않습니다. JPA, Prisma, Sequelize의 기본 조회 API(findMany, findAll, 리포지토리 메서드)는 값을 바인딩 파라미터로 보내기 때문에 대부분 안전합니다. 문제는 개발자가 ORM의 보호 범위를 벗어나 SQL 문자열을 직접 조립하는 순간입니다. OWASP의 Hibernate 문서도 Hibernate를 쓴다고 SQL 인젝션에 면역이 생기지는 않으며, API를 잘못 쓰면 얼마든지 취약해질 수 있다고 적고 있습니다.
실제 진단에서 반복해서 발견되는 지점은 크게 네 곳입니다. raw query, 동적 정렬·컬럼명, LIKE 검색, 그리고 클라이언트가 보낸 JSON을 그대로 필터로 넘기는 코드입니다. 이 글에서는 이 네 지점을 ORM별로 위험한 코드와 고친 코드를 나란히 놓고 비교하고, 코드 리뷰 때 바로 쓸 수 있는 점검 목록으로 마무리합니다.
원칙은 하나입니다. 값은 바인딩하고, 구조(테이블·컬럼·정렬 방향·연산자)는 서버가 정한 목록에서 고릅니다. 아래 예시는 모두 이 원칙을 적용한 것입니다.
ORM이 막아주는 것과 막지 못하는 것
OWASP SQL Injection Prevention Cheat Sheet는 방어 수단의 우선순위를 이렇게 정리합니다.
- 파라미터 바인딩(Prepared Statement): SQL 구조와 데이터 값을 분리합니다. 가장 우선하는 방법입니다.
- 허용 목록(allow-list) 검증: 파라미터로 처리할 수 없는 구조적 선택에 씁니다.
- 수동 이스케이프: 강하게 비권장합니다. 파라미터 바인딩의 대체 수단으로 쓰지 말라고 명시합니다.
여기서 중요한 문장이 있습니다. 파라미터 마커는 데이터 값만 표현할 수 있고, 테이블명·컬럼명이나 ASC/DESC 같은 SQL 키워드는 표현하지 못합니다. Prisma 공식 문서도 같은 내용을 적고 있습니다. 변수는 컬럼명, 테이블명, 데이터베이스명 같은 식별자나 SQL 키워드 자리에는 쓸 수 없습니다.
결국 ORM이 자동으로 막아주는 범위는 "값" 자리까지입니다. 정렬 기준, 검색 대상 컬럼, 필터 조건의 모양처럼 쿼리 구조가 사용자 입력에 따라 바뀌는 기능에서 구멍이 생깁니다. 관리자 화면의 목록·검색·정렬 기능에 이런 코드가 특히 많은데, 관련 점검 항목은 관리자 페이지 보안 체크리스트에도 정리해 두었습니다.
1. Raw query: 가장 흔하고 가장 위험한 구멍
복잡한 통계나 성능 튜닝 때문에 raw SQL을 쓰는 일은 자연스럽습니다. 문제는 쓰는 방식입니다.
JPA / Hibernate
// 위험: 사용자 입력을 JPQL 문자열에 직접 붙임
String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
List<Order> orders = em.createQuery(jpql, Order.class).getResultList();
// 안전: 이름 있는 파라미터로 바인딩
List<Order> orders = em.createQuery(
"SELECT o FROM Order o WHERE o.status = :status", Order.class)
.setParameter("status", status)
.getResultList();
JPQL/HQL도 결국 SQL로 변환되므로 문자열을 이어 붙이면 똑같이 인젝션이 생깁니다. Sonar의 Hibernate 인젝션 분석에서는 따옴표를 두 번 쓰는 방식의 자체 필터가 충분해 보였는데도 HQL 문법을 통해 우회된 사례가 나옵니다. 이스케이프에 기대지 말고 setParameter를 쓰세요. createNativeQuery와 @Query(nativeQuery = true)에도 같은 원칙이 적용됩니다. String.format이나 +로 만든 쿼리는 모두 검토 대상입니다.
Prisma
// 위험: Unsafe 메서드에 템플릿 문자열을 직접 넘김
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM "User" WHERE email = '${email}'`
);
// 위험: Prisma.raw에 사용자 입력을 넣으면 태그드 템플릿의 보호가 사라짐
const users2 = await prisma.$queryRaw`
SELECT * FROM "User" WHERE email = ${Prisma.raw(`'${email}'`)}
`;
// 안전: 태그드 템플릿 사용, 보간된 값은 prepared statement로 전달됨
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE email = ${email}
`;
// 어쩔 수 없이 Unsafe를 써야 한다면 placeholder와 인자를 분리
const users2 = await prisma.$queryRawUnsafe(
'SELECT * FROM "User" WHERE email = $1',
email
);
Prisma 문서는 $queryRawUnsafe와 $executeRawUnsafe가 SQL 인젝션 위험을 크게 높인다고 경고하고, 가능하면 $queryRaw와 $executeRaw를 쓰라고 권합니다. 또 $queryRaw를 쓰더라도 문자열을 미리 조립해서 넘기거나 Prisma.raw를 잘못 쓰면 취약해진다고 명시합니다. 이름에 Unsafe가 붙은 메서드와 Prisma.raw는 코드 리뷰에서 반드시 확인할 키워드입니다.
Sequelize
// 위험: 템플릿 문자열로 쿼리 조립
const rows = await sequelize.query(
`SELECT * FROM orders WHERE user_id = ${req.query.userId}`
);
// 위험: literal()은 이름 그대로 SQL에 그대로 삽입됨
Order.findAll({ where: sequelize.literal(`status = '${req.query.status}'`) });
// 안전: bind 파라미터 (값이 SQL 텍스트와 분리되어 DB로 전달됨)
const rows = await sequelize.query(
'SELECT * FROM orders WHERE user_id = $userId',
{ bind: { userId: req.query.userId }, type: QueryTypes.SELECT }
);
Sequelize에는 replacements와 bind 두 방식이 있습니다. 공식 문서 설명을 보면 replacements는 Sequelize가 이스케이프한 뒤 쿼리에 삽입하고, bind 파라미터는 SQL 텍스트와 분리되어 DB가 처리합니다. 둘 다 문자열을 직접 조립하는 것보다 훨씬 낫지만, 가능하면 bind를 우선 쓰는 편이 OWASP 원칙에 더 가깝습니다. 문서에 따르면 bind 파라미터 역시 SQL 키워드나 테이블·컬럼명 자리에는 쓸 수 없습니다.
2. 동적 정렬·컬럼명: 파라미터로 막을 수 없는 자리
"정렬 기준을 쿼리스트링으로 받는" 목록 API는 거의 모든 서비스에 있습니다. 그리고 이 자리는 바인딩으로 해결할 수 없습니다.
Raw SQL 공통
// 위험: 컬럼명과 방향을 그대로 삽입
const sql = `SELECT * FROM products ORDER BY ${req.query.sort} ${req.query.dir}`;
// 안전: 서버가 정한 목록에 매핑하고, 모르는 값은 거부
const SORT_COLUMNS = {
price: 'price',
created: 'created_at',
name: 'name',
} as const;
const column = SORT_COLUMNS[req.query.sort as keyof typeof SORT_COLUMNS];
const direction = req.query.dir === 'desc' ? 'DESC' : 'ASC';
if (!column) throw new BadRequestError('invalid sort');
const rows = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE category_id = $1 ORDER BY ${column} ${direction}`,
categoryId
);
핵심은 사용자 입력이 SQL에 직접 들어가지 않는다는 점입니다. 사용자는 키(price)만 고르고, 실제 SQL 조각(price, DESC)은 코드에 적힌 상수에서 나옵니다. OWASP도 정렬 방향은 리터럴 ASC/DESC로 매핑하고, 알 수 없는 값은 거부하라고 권합니다.
Spring Data JPA
// 위험: 사용자 입력을 unsafe 정렬로 사용
Sort sort = JpaSort.unsafe(Sort.Direction.ASC, request.getParameter("sort"));
JpaSort.unsafe는 LENGTH(name) 같은 함수식 정렬을 위해 만들어진 기능입니다. 도메인 모델 검증 없이 정렬 절을 그대로 붙이므로 사용자 입력과 함께 쓰면 안 됩니다. Sort.by(property)를 쓰더라도 위 예시처럼 허용 목록 매핑을 거치는 편이 안전합니다. 리포지토리 종류나 쿼리 작성 방식에 따라 검증 수준이 다르기 때문입니다.
Prisma Client · Sequelize의 정렬
// 개선 필요: Prisma는 존재하지 않는 필드면 에러를 내지만, 허용 범위가 너무 넓음
prisma.user.findMany({ orderBy: { [req.query.sort]: 'asc' } });
Prisma Client의 orderBy는 스키마에 없는 필드명이면 에러를 내므로 고전적인 인젝션으로 이어지지는 않습니다. 그래도 허용 목록은 필요합니다. 사용자가 passwordResetToken이나 내부 점수 같은 민감한 컬럼으로 정렬할 수 있으면, 결과 순서만 보고 값을 추정할 여지가 생기기 때문입니다. Sequelize의 order: [[req.query.sort, req.query.dir]]도 버전과 방언에 따라 처리 방식이 다를 수 있으니 ORM 내부 동작에 기대지 말고 같은 매핑 패턴을 적용하세요.
3. LIKE 검색: 문자열 조립과 와일드카드
검색창은 raw SQL이 가장 자주 섞이는 기능입니다.
JPA
// 위험
String jpql = "SELECT p FROM Product p WHERE p.name LIKE '%" + keyword + "%'";
// 안전: 와일드카드는 값 쪽에 붙이고, 사용자 입력의 %, _, \ 는 이스케이프
static String escapeLike(String s) {
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
em.createQuery(
"SELECT p FROM Product p WHERE p.name LIKE :kw ESCAPE '\\'", Product.class)
.setParameter("kw", "%" + escapeLike(keyword) + "%")
.getResultList();
Prisma raw query
// 위험: 와일드카드와 입력을 쿼리 문자열에서 조립
prisma.$queryRawUnsafe(`SELECT * FROM "Post" WHERE title ILIKE '%${q}%'`);
// 안전: % 를 변수 값에 포함
const pattern = `%${escapeLike(q)}%`;
prisma.$queryRaw`SELECT * FROM "Post" WHERE title ILIKE ${pattern}`;
Prisma 문서도 ILIKE를 쓸 때 % 와일드카드는 쿼리가 아니라 변수 값에 포함해야 한다고 안내합니다.
여기서 구분할 점이 있습니다. 바인딩만 제대로 해도 SQL 인젝션은 막힙니다. 하지만 사용자가 %나 _를 입력하면 의도하지 않은 패턴 매칭이 일어납니다. 검색어 % 하나로 전체 행이 매칭되는 식입니다. 이건 인젝션이라기보다 검색 의미의 오염과 성능 문제입니다. 그래서 다음 세 가지를 함께 적용하는 것을 권합니다.
- 이스케이프 함수는 백슬래시를 가장 먼저 처리합니다. 순서가 바뀌면 이스케이프가 깨집니다.
- 검색어 최소·최대 길이를 정합니다(예: 2~50자).
- 앞쪽 와일드카드(
%keyword)는 인덱스를 타지 못하므로 데이터가 많은 테이블은 전문 검색(Full-text) 기능을 검토합니다.
4. JSON 필터 파라미터: 값이 아니라 구조를 넘겨받을 때
최신 스택에서 가장 놓치기 쉬운 지점입니다. 프런트엔드 개발을 편하게 하려고 필터 객체를 통째로 받아 ORM에 넘기는 코드입니다.
// 위험: 클라이언트가 where 절의 구조 자체를 결정
app.post('/api/users/search', async (req, res) => {
const users = await prisma.user.findMany({ where: req.body.filter });
res.json(users);
});
Prisma가 값을 바인딩하므로 SQL 문법이 깨지는 인젝션은 일어나지 않습니다. 하지만 클라이언트가 { "role": "ADMIN" }처럼 원래 노출하지 않으려던 조건을 넣거나, { "passwordHash": { "startsWith": "a" } }처럼 민감한 컬럼에 연산자를 걸어 결과가 있는지 없는지로 값을 한 글자씩 추정할 수 있습니다. 이런 문제를 보통 연산자 인젝션(operator injection)이라고 부릅니다. SQL 문법은 그대로인데 쿼리의 의미를 공격자가 정하게 되는 셈입니다.
Sequelize도 같은 문제가 있습니다. 공식 문서는 $gt 같은 문자열 연산자 별칭을 끄고 Op 심볼을 쓰라고 권하면서, 그 이유로 일부 프레임워크가 사용자 입력을 JS 객체로 파싱하기 때문이라고 설명합니다. 다만 심볼 연산자를 쓰더라도 개발자가 문자열 키를 심볼로 바꿔주는 매퍼를 만들면 같은 위험이 다시 생깁니다.
// 안전: 허용할 필드와 연산을 스키마로 고정하고, where는 서버가 직접 조립
import { z } from 'zod';
const SearchSchema = z.object({
name: z.string().min(1).max(50).optional(),
status: z.enum(['ACTIVE', 'SUSPENDED']).optional(),
createdAfter: z.coerce.date().optional(),
});
app.post('/api/users/search', async (req, res) => {
const f = SearchSchema.parse(req.body);
const users = await prisma.user.findMany({
where: {
tenantId: req.user.tenantId, // 권한 조건은 항상 서버가 강제
...(f.name && { name: { contains: f.name } }),
...(f.status && { status: f.status }),
...(f.createdAfter && { createdAt: { gte: f.createdAfter } }),
},
select: { id: true, name: true, status: true }, // 응답 필드도 제한
});
res.json(users);
});
PostgreSQL JSONB 컬럼을 raw query로 검색할 때도 같습니다. data->>'${key}'처럼 키를 문자열로 붙이지 말고, 허용된 키 목록에서 고른 뒤 data->>${key}처럼 키도 바인딩 값으로 넘기세요. 이렇게 필터 구조를 서버가 통제하면 다른 사용자의 데이터에 접근하는 문제도 함께 줄어듭니다. 이 부분은 IDOR 취약점 방어법과도 연결됩니다.
코드 리뷰에서 바로 쓰는 점검 체크리스트
저장소에서 아래 키워드를 검색하는 것부터 시작하세요. 검색 결과가 나오면, 그 코드에 사용자 입력이 흘러들어가는지 추적합니다.
| ORM | 검색 키워드 |
|---|---|
| JPA/Hibernate | createQuery( + 문자열 연결, createNativeQuery, nativeQuery = true, JpaSort.unsafe, String.format |
| Prisma | $queryRawUnsafe, $executeRawUnsafe, Prisma.raw, where: req., orderBy: { [ |
| Sequelize | sequelize.query( + 템플릿 문자열, literal(, where: req., order: [[req. |
점검 항목은 다음과 같습니다.
- raw query의 모든 값이 바인딩 파라미터로 전달되는가
- 정렬 컬럼, 정렬 방향, 테이블명이 서버의 허용 목록에서만 선택되는가
- LIKE 검색에서 와일드카드를 값 쪽에 붙이고,
%·_·\를 이스케이프하는가 - 요청 body/query 객체를
where,order,include에 그대로 넘기지 않는가 - 입력 스키마 검증(zod, Bean Validation 등)이 컨트롤러 진입 시점에 있는가
- 애플리케이션 DB 계정이 DDL 권한이나 다른 스키마 접근 권한 없이 최소 권한으로 운영되는가
- DB 에러 메시지와 쿼리 원문이 API 응답에 노출되지 않는가
정적 분석 도구(Semgrep, CodeQL, SonarQube 등)에 위 패턴을 규칙으로 넣어 두면 PR 단계에서 반복해서 걸러낼 수 있습니다. 고객사 보안 점검을 앞두고 있다면 이 결과를 증빙 자료로도 활용할 수 있습니다(고객사 보안 점검 대응 가이드 참고).
자주 묻는 질문
Prisma Client의 findMany만 쓰고 raw query는 안 쓰면 안전한가요?
고전적인 SQL 인젝션 위험은 크게 줄어듭니다. 하지만 where나 orderBy에 클라이언트 객체나 키를 그대로 넘기면 연산자 인젝션이나 민감 필드 노출이 생길 수 있습니다. 필터 구조는 서버에서 조립하세요.
입력값을 escape 함수로 처리하면 충분하지 않나요?
OWASP는 수동 이스케이프를 파라미터 바인딩의 대체 수단으로 쓰지 말라고 강하게 권고합니다. 이스케이프는 DB 방언과 설정에 따라 결과가 달라지고 우회 사례도 반복해서 나옵니다. 값은 바인딩하고, 식별자는 허용 목록으로 처리하세요. LIKE 와일드카드 이스케이프는 바인딩과 함께 쓰는 보조 수단입니다.
WAF를 쓰고 있으면 코드 수정은 미뤄도 되나요?
WAF는 알려진 패턴을 걸러내는 보조 방어선입니다. 정렬 파라미터나 JSON 필터처럼 정상 요청과 구분하기 어려운 입력은 WAF로 막기 어렵습니다. 근본적인 수정은 코드에서 해야 합니다.
정리
- ORM은 "값" 자리만 자동으로 보호합니다. raw query, 정렬·컬럼명, LIKE, JSON 필터처럼 구조가 바뀌는 지점은 개발자가 직접 막아야 합니다.
- 원칙은 두 가지입니다. 값은 바인딩하고, 구조는 서버의 허용 목록에서 고릅니다.
$queryRawUnsafe,Prisma.raw,JpaSort.unsafe,sequelize.literal,where: req.*를 검색하는 것만으로도 위험한 코드의 상당 부분을 찾을 수 있습니다.
팀 내부에서 직접 점검하기 어렵다면 무료 분석 신청으로 외부 시각의 진단을 받아보실 수 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.