Back

SQL 인젝션 방어: 모던 개발자를 위한 완벽 가이드

SQL 인젝션(SQLi)은 웹 애플리케이션 역사상 가장 오래되었지만, 여전히 가장 위험한 취약점 중 하나입니다. 수십 년 동안 잘 알려진 문제임에도 불구하고, 여전히 OWASP Top 10에 단골로 등장하고 있죠. 이번 글에서는 SQLi가 내부적으로 어떻게 작동하는지, 왜 계속해서 발생하는지, 그리고 무엇보다 어떻게 하면 우리 애플리케이션을 완벽하게 보호할 수 있는지 깊이 있게 다뤄보려고 합니다.

공격의 해부학 (The Anatomy of an Attack)

SQL 인젝션의 핵심은 신뢰할 수 없는 사용자 입력이 데이터베이스 쿼리 문자열에 직접 연결(Concatenation)될 때 발생합니다. 이를 통해 공격자는 쿼리의 구조를 조작하여 접근 권한이 없는 데이터를 조회하거나 수정, 심지어 삭제까지 할 수 있게 됩니다.

다음과 같은 레거시 로그인 시스템의 코드를 예로 들어볼까요?

SELECT * FROM users WHERE username = '$username' AND password = '$password';

만약 사용자가 아이디 입력창에 admin' --라고 입력한다면 어떻게 될까요? 완성된 쿼리는 다음과 같이 변합니다.

SELECT * FROM users WHERE username = 'admin' --' AND password = '...';

여기서 --는 SQL에서 주석(Comment)을 의미합니다. 즉, 뒤따라오는 비밀번호 확인 로직이 무시되어 버리고, 공격자는 비밀번호 없이 관리자 계정으로 로그인하게 됩니다. 이것은 가장 고전적인 "Tautology" 공격 방식이지만, 현대의 SQLi 공격은 블라인드 인젝션(Blind Injection), 시간 기반 공격(Time-based), 대역 외 데이터 유출(Out-of-band) 등 훨씬 더 정교하고 위험합니다.

왜 '살균(Sanitization)'만으로는 부족할까?

흔히 하는 오해 중 하나는 입력값에서 따옴표 같은 특수 문자를 이스케이프(Escape) 처리하는 것만으로 충분하다고 생각하는 것입니다. 물론 도움이 되긴 하지만, 이것은 매우 취약한 방어막입니다. 데이터베이스 엔진마다 이스케이프 처리 방식이 다를 수 있고, 멀티바이트 문자셋을 이용한 우회 공격 등 예상치 못한 엣지 케이스가 언제나 존재하기 때문입니다.

입력을 '청소'하려고 애쓰는 대신, 업계 표준은 데이터와 코드를 완전히 분리하는 것입니다.

골드 스탠다드: 파라미터화된 쿼리 (Parameterized Queries)

파라미터화된 쿼리(또는 Prepared Statements)는 SQL 인젝션에 대한 가장 강력하고 확실한 방어책입니다. 이 방식은 데이터베이스가 사용자 입력을 실행 가능한 코드가 아닌, 오직 '데이터'로만 취급하도록 강제합니다.

Prepared Statement를 사용하면 데이터베이스는 먼저 SQL 쿼리의 구조를 컴파일하고, 데이터가 들어갈 자리를 비워둡니다. 그 후 사용자 입력이 이 자리에 바인딩됩니다. 입력값에 아무리 위험한 SQL 명령어가 포함되어 있어도, 데이터베이스는 그것을 단순한 문자열로만 인식합니다.

Node.js 예시 (pg 라이브러리)

// ❌ 취약한 코드 const query = `SELECT * FROM products WHERE id = ${req.params.id}`; client.query(query); // ✅ 안전한 코드 const query = 'SELECT * FROM products WHERE id = $1'; client.query(query, [req.params.id]);

Python 예시 (Psycopg2)

# ❌ 취약한 코드 cur.execute("SELECT * FROM users WHERE name = '" + username + "'") # ✅ 안전한 코드 cur.execute("SELECT * FROM users WHERE name = %s", (username,))

ORM을 활용한 안전한 개발

Prisma, TypeORM, Sequelize와 같은 모던 ORM(Object-Relational Mappers)은 기본적으로 내부에서 파라미터화된 쿼리를 사용합니다. ORM을 사용하면 직접 Raw SQL을 작성할 일이 줄어들기 때문에 실수로 인한 인젝션 위험이 획기적으로 낮아집니다.

하지만 주의할 점이 있습니다. ORM이 제공하는 "Raw Query" 기능(예: queryRaw, sequelize.query)을 사용할 때는 ORM의 보호를 받지 못할 수 있습니다. 이때는 반드시 ORM이 제공하는 파라미터 바인딩 기능을 사용해야 합니다.

복잡한 SQL 디버깅과 포맷팅

복잡한 쿼리를 작성하거나 레거시 코드를 디버깅할 때, 뒤죽박죽 섞인 Raw SQL 문자열을 읽는 것은 정말 고역입니다. 포맷팅이 엉망인 SQL은 잠재적인 취약점이나 로직 오류를 발견하기 어렵게 만듭니다.

Pro Tip: 복잡한 SQL 로그를 분석하거나 안전한 쿼리 구조를 잡아야 할 때, Pockit의 **SQL Formatter**를 활용해보세요. 쿼리 구조를 한눈에 들어오게 정리해주어, 어디에 파라미터를 사용해야 할지 명확하게 파악할 수 있도록 도와줍니다.

심층 방어 전략 (Defense in Depth)

파라미터화된 쿼리 외에도 다음과 같은 다층 방어 전략을 고려해야 합니다.

1. 최소 권한의 원칙 (Principle of Least Privilege)

애플리케이션이 사용하는 데이터베이스 계정에는 딱 필요한 권한만 부여하세요. 웹 애플리케이션이 DROP TABLE이나 GRANT 권한을 가질 이유는 거의 없습니다. 만약 인젝션 취약점이 발견되더라도, 권한이 제한되어 있다면 피해를 최소화할 수 있습니다.

2. 입력값 검증 (Input Validation)

엄격한 화이트리스트(Allowlist) 방식으로 입력을 검증하세요. 만약 파라미터가 정수(Integer)여야 한다면, 데이터베이스 계층으로 넘어가기 전에 애플리케이션 코드에서 확실하게 정수인지 확인해야 합니다.

3. 웹 애플리케이션 방화벽 (WAF)

WAF는 들어오는 트래픽에서 일반적인 SQL 인젝션 패턴을 탐지하고 차단하여 애플리케이션 앞단에서 안전망 역할을 해줍니다.

마치며

SQL 인젝션은 이미 해법이 나와 있는 문제입니다. 하지만 방심하는 순간 언제든 뚫릴 수 있습니다. 파라미터화된 쿼리를 타협할 수 없는 원칙으로 삼고, ORM을 현명하게 사용하며, 가독성 좋은 SQL 코드를 유지한다면, 보안 사고 걱정 없는 안전한 애플리케이션을 구축할 수 있습니다.

나쁜 입력을 '청소'하려고 하지 마세요. 나쁜 입력이 결코 나쁜 코드가 될 수 없도록 시스템을 설계하세요.

securitydatabasesqlweb-developmentbackend

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요