PostgreSQL은 오픈소스 객체-관계형 데이터베이스 관리 시스템(ORDBMS)이다.
SQL을 기반으로 데이터를 저장하고 조회하는 전통적인 관계형 데이터베이스의 기능을 제공하면서, JSON, 배열, 사용자 정의 타입, 함수, 확장 기능 등 다양한 데이터 모델링 기능도 지원한다.
MySQL과 마찬가지로 Table, Row, Column, Index, Transaction 등을 기반으로 데이터를 관리하지만 PostgreSQL은 특히 복잡한 Query 처리, 데이터 타입, 확장성, SQL 기능 측면에서 강한 모습을 보인다.
서버 애플리케이션에서 PostgreSQL을 사용하는 기본 구조는 다음과 같다.
Client -> Application Server -> PostgreSQL
애플리케이션은 PostgreSQL에 SQL을 전달하고 PostgreSQL은 데이터를 조회하거나 변경한 결과를 반환한다.
PostgreSQL을 제대로 이해하려면 SQL 문법만 외우기보다 데이터를 어떻게 구조화하고, 무결성을 어떻게 보장하며, Query를 어떻게 빠르게 처리하는지를 이해해야 한다.
PostgreSQL의 기본 구조
PostgreSQL에서 데이터를 관리하는 기본 구조는 다음과 같다.
PostgreSQL Server -> Database -> Schema -> Table -> Row / Column
Database는 데이터를 논리적으로 구분하는 단위이고 Schema는 하나의 Database 안에서 객체를 논리적으로 분리하는 Namespace다.
Table은 실제 데이터를 저장하는 기본 단위이며 Row는 하나의 데이터를, Column은 데이터의 속성을 나타낸다.
예를 들어 사용자 정보를 저장하는 Table은 다음처럼 만들 수 있다.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
데이터는 다음과 같은 형태로 저장된다.

