DB 보안 사고는 대개 거창한 해킹보다 과한 권한과 문자열 연결 쿼리에서 시작됩니다. 애플리케이션 계정 하나가 모든 테이블을 지울 수 있다면 작은 취약점이 전체 장애가 됩니다. 최소 권한과 안전한 쿼리 작성은 백엔드 개발자의 기본 방어선입니다.
DB 보안의 핵심은 세 가지입니다. 첫째, 사용자 입력을 절대 SQL에 직접 이어붙이지 않는다(Prepared Statement). 둘째, 계정마다 최소한의 권한만 부여한다(최소 권한 원칙). 셋째, 민감 데이터는 저장과 전송 모두 암호화한다. 이 세 가지만 제대로 지켜도 대부분의 DB 보안 사고를 예방할 수 있습니다.
- 1SQL Injection — 고전 공격부터 Blind SQLi까지 공격 유형
- 2Prepared Statement — 언어별 파라미터 바인딩 구현
- 3최소 권한 원칙 — GRANT/REVOKE와 ROLE 기반 권한 관리
- 4패스워드 저장 — bcrypt/argon2와 MD5/SHA의 차이
- 5전송 암호화(SSL/TLS)와 저장 암호화(pgcrypto, AES-256)
- 6pgaudit 감사 로그와 DB 보안 체크리스트
DB 보안 기초 — SQL Injection, 권한 관리, 암호화
데이터베이스는 사용자 정보, 결제 데이터, 비즈니스 기밀을 담고 있어 공격자의 1순위 표적입니다. OWASP Top 10에서 SQL Injection이 수년째 상위권을 차지하는 이유는 여전히 취약한 코드가 실무에 존재하기 때문입니다. DB 보안의 핵심은 세 가지입니다. 첫째, 사용자 입력을 절대 SQL에 직접 이어붙이지 않는다(Prepared Statement). 둘째, 계정마다 최소한의 권한만 부여한다(최소 권한 원칙). 셋째, 민감 데이터는 저장과 전송 모두 암호화한다. 이 세 가지만 제대로 지켜도 대부분의 DB 보안 사고를 예방할 수 있습니다.
SQL Injection — 원리, 공격 유형, Prepared Statement 방어
로그인 폼에 ' OR '1'='1을 입력했더니 비밀번호 없이 관리자 계정으로 접근됩니다. SQL을 직접 조합하는 코드가 있다면 이 취약점은 반드시 존재합니다. 실제 사고 사례를 보면 SQL Injection은 여전히 웹 공격의 1순위입니다. 이 취약점은 복잡하지 않습니다. 원리를 이해하고 Prepared Statement를 쓰면 원천 차단됩니다.

