기존 84강좌는 수습용으로 유지하고, 헤더 셀렉트 박스로 과정을 전환한다. 과정 구성 (각 12강좌, 배우는 순서대로): - 초급 — 코딩 첫걸음: 프로그램의 개념→HTML→JS→변수·조건·반복·함수→에러읽기→첫 게임 - 중급 — 실전 웹 개발: 비동기·배열→API설계→Express CRUD→DB·트랜잭션→인증→React 실전→테스트→배포 - 고급 — 아키텍처·성능·보안: 레이어드·패턴→인덱스·격리→캐싱·최적화→OWASP·인증설계→관측·무중단배포 - 특급 — 대규모 시스템: 분산기초·CAP→메시지큐→MSA→샤딩→대용량 설계→k8s→SRE→시스템설계 3연작→ADR 구현: - trackContext(localStorage 유지) + 헤더 TrackSelect + 테스트 5개 - levelCatalog(lv1-~lv4- 슬러그, 기존과 충돌 없음) + 라우트·퀴즈 자동 연결 - LearnHubPage 과정 인식(과정별 인사말·진도), CertificatePage 분모 버그 선제 수정 - 퀴즈 240문항: quizzes.json 415→655 + 운영 DB 삽입(655/131 확인) 생성 파이프라인: 설계 4 → 작성 48(병렬) → 검수 4 에이전트. 검수 BLOCKER 2건(백틱 충돌·OpenAPI 표기) 수정 후 빌드 통과. 운영 검증: 4개 과정 전환·강좌 렌더·퀴즈 로드·선택 유지 브라우저 확인, 스모크 11/11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
546 lines
32 KiB
JavaScript
546 lines
32 KiB
JavaScript
// 이 파일이 하는 일: "DB 연동과 트랜잭션: 데이터가 사는 집" 코스 — 서버를 재시작하면
|
||
// 증발하는 메모리 배열에서 출발해, 테이블 설계·SQL 실행·CRUD·JOIN을 지나
|
||
// all-or-nothing 트랜잭션과 롤백 실습까지 7개 섹션으로 안내하는 정적 학습 페이지.
|
||
// 프레이밍은 "블로그 API의 저장소 교체" — 이전 과정에서 만든 메모리 배열 기반 블로그 API를
|
||
// 그대로 들고 와서, 저장소만 PostgreSQL로 갈아 끼우며 DB의 존재 이유를 몸으로 익힌다.
|
||
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
|
||
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
|
||
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
|
||
|
||
import { Link } from 'react-router-dom';
|
||
|
||
// ── 예제 코드 상수들 ──
|
||
|
||
const CODE_MEMORY_LOSS = `// server.js — 지금까지의 블로그 API (저장소 = 메모리 배열)
|
||
let posts = []; // "데이터베이스"라고 부르기엔 민망한, 그냥 변수
|
||
let nextId = 1;
|
||
|
||
app.post('/api/posts', (req, res) => {
|
||
const post = { id: nextId++, title: req.body.title, body: req.body.body };
|
||
posts.push(post); // 배열에 넣는다 = RAM에 넣는다
|
||
res.status(201).json(post);
|
||
});
|
||
|
||
// 직접 목격하기:
|
||
// 1) POST로 글을 3개 만든다 → GET /api/posts 에 3개가 보인다
|
||
// 2) 터미널에서 Ctrl+C → node server.js 로 서버 재시작
|
||
// 3) GET /api/posts → [] ...... 글이 전부 증발했다
|
||
//
|
||
// 이유: 변수 posts는 프로세스의 메모리(RAM) 위에 산다.
|
||
// 프로세스가 죽으면 메모리도 반납된다. 데이터의 집이 아니라 임시 텐트였던 것.`;
|
||
|
||
const CODE_SCHEMA = `-- schema.sql — 글·댓글·사용자를 테이블로 옮기기
|
||
CREATE TABLE users (
|
||
id BIGSERIAL PRIMARY KEY, -- PK: 이 행의 '주민번호'
|
||
name VARCHAR(50) NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE posts (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
author_id BIGINT NOT NULL REFERENCES users(id), -- FK: users로 가는 연결고리
|
||
title VARCHAR(200) NOT NULL,
|
||
body TEXT NOT NULL,
|
||
comment_count INT NOT NULL DEFAULT 0, -- 6·7섹션 실습에서 트랜잭션으로 갱신할 값
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE comments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
post_id BIGINT NOT NULL REFERENCES posts(id), -- 댓글 N개 → 글 1개 (1:N)
|
||
author_id BIGINT NOT NULL REFERENCES users(id),
|
||
body TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- 1:N 관계 읽는 법: "comments.post_id가 posts.id를 가리킨다"
|
||
-- = 글 하나에 댓글 여러 개가 매달린다. 관계는 '외래키 컬럼 하나'로 표현된다.`;
|
||
|
||
const CODE_POOL = `// db.js — 커넥션 풀: 미리 뚫어 둔 전화선 여러 가닥
|
||
import pg from 'pg';
|
||
|
||
export const pool = new pg.Pool({
|
||
host: 'localhost',
|
||
database: 'blog',
|
||
user: 'blog',
|
||
password: process.env.DB_PASSWORD, // 비밀번호는 코드에 두지 않는다
|
||
max: 10, // 동시에 유지할 연결 수
|
||
});
|
||
|
||
// 왜 풀인가?
|
||
// DB 연결(connect)은 TCP 접속 + 인증을 거치는 '비싼' 작업이다.
|
||
// 요청마다 새로 연결하면: 요청 100개 = 연결 100번 = 느리고, DB도 지친다.
|
||
// 풀은 연결 10개를 미리 만들어 두고 빌려 줬다 돌려받는다.
|
||
// 요청 100개가 와도 연결은 10개를 돌려 쓴다 — 식당의 '회전하는 테이블'처럼.
|
||
|
||
const result = await pool.query('SELECT now()'); // 빌리고-쓰고-반납을 알아서
|
||
console.log(result.rows[0]);`;
|
||
|
||
const CODE_INJECTION = `// 위험한 코드 — 문자열 조립으로 SQL을 만드는 순간, 남이 내 SQL을 쓴다
|
||
app.get('/api/users/search', async (req, res) => {
|
||
const name = req.query.name;
|
||
// 절대 금지: 사용자 입력을 SQL 문장에 '이어붙이기'
|
||
const sql = "SELECT * FROM users WHERE name = '" + name + "'";
|
||
const result = await pool.query(sql);
|
||
res.json(result.rows);
|
||
});
|
||
|
||
// 공격자가 보낸 name: ' OR '1'='1
|
||
// 완성되는 SQL: SELECT * FROM users WHERE name = '' OR '1'='1'
|
||
// → '1'='1'은 항상 참 → 전체 사용자가 통째로 유출된다.
|
||
// name에 '; DROP TABLE users; -- 를 넣으면? 테이블이 날아갈 수도 있다.
|
||
|
||
// 안전한 코드 — 파라미터 바인딩($1): 값은 값의 자리로만
|
||
const result2 = await pool.query(
|
||
'SELECT * FROM users WHERE name = $1', // SQL의 구조는 여기서 확정
|
||
[name] // 값은 별도 전달 → 문장이 될 수 없음
|
||
);
|
||
// DB가 "이건 어떤 내용이든 그냥 문자열 값"으로 취급하므로
|
||
// OR이든 DROP이든 전부 '이름 글자'일 뿐이다. 인젝션이 원천 차단된다.`;
|
||
|
||
const CODE_CRUD = `// posts 라우터 — 메모리 배열 CRUD를 SQL로 다시 쓰기
|
||
|
||
// C: INSERT ... RETURNING — 만든 결과(자동 생성된 id 포함)를 바로 돌려받기
|
||
app.post('/api/posts', async (req, res) => {
|
||
const { title, body, authorId } = req.body;
|
||
const result = await pool.query(
|
||
'INSERT INTO posts (author_id, title, body) VALUES ($1, $2, $3) RETURNING *',
|
||
[authorId, title, body]
|
||
);
|
||
res.status(201).json(result.rows[0]); // id·created_at까지 채워진 완성본
|
||
});
|
||
|
||
// U: UPDATE — WHERE를 빼먹으면 '모든 글'이 같은 제목이 된다 (실제 사고 단골)
|
||
// UPDATE posts SET title = $1 ← 전 행 수정. 참사.
|
||
// UPDATE posts SET title = $1 WHERE id=$2 ← 이 한 줄이 회사를 살린다
|
||
app.put('/api/posts/:id', async (req, res) => {
|
||
const result = await pool.query(
|
||
'UPDATE posts SET title = $1, body = $2 WHERE id = $3',
|
||
[req.body.title, req.body.body, req.params.id]
|
||
);
|
||
if (result.rowCount === 0) return res.status(404).json({ error: '없는 글' });
|
||
res.json({ ok: true }); // rowCount: 실제로 영향받은 행 수 → 0이면 그 id가 없다
|
||
});
|
||
|
||
// D: DELETE — 같은 원리. rowCount로 "지울 게 있었는지"를 판단
|
||
app.delete('/api/posts/:id', async (req, res) => {
|
||
const result = await pool.query('DELETE FROM posts WHERE id = $1', [req.params.id]);
|
||
if (result.rowCount === 0) return res.status(404).json({ error: '없는 글' });
|
||
res.status(204).end();
|
||
});`;
|
||
|
||
const CODE_JOIN = `-- 화면: "글 목록 + 작성자 이름 + 댓글 수" — 쿼리 한 방으로
|
||
|
||
-- ① 작성자 이름 붙이기: INNER JOIN (FK를 따라 두 테이블을 이어 붙인다)
|
||
SELECT p.id, p.title, u.name AS author_name
|
||
FROM posts p
|
||
INNER JOIN users u ON u.id = p.author_id;
|
||
-- "p.author_id가 가리키는 users 행을 찾아 옆에 붙여라"
|
||
|
||
-- ② 댓글 수까지: LEFT JOIN + GROUP BY
|
||
SELECT p.id, p.title, u.name AS author_name,
|
||
COUNT(c.id) AS comment_count
|
||
FROM posts p
|
||
INNER JOIN users u ON u.id = p.author_id
|
||
LEFT JOIN comments c ON c.post_id = p.id -- 댓글 0개인 글도 목록엔 나와야 하니 LEFT
|
||
GROUP BY p.id, u.name -- 글 단위로 접어서(COUNT) 센다
|
||
ORDER BY p.created_at DESC;`;
|
||
|
||
const CODE_N_PLUS_ONE = `// N+1 문제 — 눈으로 확인하기
|
||
// "글 목록을 가져온 뒤, 글마다 작성자를 또 조회"하는 순진한 코드:
|
||
const posts = (await pool.query('SELECT * FROM posts')).rows; // 쿼리 1번
|
||
for (const post of posts) {
|
||
const author = await pool.query( // 글마다 +1번
|
||
'SELECT name FROM users WHERE id = $1', [post.author_id]
|
||
);
|
||
post.authorName = author.rows[0].name;
|
||
}
|
||
// 글이 100개면? 1 + 100 = 101번의 쿼리. 글이 늘수록 선형으로 느려진다.
|
||
// DB 로그를 켜고(postgresql.conf: log_statement = 'all') 위 코드를 돌려 보면
|
||
// SELECT가 폭포처럼 쏟아지는 걸 직접 볼 수 있다.
|
||
|
||
// 해결: 섹션 5의 JOIN 한 방이면 쿼리 1번으로 끝
|
||
const rows = (await pool.query(
|
||
'SELECT p.*, u.name AS author_name FROM posts p JOIN users u ON u.id = p.author_id'
|
||
)).rows;
|
||
// 반복문 속의 await pool.query(...)가 보이면 일단 의심할 것.`;
|
||
|
||
const CODE_TRANSFER = `-- 계좌이체: 트랜잭션이 없으면 생기는 일
|
||
-- A가 B에게 10,000원을 보낸다 = 사실은 UPDATE 두 번이다
|
||
UPDATE accounts SET balance = balance - 10000 WHERE id = 'A'; -- ① A에서 빼고
|
||
-- ← 만약 서버가 정확히 여기서 죽는다면?
|
||
UPDATE accounts SET balance = balance + 10000 WHERE id = 'B'; -- ② B에 더한다
|
||
|
||
-- ①만 실행되고 죽으면: A의 돈은 사라졌는데 B는 못 받았다. 10,000원 증발.
|
||
-- 이 둘은 "함께 성공하거나, 함께 없던 일"이어야 한다 = all-or-nothing.
|
||
|
||
BEGIN; -- 여기부터 한 묶음
|
||
UPDATE accounts SET balance = balance - 10000 WHERE id = 'A';
|
||
UPDATE accounts SET balance = balance + 10000 WHERE id = 'B';
|
||
COMMIT; -- 둘 다 성공했을 때만 '진짜로' 반영
|
||
-- 중간에 무엇이 잘못되든 ROLLBACK; 하면 BEGIN 이전으로 완전 복귀.
|
||
-- COMMIT 전의 변경은 '연필로 쓴 초안', COMMIT이 '볼펜으로 덮어쓰기'다.`;
|
||
|
||
const CODE_TX_PRACTICE = `// 실습 핵심 — '댓글 작성 + 글의 댓글수 갱신'을 하나의 트랜잭션으로
|
||
app.post('/api/posts/:id/comments', async (req, res) => {
|
||
const client = await pool.connect(); // 트랜잭션은 '같은 연결' 위에서만 성립!
|
||
try {
|
||
await client.query('BEGIN');
|
||
|
||
const comment = await client.query(
|
||
'INSERT INTO comments (post_id, author_id, body) VALUES ($1, $2, $3) RETURNING *',
|
||
[req.params.id, req.body.authorId, req.body.body]
|
||
);
|
||
const updated = await client.query(
|
||
'UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1',
|
||
[req.params.id]
|
||
);
|
||
if (updated.rowCount === 0) throw new Error('없는 글'); // → catch → ROLLBACK
|
||
|
||
await client.query('COMMIT'); // 둘 다 성공 → 한 번에 확정
|
||
res.status(201).json(comment.rows[0]);
|
||
} catch (e) {
|
||
await client.query('ROLLBACK'); // 하나라도 실패 → 둘 다 없던 일로
|
||
res.status(400).json({ error: e.message });
|
||
} finally {
|
||
client.release(); // 빌린 연결은 반드시 풀에 반납
|
||
}
|
||
});
|
||
// 주의: pool.query()를 섞어 쓰면 안 된다 — 풀이 매번 '다른 연결'을 빌려 줄 수 있어
|
||
// BEGIN과 INSERT가 서로 남남인 연결에서 실행될 수 있다. 트랜잭션 중엔 client만.`;
|
||
|
||
const CODE_ROLLBACK_TEST = `// 실패 시나리오 실습 ① — 일부러 중간에 에러를 던져 롤백 확인
|
||
await client.query('BEGIN');
|
||
await client.query(
|
||
'INSERT INTO comments (post_id, author_id, body) VALUES ($1, $2, $3)',
|
||
[postId, authorId, '이 댓글은 저장되면 안 됩니다']
|
||
);
|
||
throw new Error('일부러 터뜨림'); // ← COMMIT에 도달하지 못한다
|
||
// catch에서 ROLLBACK 실행 후, psql에서 확인:
|
||
// SELECT count(*) FROM comments; → 늘어나지 않았다. INSERT가 없던 일이 됐다.
|
||
|
||
-- 실습 ② — 커밋 전·후를 '다른 세션'에서 조회해 보기 (격리 맛보기)
|
||
-- [터미널1] psql │ [터미널2] psql (또 하나의 접속 = 다른 세션)
|
||
-- BEGIN; │
|
||
-- INSERT INTO comments │ SELECT count(*) FROM comments;
|
||
-- (...) VALUES (...); │ → 안 보인다! 아직 '초안'이라 남들 눈엔 없음
|
||
-- COMMIT; │ SELECT count(*) FROM comments;
|
||
-- │ → 이제 보인다. COMMIT이 '공개' 스위치다.
|
||
-- 이렇게 각 트랜잭션이 서로의 미완성 작업을 보지 못하게 하는 성질이 '격리(isolation)'.`;
|
||
|
||
// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
|
||
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
|
||
function Section({ n, title, sub, children }) {
|
||
return (
|
||
<section className="step-card" id={`sec-${n}`}>
|
||
<div className="step-head">
|
||
<span className="step-num">{n}</span>
|
||
<div>
|
||
<h3>{title}</h3>
|
||
{sub && <span className="step-sub">{sub}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="step-body">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Code({ children }) {
|
||
return <pre className="code-block"><code>{children}</code></pre>;
|
||
}
|
||
|
||
const SECTIONS = [
|
||
{ n: 1, label: '왜 DB인가' },
|
||
{ n: 2, label: '테이블 설계' },
|
||
{ n: 3, label: 'Node에서 SQL' },
|
||
{ n: 4, label: 'CRUD를 SQL로' },
|
||
{ n: 5, label: 'JOIN' },
|
||
{ n: 6, label: '트랜잭션' },
|
||
{ n: 7, label: '실패 시나리오' },
|
||
];
|
||
|
||
export default function Lv2DbTransactionsPage() {
|
||
return (
|
||
<div>
|
||
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
|
||
<div className="hero">
|
||
<div className="eyebrow">Course · 중급 과정</div>
|
||
<h1>DB 연동과 트랜잭션:<br />데이터가 사는 집</h1>
|
||
<p>
|
||
지금까지 우리 블로그 API의 데이터는 <strong>서버의 변수</strong> 안에 살았어요.
|
||
서버를 껐다 켜면? 전부 증발합니다. 이 코스에서는 그 데이터를{' '}
|
||
<strong>PostgreSQL</strong>이라는 진짜 집으로 이사시키고, "댓글도 달렸는데 댓글수는
|
||
안 올라간" 어정쩡한 상태가 생기지 않도록 <strong>트랜잭션(all-or-nothing)</strong>
|
||
으로 묶는 법까지 익힙니다.
|
||
</p>
|
||
<div className="chip-row">
|
||
<span className="chip">예상 소요 80분</span>
|
||
<span className="chip">실습: 메모리 배열 → PostgreSQL 교체</span>
|
||
<span className="chip">선수 지식: JS 기초 · HTTP API</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 섹션 바로가기 */}
|
||
<nav className="pill-nav">
|
||
{SECTIONS.map((s) => (
|
||
<a key={s.n} href={`#sec-${s.n}`}>
|
||
<b>{s.n}</b>
|
||
{s.label}
|
||
</a>
|
||
))}
|
||
</nav>
|
||
|
||
<Section n={1} title="왜 메모리가 아니라 DB인가" sub="서버 재시작 한 번이면 알게 되는 것">
|
||
<p>
|
||
백문이 불여일견이에요. 지금 만든 블로그 API에 글을 몇 개 올린 다음,{' '}
|
||
<strong>서버를 껐다 켜 보세요.</strong> 정성 들여 쓴 글이 흔적도 없이 사라집니다.
|
||
변수는 프로세스의 <strong>메모리(RAM)</strong> 위에 살고, 프로세스가 죽으면 메모리는
|
||
운영체제에 반납되니까요.
|
||
</p>
|
||
<Code>{CODE_MEMORY_LOSS}</Code>
|
||
<p>
|
||
데이터베이스는 이 문제를 포함해 크게 <strong>세 가지</strong>를 해결해 줍니다.{' '}
|
||
<strong>① 영속성</strong> — 데이터를 디스크에 안전하게 기록해서, 서버가 죽든 정전이
|
||
나든 살아남게 합니다. <strong>② 동시성</strong> — 사용자 100명이 동시에 글을 쓰고
|
||
지워도 데이터가 꼬이지 않게 교통정리를 해 줍니다(배열에 동시에 push하는 상황을
|
||
상상해 보세요). <strong>③ 검색</strong> — "제목에 'DB'가 들어간 글을 최신순으로
|
||
10개"를, 데이터가 백만 건이어도 순식간에 찾아 줍니다. 배열이었다면 매번 전체를
|
||
훑어야 했겠죠.
|
||
</p>
|
||
<p>
|
||
그중에서도 우리는 <strong>관계형 DB(PostgreSQL)</strong>를 선택합니다. 기준은
|
||
단순해요. 우리 데이터는 글·댓글·사용자처럼 <strong>서로 관계를 맺는 정형
|
||
데이터</strong>이고, "댓글수와 실제 댓글이 어긋나면 안 된다" 같은{' '}
|
||
<strong>정합성</strong>이 중요하며, 뒤에서 배울 <strong>트랜잭션</strong>이
|
||
필요하니까요. 이 세 조건에 관계형 DB만큼 검증된 도구가 없습니다.
|
||
</p>
|
||
<div className="tip">
|
||
<b>비유</b> 메모리 배열은 <strong>화이트보드</strong>예요 — 쓰긴 빠르지만 퇴근할 때
|
||
지워질 수 있죠. DB는 <strong>등기소의 장부</strong>입니다. 기록이 남고, 여러 창구가
|
||
동시에 써도 관리되고, 색인으로 빨리 찾을 수 있어요. 서비스의 데이터는 화이트보드가
|
||
아니라 장부에 살아야 합니다.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={2} title="테이블 설계와 관계" sub="글·댓글·사용자를 표로 옮기기">
|
||
<p>
|
||
관계형 DB의 세계에서 데이터는 <strong>테이블(표)</strong>에 삽니다. 우리 블로그의
|
||
등장인물은 셋 — <strong>사용자, 글, 댓글</strong>. 각각을 표 하나씩으로 옮기고,
|
||
그 사이의 관계를 선으로 잇는 게 설계의 전부예요.
|
||
</p>
|
||
<Code>{CODE_SCHEMA}</Code>
|
||
<p>
|
||
핵심 개념 두 개만 잡으면 됩니다. <strong>PK(기본키, Primary Key)</strong>는 각 행의{' '}
|
||
<strong>주민번호</strong>예요. 같은 이름의 사용자가 두 명이어도{' '}
|
||
<span className="icode">id</span>는 절대 겹치지 않아서, "그 행"을 정확히 집어낼 수
|
||
있죠. <strong>FK(외래키, Foreign Key)</strong>는 다른 테이블의 PK를 가리키는{' '}
|
||
<strong>연결고리</strong>입니다. <span className="icode">comments.post_id</span>가{' '}
|
||
<span className="icode">posts.id</span>를 가리키는 순간, "이 댓글은 저 글의 것"이라는
|
||
관계가 데이터에 새겨져요.
|
||
</p>
|
||
<p>
|
||
"글 하나에 댓글 여러 개"처럼 <strong>1:N 관계</strong>는 항상{' '}
|
||
<strong>N 쪽 테이블에 외래키 컬럼 하나</strong>를 두는 것으로 표현합니다. 댓글(N)이
|
||
자기 몸에 <span className="icode">post_id</span>를 적어 두는 거예요. 글(1) 쪽에
|
||
"내 댓글 목록"을 배열로 넣지 않는다는 점이 배열 시절과 가장 다른 사고방식입니다.
|
||
</p>
|
||
<div className="tip">
|
||
<b>FK의 보너스</b> <span className="icode">REFERENCES posts(id)</span>라고 선언해
|
||
두면, DB가 <strong>존재하지 않는 글에 댓글 다는 것을 스스로 거부</strong>해 줍니다.
|
||
앱 코드가 실수해도 데이터의 마지막 방어선이 지켜지는 셈 — 이런 걸{' '}
|
||
<strong>참조 무결성</strong>이라고 불러요.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={3} title="Node에서 SQL 실행하기" sub="커넥션 풀, 그리고 $1이 지키는 것">
|
||
<p>
|
||
이제 Node 서버에서 PostgreSQL에 말을 걸 차례예요. 공식에 가까운 드라이버인{' '}
|
||
<span className="icode">pg</span> 패키지를 쓰는데, 시작부터 습관 하나를 제대로
|
||
들입시다 — 연결은 <strong>커넥션 풀(Pool)</strong>로.
|
||
</p>
|
||
<Code>{CODE_POOL}</Code>
|
||
<p>
|
||
DB 연결 한 번에는 TCP 접속과 인증이라는 비용이 들어요. 요청이 올 때마다 새로
|
||
연결하면 그 비용을 매번 치르고, DB 쪽도 연결 수가 폭증해 버팁니다. 풀은{' '}
|
||
<strong>연결 몇 개를 미리 만들어 두고 빌려 줬다 돌려받는</strong> 구조라, 요청이
|
||
아무리 많아도 연결 수는 일정하게 유지돼요.
|
||
</p>
|
||
<p>
|
||
그리고 이 코스에서 <strong>가장 중요한 보안 습관</strong>이 나옵니다. SQL에 사용자
|
||
입력을 넣을 땐 반드시 <strong>파라미터 바인딩(<span className="icode">$1</span>,{' '}
|
||
<span className="icode">$2</span>…)</strong>을 쓰세요. 문자열을 이어붙여 SQL을 만드는
|
||
순간, 사용자가 <strong>내 SQL 문장을 대신 쓰게</strong> 됩니다.
|
||
</p>
|
||
<Code>{CODE_INJECTION}</Code>
|
||
<div className="warn">
|
||
<b>SQL 인젝션은 이론이 아니다</b> 위 시연처럼{' '}
|
||
<span className="icode">' OR '1'='1</span> 한 줄이면 전체 데이터가 유출되고,{' '}
|
||
<span className="icode">DROP TABLE</span>이 섞이면 테이블이 통째로 사라질 수 있어요.
|
||
실제 대형 유출 사고의 단골 원인입니다. 방어법은 외우지 않아도 될 만큼 단순해요 —{' '}
|
||
<strong>SQL 문장과 값을 절대 한 문자열로 섞지 않는다.</strong> 값은 언제나{' '}
|
||
<span className="icode">$1</span> 자리로만. DB는 바인딩된 값을 "어떤 내용이든 그냥
|
||
값"으로 취급하므로 문장 구조를 바꿀 수 없습니다.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={4} title="CRUD를 SQL로 다시 쓰기" sub="배열 메서드를 SQL 문장으로 번역하기">
|
||
<p>
|
||
준비가 끝났으니 본격 이사입니다. 배열 시절의{' '}
|
||
<span className="icode">push</span> · <span className="icode">find</span> ·{' '}
|
||
<span className="icode">splice</span>를 각각{' '}
|
||
<span className="icode">INSERT</span> · <span className="icode">SELECT</span> ·{' '}
|
||
<span className="icode">UPDATE</span>/<span className="icode">DELETE</span>로
|
||
번역해요. 라우터의 겉모양(URL·상태코드)은 그대로 두고 <strong>속만 갈아
|
||
끼우는</strong> 게 포인트 — API를 쓰는 쪽에서는 이사한 걸 눈치채지 못해야 합니다.
|
||
</p>
|
||
<Code>{CODE_CRUD}</Code>
|
||
<p>
|
||
새로 익힐 무기가 둘 있어요. 첫째, <strong><span className="icode">INSERT ...
|
||
RETURNING *</span></strong> — 방금 만든 행을 (DB가 채워 준{' '}
|
||
<span className="icode">id</span>·<span className="icode">created_at</span>까지 포함해)
|
||
그 자리에서 돌려받습니다. INSERT 후 다시 SELECT할 필요가 없죠. 둘째,{' '}
|
||
<strong><span className="icode">rowCount</span></strong> — 쿼리가 실제로 영향을 준
|
||
행의 수예요. UPDATE·DELETE에서 이 값이 <span className="icode">0</span>이면 "그런
|
||
id의 글은 없었다"는 뜻이니, 그대로 <strong>404</strong>로 응답하면 됩니다. 배열
|
||
시절의 <span className="icode">find()</span>가{' '}
|
||
<span className="icode">undefined</span>를 돌려주던 것과 같은 역할이에요.
|
||
</p>
|
||
<div className="warn">
|
||
<b>WHERE 없는 UPDATE — 전설의 참사</b>{' '}
|
||
<span className="icode">UPDATE posts SET title = '테스트'</span>처럼{' '}
|
||
<strong>WHERE를 빼먹으면 테이블의 모든 행</strong>이 바뀝니다. DELETE도 마찬가지 —
|
||
WHERE 없는 DELETE 한 줄로 전체 데이터가 사라진 사고가 실제로 심심찮게 일어나요.
|
||
습관 하나로 예방합시다: UPDATE·DELETE를 쓸 땐 <strong>WHERE부터 먼저 쓰고</strong>{' '}
|
||
앞부분을 채우기. 그리고 운영 DB에서 손으로 실행할 땐 다음 섹션의 트랜잭션으로 감싸고
|
||
확인 후 COMMIT하는 것이 안전벨트입니다.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={5} title="JOIN으로 화면 데이터 한 번에" sub="글 목록 + 작성자 이름 + 댓글 수를 쿼리 한 방에">
|
||
<p>
|
||
블로그 목록 화면을 떠올려 보세요. 글 제목 옆에 <strong>작성자 이름</strong>이 있고,
|
||
아래엔 <strong>댓글 수</strong>가 붙죠. 그런데 우리 테이블에서 이름은{' '}
|
||
<span className="icode">users</span>에, 댓글은 <span className="icode">comments</span>에
|
||
살아요. 흩어진 데이터를 <strong>FK를 따라 이어 붙이는</strong> 도구가{' '}
|
||
<strong>JOIN</strong>입니다.
|
||
</p>
|
||
<Code>{CODE_JOIN}</Code>
|
||
<p>
|
||
<strong>INNER JOIN</strong>은 "<span className="icode">p.author_id</span>가 가리키는{' '}
|
||
<span className="icode">users</span> 행을 찾아 옆에 붙여라"예요. 섹션 2에서 심어 둔
|
||
연결고리(FK)가 여기서 길이 됩니다. 댓글 수는{' '}
|
||
<strong>LEFT JOIN + GROUP BY</strong> 조합 — 글마다 매달린 댓글들을 글 단위로
|
||
접어서(<span className="icode">GROUP BY p.id</span>){' '}
|
||
<span className="icode">COUNT</span>로 셉니다. 댓글이 0개인 글도 목록에는 나와야
|
||
하니, 짝이 없어도 행을 남겨 주는 <strong>LEFT</strong> JOIN을 쓴다는 점도 눈여겨
|
||
두세요(INNER였다면 댓글 없는 글이 목록에서 사라집니다).
|
||
</p>
|
||
<p>
|
||
JOIN을 모르면 자연스럽게 이런 코드를 쓰게 돼요 — "글 목록을 가져온 다음, 글마다
|
||
작성자를 또 조회". 이것이 악명 높은 <strong>N+1 쿼리 문제</strong>입니다.
|
||
</p>
|
||
<Code>{CODE_N_PLUS_ONE}</Code>
|
||
<div className="tip">
|
||
<b>냄새 감지법</b> <strong>반복문 안의 <span className="icode">await
|
||
pool.query(...)</span></strong>가 보이면 일단 N+1을 의심하세요. 데이터 10개일 땐
|
||
멀쩡하다가 1,000개가 되는 순간 느려지는 API의 단골 원인이에요. 해결의 방향은 늘
|
||
같습니다 — <strong>반복 조회를 JOIN(또는 IN 절) 한 방으로</strong> 합치기.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={6} title="트랜잭션: all-or-nothing" sub="함께 성공하거나, 함께 없던 일이거나">
|
||
<p>
|
||
이 코스의 심장부입니다. 질문 하나로 시작할게요 — <strong>계좌이체</strong>는 DB
|
||
입장에서 무엇일까요? "A에서 빼는 UPDATE"와 "B에 더하는 UPDATE",{' '}
|
||
<strong>두 번의 쓰기</strong>예요. 그런데 첫 번째만 실행된 순간 서버가 죽는다면?
|
||
</p>
|
||
<Code>{CODE_TRANSFER}</Code>
|
||
<p>
|
||
이런 "여러 개의 쓰기가 반드시 한 덩어리여야 하는" 상황을 위해 DB가 주는 도구가{' '}
|
||
<strong>트랜잭션</strong>입니다. <span className="icode">BEGIN</span>으로 묶음을
|
||
열고, 그 안의 변경은 <strong>연필로 쓴 초안</strong> 상태로 쌓이다가,{' '}
|
||
<span className="icode">COMMIT</span>에서 한꺼번에 확정돼요. 중간에 무엇이든
|
||
잘못되면 <span className="icode">ROLLBACK</span> — <span className="icode">BEGIN</span>{' '}
|
||
이후의 모든 변경이 <strong>통째로 없던 일</strong>이 됩니다. 절반만 반영된 어정쩡한
|
||
상태는 존재할 수 없어요. 그래서 <strong>all-or-nothing</strong>.
|
||
</p>
|
||
<p>
|
||
우리 블로그에도 똑같은 구조가 있어요 — 실습 과제인 <strong>"댓글 작성 + 글의
|
||
댓글수(<span className="icode">comment_count</span>) 갱신"</strong>. 댓글 INSERT만
|
||
되고 댓글수 UPDATE가 실패하면, 화면엔 "댓글 3개"라는데 실제론 4개가 달린 이상한
|
||
글이 됩니다. 묶읍시다.
|
||
</p>
|
||
<Code>{CODE_TX_PRACTICE}</Code>
|
||
<div className="warn">
|
||
<b>트랜잭션은 '같은 연결' 위에서만</b> 풀에서 빌린{' '}
|
||
<span className="icode">client</span> 하나로 <span className="icode">BEGIN</span>부터{' '}
|
||
<span className="icode">COMMIT</span>까지 전부 실행해야 해요. 중간에{' '}
|
||
<span className="icode">pool.query()</span>를 섞으면 풀이 <strong>다른 연결</strong>을
|
||
빌려 줄 수 있고, 그 쿼리는 내 트랜잭션 바깥에서 실행됩니다. 그리고{' '}
|
||
<span className="icode">finally</span>의 <span className="icode">client.release()</span>를
|
||
잊으면 연결이 반납되지 않아, 풀이 말라붙어 서버 전체가 멈춰요.
|
||
</div>
|
||
<div className="tip">
|
||
<b>경계를 정하는 감각</b> 트랜잭션은 "<strong>이것들이 반쪽만 반영되면 데이터가
|
||
거짓말을 하게 되는가?</strong>"로 경계를 긋습니다. 댓글 INSERT와 댓글수 UPDATE →
|
||
반쪽이면 거짓말 → 한 트랜잭션. 반면 "댓글 작성 + 조회수 로그 남기기"처럼 하나가
|
||
실패해도 다른 하나가 유효한 작업까지 욕심내서 묶으면, 로그 실패가 댓글 작성까지
|
||
취소시키는 <strong>과잉 결합</strong>이 돼요. 필요한 만큼만, 짧게 — 가 원칙입니다.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section n={7} title="실패 시나리오 실습" sub="일부러 부수면서 롤백과 격리를 체감하기">
|
||
<p>
|
||
트랜잭션은 성공 경로만 봐서는 절대 체득되지 않아요. <strong>일부러 부수는 것</strong>이
|
||
이번 섹션의 실습입니다. 두 가지를 눈으로 확인해 봅시다.
|
||
</p>
|
||
<Code>{CODE_ROLLBACK_TEST}</Code>
|
||
<p>
|
||
<strong>실습 ①</strong>은 INSERT 다음에 고의로 에러를 던져{' '}
|
||
<span className="icode">COMMIT</span>에 도달하지 못하게 하는 거예요. catch에서{' '}
|
||
<span className="icode">ROLLBACK</span>이 돌고 나면, 분명 INSERT가 실행됐는데도
|
||
테이블엔 아무 흔적이 없습니다. <strong>실습 ②</strong>는 psql 터미널을{' '}
|
||
<strong>두 개</strong> 열고, 한쪽에서 <span className="icode">BEGIN</span> 후
|
||
INSERT만 한 상태로 다른 쪽에서 조회해 보는 것. 커밋 전엔 <strong>안 보이고</strong>,
|
||
커밋하는 순간 보입니다. 각 트랜잭션이 서로의 미완성 작업을 보지 못하게 하는 이
|
||
성질이 <strong>격리(isolation)</strong>예요 — 깊은 격리 수준 이야기는 다음 기회에
|
||
다루고, 오늘은 "커밋 전의 변경은 남에게 없는 것"이라는 감각만 가져가면 충분합니다.
|
||
</p>
|
||
<p>
|
||
마지막으로 실무 감각 하나. <strong>트랜잭션 안에서 외부 API를 호출하면 안
|
||
됩니다.</strong> 이유는 두 겹이에요. 첫째, 트랜잭션이 열려 있는 동안 그 연결(그리고
|
||
DB가 잡은 잠금)은 <strong>계속 점유</strong>되는데, 외부 API가 3초 걸리면 그 3초
|
||
내내 다른 요청들이 줄을 서요. 느린 외부 호출 하나가 풀 전체를 말려 버릴 수 있습니다.
|
||
둘째, DB는 롤백이 되지만 <strong>외부 세계는 롤백이 안 돼요.</strong> 트랜잭션
|
||
안에서 알림 메일을 보냈는데 그 뒤 롤백되면? 댓글은 없던 일이 됐는데 메일은 이미
|
||
날아갔죠. 그래서 원칙은 — <strong>트랜잭션은 DB 작업만으로 짧게 닫고, 외부 호출은
|
||
커밋이 확정된 다음에.</strong>
|
||
</p>
|
||
<div className="tip">
|
||
<b>직접 확인해 보기</b> 실습 ②를 할 때, BEGIN만 하고 COMMIT을 안 한 채 psql을
|
||
그냥 꺼 보세요. PostgreSQL은 연결이 끊긴 트랜잭션을 <strong>자동으로 롤백</strong>
|
||
합니다 — "중간에 죽으면?"의 답이 바로 이거예요. 서버가 어떻게 죽든, 커밋에 도달하지
|
||
못한 변경은 DB가 알아서 없던 일로 만들어 줍니다.
|
||
</div>
|
||
</Section>
|
||
|
||
<div className="step-card">
|
||
<div className="step-body">
|
||
<h3 style={{ marginBottom: 6 }}>🗄️ 여기까지 왔다면</h3>
|
||
<p className="muted">
|
||
이제 우리 블로그의 데이터는 서버를 껐다 켜도 살아남는 <strong>진짜 집</strong>에
|
||
삽니다. 테이블과 관계(PK·FK), 커넥션 풀과 <strong>$1 바인딩</strong>, RETURNING과
|
||
rowCount, JOIN과 N+1, 그리고 <strong>all-or-nothing 트랜잭션</strong>까지 —
|
||
"돌아가는 API"를 "믿을 수 있는 API"로 끌어올리는 재료를 손에 쥐었어요. 그런데
|
||
데이터가 영원히 남는 순간, 새 질문이 따라옵니다. <strong>"이 요청을 보낸 사람이
|
||
누구인지"</strong>는 어떻게 믿죠? 비밀번호는 DB에 어떻게 저장해야 유출돼도
|
||
안전할까요? 다음{' '}
|
||
<Link to="/learn/lv2-auth-session-hash"><strong>로그인 구현: 세션과 비밀번호
|
||
해시</strong></Link> 코스에서, 이 집에 <strong>잠금장치</strong>를 달아 봅시다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|