관계형 데이터베이스의 기본 구조 자체는 MySQL과 크게 다르지 않다.
Schema
PostgreSQL에는 Database 내부에 Schema라는 논리적인 Namespace가 존재한다.
기본적으로 public Schema를 사용할 수 있다.
Schema를 이용하면 하나의 Database 안에서도 Table이나 Function 같은 객체를 논리적으로 구분할 수 있다.
Database -> public -> users / orders
Database -> game -> characters / items
특정 Schema에 속한 Table은 다음과 같이 접근할 수 있다.
SELECT *
FROM game.characters;
서비스 규모가 커지면 기능이나 도메인 단위로 Schema를 나누는 방식도 사용할 수 있다.
데이터 타입
PostgreSQL은 다양한 데이터 타입을 제공한다.
대표적인 타입은 다음과 같다.
INTEGER
BIGINT
NUMERIC
VARCHAR
TEXT
BOOLEAN
DATE
TIMESTAMP
UUID
JSON
JSONB
ARRAY
예를 들어 다음과 같이 Table을 만들 수 있다.
CREATE TABLE users (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
PostgreSQL에서는 TEXT와 VARCHAR를 모두 사용할 수 있다.
특별한 문자열 길이 제한이 필요하지 않다면 TEXT를 사용하는 경우도 많다.
숫자 타입도 저장하려는 데이터에 따라 선택해야 한다.
일반적인 정수 -> INTEGER
큰 정수 -> BIGINT
정확한 숫자 계산 -> NUMERIC
특히 금액처럼 부동소수점 오차가 문제가 되는 값은 NUMERIC 같은 정확한 숫자 타입을 고려할 수 있다.
Primary Key
Primary Key는 Table에서 각 Row를 고유하게 식별하는 Key다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL
);
id가 Primary Key라면 동일한 값을 가진 Row를 여러 개 저장할 수 없다.
또한 Primary Key는 NULL을 허용하지 않는다.
PostgreSQL에서는 자동 증가 값을 생성하기 위해 Identity Column을 사용할 수 있다.
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL
);
예전 PostgreSQL에서는 SERIAL도 많이 사용했지만 최근에는 SQL 표준에 가까운 Identity Column을 사용하는 방식도 많이 볼 수 있다.
실제 서비스에서는 숫자형 ID나 UUID 등을 Primary Key로 사용하는 경우가 많다.
Foreign Key
Foreign Key는 Table 사이의 관계를 표현한다.
예를 들어 사용자와 주문이 있다면 다음과 같은 관계를 만들 수 있다.
users.id -> orders.user_id
SQL에서는 다음과 같이 정의한다.
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(12, 2),
FOREIGN KEY (user_id)
REFERENCES users(id)
);
이렇게 하면 존재하지 않는 사용자를 참조하는 주문을 데이터베이스 수준에서 방지할 수 있다.
이런 방식으로 데이터베이스가 직접 데이터 관계를 검증하도록 만드는 것이 관계형 데이터베이스의 중요한 특징이다.
Constraint
Constraint는 데이터가 특정 조건을 만족하도록 데이터베이스에 규칙을 설정하는 기능이다.
대표적인 Constraint는 다음과 같다.
PRIMARY KEY
FOREIGN KEY
UNIQUE
NOT NULL
CHECK
예를 들어 나이가 음수가 될 수 없도록 만들 수 있다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
age INTEGER CHECK (age >= 0)
);
이렇게 하면 애플리케이션에서 실수하더라도 데이터베이스가 잘못된 데이터를 저장하는 것을 막을 수 있다.
따라서 데이터 무결성이 중요한 규칙은 애플리케이션 코드뿐만 아니라 데이터베이스의 Constraint를 이용해 방어하는 것이 좋다.
CRUD
PostgreSQL에서 데이터를 다루는 기본적인 SQL은 CRUD로 정리할 수 있다.
데이터 조회는 SELECT를 사용한다.
SELECT id, name
FROM users
WHERE age >= 20;
데이터 추가는 INSERT를 사용한다.
INSERT INTO users (name, age)
VALUES ('Kim', 25);
데이터 수정은 UPDATE를 사용한다.
UPDATE users
SET age = 26
WHERE id = 1;
데이터 삭제는 DELETE를 사용한다.
DELETE FROM users
WHERE id = 1;
실제 서비스에서는 데이터를 물리적으로 삭제하지 않고 삭제 상태만 기록하는 Soft Delete를 사용하는 경우도 있다.