SQL Injection의 원리
SQL Injection은 사용자 입력값이 쿼리 구조의 일부로 해석될 때 발생합니다. 개발자가 사용자 입력을 문자열 연결로 SQL에 직접 삽입하면, 공격자는 입력값에 SQL 문법을 포함시켜 쿼리의 의도를 바꿀 수 있습니다.
아래 취약한 코드는 username 파라미터를 f-string으로 SQL에 직접 이어붙입니다. 정상 입력 "admin"은 아무 문제가 없어 보이지만, 입력값이 "' OR '1'='1"이면 WHERE 조건이 항상 참이 되어 모든 사용자가 반환됩니다. 입력값이 "'; DROP TABLE users; --"이면 테이블 전체가 삭제됩니다.
def get_user_vulnerable(username: str):
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
return cursor.fetchone()
SQL Injection 공격 유형 4가지와 예방
1. 고전 공격 — 인증 우회: 로그인 폼에 ' OR '1'='1을 입력하면 WHERE 절이 항상 참이 되어 첫 번째 사용자(보통 관리자)로 로그인됩니다.
2. UNION 기반 공격 — 데이터 탈취: 검색 필드에 ' UNION SELECT username, password, NULL FROM users --를 입력하면 원래 검색 결과와 함께 users 테이블 전체가 반환됩니다.
3. Boolean 기반 Blind SQLi: DB 이름 첫 글자가 'p'인지 확인하는 조건을 삽입해 응답 여부로 데이터를 한 글자씩 추측합니다.
4. Time 기반 Blind SQLi: pg_sleep(5) 같은 지연 함수를 삽입해 응답 시간으로 데이터 존재 여부를 추측합니다.
Prepared Statement — 취약한 코드 vs 안전한 코드
Prepared Statement는 쿼리 구조(SQL 문법)를 먼저 DB에 전송해 컴파일하고, 데이터는 별도로 전달합니다. DB는 두 가지를 엄격하게 분리해 처리하므로 데이터가 절대로 SQL 명령어로 해석되지 않습니다.
아래 두 Python 코드를 나란히 비교합니다. 왼쪽(취약)은 f-string으로 직접 삽입하고, 오른쪽(안전)은 %s 플레이스홀더와 별도 튜플로 바인딩합니다.
취약한 방식:
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
안전한 방식 (Python psycopg2):
cursor.execute(
"SELECT id, username, email FROM users WHERE username = %s",
(username,)
)
**Node.js (pg 라이브러리)**에서는 $1, $2 번호 플레이스홀더를 사용합니다.
const result = await pool.query(
'SELECT id, username, email FROM users WHERE id = $1',
[userId]
);
const result = await pool.query(
`INSERT INTO orders (user_id, product_id, quantity, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id`,
[userId, productId, quantity]
);
Java JDBC에서는 ? 플레이스홀더와 setLong, setString 메서드로 바인딩합니다.
String sql = "SELECT id, username, email FROM users WHERE id = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();
}
JPA/Hibernate는 JPQL 파라미터 바인딩(:username)으로 SQL Injection을 자동으로 방어합니다.
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional<User> findByUsername(@Param("username") String username);
ORM 사용 시 raw query 주의
ORM을 사용해도 raw query를 잘못 작성하면 SQL Injection이 발생합니다. Django ORM에서 User.objects.filter(name=name) 같은 ORM 메서드는 자동으로 파라미터 바인딩을 적용하지만, raw()를 f-string과 함께 쓰면 취약해집니다.
User.objects.filter(name=name)
User.objects.raw("SELECT * FROM users WHERE name = %s", [name])
query = f"SELECT * FROM users WHERE id = {user_id}" 같은 코드는 user_id에 1 OR 1=1이 들어오면 모든 행을 반환합니다. 더 심각하게는 1; DROP TABLE users; --이 들어오면 테이블이 삭제됩니다. 입력값 검증이나 이스케이프로는 완전한 방어가 불가능합니다. 우회 패턴이 너무 많기 때문입니다.
근본적인 해결책은 모든 쿼리를 Prepared Statement(파라미터 바인딩)로 작성하는 것입니다. 쿼리 구조와 데이터를 분리하면 입력값이 어떤 내용이든 SQL 명령어로 해석되지 않습니다. 코드 리뷰 체크리스트에 "f-string/format/concat으로 SQL 조립 금지" 항목을 추가하고, CI에서 정적 분석 도구(bandit, semgrep)를 돌려 자동으로 잡아내는 것을 권장합니다.
최소 권한 원칙과 암호화 — DB 보안 체크리스트
배포 편의를 위해 애플리케이션이 DB superuser 계정을 사용합니다. SQL Injection 공격이 성공하면 공격자는 테이블을 삭제하거나 다른 DB까지 접근할 수 있습니다. 비밀번호는 평문으로 저장돼 있어서 DB 파일이 유출되면 모든 계정이 즉시 노출됩니다. 최소 권한과 암호화는 보안 사고가 발생했을 때 피해 범위를 제한하는 핵심 장치입니다.

