데이터베이스 학습 노트 목차

보안·권한 — 인증·인가·암호화·SQL 인젝션

DB 보안의 네 기둥

DB는 가장 민감한 자산을 담는다 — 개인정보·결제 정보·인증 토큰. 한 번 유출되면 회사가 흔들린다. 그래서 DB 보안은 네 가지 질문에 모두 답해야 한다 — 누가 접속하는가(인증), 그가 무엇을 할 수 있는가(인가), 데이터가 새도 못 읽게 했는가(암호화), 입력으로 뚫리지 않는가(인젝션 방어). 이 네 기둥을 하나라도 빼면 전체가 무너진다(아무리 암호화해도 인가가 헐거우면 합법 계정으로 다 가져간다). (출처: PostgreSQL — Roles & Privileges / RLS / OWASP SQL Injection Prevention.)

인증 vs 인가 — 헷갈리지 말 것

둘은 자주 묶이지만 다른 질문이다. **인증(authentication)**은 "너 누구야" — 비밀번호·인증서·IAM으로 신원을 확인한다(PostgreSQL은 pg_hba.conf로 접속 방식을, MySQL은 사용자@호스트 + 인증 플러그인 caching_sha2_password[09]로). **인가(authorization)**는 "너 뭘 할 수 있어" — 인증된 사용자가 어떤 테이블에 어떤 작업을 할 수 있는지를 GRANT/REVOKE로 정한다. 로그인에 성공했다(인증)고 무엇이든 할 수 있는(인가) 건 아니다.

권한과 역할 — 최소 권한이 왜 결정적인가

-- 객체 권한 부여/회수
GRANT SELECT, INSERT, UPDATE ON orders TO app_user;
REVOKE DELETE ON orders FROM app_user;          -- 앱이 삭제할 일 없으면 회수

-- 역할(ROLE)로 권한을 묶어 사람·서비스에 부여 (PostgreSQL)
CREATE ROLE readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
GRANT readonly TO analyst1, analyst2;
다이어그램 로딩 중…

핵심 원칙은 최소 권한(least privilege)각 계정에 딱 필요한 권한만 준다. 왜 이게 보안의 중심일까? 애플리케이션 계정이 모든 권한(DROP·DDL 포함)을 가지면그 앱이 한 번 뚫리는 순간(예: SQL 인젝션) DB 전체가 뚫린다(공격자가 테이블을 지우고 스키마를 바꾼다). 반면 앱 계정에 SELECT/INSERT/UPDATE만 줘 두면, 뚫려도 DROP은 못 해 피해가 제한된다. 그래서 앱 계정과 관리(DDL·운영) 계정을 반드시 분리하고, 권한은 역할(ROLE)로 묶어 관리한다(사람이 바뀌어도 역할만 떼고 붙이면 된다).

행 수준 보안(RLS) — 행 단위로 통제

멀티테넌트(여러 고객이 한 테이블을 공유) 환경에서 "각 고객은 자기 행만 봐야" 한다. 이를 애플리케이션의 WHERE tenant_id = ?에만 의존하면 — 한 번이라도 그 조건을 빠뜨리면 다른 고객 데이터가 새어 나간다. PostgreSQL의 **행 수준 보안(Row-Level Security)**은 DB가 직접 행을 필터해 이 실수를 원천 차단한다.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant')::bigint);
-- 이제 어떤 쿼리(SELECT *)든 자기 tenant 행만 반환 — 앱이 WHERE를 깜빡해도 DB가 강제

이처럼 접근 통제를 DB 층에서 강제하면 애플리케이션 버그가 데이터 유출로 직결되지 않는다. 컬럼 제한([15])나 컬럼 단위 GRANT로, 행 제한RLS로 한다.

SQL 인젝션 — 공격이 어떻게 일어나고 왜 막히나

가장 흔하고 치명적인 공격이 SQL 인젝션이다. 원리를 정확히 알아야 막을 수 있다. 사용자 입력을 쿼리 문자열에 그대로 이어 붙이면 — 입력이 *데이터가 아니라 쿼리의 일부(구조)*로 해석되어, 공격자가 쿼리의 의도를 바꾼다.

-- ❌ 취약 — 입력을 SQL에 직접 연결
"SELECT * FROM member WHERE name = '" + input + "'"
--  input = ' OR '1'='1   → WHERE name = '' OR '1'='1'  (항상 참 → 전체 노출)
--  input = '; DROP TABLE member; --  → 쿼리를 끊고 파괴 명령 주입

