DB 보안 사고는 대개 거창한 해킹보다 과한 권한과 문자열 연결 쿼리에서 시작됩니다. 애플리케이션 계정 하나가 모든 테이블을 지울 수 있다면 작은 취약점이 전체 장애가 됩니다. 최소 권한과 안전한 쿼리 작성은 백엔드 개발자의 기본 방어선입니다.
DB 보안의 핵심은 세 가지입니다. 첫째, 사용자 입력을 절대 SQL에 직접 이어붙이지 않는다(Prepared Statement). 둘째, 계정마다 최소한의 권한만 부여한다(최소 권한 원칙). 셋째, 민감 데이터는 저장과 전송 모두 암호화한다. 이 세 가지만 제대로 지켜도 대부분의 DB 보안 사고를 예방할 수 있습니다.
- 1고전 공격부터 Blind SQLi까지 SQL Injection 공격 유형을 이해하고 방어할 수 있다
- 2Prepared Statement로 언어별 파라미터 바인딩을 구현할 수 있다
- 3GRANT/REVOKE와 ROLE 기반 권한으로 최소 권한 원칙을 적용할 수 있다
- 4bcrypt/argon2와 MD5/SHA의 차이를 이해하고 안전하게 패스워드를 저장할 수 있다
- 5SSL/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)를 돌려 자동으로 잡아내는 것을 권장합니다.
접속 요청이 인증·인가를 거쳐 쿼리에 이르기까지 — 동작 5단계
접속 문자열로 연결하고 쿼리 하나를 던지면, 결과가 오거나 authentication failed 또는 permission denied가 돌아옵니다. 이 사이에서 DB는 전송 보안 → 인증(누구인가) → 인가(무엇을 할 수 있나) → 실행 시 접근 제어 → 감사 기록을 차례로 통과시킵니다. 이 관문들을 알면 어느 방어(pg_hba·GRANT·RLS·pgaudit)를 어디에 둘지, 그리고 거절당했을 때 어느 관문이 막았는지를 바로 읽을 수 있습니다.
[클라이언트] psql "postgresql://app_user:***@db:5432/appdb?sslmode=require" + SELECT ...
│
① 연결·전송보안 TCP 접속 후 TLS 협상 (sslmode=require면 암호화 안 되면 거부)
│
② 인증(누구인가) pg_hba.conf 규칙 대조 — 호스트·DB·사용자·방식(scram-sha-256)
│ 비번 불일치·해당 규칙 없음 → 인증 실패로 여기서 끊김
│
③ 인가(무엇을 하나) 롤에 GRANT된 객체·동작 확인 (CONNECT·USAGE·SELECT/INSERT…)
│ 권한 없으면 permission denied
│
④ 실행 시 제어 행 수준(RLS 정책)·열 수준(컬럼 GRANT)으로 접근 범위를 좁힘
│
⑤ 감사 기록 pgaudit가 누가·언제·무슨 SQL을 실행했는지 로그로 남김
▼
[결과] 허용된 행·열만 반환 + 감사 로그에 흔적
각 관문이 하는 일과, 막히거나 놓치면 생기는 일:
| 단계 | 하는 일 | 막히면·놓치면 |
|---|---|---|
| ① 연결·전송보안 | TCP+TLS 협상, sslmode 강제 | 비암호화 연결이 통과 → 패킷 스니핑 노출 (hostssl로 차단) |
| ② 인증 | pg_hba.conf로 호스트·사용자·방식 검증 | 비번 틀림·규칙 없음 → authentication failed |
| ③ 인가 | GRANT된 객체·동작만 허용 | 권한 부족 → permission denied / 과다 권한(superuser 앱) → 사고 시 피해 확대 |
| ④ 실행 시 제어 | 행(RLS)·열 단위 접근 제한 | 정책 누락 → 다른 테넌트·타인의 행까지 노출 |
| ⑤ 감사 | pgaudit로 실행 이력 기록 | 미설정 → 사고 후 "누가 했나" 추적 불가 |
즉 쿼리 결과는 이 관문들을 모두 통과한 끝에 나오는 것입니다. 거절당하면 메시지가 관문을 알려줍니다 — authentication failed는 ②(pg_hba·비번), permission denied는 ③(GRANT), 결과 행이 이상하게 많거나 적으면 ④(RLS·열 권한), 사고를 추적할 수 없으면 ⑤(감사 미설정)입니다. 앞서 다룬 SQL Injection은 이 관문을 뚫는 게 아니라 ③에서 앱이 이미 가진 권한 안에서 피해를 키우므로, 최소 권한(③)과 파라미터 바인딩이 늘 한 세트로 가야 합니다.
최소 권한 원칙과 암호화 — DB 보안 체크리스트
배포 편의를 위해 애플리케이션이 DB superuser 계정을 사용합니다. SQL Injection 공격이 성공하면 공격자는 테이블을 삭제하거나 다른 DB까지 접근할 수 있습니다. 비밀번호는 평문으로 저장돼 있어서 DB 파일이 유출되면 모든 계정이 즉시 노출됩니다. 최소 권한과 암호화는 보안 사고가 발생했을 때 피해 범위를 제한하는 핵심 장치입니다.
확대
최소 권한 원칙 (Principle of Least Privilege)
애플리케이션 DB 계정은 필요한 최소한의 권한만 가져야 합니다. 공격자가 SQL Injection에 성공해도 할 수 있는 일을 제한하는 것이 목적입니다. 애플리케이션에는 DML(SELECT, INSERT, UPDATE, DELETE)만 허용하고 DDL(CREATE, DROP, ALTER, TRUNCATE)은 절대 부여하지 않습니다.
앱 계정에는 DML만 주고 DDL은 막습니다. 권한 부여 후 그 계정으로 CREATE TABLE을 시도해 거부되는지 확인하는 것이 핵심 — SQL Injection이 성공해도 DROP/CREATE를 못 하게 만드는 방어선입니다.
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;
-- app_user로 접속해 DDL 시도 → 거부되어야 정상
-- psql "postgresql://app_user:...@host/myapp_db"
CREATE TABLE hack_test (id INT);
-- app_user의 CREATE 시도
ERROR: permission denied for schema public
-- 권한 확인 결과
grantee | privilege_type
-----------+----------------
app_user | SELECT
app_user | INSERT
app_user | UPDATE
app_user | DELETE
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;- app_user 권한 확인 먼저: SELECT usename, usesuper FROM pg_user WHERE usename = 'app_user'; 를 실행합니다. usesuper = true가 나오면 즉시 위험 상태입니다. superuser 계정으로 앱이 연결돼 있으면 SQL Injection 성공 시 DROP TABLE이 가능합니다.
- DDL 권한 차단 검증: app_user로 접속 후 CREATE TABLE test_hack (id INT); 를 실행합니다. ERROR 42501 (insufficient_privilege)이 나와야 정상입니다. 오류가 나지 않으면 REVOKE CREATE ON SCHEMA public FROM app_user; 를 즉시 실행하세요.
- 권한 목록 조회: \dp 테이블명 또는 SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_name = 'users'; 실행 결과에 app_user의 privilege_type이 SELECT, INSERT, UPDATE, DELETE만 있어야 합니다. TRUNCATE, REFERENCES, TRIGGER가 있으면 REVOKE하세요.
- SSL 연결 확인: SELECT ssl, count(*) FROM pg_stat_ssl JOIN pg_stat_activity USING(pid) GROUP BY ssl; 에서 ssl=false 연결이 1개라도 있으면 암호화 안 된 연결이 존재합니다. pg_hba.conf를 hostssl 전용으로 변경해야 합니다.
읽기 전용 리포팅 계정은 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합니다. 이 두 단계만으로도 공격 피해 범위를 크게 줄일 수 있습니다.
심화 — 암호화한 컬럼은 '검색'과 맞바꾼 것이다
심화: 암호화된 컬럼은 인덱스를 못 탄다 — 비결정적 암호화와 blind index
앞에서 pgcrypto로 카드번호를 암호화했습니다. 그런데 암호화는 저장의 끝이 아니라 시작입니다. 그 컬럼으로 다시 검색하는 순간, 암호화와 인덱스가 서로 상극이라는 벽에 부딪힙니다.
- 인덱스는 값의 순서로 동작합니다: B-Tree 인덱스는 컬럼 값을 정렬해 두고 범위를 좁혀 찾습니다(B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건). 그런데 암호문은 평문의 순서를 일부러 뭉갭니다 — abc와 abd의 암호문은 전혀 인접하지 않습니다. 그래서 암호화된 컬럼에 인덱스를 만들어도 평문 조건으로는 탈 수가 없습니다.
- pgp_sym_encrypt는 비결정적입니다: 같은 평문을 두 번 암호화해도 매번 다른 암호문이 나옵니다(내부 랜덤 IV). 보안상 옳은 설계지만, 그 결과 암호문끼리 등호로 비교하면 방금 넣은 값조차 영원히 0건입니다.
- 그래서 검색용 결정적 값을 따로 둡니다(blind index): 검색이 필요한 민감 컬럼은 평문을 키로 HMAC 같은 결정적 해시로 계산한 별도 컬럼(예: card_hmac)을 만들어 그 컬럼에 인덱스를 겁니다. 조회는 미리 계산한 HMAC 등호로 하고, 표시할 실제 값은 암호문에서 복호화해 씁니다.
- 부분·범위 검색은 여전히 불가합니다: blind index는 완전 일치만 가속합니다. LIKE·범위·정렬이 필요하면 암호화 대신 토큰화나 애플리케이션단 검색 설계를 다시 고민해야 합니다.
핵심은, 컬럼 암호화가 공짜가 아니라 검색 능력을 대가로 내준 거래라는 점입니다. 무엇을 암호화할지는 항상 그 컬럼을 어떻게 조회할지와 한 세트로 설계해야 합니다.
상황: 규정 대응으로 users.ssn 컬럼을 pgp_sym_encrypt로 암호화해 BYTEA로 저장했습니다. 이후 주민번호로 회원 찾기 기능이 어떤 값을 넣어도 결과가 비고, 성능을 위해 인덱스를 추가했지만 실행계획은 여전히 전체 스캔입니다.
원인: 두 가지가 겹쳤습니다. 첫째, pgp_sym_encrypt는 비결정적이라 같은 주민번호도 저장할 때마다 다른 암호문이 됩니다 — 그래서 암호문끼리의 등호 비교는 절대 일치하지 않습니다. 둘째, 복호화해서 비교하도록 조건을 바꿔도 그것은 모든 행을 복호화한 뒤 필터링하는 표현식이라 인덱스를 탈 수 없어 Seq Scan이 강제됩니다.
진단: 먼저 저장이 비결정적인지 확인합니다 — 같은 평문을 두 번 넣고 두 암호문 BYTEA가 다르면 확정입니다. 그리고 EXPLAIN에서 필터가 pgp_sym_decrypt 함수 호출로 표현되는지 봅니다. 함수 결과에 대한 조건은 일반 인덱스로 가속되지 않습니다.
해결: 검색이 필요한 민감 컬럼은 blind index를 도입합니다. 평문에 대한 결정적 HMAC 값을 담는 ssn_hmac 컬럼을 추가해 인덱스를 걸고, 조회는 미리 계산한 HMAC 등호로 하며 표시는 암호문을 복호화해 씁니다. 키는 DB가 아니라 애플리케이션이나 Vault에서 관리합니다. 완전 일치가 아니라 부분·범위 검색이 필요하다면 암호화 대상 자체를 재검토해야 합니다(B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건).
명령어·구문 빠른 참조
이 모듈에서 다룬 권한 관리·암호화·안전한 쿼리 구문을 실전 예와 함께 모았습니다.
| 구문/명령 | 용도 | 예 |
|---|---|---|
Prepared Statement(파라미터 바인딩) | SQL Injection 근본 차단 — 쿼리 구조와 데이터 분리 | psycopg2 cursor.execute("... WHERE username = %s", (name,)) |
$1 / ? / :param 플레이스홀더 | 언어별 파라미터 바인딩 | node-pg $1, JDBC setString(1, ...), JPQL :username |
CREATE USER ... WITH PASSWORD | 앱 전용 계정 생성 | CREATE USER app_user WITH PASSWORD '...'; |
GRANT ... ON ALL TABLES IN SCHEMA | DML만 최소 권한 부여 | GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; |
REVOKE CREATE ON SCHEMA | 앱 계정의 DDL(테이블 생성) 차단 | REVOKE CREATE ON SCHEMA public FROM app_user; |
ALTER DEFAULT PRIVILEGES | 앞으로 만들 테이블에도 권한 자동 적용 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ... TO app_user; |
CREATE ROLE + GRANT 롤 TO 계정 | 권한을 그룹으로 묶어 일괄 관리·회수 | CREATE ROLE app_readonly; GRANT app_readonly TO readonly_user; |
\dp 테이블 | psql에서 테이블별 권한 확인 | \dp users — app_user에 DDL 없어야 정상 |
SELECT usename, usesuper FROM pg_user | 앱 계정이 superuser인지 점검 | ... WHERE usesuper = true; (앱 계정이 나오면 위험) |
pgp_sym_encrypt() / pgp_sym_decrypt() | pgcrypto AES-256 컬럼 암호화 | pgp_sym_encrypt('4111...', current_setting('app.encryption_key')) |
ALTER SYSTEM SET pgaudit.log | 감사 로그 대상 지정 후 reload | ALTER SYSTEM SET pgaudit.log = 'write, ddl, role'; SELECT pg_reload_conf(); |
sslmode=require / SHOW ssl | 전송 구간 TLS 강제·확인 | postgresql://.../db?sslmode=require, SHOW ssl; |
관련 모듈로 더 깊이:
- N+1 문제, SELECT *, 인덱스 무력화 안티패턴 방지 — SQL Injection을 부르는 문자열 조합 쿼리 등 위험 패턴 회피
- 실시간 DB 모니터링 및 슬로우 쿼리 슬랙 알림 설정 —
pg_stat_ssl, 권한 변화 등 보안 이상을 관측으로 감지하는 법
다음 모듈에서는 트랜잭션 격리 수준의 4단계와 각 수준에서 발생하는 이상 현상을 심화 학습합니다.