이 경우 실제 Row는 남겨두고 deleted_at 같은 Column을 이용해 삭제 여부를 관리한다.
JOIN
관계형 데이터베이스에서 중요한 기능 중 하나가 JOIN이다.
사용자와 주문이 서로 다른 Table에 저장되어 있다고 하자.
users.id -> orders.user_id
사용자 이름과 주문 금액을 함께 조회하려면 JOIN을 사용할 수 있다.
SELECT
users.name,
orders.amount
FROM users
JOIN orders
ON users.id = orders.user_id;
JOIN을 이용하면 여러 Table에 분리되어 있는 데이터를 하나의 조회 결과로 구성할 수 있다.
대표적인 JOIN은 다음과 같다.
INNER JOIN
LEFT JOIN
RIGHT JOIN
FULL OUTER JOIN
CROSS JOIN
실무에서는 특히 INNER JOIN과 LEFT JOIN을 자주 사용한다.
Index
데이터가 많아지면 조건에 맞는 Row를 찾기 위해 모든 데이터를 순차적으로 확인하는 방식은 비효율적일 수 있다.
이때 Index를 사용한다.
예를 들어 Email을 자주 검색한다면 다음과 같이 Index를 생성할 수 있다.
CREATE INDEX idx_users_email
ON users(email);
이후 다음 Query를 실행할 때 PostgreSQL이 해당 Index를 사용할 수 있다.
SELECT *
FROM users
WHERE email = 'test@example.com';
Index는 조회 성능을 높이는 대신 저장 공간을 사용한다.
또한 INSERT, UPDATE, DELETE가 발생하면 관련 Index도 함께 관리해야 하기 때문에 무조건 많은 Index를 만드는 것은 좋은 설계가 아니다.
실제 Query 패턴을 기준으로 어떤 Column에 Index가 필요한지 판단해야 한다.
Composite Index
여러 Column을 함께 조건으로 사용하는 Query라면 Composite Index를 고려할 수 있다.
CREATE INDEX idx_users_class_level
ON users(class, level);
다음과 같은 Query에서 사용할 수 있다.
SELECT *
FROM users
WHERE class = 'Knight'
AND level >= 30;
Composite Index에서 중요한 것은 Column의 순서다.
(class, level)
과
(level, class)
은 동일한 Index가 아니다.
따라서 Index를 설계할 때는 단순히 조건에 사용되는 Column만 보는 것이 아니라 실제 Query의 WHERE 조건, JOIN 조건, ORDER BY 등을 함께 확인해야 한다.
EXPLAIN
Query가 느릴 때는 SQL 자체만 보는 것이 아니라 PostgreSQL이 Query를 어떻게 실행하는지도 확인해야 한다.
이때 EXPLAIN을 사용할 수 있다.
EXPLAIN
SELECT *
FROM users
WHERE email = 'test@example.com';
실제 Query를 실행하면서 실행 시간까지 확인하려면 EXPLAIN ANALYZE를 사용할 수 있다.
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'test@example.com';
실행 계획에서는 PostgreSQL이 어떤 방식으로 Table과 Index에 접근했는지 확인할 수 있다.
대표적인 접근 방식은 다음과 같다.
Seq Scan
Index Scan
Index Only Scan
Bitmap Index Scan
Seq Scan은 Table을 순차적으로 읽는 방식이고 Index Scan은 Index를 이용해서 데이터를 찾는 방식이다.
하지만 Seq Scan이 무조건 나쁜 것은 아니다.
Table이 작거나 조회 대상이 대부분의 Row라면 순차적으로 읽는 것이 더 효율적일 수 있다.
따라서 실행 계획은 Index 사용 여부 하나만 보고 판단할 것이 아니라 실제 데이터의 양과 분포, Query 조건을 함께 봐야 한다.
Transaction
Transaction은 여러 데이터베이스 작업을 하나의 논리적인 작업 단위로 묶는 기능이다.
예를 들어 계좌 이체는 다음 두 작업으로 구성된다.
A 계좌 -> 10,000원 차감
B 계좌 -> 10,000원 증가
첫 번째 작업만 성공하고 두 번째 작업이 실패하면 데이터가 잘못된 상태가 된다.
따라서 두 작업을 하나의 Transaction으로 처리한다.
BEGIN;
UPDATE accounts
SET balance = balance - 10000
WHERE id = 1;
UPDATE accounts
SET balance = balance + 10000
WHERE id = 2;
COMMIT;
중간에 문제가 발생하면 Transaction을 취소할 수 있다.
ROLLBACK;
Transaction의 핵심 특성은 ACID로 정리할 수 있다.
Atomicity
Consistency
Isolation
Durability
실제 서버에서는 하나의 비즈니스 작업에 여러 DB 변경이 함께 이루어져야 하는지를 판단하고 필요한 범위에서 Transaction을 적용한다.
MVCC
PostgreSQL을 이해할 때 중요한 개념 중 하나가 MVCC다.
MVCC는 Multi-Version Concurrency Control의 약자다.
여러 Transaction이 동시에 데이터를 읽고 수정할 때 불필요한 충돌을 줄이기 위해 데이터의 여러 버전을 관리하는 방식이다.
개념적으로 보면 다음과 같다.
Transaction A -> 데이터 읽기
Transaction B -> 데이터 수정
Transaction A -> 자신의 Transaction에서 볼 수 있는 데이터 버전 유지
PostgreSQL에서는 UPDATE나 DELETE가 기존 Row를 단순히 덮어쓰는 방식으로만 처리되지 않는다.
기존 Row Version과 새로운 Row Version을 관리하는 구조를 사용한다.
이 때문에 PostgreSQL에서는 더 이상 필요하지 않은 Row Version을 정리하는 과정이 중요해진다.
그 역할을 하는 것이 VACUUM이다.
VACUUM
PostgreSQL에서는 UPDATE와 DELETE가 발생하면서 더 이상 사용되지 않는 Row Version이 생길 수 있다.
이런 데이터는 계속 쌓이게 되므로 PostgreSQL에서는 이를 관리하기 위한 VACUUM이 필요하다.
기본적인 흐름은 다음과 같다.
UPDATE / DELETE -> 새로운 Row Version 또는 변경 상태 생성 -> 기존 Row Version이 더 이상 필요하지 않음 -> VACUUM -> 공간 재사용 및 정리
일반적인 PostgreSQL 환경에서는 AutoVacuum이 백그라운드에서 이러한 작업을 자동으로 수행한다.
따라서 PostgreSQL을 운영할 때는 VACUUM과 AutoVacuum의 역할을 이해하는 것이 중요하다.
JSON과 JSONB
PostgreSQL은 관계형 데이터뿐만 아니라 JSON 형태의 데이터도 저장할 수 있다.
예를 들어 다음과 같은 Table을 만들 수 있다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
profile JSONB
);
그리고 다음과 같은 데이터를 저장할 수 있다.
{
"nickname": "player",
"level": 30,
"items": ["sword", "shield"]
}
PostgreSQL에서는 JSON과 JSONB 타입을 제공한다.
일반적인 애플리케이션에서는 JSON 데이터를 검색하거나 Index를 적용할 필요가 있기 때문에 JSONB를 사용하는 경우가 많다.
JSONB 내부의 값을 조회할 수도 있다.
SELECT *
FROM users
WHERE profile->>'nickname' = 'player';
JSONB에 GIN Index를 생성하는 것도 가능하다.
CREATE INDEX idx_users_profile
ON users
USING GIN (profile);
이 기능을 이용하면 관계형 데이터와 일부 반정형 데이터를 하나의 데이터베이스에서 함께 처리할 수 있다.
다만 JSONB가 일반적인 관계형 모델을 대체하는 것은 아니다.
자주 조회하거나 다른 데이터와 관계를 맺는 핵심 데이터라면 일반적인 Column과 Table로 모델링하는 것이 더 적합한 경우가 많다.
Array
PostgreSQL은 Array 타입도 지원한다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
tags TEXT[]
);
배열 형태의 데이터를 저장할 수 있다.
INSERT INTO users (id, tags)
VALUES (1, ARRAY['game', 'developer']);
태그처럼 단순한 여러 값을 하나의 Column에서 관리해야 하는 경우 유용하다.
다만 배열 내부 데이터 사이에 복잡한 관계가 생기거나 개별 값을 자주 검색해야 한다면 별도의 Table로 분리하는 것이 더 적합할 수 있다.
Function과 Trigger
PostgreSQL은 데이터베이스 내부에서 Function을 정의하고 실행할 수 있다.
또한 특정 데이터 변경이 발생했을 때 자동으로 실행되는 Trigger를 만들 수 있다.
예를 들어 데이터가 변경될 때 로그를 기록하도록 구성할 수 있다.
INSERT / UPDATE / DELETE -> Trigger -> Log 처리
이런 기능은 데이터베이스 내부에서 일관된 규칙을 적용해야 할 때 사용할 수 있다.
하지만 비즈니스 로직을 지나치게 Database 내부에 넣으면 전체 시스템 구조를 이해하기 어려워질 수 있다.
따라서 데이터 무결성을 보장하기 위한 DB 규칙과 애플리케이션의 핵심 비즈니스 로직을 구분해서 설계하는 것이 중요하다.
Extension
PostgreSQL의 특징 중 하나는 Extension 시스템이다.
PostgreSQL 자체에 추가 기능을 설치해서 데이터베이스의 기능을 확장할 수 있다.
대표적인 예가 PostGIS다.
PostGIS를 사용하면 PostgreSQL에서 공간 데이터를 처리할 수 있다.
PostgreSQL -> PostGIS -> Spatial Data / Spatial Query
Point, Polygon 같은 공간 데이터를 저장하고 위치 기반 Query를 수행할 수 있다.
이런 확장성 때문에 PostgreSQL은 단순한 관계형 데이터 저장소를 넘어 특정 도메인에 필요한 기능을 추가해서 사용하는 경우도 많다.
Node.js와 PostgreSQL
Node.js 서버에서 PostgreSQL을 연결할 때는 PostgreSQL 클라이언트 라이브러리를 사용할 수 있다.
대표적으로 pg 패키지가 있다.
npm install pg
기본적인 연결 구조는 다음과 같다.
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
port: 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
});
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
여기서 중요한 것은 사용자 입력값을 SQL 문자열에 직접 연결하지 않는 것이다.
잘못된 방식은 다음과 같다.
const query = `SELECT * FROM users WHERE id = ${userId}`;
대신 Parameterized Query를 사용한다.
await pool.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
이런 방식은 SQL Injection 같은 문제를 방지하는 데 중요하다.
또한 Node.js 서버에서는 요청마다 DB Connection을 새로 생성하는 것보다 Connection Pool을 사용하는 것이 일반적이다.
Node.js -> Connection Pool -> PostgreSQL
Pool이 여러 DB Connection을 관리하고 애플리케이션은 필요한 Connection을 빌려 사용한 뒤 반환한다.
ORM
Node.js에서 PostgreSQL을 사용할 때 SQL을 직접 작성하는 방법 외에 ORM을 사용할 수도 있다.
대표적으로 Prisma, TypeORM 등이 있다.
기본적인 구조는 다음과 같다.
Node.js -> ORM -> PostgreSQL
ORM을 사용하면 애플리케이션의 객체와 Database Table 사이의 Mapping을 관리할 수 있고 Migration이나 Type 관리 같은 기능도 함께 사용할 수 있다.
반대로 복잡한 Query나 데이터베이스의 세밀한 기능을 사용해야 하는 경우에는 Raw SQL이 더 적합할 수 있다.
ORM을 사용한다고 해서 SQL과 Database의 동작을 몰라도 되는 것은 아니다.
실제 성능 문제를 분석하려면 JOIN, Index, Transaction, Execution Plan 등을 이해해야 한다.
Migration
Database Schema도 애플리케이션 코드와 마찬가지로 계속 변경된다.
처음에는 다음과 같은 Table이었다고 하자.
users -> id / name
이후 Email Column을 추가할 수 있다.
users -> id / name / email
이런 Schema 변경을 코드와 함께 관리하는 것이 Migration이다.
기본적인 흐름은 다음과 같다.
Migration 001 -> users Table 생성 -> Migration 002 -> email Column 추가 -> Migration 003 -> Index 추가
Migration을 사용하면 개발 환경과 운영 환경의 Database Schema 변경 이력을 코드로 관리할 수 있다.
PostgreSQL과 MySQL
PostgreSQL을 이해할 때 단순히 MySQL의 대체품으로 생각하면 PostgreSQL의 특징을 놓치기 쉽다.
두 데이터베이스 모두 관계형 데이터베이스이고 다음과 같은 기본 개념을 공유한다.
Table
Primary Key
Foreign Key
JOIN
Index
Transaction
Constraint
SQL
따라서 기본적인 Database 구조와 SQL을 이해하고 있다면 다른 RDBMS로 넘어가는 과정 자체는 어렵지 않다.
차이는 세부 기능과 구현, 데이터 타입, Query 처리 능력, 확장 기능 등에서 나타난다.
PostgreSQL은 특히 다음과 같은 상황에서 강력한 선택지가 될 수 있다.
복잡한 관계형 데이터 -> PostgreSQL
복잡한 JOIN / Query -> PostgreSQL
강한 데이터 무결성 요구 -> PostgreSQL
Transaction 중심 시스템 -> PostgreSQL
관계형 데이터 + JSONB -> PostgreSQL
공간 데이터 -> PostgreSQL + PostGIS
다만 이것이 PostgreSQL만 가능한 기능이라는 의미는 아니다.
MySQL 역시 대부분의 기본적인 Database 기능을 제공한다.
결국 특정 Database가 무조건 우월한 것이 아니라 서비스의 데이터 구조와 Query 패턴, 운영 환경에 맞는 Database를 선택하는 것이 중요하다.
PostgreSQL의 동시성
서버에서는 여러 요청이 동시에 Database에 접근한다.
Request A -> PostgreSQL
Request B -> PostgreSQL
Request C -> PostgreSQL
Request D -> PostgreSQL
이때 같은 데이터를 동시에 읽거나 수정할 수 있다.
PostgreSQL은 Transaction과 MVCC 등을 이용해서 이런 동시성 상황을 처리한다.
개념적으로 보면 다음과 같이 연결된다.
Concurrent Request -> Transaction -> MVCC -> Lock -> Data Consistency
따라서 Database 성능을 단순히 Query 하나의 실행 시간으로만 판단해서는 안 된다.
Transaction이 너무 오래 유지되거나 불필요한 Lock이 발생하면 다른 요청의 처리에도 영향을 줄 수 있다.
PostgreSQL 전체 구조
지금까지의 내용을 전체적으로 연결하면 다음과 같다.
Application -> SQL / ORM -> PostgreSQL -> Database -> Schema -> Table -> Row / Column
Table에는 다음과 같은 데이터 관리 요소가 연결된다.
Table -> Primary Key / Foreign Key / Constraint / Index
Query는 다음과 같이 처리된다.
SQL -> SELECT / INSERT / UPDATE / DELETE -> JOIN -> Execution Plan -> Index
동시성 처리는 다음과 같이 연결된다.
Transaction -> Isolation -> MVCC -> Lock -> Consistency
PostgreSQL의 확장 기능은 다음과 같이 연결할 수 있다.
PostgreSQL -> JSONB / Array / Function / Trigger / Extension / PostGIS
Node.js 서버까지 포함하면 전체적인 구조는 다음과 같다.
Client -> Node.js API Server -> Service -> Repository / ORM -> PostgreSQL
Connection Pool까지 포함하면 다음과 같다.
Client -> Node.js API Server -> Connection Pool -> PostgreSQL
정리
PostgreSQL은 단순히 데이터를 저장하는 SQL 서버가 아니다.
Table을 이용해서 데이터를 구조화하고 Primary Key와 Foreign Key로 관계를 표현하며 Constraint를 이용해 데이터 무결성을 관리한다.
Index를 이용해 Query 성능을 개선하고 EXPLAIN을 통해 실제 실행 계획을 분석할 수 있다.
Transaction과 MVCC를 통해 여러 요청이 동시에 데이터를 처리하는 상황을 관리하고, VACUUM을 통해 PostgreSQL의 Row Version을 정리한다.
여기에 JSONB, Array, Function, Trigger, Extension, PostGIS 같은 기능까지 제공하기 때문에 전통적인 관계형 데이터뿐만 아니라 다양한 형태의 데이터를 하나의 시스템에서 처리할 수 있다.
Node.js 같은 애플리케이션 서버와 연결하면 일반적으로 다음과 같은 구조가 된다.
Client -> Node.js -> Service -> Repository / ORM -> PostgreSQL
PostgreSQL을 제대로 사용하려면 SQL 문법만 익히는 것으로는 부족하다.
핵심 흐름은 다음과 같이 볼 수 있다.
데이터 모델링 -> Constraint -> Query -> Index -> Execution Plan -> Transaction -> MVCC -> VACUUM
결국 PostgreSQL의 핵심은 데이터를 어떻게 모델링하고, 무결성을 어떻게 보장하며, Query를 어떻게 실행하고, 동시성을 어떻게 제어할 것인가에 있다.
Database를 단순한 저장 공간으로 보면 SQL 문법만 보게 된다.
하지만 서버 시스템의 일부로 보면 Table, Constraint, Index, Query, Transaction, MVCC가 하나의 연결된 구조로 보이기 시작한다.