최소 권한 원칙 (Principle of Least Privilege)
애플리케이션 DB 계정은 필요한 최소한의 권한만 가져야 합니다. 공격자가 SQL Injection에 성공해도 할 수 있는 일을 제한하는 것이 목적입니다. 애플리케이션에는 DML(SELECT, INSERT, UPDATE, DELETE)만 허용하고 DDL(CREATE, DROP, ALTER, TRUNCATE)은 절대 부여하지 않습니다.
CREATE USER app_user WITH PASSWORD 'strong_random_password_here';
GRANT CONNECT ON DATABASE myapp_db TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
REVOKE CREATE ON SCHEMA public FROM app_user;
실행 완료 또는 조회 결과가 표시됩니다.
- 권한 범위—애플리케이션 계정에 DDL 권한이 없는지 확인합니다.
- 파라미터 바인딩—입력값이 문자열 연결이 아니라 바인딩 변수로 전달되는지 봅니다.
- 감사 로그—위험한 DDL/DCL 시도가 로그에 남는지 점검합니다.
읽기 전용 리포팅 계정은 SELECT만 허용합니다.
CREATE USER readonly_user WITH PASSWORD 'another_strong_password';
GRANT CONNECT ON DATABASE myapp_db TO readonly_user;
GRANT USAGE ON SCHEMA public TO readonly_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;
ROLE을 사용하면 여러 사용자의 권한을 그룹으로 관리하고 한 번에 회수할 수 있습니다.
CREATE ROLE app_readwrite;
CREATE ROLE app_readonly;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_readwrite;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
GRANT app_readwrite TO app_user;
GRANT app_readonly TO readonly_user;
REVOKE app_readwrite FROM app_user;
패스워드 컬럼 저장 — 절대 금지와 권장 방법
패스워드 저장 방식에 따라 DB가 유출됐을 때의 피해 범위가 크게 달라집니다.
| 방식 | 예시 | 위험도 | 이유 |
|---|---|---|---|
| 평문 | mypassword123 | 치명적 | DB 유출 즉시 노출 |
| MD5 | a29e5524c48f2b26... | 높음 | GPU로 수억 개/초 크래킹 가능 |
| SHA-256 | ef92b778bafe771... | 높음 | 빠른 해시, 여전히 취약 |
| bcrypt | $2b$12$K8hvMPfB... | 낮음 | 의도적으로 느림, salt 내장 |
| argon2 | $argon2id$v=19$... | 낮음 | 현재 가장 권장, 메모리 하드 |
Python에서는 bcrypt 라이브러리를 사용합니다. rounds=12는 연산 비용으로 높을수록 느려집니다. salt는 자동으로 생성됩니다.
import bcrypt
def hash_password(plain_password: str) -> str:
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(plain_password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
Java Spring에서는 BCryptPasswordEncoder를 빈으로 등록해 사용합니다.
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
public User register(String username, String rawPassword) {
String hashedPassword = encoder.encode(rawPassword);
return userRepository.save(new User(username, hashedPassword));
}
public boolean authenticate(String rawPassword, String storedHash) {
return encoder.matches(rawPassword, storedHash);
}
전송 중 암호화 — SSL/TLS 강제
암호화 없는 연결에서는 네트워크상의 공격자가 패킷을 스니핑해 쿼리, 결과, 패스워드를 그대로 볼 수 있습니다. 프로덕션 환경에서는 반드시 sslmode=require 이상으로 설정합니다.
연결 문자열에 SSL을 적용하는 방법은 언어에 무관하게 동일합니다.
postgresql://user:pass@db-host:5432/mydb?sslmode=require
Python에서 서버 인증서까지 검증하려면 sslmode=verify-full과 CA 인증서 경로를 함께 지정합니다.
conn = psycopg2.connect(
host="db-host",
database="mydb",
user="app_user",
password="...",
sslmode="verify-full",
sslrootcert="/path/to/ca.crt"
)
PostgreSQL 서버에서 비암호화 연결을 원천 차단하려면 pg_hba.conf에서 host 대신 hostssl만 허용합니다.
hostssl all all 0.0.0.0/0 scram-sha-256
민감 컬럼 암호화 — pgcrypto 활용
카드 번호, 주민등록번호 같은 민감 데이터는 DB 컬럼 수준에서 암호화합니다. pgcrypto 확장의 pgp_sym_encrypt는 AES-256 대칭키 암호화를 사용합니다. 암호화 키는 절대 DB에 저장하지 않고 환경변수나 Vault에서 관리합니다.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE payment_info (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
card_number BYTEA,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO payment_info (user_id, card_number)
VALUES (
42,
pgp_sym_encrypt('4111-1111-1111-1111', current_setting('app.encryption_key'))
);
SELECT
user_id,
pgp_sym_decrypt(card_number, current_setting('app.encryption_key')) AS card_number
FROM payment_info
WHERE user_id = 42;
pgaudit — DB 감사 로그
pgaudit는 DDL, 쓰기 DML, GRANT/REVOKE 등 주요 이벤트를 로그로 기록합니다. SOC2, PCI-DSS 같은 컴플라이언스 요건 충족에 필수입니다. read(SELECT)는 트래픽이 많으면 저장소 부담이 크므로 처음에는 write, ddl, role만 활성화하는 것을 권장합니다.
CREATE EXTENSION IF NOT EXISTS pgaudit;
ALTER SYSTEM SET pgaudit.log = 'write, ddl, role';
SELECT pg_reload_conf();
pgaudit 로그는 아래 형식으로 남습니다. 누가 언제 어떤 테이블에 어떤 SQL을 실행했는지 추적할 수 있습니다.
2024-03-15 10:23:45 UTC [1234] app_user LOG: AUDIT: SESSION,1,1,DDL,DROP TABLE,TABLE,public.users,DROP TABLE users
2024-03-15 10:24:01 UTC [1234] app_user LOG: AUDIT: SESSION,2,1,WRITE,INSERT,TABLE,public.orders,INSERT INTO orders VALUES (...)
DB 보안 체크리스트
| 항목 | 위험도 | 확인 방법 |
|---|---|---|
| Prepared Statement 사용 | 치명적 | 코드 리뷰, SQLi 스캔 도구 |
| app 계정 DDL 권한 없음 | 높음 | \dp 명령으로 권한 확인 |
| superuser로 앱 연결 금지 | 높음 | SELECT usename, usesuper FROM pg_user |
| 패스워드 bcrypt/argon2 저장 | 높음 | 코드 리뷰 |
| SSL/TLS 연결 강제 | 높음 | SHOW ssl; 및 sslmode=require 확인 |
| 기본 포트(5432) 변경 검토 | 중간 | 포트 스캔 방어 (심층 방어) |
| 불필요한 DB 계정 삭제 | 중간 | SELECT usename FROM pg_user |
| pgaudit 감사 로그 활성화 | 중간 | SELECT * FROM pg_extension WHERE extname = 'pgaudit' |
| DB 접근 IP 화이트리스트 | 중간 | pg_hba.conf 검토 |
| 정기 보안 패치 적용 | 중간 | SELECT version() — 최신 마이너 버전 유지 |
즉시 실행 가능한 보안 점검 쿼리입니다.
SELECT usename, usesuper, usecreatedb, usecreaterole
FROM pg_user
WHERE usesuper = true;
SELECT grantee, privilege_type, table_name
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
ORDER BY grantee, table_name;
SELECT ssl, count(*)
FROM pg_stat_ssl
JOIN pg_stat_activity ON pg_stat_ssl.pid = pg_stat_activity.pid
GROUP BY ssl;
ssl=true가 아닌 연결이 하나라도 있으면 즉시 원인을 파악해야 합니다.
서비스 계정이 postgres 슈퍼유저로 연결되어 있거나 CREATE, DROP 권한이 있는 경우를 자주 발견합니다. 개발 편의로 superuser를 그대로 두는 경우가 많지만, SQL Injection 취약점이 하나라도 있으면 공격자가 DROP TABLE 한 줄로 전체 데이터를 날릴 수 있습니다.
보안 리뷰 체크리스트에서 가장 먼저 확인하는 항목은 SELECT usename, usesuper FROM pg_user WHERE usename = 'app_user'입니다. usesuper=true가 나오면 즉시 DML 전용 계정으로 교체해야 합니다. 그 다음으로 \dp tablename으로 테이블별 권한을 확인하고, DDL 권한이 있으면 REVOKE합니다. 이 두 단계만으로도 공격 피해 범위를 크게 줄일 수 있습니다.
다음 모듈에서는 트랜잭션 격리 수준의 4단계와 각 수준에서 발생하는 이상 현상을 심화 학습합니다.