mirim-app/frontend/src/pages/levels/Lv2DbTransactionsPage.jsx
AWESOMEDEV c5f875d990
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-build (push) Successful in 42s
CI / backend-dep-scan (push) Successful in 30s
feat(learn): 수준별 학습 과정 4종 — 초급·중급·고급·특급 48강좌 + 퀴즈 240문항
기존 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>
2026-07-20 12:13:25 +09:00

546 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 이 파일이 하는 일: "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>
);
}