보안

[보안] SQL Injection - 진짜 위험한 이유와 막는 법

jmineekim 2026. 5. 4. 10:00

[보안] SQL Injection - 진짜 위험한 이유와 막는 법

신입 개발자 시절, 사수가 PR 리뷰하다가 "어 이거 인젝션 나오는데?" 한 마디 하면 등에서 식은땀이 흘렀습니다. SQL Injection은 OWASP Top 10에 십수 년째 단골로 들어가 있는 공격이고, 매년 어딘가에서 대형 사고가 한 번씩 터져요. 분명히 책에서 백 번은 읽었는데, 막상 코드 짜다 보면 "이거 안전한 거 맞나?" 헷갈리는 순간이 옵니다.

오늘은 SQL Injection이 뭐고, 왜 그렇게 위험하다고 하고, 실무에서 어떻게 막아야 하는지를 한 번에 정리해 볼게요.

개념 / 원리

SQL Injection은 한 마디로 공격자가 입력값에 SQL 구문을 끼워넣어 원래 의도하지 않은 쿼리를 실행시키는 공격입니다.

원인은 단순합니다. 사용자 입력을 SQL 문자열에 그냥 이어붙이기 때문에 발생해요. 예를 들어 로그인 쿼리를 이렇게 만든다고 합시다.

SELECT * FROM users WHERE id = '입력값'

여기 입력값 자리에 ' OR '1'='1이 들어가면 쿼리가 이렇게 변합니다.

SELECT * FROM users WHERE id = '' OR '1'='1'

'1'='1'은 항상 참이라 모든 사용자가 매칭되고, 시스템에 따라 첫 번째 행(보통 admin)으로 그냥 로그인이 돼버립니다. 입력값에 따옴표 한 글자 끼워 넣었을 뿐인데 인증이 통째로 무력화된 거죠.

이게 왜 무섭냐면, 한 번 뚫리면 할 수 있는 게 정말 많거든요.

  • 인증 우회: 위 예시처럼 비밀번호 없이 로그인.
  • 데이터 유출: UNION SELECT로 다른 테이블의 데이터를 끌어다 보기.
  • 데이터 변조/삭제: '; DROP TABLE users; -- 같은 구문으로 테이블 자체를 날리기.
  • 서버 명령 실행: 일부 DB는 xp_cmdshell 같은 기능으로 OS 명령까지 실행 가능.

실전 예제

취약한 코드 (Python)

이렇게 짜면 100% 뚫립니다.

import sqlite3

def login(username, password):
    conn = sqlite3.connect('users.db')
    cur = conn.cursor()
    query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
    cur.execute(query)
    return cur.fetchone()

공격 입력으로 username = "admin' --", password = "아무거나"를 넣으면 실행되는 쿼리는 이렇게 됩니다.

SELECT * FROM users WHERE username = 'admin' --' AND password = '아무거나'

-- 뒤는 SQL 주석이라 비밀번호 비교가 통째로 무시되고, admin 계정으로 로그인이 성공합니다.

안전한 코드 - 파라미터 바인딩

해결책은 하나입니다. Prepared Statement (파라미터 바인딩).

def login(username, password):
    conn = sqlite3.connect('users.db')
    cur = conn.cursor()
    cur.execute(
        "SELECT * FROM users WHERE username = ? AND password = ?",
        (username, password)
    )
    return cur.fetchone()

? 자리는 DB 드라이버가 데이터로만 처리합니다. 따옴표가 들어가든 세미콜론이 들어가든 SQL 구문으로 해석되지 않아요. 그래서 위 공격이 들어와도 그냥 "username이 admin' -- 인 사용자 찾기"가 될 뿐, 검색 결과는 0건입니다.

Java JDBC도 동일

String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();

언어와 DB 종류는 달라도 패턴은 똑같습니다. 자리 표시자(? 또는 :name)를 두고, 값은 별도 메서드로 바인딩.

주의할 점

여기서 신입 분들이 자주 놓치는 함정 다섯 가지.

  1. ORM 쓴다고 무조건 안전한 거 아닙니다. JPA, SQLAlchemy 다 좋은데, raw 문자열 조립하는 메서드(.where("name = '" + name + "'"))를 쓰는 순간 그대로 뚫립니다. ORM의 안전함은 "파라미터 바인딩을 자동으로 해 줄 때" 한정이에요.

  2. Stored Procedure도 안에서 동적 쿼리 만들면 똑같이 위험합니다. EXEC()sp_executesql로 문자열 이어붙이는 SP는 SP라는 이유만으로 안전해지지 않아요.

  3. 테이블명·컬럼명은 파라미터 바인딩이 안 됩니다. ORDER BY ? 같은 건 동작하지 않아요. 정렬 컬럼이나 동적 테이블명을 외부에서 받아야 할 땐 화이트리스트 검증이 정답입니다. "허용된 컬럼명 목록에 있는지 체크"하고 그대로 쿼리에 박는 식.

  4. 에러 메시지 그대로 노출 금지. 운영 환경에서 SQL 에러를 그대로 화면에 띄우면, 공격자에게 "이 페이지에 인젝션 가능성이 있고, 테이블 구조는 이렇습니다" 친절히 알려주는 꼴이에요. 운영은 일반 에러 페이지로 통일하고, 상세 로그는 서버 안쪽에만.

  5. DB 계정 권한을 최소화하세요. 웹앱이 쓰는 DB 계정에 굳이 DROP, DELETE 권한을 줄 필요가 없는 경우가 많습니다. 인젝션이 터졌을 때 SELECT만 되는 계정과 모든 권한이 있는 계정의 사고 규모는 천지차이입니다. 저는 예전에 이 권한 분리만으로 큰 사고 한 번 막은 적이 있어요.

마무리

정리하면 세 줄입니다.

  • SQL Injection의 본질은 "사용자 입력을 SQL 구문으로 해석" 하게 만드는 것이고, 원인은 거의 항상 문자열 이어붙이기다.
  • 해결책은 단 하나, 파라미터 바인딩(Prepared Statement). ORM이든 SP든 raw 문자열 조립을 하는 순간 똑같이 뚫린다.
  • 동적 컬럼명·테이블명처럼 바인딩이 안 되는 부분은 화이트리스트 검증으로 막고, DB 계정 권한 최소화로 사고 규모를 줄이자.

다음 글에서는 SQL Injection의 친척인 NoSQL Injection (MongoDB 등)과 ORM에서 자주 빠지는 함정을 다뤄볼게요.

반응형