백엔드 · 5장 DB · Liquibase

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가 관리
테이블은 누가 만드나 — Liquibase

JPA?(Hibernate)는 엔티티를 보고 테이블을 자동 생성할 수도 있지만, 이 프로젝트는 Liquibase를 쓰므로 Hibernate의 자동 DDL에 의존하지 않습니다(Liquibase가 클래스패스에 있으면 자동 생성이 사실상 꺼진 것처럼 동작). 테이블을 만들고 바꾸는 일은 전부 Liquibase가 맡아요. 즉 엔티티는 "이미 있는 테이블을 자바 객체로 읽고 쓰는 쪽", 테이블 구조 자체는 "Liquibase가 SQL로 정의하는 쪽"으로 역할이 갈립니다.

왜 Liquibase인가?

테이블 구조(스키마)도 결국 코드처럼 계속 바뀝니다. 컬럼을 추가하고, 인덱스를 만들고, 기본 데이터를 넣고…. 이걸 사람이 손으로 운영 DB에 직접 ALTER TABLE 하면 환경마다 달라지고 추적이 안 됩니다. Liquibase는 그 변경들을 SQL 파일(changelog)로 적어 버전 관리하고, 앱이 켜질 때 자동으로 적용합니다.

Liquibase는 DB 스키마의 "git 커밋 로그". 누가·언제·무엇을 바꿨는지 순서대로 쌓이고, 어느 컴퓨터에서 실행해도 같은 결과(같은 테이블)가 만들어집니다.
  • 코드로 버전 관리 — 스키마 변경이 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

includeAllchanges 폴더의 SQL을 파일명 순서(001…, 002…)로 모두 적용합니다. 그래서 파일 이름 앞의 번호가 곧 실행 순서예요. 현재 두 파일이 있습니다.

파일역할
001-initial-schema.sql테이블 7개 생성 (구조 정의)
002-seed-data.sql기본 데이터 입력 (역할·관리자·메뉴·권한)
runOnChange: true 가 하는 일

보통 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_rolename 도 unique, is_system(시스템 필수 역할), active(사용 여부), sort_order(정렬).
  • tb_user — PK가 uuid, userid unique, encrypted_password(BCrypt 해시), tenant 기본값 'DJ', last_login, auth_method. 그리고 roles varchar(50)[] 배열 컬럼(기본값 {ROLE_USER}).
  • tb_user_roleuser_id+role_code 복합 PK. tb_user·tb_role로 가는 외래키(FK) 두 개를 가진 연결 테이블.
  • tb_menuparent_code자기 자신을 가리키는 FK(자기참조)라 메뉴가 트리 구조를 이룹니다. route_path(프론트 경로), properties jsonb(아이콘 등 자유 속성).
  • tb_menu_rolemenu_code+role_code 복합 PK. 권한을 5종으로 나눔: can_read / can_create / can_update / can_delete / can_export.
  • refresh_tokenstoken unique, userid, expiry_date.
  • auth_loguserid, 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
);
주의 — roles 배열과 연결 테이블이 함께 존재

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 해시로 저장), idgen_random_uuid()로 생성.
  • 역할 연결tb_user_role에 admin ↔ ROLE_ADMIN 한 줄.
  • 메뉴settings(하위 users·rbac), system(하위 auth-log). 상위 메뉴는 properties jsonb에 아이콘 지정.
  • 권한 부여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_rolecreated_*만, refresh_tokens·auth_log는 감사 컬럼이 없습니다.)

  • DB 쪽created_at/updated_atdefault 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)을 추가해 점진적으로 바꿉니다.

이 지식이 실제로 어디에
  • 새 테이블 추가 — 엔티티만 만들면 끝이 아니다. changes003-...sqlCREATE TABLE을 추가해야 실제 DB에 생긴다(스키마는 Liquibase가 관리하므로).
  • "테이블이 없다" 에러 — Liquibase가 적용됐는지, changelog 파일명 순서·체크섬을 먼저 확인한다.
  • 로그인이 안 될 때 — 시드(002)가 들어갔는지, tb_user/tb_user_role에 admin이 있는지 확인한다.
  • 권한 문제 — 메뉴가 안 보이면 tb_menu_role의 역할별 can_read 등을 본다.

정리

  • 데이터는 PostgreSQL studydblds 스키마에 저장된다.
  • 테이블 구조는 Hibernate 자동 생성이 아니라 Liquibase가 SQL로 관리한다.
  • changelog-index.yamlincludeAllchanges 폴더의 SQL을 파일명 순(001, 002…)으로 적용한다.
  • 001은 테이블 7개(역할·사용자·연결·메뉴·메뉴권한·리프레시토큰·인증로그), 002는 기본 데이터(역할·admin·메뉴·권한)다.
  • tb_user.roles 배열과 tb_user_role 연결 테이블이 공존하지만, 엔티티는 연결 테이블을 쓴다.
  • 개발 초기화는 drop/create schemagradle clearChecksums → 재시작.