feat(db): Flyway 마이그레이션 도입 (ddl-auto update → validate)
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-build (push) Successful in 23s
CI / backend-dep-scan (push) Successful in 29s

스키마를 '앱이 자동 변경'에서 '버전 관리되는 마이그레이션'으로 전환:
- flyway-core + flyway-database-postgresql 추가
- V1__baseline.sql: 현재 운영 스키마(테이블 13·제약 31·인덱스 1)를 그대로 박제
- ddl-auto: update → validate (Hibernate는 이제 검증만, 스키마는 Flyway 소유)
- baseline-on-migrate: 기존 운영 DB는 V1 미실행·baseline 표시, 신규 DB는 V1 실행

안전 절차로 진행:
- 도입 전 운영 DB 백업(pg_dump -Fc)
- 신규 DB 경로 검증(빈 DB→V1 실행→validate 통과→기동) 후 운영 배포
- 운영: 기존 스키마 baseline(무실행)→validate 통과→기동, 데이터 무손실(users=4·profile=3) 확인
- 테스트 잔재 pgcrypto 확장 제거

향후 스키마 변경은 V2, V3...로 의도를 남긴다. 엔티티만 바꾸고 마이그레이션을
안 쓰면 validate가 기동 시 실패해 알려 준다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-20 07:59:20 +09:00
parent 0e3ab14b14
commit db54b9bc19
3 changed files with 396 additions and 8 deletions

View File

@ -38,6 +38,17 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<!-- DB 마이그레이션 (Flyway) — 스키마 변경을 버전 관리한다.
학습 포인트: flyway-core는 엔진, flyway-database-postgresql은 Postgres 방언 지원.
(Flyway 10부터 DB별 지원이 별도 모듈로 분리됐다.) 버전은 Spring Boot BOM이 관리. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- 로그인/권한 (세션 기반) --> <!-- 로그인/권한 (세션 기반) -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@ -10,20 +10,27 @@ spring:
password: ${DB_PASSWORD:mirim123} password: ${DB_PASSWORD:mirim123}
jpa: jpa:
hibernate: hibernate:
# 학습 포인트: ddl-auto: update의 의미 — # 학습 포인트: 이제 스키마는 Flyway가 소유한다. Hibernate는 validate로 "엔티티와 실제
# 앱이 뜰 때 Hibernate가 엔티티(@Entity 클래스)와 실제 테이블을 비교해서 # 테이블이 일치하는지 검증"만 하고, 테이블을 만들거나 바꾸지 않는다.
# 없는 테이블/컬럼을 자동으로 만들어 준다(CREATE/ALTER를 대신 실행). # - update였을 때의 문제(컬럼 삭제 미반영·변경 이력 없음·조용한 드리프트)를 끝낸다.
# 개발 단계에선 편하지만 운영에선 위험하다: # - 엔티티를 바꿨는데 마이그레이션(Vn.sql)을 안 쓰면 validate가 기동 시 실패해 알려 준다
# - 컬럼 삭제/이름 변경은 반영하지 못해 엔티티와 테이블이 조용히 어긋난다. # → "엔티티와 DB를 함께 바꿔라"를 기계가 강제한다.
# - 어떤 DDL이 언제 실행됐는지 기록이 남지 않아 되돌리기가 불가능하다. ddl-auto: validate
# 운영에선 validate(검증만)로 바꾸고 Flyway 같은 마이그레이션 도구로 스키마를 관리한다.
ddl-auto: update # 개발 단계: 엔티티에 맞춰 테이블 자동 생성/수정
# open-in-view: 화면 렌더링이 끝날 때까지 DB 커넥션을 물고 있는 기능. REST API 서버에선 # open-in-view: 화면 렌더링이 끝날 때까지 DB 커넥션을 물고 있는 기능. REST API 서버에선
# 커넥션 낭비일 뿐이라 끈다. (끄면 LAZY 로딩은 반드시 @Transactional 안에서 끝내야 한다) # 커넥션 낭비일 뿐이라 끈다. (끄면 LAZY 로딩은 반드시 @Transactional 안에서 끝내야 한다)
open-in-view: false open-in-view: false
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
# Flyway — DB 스키마 마이그레이션(버전 관리). classpath의 db/migration/V*.sql을 순서대로 적용.
flyway:
# 학습 포인트: baseline-on-migrate —
# 이미 테이블이 있는(비어있지 않은) 기존 운영 DB엔 V1을 "실행"하지 않고 "이미 반영됨"으로
# 표시(baseline)만 한다. 안 그러면 V1의 CREATE TABLE이 "이미 존재"로 터진다.
# 빈 DB(신규/개발)에선 baseline이 일어나지 않고 V1부터 정상 실행돼 스키마를 만든다.
# → 같은 마이그레이션 파일로 "기존 운영 DB 편입"과 "신규 DB 생성"을 모두 안전하게 처리.
baseline-on-migrate: true
baseline-version: 1
server: server:
port: 8080 port: 8080

View File