여기서 *입력 ' OR '1'='1*이 문자열 값이 아니라 SQL 논리로 섞여 들어가 조건을 항상 참으로 만든다. 방어의 핵심은 **파라미터 바인딩(prepared statement)**이다 — 쿼리의 구조(파싱된 트리)를 먼저 확정하고, 바인딩 값은 데이터로만 채운다.

-- ✅ 방어 — 구조는 ?로 고정, 입력은 '값'으로만
"SELECT * FROM member WHERE name = ?"   -- ' OR '1'='1 가 와도 통째로 하나의 '문자열 값'

왜 이게 근본적으로 막을까? — 바인딩 값은 이미 확정된 쿼리 구조를 바꿀 수 없기 때문이다. ' OR '1'='1이 들어와도 그것이 통째로 name이라는 컬럼의 검색 문자열이 될 뿐, 쿼리의 의도를 못 바꾼다. 바인딩이 *안 되는 부분(테이블·컬럼명)*은 화이트리스트 검증으로 막는다. ORM·QueryDSL([10])도 내부적으로 바인딩을 기본으로 쓴다. 그리고 최소 권한마지막 그물 — 설령 인젝션이 일부 성공해도 DROP 권한이 없으면 테이블을 못 지운다.

암호화 — 저장과 전송

데이터는 두 지점에서 노출된다.

  • 전송 중(in transit)TLS/SSL로 클라이언트↔DB 통신을 암호화한다(공용 네트워크 도청 방어, 평문 전송 금지).
  • 저장 시(at rest) — *디스크/테이블스페이스 암호화(TDE)*로 디스크를 통째로 훔쳐도 못 읽게 한다. 특히 주민번호·카드번호 같은 초민감 컬럼은 컬럼 단위 암호화(앱이 암호화해 저장하거나 pgcrypto)를 더한다.
  • 비밀번호는 절대 평문·복호화 가능 형태로 저장하지 않는다 — *솔트 + 강한 단방향 해시(bcrypt/argon2)*로 저장한다(앱 책임이지만 DB 보안과 함께 다룬다). 해시는 복호화가 불가능해, DB가 털려도 원문 비밀번호가 안 나온다.

감사 — 누가 무엇을 했나

규제(금융·의료)와 사고 조사를 위해 **감사 로그(audit log)**가 필요하다 — 민감 테이블에 누가 언제 무엇을 했는지 기록한다. 트리거([15])로 변경을 audit 테이블에 남기거나, PostgreSQL pgaudit·MySQL Enterprise Audit 같은 전용 확장을 쓴다. 사고가 났을 때 *"누가 이 데이터를 봤/바꿨나"*를 추적할 수 있어야 한다.

정리 — 애플리케이션을 믿지 말고 DB가 지키게

DB 보안의 핵심 — 인증(누구)·인가(무엇을·최소 권한·역할·앱/관리 계정 분리), 접근 통제를 DB 층에(RLS·뷰·컬럼 GRANT) 두어 앱 버그가 유출로 직결되지 않게, SQL 인젝션은 파라미터 바인딩으로 근본 차단, 암호화(전송 TLS·저장 TDE/컬럼·비밀번호 단방향 해시), 감사 로그. 관통하는 사고는 — *"애플리케이션을 믿지 말고, DB와 최소 권한이 스스로 지키게"*다.

한 줄 요약 — 인증(pg_hba/사용자@호스트) vs 인가(GRANT/REVOKE·ROLE). 최소 권한(앱은 SELECT/INSERT/UPDATE만·DROP 회수·관리 계정 분리)→뚫려도 피해 제한. 접근 통제는 DB 층에(RLS로 행·뷰/컬럼 GRANT로 컬럼). SQL 인젝션=입력이 쿼리 구조로 섞이는 것→파라미터 바인딩으로 구조 고정·값만 채워 근본 차단(+화이트리스트·최소 권한). 암호화: 전송 TLS·저장 TDE/컬럼·비밀번호 bcrypt/argon2(단방향). 감사(트리거·pgaudit).

(출처: PostgreSQL — Database Roles / Row Security Policies / Encryption / MySQL 8.4 — Access Control / Security / OWASP — SQL Injection Prevention.)

QueryDSL — 타입세이프 동적 쿼리(OpenFeign 포크)NoSQL — KV·문서·컬럼·그래프·시계열·NewSQL