5장 · 데이터베이스 & Liquibase
데이터가 실제로 어디에 어떤 모양으로 저장되는지 봅니다.
이 프로젝트의 테이블 구조는 엔티티?가 아니라
Liquibase?가 SQL 파일로 관리합니다.
7개 테이블과 기본 데이터(admin 계정·역할·메뉴)가 어떻게 만들어지는지 따라가 봅니다.
어디에 저장되나 — PostgreSQL studydb / lds
이 프로젝트는 PostgreSQL 데이터베이스 studydb 안의
lds 스키마에 테이블을 둡니다. 스키마는 테이블을 묶는 폴더 같은 이름공간이에요.
접속 정보는 application.properties에 있습니다.
# application.properties (발췌) spring.datasource.url=jdbc:postgresql://localhost:5432/studydb?currentSchema=lds spring.liquibase.change-log=classpath:/db/changelog/changelog-index.yaml # 스키마는 Liquibase가 관리
JPA?(Hibernate)는 엔티티를 보고 테이블을 자동 생성할 수도 있지만, 이 프로젝트는 Liquibase를 쓰므로 Hibernate의 자동 DDL에 의존하지 않습니다(Liquibase가 클래스패스에 있으면 자동 생성이 사실상 꺼진 것처럼 동작). 테이블을 만들고 바꾸는 일은 전부 Liquibase가 맡아요. 즉 엔티티는 "이미 있는 테이블을 자바 객체로 읽고 쓰는 쪽", 테이블 구조 자체는 "Liquibase가 SQL로 정의하는 쪽"으로 역할이 갈립니다.
왜 Liquibase인가?
테이블 구조(스키마)도 결국 코드처럼 계속 바뀝니다. 컬럼을 추가하고, 인덱스를 만들고, 기본 데이터를 넣고….
이걸 사람이 손으로 운영 DB에 직접 ALTER TABLE 하면 환경마다 달라지고 추적이 안 됩니다.
Liquibase는 그 변경들을 SQL 파일(changelog)로 적어 버전 관리하고, 앱이 켜질 때 자동으로 적용합니다.
- 코드로 버전 관리 — 스키마 변경이 SQL 파일로 남아 git에 함께 들어갑니다.
- 모든 환경 동일 — 내 PC·동료 PC·서버 어디서 켜도 같은 테이블이 생깁니다.
- 적용 이력 추적 — Liquibase가 이미 적용한 변경을 기록해, 다음 실행 때 중복 적용하지 않습니다.
changelog 구조
시작점은 db/changelog/changelog-index.yaml 입니다. 이 파일은 변경 SQL을 직접 적지 않고,
changes 폴더 안의 파일을 전부 포함하라고만 지시합니다.
# db/changelog/changelog-index.yaml
databaseChangeLog:
- includeAll:
path: /changes
relativeToChangelogFile: true
runOnChange: true
includeAll 은 changes 폴더의 SQL을 파일명 순서(001…, 002…)로 모두 적용합니다.
그래서 파일 이름 앞의 번호가 곧 실행 순서예요. 현재 두 파일이 있습니다.
| 파일 | 역할 |
|---|---|
001-initial-schema.sql | 테이블 7개 생성 (구조 정의) |
002-seed-data.sql | 기본 데이터 입력 (역할·관리자·메뉴·권한) |
보통 Liquibase는 한 번 적용한 변경은 다시 실행하지 않습니다. runOnChange: true는
"파일 내용이 바뀌면 다시 적용"하라는 뜻이에요. 그래서 SQL 파일을 수정하면 다음 실행 때 반영됩니다
(단, 이미 만든 테이블을 또 create 하면 충돌하므로, 개발 중 구조를 크게 바꿀 땐 아래 "스키마 초기화"를 씁니다).
테이블 7개 개요
모든 업무 테이블은 lds 스키마에 있습니다(refresh_tokens·auth_log는 스키마 없이 기본 위치).
| 테이블 | 역할 | 기본키(PK) |
|---|---|---|
lds.tb_role | 역할 정의 | code |
lds.tb_user | 사용자 계정 | id (uuid) |
lds.tb_user_role | 사용자↔역할 연결 | user_id + role_code |
lds.tb_menu | 메뉴 트리 | code |
lds.tb_menu_role | 메뉴↔역할 권한 | menu_code + role_code |
refresh_tokens | 리프레시 토큰 저장 | id (bigserial) |
auth_log | 로그인 시도 이력 | id (bigserial) |
핵심 컬럼과 관계
- tb_role —
name도 unique,is_system(시스템 필수 역할),active(사용 여부),sort_order(정렬). - tb_user — PK가
uuid,useridunique,encrypted_password(BCrypt 해시),tenant기본값'DJ',last_login,auth_method. 그리고roles varchar(50)[]배열 컬럼(기본값{ROLE_USER}). - tb_user_role —
user_id+role_code복합 PK.tb_user·tb_role로 가는 외래키(FK) 두 개를 가진 연결 테이블. - tb_menu —
parent_code가 자기 자신을 가리키는 FK(자기참조)라 메뉴가 트리 구조를 이룹니다.route_path(프론트 경로),properties jsonb(아이콘 등 자유 속성). - tb_menu_role —
menu_code+role_code복합 PK. 권한을 5종으로 나눔:can_read / can_create / can_update / can_delete / can_export. - refresh_tokens —
tokenunique,userid,expiry_date. - auth_log —
userid,auth_type,status,attempt_time+ 인덱스 3종(userid,attempt_time,status).
-- 001-initial-schema.sql (발췌) — tb_user
create table lds.tb_user
(
id uuid not null primary key,
userid varchar(255) not null constraint uk_userid unique,
encrypted_password varchar(255) not null,
name varchar(255) not null,
active boolean not null default true,
tenant varchar(100) default 'DJ' not null,
last_login timestamp,
auth_method varchar(10),
roles varchar(50)[] not null default '{ROLE_USER}',
created_at timestamp not null default now(),
created_by varchar(255),
updated_at timestamp not null default now(),
updated_by varchar(255)
);-- 001-initial-schema.sql (발췌) — 메뉴 트리(자기참조)와 메뉴 권한
CREATE TABLE lds.tb_menu (
code VARCHAR(100) PRIMARY KEY,
parent_code VARCHAR(100), -- 부모 메뉴 코드
name VARCHAR(255) NOT NULL,
route_path VARCHAR(255),
properties JSONB, -- 아이콘 등 자유 속성
CONSTRAINT fk_parent_menu
FOREIGN KEY (parent_code) REFERENCES lds.tb_menu(code) -- 자기참조
);
CREATE TABLE lds.tb_menu_role (
menu_code VARCHAR(100) NOT NULL,
role_code VARCHAR(50) NOT NULL,
can_read BOOLEAN NOT NULL DEFAULT FALSE,
can_create BOOLEAN NOT NULL DEFAULT FALSE,
can_update BOOLEAN NOT NULL DEFAULT FALSE,
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
can_export BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (menu_code, role_code) -- 복합 PK
);tb_user.roles varchar(50)[] 배열 컬럼과, 별도의 tb_user_role 연결 테이블이
둘 다 있습니다(레거시 배열 → 정규화된 연결 테이블로 옮겨가는 흔적).
실제 JPA 엔티티(User.userRoles)는 연결 테이블 쪽을 사용합니다.
역할을 다룰 때는 tb_user_role이 기준이라는 점을 기억하세요.
시드 데이터 (002)
테이블만 있으면 로그인할 계정이 없습니다. 002-seed-data.sql이 처음 켤 때 필요한 데이터를 채웁니다.
- 역할 2개 —
ROLE_ADMIN(is_system=true, 활성),ROLE_USER(시드에서는active=false로 들어감). - 관리자 계정 —
userid=admin, 비밀번호admin123(BCrypt 해시로 저장),id는gen_random_uuid()로 생성. - 역할 연결 —
tb_user_role에 admin ↔ROLE_ADMIN한 줄. - 메뉴 —
settings(하위users·rbac),system(하위auth-log). 상위 메뉴는propertiesjsonb에 아이콘 지정. - 권한 부여 —
ROLE_ADMIN에 모든 활성 메뉴 전체 권한(read/create/update/delete/export)을 한 번에 부여.
-- 002-seed-data.sql (발췌)
-- 관리자 계정 (password: admin123)
INSERT INTO lds.tb_user (id, userid, encrypted_password, name, roles, ...)
VALUES (
gen_random_uuid(),
'admin',
'$2a$10$BN8.NZYtbB580WBe0do9tuALB4LXdUJHfHurFK3Mp9W.n4MjpP8ee', -- BCrypt
'관리자',
'{ROLE_ADMIN}',
...
);
-- admin ↔ ROLE_ADMIN 연결
INSERT INTO lds.tb_user_role (user_id, role_code, created_by)
SELECT id, 'ROLE_ADMIN', 'seed' FROM lds.tb_user WHERE userid = 'admin';
-- ROLE_ADMIN: 모든 활성 메뉴 전체 권한
INSERT INTO lds.tb_menu_role (menu_code, role_code,
can_read, can_create, can_update, can_delete, can_export, created_by)
SELECT code, 'ROLE_ADMIN', TRUE, TRUE, TRUE, TRUE, TRUE, 'seed'
FROM lds.tb_menu WHERE active = TRUE;최초 실행 후 ID admin / PW admin123 으로 로그인할 수 있습니다.
이 권한 데이터(역할→메뉴 권한)가 3장에서 본 RBAC의 토대가 됩니다.
감사 컬럼 (created_at / updated_at …)
주요 업무 테이블(tb_user·tb_role·tb_menu 등)에 created_at·created_by·updated_at·updated_by
4개의 감사(audit) 컬럼이 들어갑니다. "누가 언제 만들고 고쳤는지"를 자동으로 남기기 위함이에요.
(연결 테이블 tb_user_role·tb_menu_role은 created_*만, refresh_tokens·auth_log는 감사 컬럼이 없습니다.)
- DB 쪽 —
created_at/updated_at은default now()로 SQL에서 기본값을 줍니다. - 앱 쪽 — 엔티티가 공통 베이스(
BaseAuditEntity)를 상속해, JPA가 저장·수정 시 값을 채웁니다(4장의 엔티티, 3장의 인증 사용자 연결).
-- 모든 주요 테이블에 반복되는 감사 컬럼 created_at timestamp not null default now(), created_by varchar(255), updated_at timestamp not null default now(), updated_by varchar(255)
개발용 스키마 초기화
개발 중 테이블 구조를 크게 바꿔 처음부터 다시 만들고 싶다면, 스키마를 통째로 비우고 다시 적용합니다. PostgreSQL에 접속해 스키마를 지웠다 만든 뒤, Liquibase의 적용 이력 체크섬을 비우고 앱을 재시작하면 됩니다.
-- 1) psql 등 DB 클라이언트에서 drop schema lds cascade; create schema lds;
# 2) Liquibase 체크섬 초기화 후 앱 재시작 gradle clearChecksums # 그 다음 StudyApplication 실행 → 001, 002 가 다시 적용됨
drop schema ... cascade 는 그 스키마의 모든 테이블·데이터를 삭제합니다.
이 방법은 로컬 개발 DB 초기화 전용이에요. 운영 환경에서는 새 번호의 changelog 파일(예: 003-...sql)을
추가해 점진적으로 바꿉니다.
- 새 테이블 추가 — 엔티티만 만들면 끝이 아니다.
changes에003-...sql로CREATE TABLE을 추가해야 실제 DB에 생긴다(스키마는 Liquibase가 관리하므로). - "테이블이 없다" 에러 — Liquibase가 적용됐는지, changelog 파일명 순서·체크섬을 먼저 확인한다.
- 로그인이 안 될 때 — 시드(002)가 들어갔는지,
tb_user/tb_user_role에 admin이 있는지 확인한다. - 권한 문제 — 메뉴가 안 보이면
tb_menu_role의 역할별can_read등을 본다.
정리
- 데이터는 PostgreSQL
studydb의lds스키마에 저장된다. - 테이블 구조는 Hibernate 자동 생성이 아니라 Liquibase가 SQL로 관리한다.
changelog-index.yaml→includeAll→changes폴더의 SQL을 파일명 순(001, 002…)으로 적용한다.- 001은 테이블 7개(역할·사용자·연결·메뉴·메뉴권한·리프레시토큰·인증로그), 002는 기본 데이터(역할·
admin·메뉴·권한)다. tb_user.roles배열과tb_user_role연결 테이블이 공존하지만, 엔티티는 연결 테이블을 쓴다.- 개발 초기화는
drop/create schema→gradle clearChecksums→ 재시작.