@ -0,0 +1,370 @@
-- V1__baseline.sql — 운영 DB의 "현재 스키마"를 그대로 박제한 베이스라인.
--
-- 이 파일이 하는 일:
-- Flyway 도입 시점의 스키마 전체(테이블 13개·제약·인덱스)를 기록한다.
-- 이 시점 이후의 모든 스키마 변경은 V2, V3... 로 "의도를 남기며" 추가한다.
--
-- 학습 포인트 - 왜 ddl-auto:update에서 Flyway로 옮기나?
-- ddl-auto:update는 앱이 뜰 때 엔티티에 맞춰 테이블을 자동 변경했다. 편했지만:
-- - 컬럼 삭제/이름변경을 반영 못 해 엔티티와 DB가 조용히 어긋나고,
-- - 무엇이 언제 바뀌었는지 기록이 없어 되돌릴 수 없었다.
-- - 그 흔적이 이 스키마에도 남아 있다(예: submissions에 같은 컬럼 unique 제약이
-- 두 개 - 하나는 명시명, 하나는 Hibernate 자동해시명 uk...). 이런 드리프트를
-- 끝내려고 Flyway로 옮긴다.
-- 이제부터 스키마는 "버전 관리되는 마이그레이션"으로만 바뀐다. Hibernate는 ddl-auto:validate로
-- "엔티티와 DB가 맞는지 검증"만 한다(스키마를 건드리지 않는다).
--
-- 주의: 이 베이스라인은 자동 캡처된 현재 상태라 제약/시퀀스 이름이 Hibernate 자동생성(해시)이다.
-- 깔끔한 재명명은 이후 별도 마이그레이션(Vn)에서 다룬다 - 베이스라인은 "있는 그대로"가 원칙.
CREATE TABLE public.assignments (
id bigint NOT NULL,
day integer NOT NULL,
kind character varying(255) NOT NULL,
summary text,
title character varying(255) NOT NULL,
week integer NOT NULL
);
ALTER TABLE public.assignments ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.assignments_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.audit_log (
id bigint NOT NULL,
action character varying(30) NOT NULL,
actor character varying(40) NOT NULL,
actor_role character varying(20) NOT NULL,
created_at timestamp(6) with time zone NOT NULL,
detail character varying(200),
ip character varying(45) NOT NULL,
target_user_id bigint,
CONSTRAINT audit_log_action_check CHECK (((action)::text = ANY ((ARRAY['EXPORT_STUDENT_CSV'::character varying, 'VIEW_STUDENT_ROSTER'::character varying, 'RESET_STUDENT_PASSWORD'::character varying, 'PURGE_STUDENT'::character varying])::text[])))
);
ALTER TABLE public.audit_log ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.audit_log_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.checklist_items (
id bigint NOT NULL,
day integer,
label character varying(255) NOT NULL,
sort_order integer NOT NULL,
week integer NOT NULL
);
ALTER TABLE public.checklist_items ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.checklist_items_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.coding_problem (
id bigint NOT NULL,
description text NOT NULL,
difficulty character varying(8) NOT NULL,
function_name character varying(60),
language character varying(8) NOT NULL,
sort_order integer NOT NULL,
starter_code text NOT NULL,
tests_json text,
title character varying(255) NOT NULL,
topic character varying(60) NOT NULL,
setup_sql text
);
ALTER TABLE public.coding_problem ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.coding_problem_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.coding_submission (
id bigint NOT NULL,
attempts integer NOT NULL,
code text NOT NULL,
feedback text,
passed_count integer,
results_json text,
status character varying(12) NOT NULL,
submitted_at timestamp(6) with time zone NOT NULL,
total_count integer,
problem_id bigint NOT NULL,
user_id bigint NOT NULL
);
ALTER TABLE public.coding_submission ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.coding_submission_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.course_progress (
id bigint NOT NULL,
completed_at timestamp(6) with time zone NOT NULL,
course_slug character varying(60) NOT NULL,
user_id bigint NOT NULL
);
ALTER TABLE public.course_progress ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.course_progress_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.documents (
id bigint NOT NULL,
audience character varying(255) NOT NULL,
category character varying(255) NOT NULL,
file_path character varying(255) NOT NULL,
slug character varying(255) NOT NULL,
sort_order integer NOT NULL,
title character varying(255) NOT NULL,
week integer
);
ALTER TABLE public.documents ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.documents_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.progress (
id bigint NOT NULL,
checked_at timestamp(6) with time zone NOT NULL,
item_id bigint NOT NULL,
user_id bigint NOT NULL
);
ALTER TABLE public.progress ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.progress_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.quiz_question (
id bigint NOT NULL,
answer_index integer NOT NULL,
course_slug character varying(60) NOT NULL,
explanation text NOT NULL,
option1 text NOT NULL,
option2 text NOT NULL,
option3 text NOT NULL,
option4 text NOT NULL,
question text NOT NULL,
sort_order integer NOT NULL
);
ALTER TABLE public.quiz_question ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.quiz_question_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.quiz_result (
id bigint NOT NULL,
course_slug character varying(60) NOT NULL,
score integer NOT NULL,
submitted_at timestamp(6) with time zone NOT NULL,
total integer NOT NULL,
user_id bigint NOT NULL
);
ALTER TABLE public.quiz_result ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.quiz_result_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.student_profile (
id bigint NOT NULL,
address text NOT NULL,
birth_date date NOT NULL,
company_email_local character varying(40) NOT NULL,
emergency_name character varying(30) NOT NULL,
emergency_phone character varying(20) NOT NULL,
emergency_relation character varying(20) NOT NULL,
major character varying(60) NOT NULL,
phone character varying(20) NOT NULL,
school character varying(60) NOT NULL,
user_id bigint NOT NULL,
privacy_consent_at timestamp(6) with time zone NOT NULL
);
ALTER TABLE public.student_profile ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.student_profile_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.submissions (
id bigint NOT NULL,
content text NOT NULL,
feedback text,
link character varying(255),
status character varying(255) NOT NULL,
submitted_at timestamp(6) with time zone NOT NULL,
assignment_id bigint NOT NULL,
user_id bigint NOT NULL
);
ALTER TABLE public.submissions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.submissions_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.users (
id bigint NOT NULL,
created_at timestamp(6) with time zone NOT NULL,
name character varying(255) NOT NULL,
password_hash character varying(255) NOT NULL,
role character varying(255) NOT NULL,
track character varying(255) NOT NULL,
username character varying(255) NOT NULL
);
ALTER TABLE public.users ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.users_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
ALTER TABLE ONLY public.assignments
ADD CONSTRAINT assignments_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.audit_log
ADD CONSTRAINT audit_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.checklist_items
ADD CONSTRAINT checklist_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.coding_problem
ADD CONSTRAINT coding_problem_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.coding_submission
ADD CONSTRAINT coding_submission_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.course_progress
ADD CONSTRAINT course_progress_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.documents
ADD CONSTRAINT documents_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.progress
ADD CONSTRAINT progress_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.quiz_question
ADD CONSTRAINT quiz_question_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.quiz_result
ADD CONSTRAINT quiz_result_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.student_profile
ADD CONSTRAINT student_profile_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.submissions
ADD CONSTRAINT submissions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.progress
ADD CONSTRAINT uk3328ayw5asr3mnmp0c31n21jf UNIQUE (user_id, item_id);
ALTER TABLE ONLY public.student_profile
ADD CONSTRAINT uk3whrp0g2trsa8qsb2nk142jea UNIQUE (company_email_local);
ALTER TABLE ONLY public.student_profile
ADD CONSTRAINT uk99mm2qc8gq78mojsjmdhqqrtd UNIQUE (user_id);
ALTER TABLE ONLY public.submissions
ADD CONSTRAINT uk_submissions_assignment_user UNIQUE (assignment_id, user_id);
ALTER TABLE ONLY public.coding_submission
ADD CONSTRAINT ukepcri27qov9okseajuck21p1q UNIQUE (problem_id, user_id);
ALTER TABLE ONLY public.course_progress
ADD CONSTRAINT ukk2refcthyrufe59nnxsjkxklv UNIQUE (user_id, course_slug);
ALTER TABLE ONLY public.submissions
ADD CONSTRAINT ukmg9jk4whp661fe4kyo8pdapkf UNIQUE (assignment_id, user_id);
ALTER TABLE ONLY public.documents
ADD CONSTRAINT ukpbof73ceduubbvfrqc0wj4eb1 UNIQUE (slug);
ALTER TABLE ONLY public.users
ADD CONSTRAINT ukr43af9ap4edm43mmtq01oddj6 UNIQUE (username);
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
CREATE INDEX ix_audit_log_created_at ON public.audit_log USING btree (created_at);
ALTER TABLE ONLY public.progress
ADD CONSTRAINT fk4gq8i0p0nmn2dwgcq11nmpjm4 FOREIGN KEY (item_id) REFERENCES public.checklist_items(id);
ALTER TABLE ONLY public.submissions
ADD CONSTRAINT fk760bgu69957phd7hax608jdms FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.coding_submission
ADD CONSTRAINT fk78tg75ufvjeo1lc6g7jc8y9g0 FOREIGN KEY (problem_id) REFERENCES public.coding_problem(id);
ALTER TABLE ONLY public.progress
ADD CONSTRAINT fk7fyumbty8qgbd7sfbbjnqdo62 FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.student_profile
ADD CONSTRAINT fkh6555c9k0gv0yddac6llslk3t FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.coding_submission
ADD CONSTRAINT fkiym0dwju9tunrvp1mjdep8bl4 FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.course_progress
ADD CONSTRAINT fkmootcccgd4bqe8cjtma7stkp1 FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.quiz_result
ADD CONSTRAINT fknuben697e3lorbhgsbif22f2t FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.submissions
ADD CONSTRAINT fkrirbb44savy2g7nws0hoxs949 FOREIGN KEY (assignment_id) REFERENCES public.assignments(id);