하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
전체 글 (45)
2025-07-21 19:07:53
728x90
반응형

🗄️ 들어가며

"개발할 때는 잘 되는데, 배포하니까 데이터가 다 사라졌어요!"

H2 인메모리 데이터베이스로 개발하다가 겪는 흔한 문제입니다. 이제는 실제 운영 환경에서 사용할 수 있는 MariaDB/MySQL로 전환할 때입니다.

이 글에서는 Spring Boot 프로젝트를 H2에서 MariaDB/MySQL로 마이그레이션하는 완벽 가이드를 제공합니다. Docker를 활용한 설치부터 트러블슈팅까지, 실무에서 마주칠 모든 상황을 다룹니다.

📑 목차

  1. MariaDB vs MySQL 선택 가이드
  2. 설치 방법 3가지
  3. Spring Boot 연결 설정
  4. 데이터베이스 설정 최적화
  5. JPA 설정과 Dialect
  6. 마이그레이션 전략
  7. 트러블슈팅
  8. 운영 환경 베스트 프랙티스

MariaDB vs MySQL 선택 가이드

🤔 뭘 선택해야 할까?

구분MariaDBMySQL

라이선스 GPL v2 (완전 오픈소스) GPL + 상업 라이선스 (Oracle)
성능 일반적으로 더 빠름 대규모 엔터프라이즈 최적화
기능 더 많은 스토리지 엔진 Oracle 지원
호환성 MySQL 5.5까지 100% 호환 원조
커뮤니티 활발한 오픈소스 커뮤니티 Oracle 주도

💡 결론

  • 스타트업/중소기업: MariaDB 추천 (무료, 빠름, 기능 풍부)
  • 대기업/레거시: MySQL 추천 (Oracle 지원, 안정성)
  • Spring Boot: 둘 다 완벽 지원! 설정만 약간 다름

설치 방법 3가지

방법 1: Docker 🐳 (추천!)

MariaDB Docker 설치

 
yaml
# docker-compose.yml
version: '3.8'

services:
  mariadb:
    image: mariadb:11.2
    container_name: my-mariadb
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root1234!
      MYSQL_DATABASE: myapp
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypass1234!
      TZ: Asia/Seoul
    ports:
      - "3306:3306"
    volumes:
      - mariadb_data:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d  # 초기화 SQL
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --skip-character-set-client-handshake
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  mariadb_data:
    driver: local

MySQL Docker 설치

 
yaml
# docker-compose.yml
version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: my-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root1234!
      MYSQL_DATABASE: myapp
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypass1234!
      TZ: Asia/Seoul
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d
    command:
      - --default-authentication-plugin=mysql_native_password
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --skip-character-set-client-handshake
      - --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

volumes:
  mysql_data:
    driver: local

실행 명령어

 
bash
# 시작
docker-compose up -d

# 로그 확인
docker-compose logs -f

# 접속 테스트
docker exec -it my-mariadb mysql -u root -p

# 종료
docker-compose down

# 데이터까지 삭제
docker-compose down -v

방법 2: 로컬 설치

Windows

 
powershell
# Chocolatey 사용
choco install mariadb

# 또는 공식 사이트에서 다운로드
# https://mariadb.org/download/

macOS

 
bash
# Homebrew 사용
brew install mariadb

# 서비스 시작
brew services start mariadb

# 보안 설정
mysql_secure_installation

Ubuntu/Debian

 
bash
# 패키지 업데이트
sudo apt update

# MariaDB 설치
sudo apt install mariadb-server mariadb-client

# 보안 설정
sudo mysql_secure_installation

# 서비스 상태 확인
sudo systemctl status mariadb

방법 3: 클라우드 서비스

AWS RDS

 
terraform
resource "aws_db_instance" "mariadb" {
  identifier     = "myapp-mariadb"
  engine         = "mariadb"
  engine_version = "10.11"
  instance_class = "db.t3.micro"
  
  allocated_storage = 20
  storage_type      = "gp3"
  
  db_name  = "myapp"
  username = "admin"
  password = var.db_password
  
  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name
  
  skip_final_snapshot = true
  
  tags = {
    Name = "MyApp MariaDB"
  }
}

Spring Boot 연결 설정

1. 의존성 추가

 
xml
<!-- pom.xml -->
<dependencies>
    <!-- MariaDB -->
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
        <version>3.3.3</version>
    </dependency>
    
    <!-- 또는 MySQL -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    
    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    
    <!-- Connection Pool (HikariCP는 기본 포함) -->
    <!-- 추가 설정만 필요 -->
</dependencies>

Gradle:

 
gradle
dependencies {
    // MariaDB
    implementation 'org.mariadb.jdbc:mariadb-java-client:3.3.3'
    
    // 또는 MySQL
    implementation 'mysql:mysql-connector-java:8.0.33'
    
    // Spring Data JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

2. application.yml 설정 (추천)

 
yaml
spring:
  # 프로파일별 설정
  profiles:
    active: local

---
# 로컬 개발 환경
spring:
  config:
    activate:
      on-profile: local
  
  datasource:
    url: jdbc:mariadb://localhost:3306/myapp?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
    username: myuser
    password: mypass1234!
    driver-class-name: org.mariadb.jdbc.Driver
    
    # HikariCP 설정
    hikari:
      pool-name: MyAppHikariPool
      maximum-pool-size: 10
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000
      auto-commit: true
      connection-test-query: SELECT 1
      
  jpa:
    hibernate:
      ddl-auto: update  # 개발: update, 운영: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDBDialect
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 25
          batch_versioned_data: true
        order_inserts: true
        order_updates: true
    show-sql: true
    open-in-view: false  # 성능 최적화

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    com.zaxxer.hikari: DEBUG

---
# 운영 환경
spring:
  config:
    activate:
      on-profile: prod
      
  datasource:
    url: jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=true&requireSSL=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: org.mariadb.jdbc.Driver
    
    hikari:
      pool-name: ProdHikariPool
      maximum-pool-size: 30
      minimum-idle: 10
      idle-timeout: 600000
      connection-timeout: 30000
      max-lifetime: 1800000
      leak-detection-threshold: 60000
      
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDBDialect
        jdbc:
          batch_size: 50
    show-sql: false
    
logging:
  level:
    org.hibernate: WARN
    com.zaxxer.hikari: INFO

3. application.properties 설정

 
properties
# MariaDB 설정
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.username=myuser
spring.datasource.password=mypass1234!
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver

# MySQL 설정 (대체)
#spring.datasource.url=jdbc:mysql://localhost:3306/myapp?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
#spring.datasource.username=myuser
#spring.datasource.password=mypass1234!
#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA 설정
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect

# HikariCP 설정
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.idle-timeout=300000

데이터베이스 설정 최적화

1. 초기 데이터베이스 생성

 
sql
-- init.sql
-- 데이터베이스 생성
CREATE DATABASE IF NOT EXISTS myapp 
CHARACTER SET utf8mb4 
COLLATE utf8mb4_unicode_ci;

-- 사용자 생성 및 권한 부여
CREATE USER IF NOT EXISTS 'myuser'@'%' IDENTIFIED BY 'mypass1234!';
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'%';
FLUSH PRIVILEGES;

-- 테이블 생성 예시
USE myapp;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT NOT NULL AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);

2. 성능 최적화 설정

 
sql
-- MariaDB/MySQL 튜닝 (my.cnf)
[mysqld]
# 기본 설정
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
init-connect='SET NAMES utf8mb4'

# InnoDB 설정
innodb_buffer_pool_size=1G  # RAM의 50-70%
innodb_log_file_size=256M
innodb_flush_log_at_trx_commit=2
innodb_flush_method=O_DIRECT
innodb_file_per_table=1

# 쿼리 캐시 (MySQL 8.0부터 제거됨)
query_cache_type=1  # MariaDB만
query_cache_size=128M

# 연결 설정
max_connections=200
connect_timeout=10
wait_timeout=600
interactive_timeout=600

# 로그 설정
slow_query_log=1
slow_query_log_file=/var/log/mysql/slow.log
long_query_time=2

# 기타 최적화
join_buffer_size=256K
sort_buffer_size=256K
read_buffer_size=256K
read_rnd_buffer_size=256K

JPA 설정과 Dialect

1. Hibernate Dialect 선택

 
java
// Hibernate 6.x 기준
// MariaDB
org.hibernate.dialect.MariaDBDialect         // 자동 버전 감지
org.hibernate.dialect.MariaDB103Dialect      // 10.3+
org.hibernate.dialect.MariaDB106Dialect      // 10.6+

// MySQL  
org.hibernate.dialect.MySQLDialect           // 자동 버전 감지
org.hibernate.dialect.MySQL8Dialect          // 8.0+
org.hibernate.dialect.MySQL57Dialect         // 5.7

2. 커스텀 Dialect

 
java
package com.myapp.config;

import org.hibernate.dialect.MariaDBDialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class CustomMariaDBDialect extends MariaDBDialect {
    
    public CustomMariaDBDialect() {
        super();
        
        // 커스텀 함수 등록
        registerFunction("group_concat", 
            new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
        
        registerFunction("json_extract",
            new StandardSQLFunction("json_extract", StandardBasicTypes.STRING));
    }
    
    @Override
    public String getTableTypeString() {
        return " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
    }
}

3. Entity 설정 최적화

 
java
@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_email", columnList = "email"),
    @Index(name = "idx_created_at", columnList = "createdAt")
})
@EntityListeners(AuditingEntityListener.class)
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 50)
    private String username;
    
    @Column(nullable = false, unique = true, length = 100)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // JSON 타입 사용 (MariaDB 10.3+)
    @Column(columnDefinition = "JSON")
    @Convert(converter = JsonConverter.class)
    private Map<String, Object> metadata;
    
    // Getters and Setters
}

마이그레이션 전략

1. H2 → MariaDB/MySQL 데이터 마이그레이션

 
java
@Component
@Profile("migration")
public class DataMigrationRunner implements CommandLineRunner {
    
    @Autowired
    @Qualifier("h2DataSource")
    private DataSource h2DataSource;
    
    @Autowired
    @Qualifier("mariadbDataSource")
    private DataSource mariadbDataSource;
    
    @Override
    public void run(String... args) throws Exception {
        // 1. H2에서 데이터 읽기
        List<Map<String, Object>> data = readFromH2();
        
        // 2. MariaDB로 데이터 쓰기
        writeToMariaDB(data);
        
        // 3. 검증
        validateMigration();
    }
    
    private List<Map<String, Object>> readFromH2() {
        JdbcTemplate h2Jdbc = new JdbcTemplate(h2DataSource);
        return h2Jdbc.queryForList("SELECT * FROM users");
    }
    
    private void writeToMariaDB(List<Map<String, Object>> data) {
        JdbcTemplate mariadbJdbc = new JdbcTemplate(mariadbDataSource);
        
        String sql = "INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, ?)";
        
        mariadbJdbc.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Map<String, Object> row = data.get(i);
                ps.setString(1, (String) row.get("username"));
                ps.setString(2, (String) row.get("email"));
                ps.setString(3, (String) row.get("password"));
                ps.setTimestamp(4, (Timestamp) row.get("created_at"));
            }
            
            @Override
            public int getBatchSize() {
                return data.size();
            }
        });
    }
}

2. Flyway를 이용한 마이그레이션

 
xml
<!-- pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-mysql</artifactId>
</dependency>
 
yaml
# application.yml
spring:
  flyway:
    enabled: true
    baseline-on-migrate: true
    locations: classpath:db/migration
    sql-migration-prefix: V
    sql-migration-suffixes: .sql
    validate-on-migrate: true
 
sql
-- src/main/resources/db/migration/V1__init_schema.sql
CREATE TABLE IF NOT EXISTS users (
    id BIGINT NOT NULL AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- src/main/resources/db/migration/V2__add_user_roles.sql
CREATE TABLE IF NOT EXISTS roles (
    id BIGINT NOT NULL AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL UNIQUE,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS user_roles (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

트러블슈팅

1. 연결 문제

오류: Access denied for user

 
Access denied for user 'root'@'localhost' (using password: YES)

해결방법:

 
bash
# MariaDB/MySQL 접속
sudo mysql -u root

# 권한 재설정
ALTER USER 'root'@'localhost' IDENTIFIED BY 'newpassword';
FLUSH PRIVILEGES;

# 또는 새 사용자 생성
CREATE USER 'myuser'@'%' IDENTIFIED BY 'mypass1234!';
GRANT ALL PRIVILEGES ON *.* TO 'myuser'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;

오류: Unknown database

 
Unknown database 'myapp'

해결방법:

 
properties
# URL에 createDatabaseIfNotExist 추가
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?createDatabaseIfNotExist=true

오류: Public Key Retrieval

 
Public Key Retrieval is not allowed

해결방법:

 
properties
# 개발 환경에서만 사용
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?allowPublicKeyRetrieval=true&useSSL=false

2. 문자 인코딩 문제

오류: Incorrect string value

 
Incorrect string value: '\xF0\x9F\x98\x80' for column 'content'

해결방법:

 
sql
-- 데이터베이스 인코딩 변경
ALTER DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 테이블 인코딩 변경
ALTER TABLE posts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 컬럼 인코딩 변경
ALTER TABLE posts MODIFY content TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

3. 타임존 문제

오류: The server time zone value 'KST' is unrecognized

 
The server time zone value 'KST' is unrecognized

해결방법:

 
properties
# URL에 serverTimezone 추가
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?serverTimezone=Asia/Seoul

# 또는 UTC 사용
spring.datasource.url=jdbc:mariadb://localhost:3306/myapp?serverTimezone=UTC

4. Dialect 문제

오류: Unable to resolve name MariaDB103Dialect

 
Unable to resolve name [org.hibernate.dialect.MariaDB103Dialect] as strategy [org.hibernate.dialect.Dialect]

해결방법:

 
properties
# Hibernate 6.x용 Dialect 사용
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

# 또는 MySQL
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

5. 성능 문제

N+1 쿼리 문제

 
java
// 문제: Lazy Loading으로 인한 N+1
@Entity
public class Post {
    @ManyToOne(fetch = FetchType.LAZY)
    private User author;
    
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    private List<Comment> comments;
}

// 해결: Fetch Join 사용
@Query("SELECT p FROM Post p LEFT JOIN FETCH p.author LEFT JOIN FETCH p.comments WHERE p.id = :id")
Optional<Post> findByIdWithAuthorAndComments(@Param("id") Long id);

// 또는 @EntityGraph 사용
@EntityGraph(attributePaths = {"author", "comments"})
Optional<Post> findById(Long id);

운영 환경 베스트 프랙티스

1. 환경 변수 사용

 
yaml
spring:
  datasource:
    url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:myapp}
    username: ${DB_USERNAME:myuser}
    password: ${DB_PASSWORD:mypass1234!}

2. 연결 풀 모니터링

 
java
@Configuration
public class DataSourceConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public HikariDataSource dataSource() {
        return DataSourceBuilder.create()
            .type(HikariDataSource.class)
            .build();
    }
    
    @Bean
    public MeterRegistry meterRegistry() {
        return new SimpleMeterRegistry();
    }
    
    @EventListener
    public void handleContextRefresh(ContextRefreshedEvent event) {
        HikariDataSource dataSource = event.getApplicationContext()
            .getBean(HikariDataSource.class);
        
        dataSource.setMetricRegistry(meterRegistry());
    }
}

3. 읽기/쓰기 분리

 
java
@Configuration
public class DataSourceConfig {
    
    @Bean
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource routingDataSource(
            @Qualifier("writeDataSource") DataSource writeDataSource,
            @Qualifier("readDataSource") DataSource readDataSource) {
        
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("write", writeDataSource);
        dataSourceMap.put("read", readDataSource);
        
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(writeDataSource);
        
        return routingDataSource;
    }
}

4. 백업 전략

 
bash
#!/bin/bash
# backup.sh

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/mariadb"
DB_NAME="myapp"
DB_USER="backup_user"
DB_PASS="backup_pass"

# 백업 실행
mysqldump -u $DB_USER -p$DB_PASS \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  --add-drop-database \
  --databases $DB_NAME | gzip > "$BACKUP_DIR/backup_$DATE.sql.gz"

# 30일 이상된 백업 삭제
find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +30 -delete

5. 모니터링

 
sql
-- 느린 쿼리 확인
SELECT * FROM mysql.slow_log ORDER BY query_time DESC LIMIT 10;

-- 현재 실행 중인 쿼리
SHOW FULL PROCESSLIST;

-- 테이블 상태 확인
SHOW TABLE STATUS FROM myapp;

-- 인덱스 사용 통계
SELECT * FROM information_schema.index_statistics 
WHERE table_schema = 'myapp' 
ORDER BY rows_read DESC;

마무리

H2에서 MariaDB/MySQL로의 전환은 실제 운영 환경으로 나아가는 중요한 단계입니다. 이 가이드를 통해 안정적이고 확장 가능한 데이터베이스 환경을 구축할 수 있기를 바랍니다.

체크리스트

  • ✅ Docker 또는 로컬 DB 설치
  • ✅ Spring Boot 의존성 및 설정
  • ✅ 문자 인코딩 (utf8mb4) 설정
  • ✅ 타임존 설정
  • ✅ 연결 풀 최적화
  • ✅ 백업 전략 수립
  • ✅ 모니터링 도구 설정

다음 단계

  • Redis 캐싱 적용
  • 읽기 전용 복제본 구성
  • 파티셔닝 전략
  • 샤딩 구현

태그: #SpringBoot #MariaDB #MySQL #Database #JPA #Docker

728x90
반응형
2025-07-20 19:54:04
728x90
반응형

🎨 들어가며

백엔드 API는 완성했는데, 이제 어떻게 화면에 보여줄까요? React나 Vue.js를 배워야 할까요?

잠깐! Spring Boot와 함께라면 Thymeleaf로 충분합니다. 서버 사이드 렌더링의 강력함과 Bootstrap의 예쁜 디자인을 결합하여, 빠르게 실무에서 사용 가능한 웹 애플리케이션을 만들어보겠습니다.

이 글에서는 Spring Boot CRUD API에 Thymeleaf 템플릿 엔진을 적용하여 완전한 웹 애플리케이션으로 변신시키는 과정을 다룹니다.

📑 목차

  1. Thymeleaf란?
  2. 프로젝트 설정
  3. Controller 리팩토링
  4. 레이아웃 템플릿 만들기
  5. CRUD 페이지 구현
  6. 폼 유효성 검증
  7. JavaScript 연동
  8. 에러 처리
  9. 실전 팁

Thymeleaf란?

🌿 특징

Thymeleaf는 Java 템플릿 엔진으로, HTML을 그대로 사용하면서 동적 콘텐츠를 생성할 수 있습니다.

 
html
<!-- 일반 HTML처럼 보이지만 -->
<p>안녕하세요, <span th:text="${user.name}">손님</span>님!</p>

<!-- 서버에서 렌더링되면 -->
<p>안녕하세요, <span>김한동</span>님!</p>

장점

  • Natural Template: 브라우저에서 바로 열어도 깨지지 않음
  • Spring Boot 통합: 설정이 간단하고 Spring과 완벽 호환
  • 서버 사이드 렌더링: SEO 최적화, 초기 로딩 속도 빠름
  • 학습 곡선 완만: HTML만 알면 바로 시작 가능

단점

  • ❌ SPA(Single Page Application) 구현 어려움
  • ❌ 복잡한 상호작용은 JavaScript 필요
  • ❌ 페이지 전체 새로고침

프로젝트 설정

1. 의존성 추가

 
xml
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Thymeleaf -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    
    <!-- Thymeleaf Layout Dialect -->
    <dependency>
        <groupId>nz.net.ultraq.thymeleaf</groupId>
        <artifactId>thymeleaf-layout-dialect</artifactId>
        <version>3.3.0</version>
    </dependency>
    
    <!-- Bootstrap (WebJar) -->
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>bootstrap</artifactId>
        <version>5.3.0</version>
    </dependency>
    
    <!-- jQuery (WebJar) -->
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery</artifactId>
        <version>3.7.1</version>
    </dependency>
</dependencies>

2. application.properties 설정

 
properties
# Thymeleaf 설정
spring.thymeleaf.cache=false  # 개발 중에는 캐시 비활성화
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.servlet.content-type=text/html

# 정적 리소스 설정
spring.web.resources.static-locations=classpath:/static/
spring.web.resources.cache.period=0

3. 프로젝트 구조

 
src/main/resources/
├── templates/
│   ├── layout/
│   │   └── layout.html         # 공통 레이아웃
│   ├── students/
│   │   ├── list.html          # 목록 페이지
│   │   ├── form.html          # 등록/수정 폼
│   │   └── detail.html        # 상세 페이지
│   ├── error/
│   │   ├── 404.html          # 404 에러 페이지
│   │   └── 500.html          # 500 에러 페이지
│   └── index.html             # 홈페이지
└── static/
    ├── css/
    │   └── style.css          # 커스텀 CSS
    ├── js/
    │   └── app.js             # 커스텀 JavaScript
    └── images/
        └── logo.png           # 이미지 파일

Controller 리팩토링

REST Controller → View Controller 변환

기존의 @RestController를 @Controller로 변경하고 뷰를 반환하도록 수정합니다.

 
java
package com.university.controller;

import com.university.dto.StudentDto;
import com.university.service.StudentService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import jakarta.validation.Valid;

@Controller  // @RestController 대신 @Controller
@RequestMapping("/students")
@RequiredArgsConstructor
public class StudentController {
    
    private final StudentService studentService;
    
    // 학생 목록 페이지
    @GetMapping
    public String listStudents(
            @RequestParam(required = false) String search,
            Model model) {
        
        if (search != null && !search.isEmpty()) {
            model.addAttribute("students", studentService.searchStudents(search));
            model.addAttribute("search", search);
        } else {
            model.addAttribute("students", studentService.getAllStudents());
        }
        
        return "students/list";  // templates/students/list.html
    }
    
    // 학생 등록 폼
    @GetMapping("/new")
    public String showCreateForm(Model model) {
        model.addAttribute("student", new StudentDto());
        model.addAttribute("pageTitle", "학생 등록");
        return "students/form";
    }
    
    // 학생 등록 처리
    @PostMapping
    public String createStudent(
            @Valid @ModelAttribute("student") StudentDto studentDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (result.hasErrors()) {
            return "students/form";
        }
        
        try {
            studentService.createStudent(studentDto);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생이 성공적으로 등록되었습니다.");
            return "redirect:/students";
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "등록 중 오류가 발생했습니다: " + e.getMessage());
            return "redirect:/students/new";
        }
    }
    
    // 학생 수정 폼
    @GetMapping("/edit/{id}")
    public String showEditForm(@PathVariable Long id, Model model) {
        StudentDto student = studentService.getStudentById(id);
        model.addAttribute("student", student);
        model.addAttribute("pageTitle", "학생 정보 수정");
        return "students/form";
    }
    
    // 학생 수정 처리
    @PostMapping("/edit/{id}")
    public String updateStudent(
            @PathVariable Long id,
            @Valid @ModelAttribute("student") StudentDto studentDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (result.hasErrors()) {
            return "students/form";
        }
        
        try {
            studentService.updateStudent(id, studentDto);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생 정보가 수정되었습니다.");
            return "redirect:/students";
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "수정 중 오류가 발생했습니다: " + e.getMessage());
            return "redirect:/students/edit/" + id;
        }
    }
    
    // 학생 삭제
    @PostMapping("/delete/{id}")
    public String deleteStudent(
            @PathVariable Long id,
            RedirectAttributes redirectAttributes) {
        
        try {
            studentService.deleteStudent(id);
            redirectAttributes.addFlashAttribute("successMessage", 
                "학생이 삭제되었습니다.");
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("errorMessage", 
                "삭제 중 오류가 발생했습니다: " + e.getMessage());
        }
        
        return "redirect:/students";
    }
    
    // 학생 상세 정보
    @GetMapping("/{id}")
    public String viewStudent(@PathVariable Long id, Model model) {
        model.addAttribute("student", studentService.getStudentById(id));
        return "students/detail";
    }
}

레이아웃 템플릿 만들기

공통 레이아웃 (layout/layout.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
    <!-- 페이지별 제목 -->
    <title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">학생 관리 시스템</title>
    
    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" 
          rel="stylesheet">
    
    <!-- Bootstrap Icons -->
    <link rel="stylesheet" 
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
    
    <!-- Custom CSS -->
    <link th:href="@{/css/style.css}" rel="stylesheet">
    
    <!-- 페이지별 추가 CSS -->
    <th:block layout:fragment="css"></th:block>
</head>
<body>
    <!-- Navigation -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
        <div class="container">
            <a class="navbar-brand" th:href="@{/}">
                <i class="bi bi-mortarboard-fill"></i>
                학생 관리 시스템
            </a>
            
            <button class="navbar-toggler" type="button" 
                    data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/}" 
                           th:classappend="${#httpServletRequest.requestURI == '/'} ? 'active'">
                            <i class="bi bi-house"></i> 홈
                        </a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/students}"
                           th:classappend="${#strings.contains(#httpServletRequest.requestURI, '/students')} ? 'active'">
                            <i class="bi bi-people"></i> 학생 관리
                        </a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" th:href="@{/students/new}">
                            <i class="bi bi-person-plus"></i> 학생 등록
                        </a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>
    
    <!-- Alert Messages -->
    <div class="container mt-3">
        <!-- Success Message -->
        <div th:if="${successMessage}" 
             class="alert alert-success alert-dismissible fade show" role="alert">
            <i class="bi bi-check-circle"></i>
            <span th:text="${successMessage}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
        
        <!-- Error Message -->
        <div th:if="${errorMessage}" 
             class="alert alert-danger alert-dismissible fade show" role="alert">
            <i class="bi bi-exclamation-circle"></i>
            <span th:text="${errorMessage}"></span>
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
    </div>
    
    <!-- Main Content -->
    <main class="container my-4">
        <div layout:fragment="content">
            <!-- 각 페이지의 콘텐츠가 여기에 삽입됩니다 -->
        </div>
    </main>
    
    <!-- Footer -->
    <footer class="bg-light py-4 mt-5">
        <div class="container text-center">
            <p class="text-muted mb-0">
                &copy; <span th:text="${#dates.year(#dates.createNow())}"></span> 
                학생 관리 시스템. All rights reserved.
            </p>
        </div>
    </footer>
    
    <!-- Bootstrap JS Bundle -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    
    <!-- jQuery -->
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    
    <!-- Custom JS -->
    <script th:src="@{/js/app.js}"></script>
    
    <!-- 페이지별 추가 JavaScript -->
    <th:block layout:fragment="script"></th:block>
</body>
</html>

CRUD 페이지 구현

1. 학생 목록 페이지 (students/list.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>학생 목록</title>
</head>
<body>
<div layout:fragment="content">
    <!-- Page Header -->
    <div class="row mb-4">
        <div class="col">
            <h1 class="h2">
                <i class="bi bi-people-fill"></i> 학생 목록
            </h1>
        </div>
        <div class="col-auto">
            <a th:href="@{/students/new}" class="btn btn-primary">
                <i class="bi bi-plus-circle"></i> 새 학생 등록
            </a>
        </div>
    </div>
    
    <!-- Search Form -->
    <div class="card mb-4">
        <div class="card-body">
            <form th:action="@{/students}" method="get" class="row g-3">
                <div class="col-md-10">
                    <div class="input-group">
                        <span class="input-group-text">
                            <i class="bi bi-search"></i>
                        </span>
                        <input type="text" name="search" class="form-control" 
                               placeholder="이름, 학번, 이메일로 검색..."
                               th:value="${search}">
                    </div>
                </div>
                <div class="col-md-2">
                    <button type="submit" class="btn btn-primary w-100">검색</button>
                </div>
            </form>
        </div>
    </div>
    
    <!-- Students Table -->
    <div class="card">
        <div class="card-header">
            <span class="badge bg-secondary" th:text="${#lists.size(students)} + '명'"></span>
            의 학생이 등록되어 있습니다.
        </div>
        <div class="card-body p-0">
            <div class="table-responsive">
                <table class="table table-hover mb-0">
                    <thead class="table-light">
                        <tr>
                            <th>학번</th>
                            <th>이름</th>
                            <th>이메일</th>
                            <th>학과</th>
                            <th>전화번호</th>
                            <th>상태</th>
                            <th>등록일</th>
                            <th class="text-center">액션</th>
                        </tr>
                    </thead>
                    <tbody>
                        <!-- 학생 데이터 반복 -->
                        <tr th:each="student : ${students}" 
                            th:onclick="'window.location.href=\'' + @{/students/{id}(id=${student.id})} + '\''"
                            style="cursor: pointer;">
                            <td th:text="${student.studentId}">20240001</td>
                            <td>
                                <strong th:text="${student.name}">홍길동</strong>
                            </td>
                            <td>
                                <a th:href="'mailto:' + ${student.email}" 
                                   th:text="${student.email}"
                                   onclick="event.stopPropagation();">hong@example.com</a>
                            </td>
                            <td th:text="${student.department}">컴퓨터공학과</td>
                            <td th:text="${student.phoneNumber}">010-1234-5678</td>
                            <td>
                                <span class="badge"
                                      th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' : 
                                                     (${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
                                      th:text="${student.status == 'ACTIVE'} ? '활동중' : 
                                              (${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
                                    활동중
                                </span>
                            </td>
                            <td th:text="${#temporals.format(student.joinDate, 'yyyy-MM-dd')}">
                                2024-01-01
                            </td>
                            <td class="text-center" onclick="event.stopPropagation();">
                                <div class="btn-group btn-group-sm" role="group">
                                    <a th:href="@{/students/{id}(id=${student.id})}" 
                                       class="btn btn-outline-info" title="상세보기">
                                        <i class="bi bi-eye"></i>
                                    </a>
                                    <a th:href="@{/students/edit/{id}(id=${student.id})}" 
                                       class="btn btn-outline-primary" title="수정">
                                        <i class="bi bi-pencil"></i>
                                    </a>
                                    <button type="button" 
                                            class="btn btn-outline-danger delete-btn" 
                                            th:data-id="${student.id}"
                                            th:data-name="${student.name}"
                                            title="삭제">
                                        <i class="bi bi-trash"></i>
                                    </button>
                                </div>
                            </td>
                        </tr>
                        
                        <!-- 데이터가 없을 때 -->
                        <tr th:if="${#lists.isEmpty(students)}">
                            <td colspan="8" class="text-center py-5">
                                <div class="text-muted">
                                    <i class="bi bi-inbox display-1"></i>
                                    <p class="mt-3">등록된 학생이 없습니다.</p>
                                    <a th:href="@{/students/new}" class="btn btn-primary">
                                        첫 학생 등록하기
                                    </a>
                                </div>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    
    <!-- Delete Confirmation Modal -->
    <div class="modal fade" id="deleteModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">
                        <i class="bi bi-exclamation-triangle text-danger"></i>
                        삭제 확인
                    </h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <p>정말로 <strong id="deleteStudentName"></strong> 학생을 삭제하시겠습니까?</p>
                    <p class="text-danger mb-0">
                        <small>이 작업은 되돌릴 수 없습니다.</small>
                    </p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
                        취소
                    </button>
                    <form id="deleteForm" method="post" style="display: inline;">
                        <button type="submit" class="btn btn-danger">
                            <i class="bi bi-trash"></i> 삭제
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
    // 삭제 버튼 클릭 이벤트
    $('.delete-btn').click(function() {
        const id = $(this).data('id');
        const name = $(this).data('name');
        
        $('#deleteStudentName').text(name);
        $('#deleteForm').attr('action', '/students/delete/' + id);
        
        const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
        deleteModal.show();
    });
    
    // 검색어 하이라이트
    const searchTerm = '[[${search}]]';
    if (searchTerm) {
        $('.table tbody td').each(function() {
            const text = $(this).text();
            const regex = new RegExp('(' + searchTerm + ')', 'gi');
            const highlighted = text.replace(regex, '<mark>$1</mark>');
            $(this).html(highlighted);
        });
    }
});
</script>
</th:block>
</body>
</html>

2. 학생 등록/수정 폼 (students/form.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title th:text="${pageTitle}">학생 정보</title>
</head>
<body>
<div layout:fragment="content">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">
                    <h3 class="mb-0" th:text="${pageTitle}">학생 정보</h3>
                </div>
                <div class="card-body">
                    <form th:action="${student.id != null} ? @{/students/edit/{id}(id=${student.id})} : @{/students}"
                          th:object="${student}" 
                          method="post" 
                          novalidate>
                        
                        <!-- 학번 -->
                        <div class="mb-3">
                            <label for="studentId" class="form-label">
                                학번 <span class="text-danger">*</span>
                            </label>
                            <input type="text" 
                                   class="form-control" 
                                   id="studentId"
                                   th:field="*{studentId}"
                                   th:classappend="${#fields.hasErrors('studentId')} ? 'is-invalid'"
                                   placeholder="20240001"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('studentId')}" 
                                 th:errors="*{studentId}">
                                학번 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 이름 -->
                        <div class="mb-3">
                            <label for="name" class="form-label">
                                이름 <span class="text-danger">*</span>
                            </label>
                            <input type="text" 
                                   class="form-control" 
                                   id="name"
                                   th:field="*{name}"
                                   th:classappend="${#fields.hasErrors('name')} ? 'is-invalid'"
                                   placeholder="홍길동"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('name')}" 
                                 th:errors="*{name}">
                                이름 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 이메일 -->
                        <div class="mb-3">
                            <label for="email" class="form-label">
                                이메일 <span class="text-danger">*</span>
                            </label>
                            <input type="email" 
                                   class="form-control" 
                                   id="email"
                                   th:field="*{email}"
                                   th:classappend="${#fields.hasErrors('email')} ? 'is-invalid'"
                                   placeholder="hong@example.com"
                                   required>
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('email')}" 
                                 th:errors="*{email}">
                                이메일 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 학과 -->
                        <div class="mb-3">
                            <label for="department" class="form-label">학과</label>
                            <select class="form-select" 
                                    id="department" 
                                    th:field="*{department}">
                                <option value="">선택하세요</option>
                                <option value="컴퓨터공학과">컴퓨터공학과</option>
                                <option value="전자공학과">전자공학과</option>
                                <option value="기계공학과">기계공학과</option>
                                <option value="경영학과">경영학과</option>
                                <option value="국제학부">국제학부</option>
                            </select>
                        </div>
                        
                        <!-- 전화번호 -->
                        <div class="mb-3">
                            <label for="phoneNumber" class="form-label">전화번호</label>
                            <input type="tel" 
                                   class="form-control" 
                                   id="phoneNumber"
                                   th:field="*{phoneNumber}"
                                   th:classappend="${#fields.hasErrors('phoneNumber')} ? 'is-invalid'"
                                   placeholder="010-1234-5678">
                            <div class="invalid-feedback" 
                                 th:if="${#fields.hasErrors('phoneNumber')}" 
                                 th:errors="*{phoneNumber}">
                                전화번호 오류 메시지
                            </div>
                        </div>
                        
                        <!-- 상태 (수정 시에만 표시) -->
                        <div class="mb-3" th:if="${student.id != null}">
                            <label for="status" class="form-label">상태</label>
                            <select class="form-select" 
                                    id="status" 
                                    th:field="*{status}">
                                <option value="ACTIVE">활동중</option>
                                <option value="INACTIVE">비활동</option>
                                <option value="GRADUATED">졸업</option>
                            </select>
                        </div>
                        
                        <!-- 자기소개 -->
                        <div class="mb-3">
                            <label for="introduction" class="form-label">자기소개</label>
                            <textarea class="form-control" 
                                      id="introduction" 
                                      th:field="*{introduction}"
                                      rows="3"
                                      placeholder="간단한 자기소개를 작성해주세요."></textarea>
                        </div>
                        
                        <!-- 버튼 -->
                        <div class="d-grid gap-2 d-md-flex justify-content-md-end">
                            <a th:href="@{/students}" class="btn btn-secondary">
                                <i class="bi bi-x-circle"></i> 취소
                            </a>
                            <button type="submit" class="btn btn-primary">
                                <i class="bi bi-check-circle"></i>
                                <span th:text="${student.id != null} ? '수정' : '등록'">등록</span>
                            </button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
    // 전화번호 자동 포맷
    $('#phoneNumber').on('input', function() {
        let value = $(this).val().replace(/[^0-9]/g, '');
        let formatted = '';
        
        if (value.length <= 3) {
            formatted = value;
        } else if (value.length <= 7) {
            formatted = value.slice(0, 3) + '-' + value.slice(3);
        } else if (value.length <= 11) {
            formatted = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7);
        }
        
        $(this).val(formatted);
    });
    
    // 클라이언트 사이드 유효성 검증
    $('form').on('submit', function(e) {
        let isValid = true;
        
        // 학번 검증
        const studentId = $('#studentId').val();
        if (!/^\d{8}$/.test(studentId)) {
            $('#studentId').addClass('is-invalid');
            isValid = false;
        }
        
        // 이메일 검증
        const email = $('#email').val();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
            $('#email').addClass('is-invalid');
            isValid = false;
        }
        
        if (!isValid) {
            e.preventDefault();
        }
    });
});
</script>
</th:block>
</body>
</html>

3. 학생 상세 페이지 (students/detail.html)

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>학생 상세 정보</title>
</head>
<body>
<div layout:fragment="content">
    <div class="row">
        <div class="col-md-8 mx-auto">
            <!-- 학생 정보 카드 -->
            <div class="card mb-4">
                <div class="card-header bg-primary text-white">
                    <h3 class="mb-0">
                        <i class="bi bi-person-badge"></i>
                        학생 정보
                    </h3>
                </div>
                <div class="card-body">
                    <div class="row mb-3">
                        <div class="col-md-4 text-center mb-3">
                            <div class="avatar-placeholder">
                                <i class="bi bi-person-circle" style="font-size: 8rem;"></i>
                            </div>
                            <h4 class="mt-3" th:text="${student.name}">홍길동</h4>
                            <span class="badge" 
                                  th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' : 
                                                 (${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
                                  th:text="${student.status == 'ACTIVE'} ? '활동중' : 
                                          (${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
                                활동중
                            </span>
                        </div>
                        <div class="col-md-8">
                            <table class="table table-borderless">
                                <tr>
                                    <th class="text-muted" style="width: 30%;">학번</th>
                                    <td th:text="${student.studentId}">20240001</td>
                                </tr>
                                <tr>
                                    <th class="text-muted">이메일</th>
                                    <td>
                                        <a th:href="'mailto:' + ${student.email}" 
                                           th:text="${student.email}">
                                            hong@example.com
                                        </a>
                                    </td>
                                </tr>
                                <tr>
                                    <th class="text-muted">학과</th>
                                    <td th:text="${student.department ?: '-'}">컴퓨터공학과</td>
                                </tr>
                                <tr>
                                    <th class="text-muted">전화번호</th>
                                    <td>
                                        <a th:if="${student.phoneNumber}" 
                                           th:href="'tel:' + ${student.phoneNumber}" 
                                           th:text="${student.phoneNumber}">
                                            010-1234-5678
                                        </a>
                                        <span th:unless="${student.phoneNumber}">-</span>
                                    </td>
                                </tr>
                                <tr>
                                    <th class="text-muted">가입일</th>
                                    <td th:text="${#temporals.format(student.joinDate, 'yyyy년 MM월 dd일')}">
                                        2024년 01월 01일
                                    </td>
                                </tr>
                            </table>
                        </div>
                    </div>
                    
                    <!-- 자기소개 -->
                    <div th:if="${student.introduction}" class="mt-4">
                        <h5 class="text-muted">자기소개</h5>
                        <div class="card bg-light">
                            <div class="card-body">
                                <p class="mb-0" th:text="${student.introduction}">
                                    자기소개 내용이 여기에 표시됩니다.
                                </p>
                            </div>
                        </div>
                    </div>
                    
                    <!-- 타임스탬프 -->
                    <div class="text-muted small mt-4">
                        <div th:if="${student.createdAt}">
                            등록일시: <span th:text="${#temporals.format(student.createdAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
                        </div>
                        <div th:if="${student.updatedAt}">
                            수정일시: <span th:text="${#temporals.format(student.updatedAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
                        </div>
                    </div>
                </div>
                <div class="card-footer">
                    <div class="d-flex justify-content-between">
                        <a th:href="@{/students}" class="btn btn-secondary">
                            <i class="bi bi-arrow-left"></i> 목록으로
                        </a>
                        <div>
                            <a th:href="@{/students/edit/{id}(id=${student.id})}" 
                               class="btn btn-primary">
                                <i class="bi bi-pencil"></i> 수정
                            </a>
                            <button type="button" 
                                    class="btn btn-danger"
                                    th:data-id="${student.id}"
                                    th:data-name="${student.name}"
                                    onclick="confirmDelete(this)">
                                <i class="bi bi-trash"></i> 삭제
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
function confirmDelete(button) {
    const id = button.getAttribute('data-id');
    const name = button.getAttribute('data-name');
    
    if (confirm(`정말로 ${name} 학생을 삭제하시겠습니까?`)) {
        const form = document.createElement('form');
        form.method = 'POST';
        form.action = `/students/delete/${id}`;
        document.body.appendChild(form);
        form.submit();
    }
}
</script>
</th:block>
</body>
</html>

폼 유효성 검증

DTO with Validation

 
java
package com.university.dto;

import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;

@Data
public class StudentDto {
    
    private Long id;
    
    @NotBlank(message = "학번은 필수입니다")
    @Pattern(regexp = "^\\d{8}$", message = "학번은 8자리 숫자여야 합니다")
    private String studentId;
    
    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
    private String name;
    
    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;
    
    @Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
    private String department;
    
    @Pattern(regexp = "^(010-\\d{4}-\\d{4})?$", 
             message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다")
    private String phoneNumber;
    
    @Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
    private String introduction;
    
    private String status = "ACTIVE";
    
    private LocalDate joinDate;
}

전역 유효성 검증 메시지

src/main/resources/messages.properties:

 
properties
# 공통 메시지
NotBlank={0}은(는) 필수 입력 항목입니다.
NotNull={0}은(는) null일 수 없습니다.
Size={0}은(는) {2}자 이상 {1}자 이하여야 합니다.
Email=올바른 이메일 형식이 아닙니다.
Pattern={0}의 형식이 올바르지 않습니다.

# 필드별 메시지
studentId=학번
name=이름
email=이메일
department=학과
phoneNumber=전화번호
introduction=자기소개

# 커스텀 메시지
student.duplicate.studentId=이미 존재하는 학번입니다.
student.duplicate.email=이미 존재하는 이메일입니다.
student.notfound=학생을 찾을 수 없습니다.

JavaScript 연동

static/js/app.js

 
javascript
// 전역 설정
$(document).ready(function() {
    // CSRF 토큰 설정 (Spring Security 사용 시)
    const token = $("meta[name='_csrf']").attr("content");
    const header = $("meta[name='_csrf_header']").attr("content");
    
    if (token && header) {
        $.ajaxSetup({
            beforeSend: function(xhr) {
                xhr.setRequestHeader(header, token);
            }
        });
    }
    
    // 툴팁 초기화
    const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
    tooltipTriggerList.map(function (tooltipTriggerEl) {
        return new bootstrap.Tooltip(tooltipTriggerEl);
    });
    
    // 알림 메시지 자동 숨김
    setTimeout(function() {
        $('.alert').fadeOut('slow');
    }, 5000);
});

// 공통 함수
const StudentApp = {
    // Ajax 요청 래퍼
    ajax: function(url, method, data, successCallback, errorCallback) {
        $.ajax({
            url: url,
            method: method,
            data: JSON.stringify(data),
            contentType: 'application/json',
            success: successCallback,
            error: function(xhr, status, error) {
                if (errorCallback) {
                    errorCallback(xhr, status, error);
                } else {
                    alert('오류가 발생했습니다: ' + error);
                }
            }
        });
    },
    
    // 폼 데이터를 JSON으로 변환
    formToJson: function(formSelector) {
        const formData = {};
        $(formSelector).serializeArray().forEach(function(item) {
            formData[item.name] = item.value;
        });
        return formData;
    },
    
    // 로딩 표시
    showLoading: function() {
        $('body').append('<div class="loading-overlay"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div></div>');
    },
    
    hideLoading: function() {
        $('.loading-overlay').remove();
    }
};

에러 처리

에러 페이지

templates/error/404.html:

 
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <title>페이지를 찾을 수 없습니다</title>
</head>
<body>
<div layout:fragment="content">
    <div class="text-center py-5">
        <h1 class="display-1">404</h1>
        <p class="fs-3"><span class="text-danger">앗!</span> 페이지를 찾을 수 없습니다.</p>
        <p class="lead">요청하신 페이지가 존재하지 않습니다.</p>
        <a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
    </div>
</div>
</body>
</html>

GlobalExceptionHandler

 
java
@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(StudentNotFoundException.class)
    public String handleStudentNotFound(
            StudentNotFoundException ex,
            RedirectAttributes redirectAttributes) {
        redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
        return "redirect:/students";
    }
    
    @ExceptionHandler(DataIntegrityViolationException.class)
    public String handleDataIntegrityViolation(
            DataIntegrityViolationException ex,
            RedirectAttributes redirectAttributes) {
        redirectAttributes.addFlashAttribute("errorMessage", 
            "데이터 무결성 오류가 발생했습니다. 입력값을 확인해주세요.");
        return "redirect:/students";
    }
}

실전 팁

1. Thymeleaf 유용한 표현식

 
html
<!-- 조건부 렌더링 -->
<div th:if="${user.role == 'ADMIN'}">관리자 메뉴</div>
<div th:unless="${user.role == 'ADMIN'}">일반 사용자 메뉴</div>

<!-- 삼항 연산자 -->
<span th:text="${user.active} ? '활성' : '비활성'"></span>

<!-- Switch 문 -->
<div th:switch="${user.status}">
    <p th:case="'ACTIVE'">활동중</p>
    <p th:case="'INACTIVE'">비활동</p>
    <p th:case="*">알 수 없음</p>
</div>

<!-- 컬렉션 반복 with 인덱스 -->
<tr th:each="item, stat : ${items}">
    <td th:text="${stat.index}">0</td>
    <td th:text="${stat.count}">1</td>
    <td th:text="${item.name}">이름</td>
    <td th:text="${stat.first} ? '첫번째' : ''"></td>
    <td th:text="${stat.last} ? '마지막' : ''"></td>
</tr>

<!-- Fragment 파라미터 -->
<div th:replace="~{fragments/pagination :: pagination(${page}, ${totalPages})}"></div>

<!-- JavaScript 인라인 -->
<script th:inline="javascript">
    const userId = /*[[${user.id}]]*/ 0;
    const userName = /*[[${user.name}]]*/ 'Guest';
</script>

2. 성능 최적화

 
properties
# 프로덕션 환경 설정
spring.thymeleaf.cache=true
spring.web.resources.cache.period=31536000
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**

3. 보안 강화

 
html
<!-- CSRF 토큰 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>

<!-- XSS 방지 -->
<p th:text="${userInput}">자동 이스케이프</p>
<p th:utext="${trustedHtml}">HTML 허용 (주의!)</p>

4. 국제화 (i18n)

messages.properties:

 
properties
app.title=학생 관리 시스템
student.list.title=학생 목록
student.form.title=학생 정보

messages_en.properties:

 
properties
app.title=Student Management System
student.list.title=Student List
student.form.title=Student Information

사용:

 
html
<h1 th:text="#{student.list.title}">학생 목록</h1>

마무리

Thymeleaf는 Spring Boot와 완벽하게 통합되어 빠르게 웹 애플리케이션을 개발할 수 있게 해줍니다. 특히 관리자 페이지나 내부 시스템처럼 복잡한 상호작용이 필요 없는 경우에는 최적의 선택입니다.

장점 정리

  • 빠른 개발: 설정이 간단하고 바로 시작 가능
  • 서버 사이드 렌더링: SEO 친화적, 초기 로딩 빠름
  • Spring 통합: Spring Security, Validation 등과 완벽 호환
  • Natural Template: 디자이너와 협업 용이

언제 사용할까?

  • 관리자 대시보드
  • 기업 내부 시스템
  • SEO가 중요한 콘텐츠 사이트
  • 빠른 프로토타이핑

다음 단계

  • Spring Security 통합
  • WebSocket으로 실시간 기능 추가
  • PDF/Excel 내보내기
  • 파일 업로드 구현

태그: #SpringBoot #Thymeleaf #Bootstrap #CRUD #WebDevelopment #Java

작성일: 2025년 1월 20일

이 포스트가 도움이 되셨다면 ⭐️ 스타를 눌러주세요!

728x90
반응형
2025-07-11 09:30:30
728x90
반응형

Spring Boot H2 Console 사용 완벽 가이드

🗄️ H2 Console이란?

H2 Database는 자바 기반의 경량 인메모리 데이터베이스로, 개발 중에 매우 유용합니다. H2 Console은 웹 기반 데이터베이스 관리 도구로, 별도의 프로그램 설치 없이 브라우저에서 직접 데이터베이스를 조회하고 관리할 수 있습니다.

1. H2 Console 설정하기

application.properties 설정

 
properties
# H2 Console 활성화
spring.h2.console.enabled=true

# H2 Console 경로 설정 (기본값: /h2-console)
spring.h2.console.path=/h2-console

# 외부 접속 허용 (개발 환경에서만 사용)
spring.h2.console.settings.web-allow-others=true

# H2 Database 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

2. H2 Console 접속하기

Step 1: 애플리케이션 실행

 
bash
mvn spring-boot:run
# 또는
./mvnw spring-boot:run

Step 2: 브라우저에서 H2 Console 열기

 
http://localhost:8080/h2-console

Step 3: 로그인 화면 설정

이미지 표시

로그인 화면에서 다음과 같이 입력합니다:

필드값설명
Driver Class org.h2.Driver H2 드라이버 클래스
JDBC URL jdbc:h2:mem:testdb 인메모리 DB URL
User Name sa 기본 사용자명
Password (비워둠) 비밀번호 없음

⚠️ 중요: JDBC URL은 application.properties의 spring.datasource.url과 정확히 일치해야 합니다!

Step 4: Connect 클릭

3. H2 Console 주요 기능

3-1. 테이블 목록 보기

접속 후 왼쪽 패널에서 테이블 목록을 확인할 수 있습니다:

 
MEMBERS
PROJECTS  
EVENTS
PROJECT_MEMBERS (조인 테이블)

3-2. 테이블 구조 확인

테이블 이름을 클릭하면 자동으로 SQL이 생성됩니다:

 
sql
-- MEMBERS 테이블 구조 보기
SHOW COLUMNS FROM MEMBERS;

-- 또는 테이블 생성 스크립트 보기
SCRIPT NODATA;

3-3. 데이터 조회

 
sql
-- 모든 회원 조회
SELECT * FROM MEMBERS;

-- 조건부 조회
SELECT * FROM MEMBERS WHERE STATUS = 'ACTIVE';

-- 조인 쿼리
SELECT m.*, p.TITLE 
FROM MEMBERS m
JOIN PROJECT_MEMBERS pm ON m.ID = pm.MEMBER_ID
JOIN PROJECTS p ON pm.PROJECT_ID = p.ID;

3-4. 데이터 삽입

 
sql
-- 회원 추가
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, ROLE, STATUS, JOIN_DATE) 
VALUES ('20240001', '김한동', 'kim@handong.edu', '전산전자공학부', 'MEMBER', 'ACTIVE', CURRENT_DATE);

-- 여러 회원 한번에 추가
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, ROLE, STATUS, JOIN_DATE) VALUES
('20240002', '이한동', 'lee@handong.edu', '전산전자공학부', 'MEMBER', 'ACTIVE', CURRENT_DATE),
('20240003', '박한동', 'park@handong.edu', 'AI융합교육원', 'VICE_PRESIDENT', 'ACTIVE', CURRENT_DATE),
('20240004', '최한동', 'choi@handong.edu', '경영경제학부', 'SECRETARY', 'ACTIVE', CURRENT_DATE);

3-5. 데이터 수정

 
sql
-- 회원 정보 수정
UPDATE MEMBERS 
SET ROLE = 'PRESIDENT', 
    UPDATED_AT = CURRENT_TIMESTAMP 
WHERE STUDENT_ID = '20240001';

-- 여러 회원 상태 변경
UPDATE MEMBERS 
SET STATUS = 'INACTIVE' 
WHERE JOIN_DATE < '2023-01-01';

3-6. 데이터 삭제

 
sql
-- 특정 회원 삭제
DELETE FROM MEMBERS WHERE ID = 1;

-- 조건부 삭제
DELETE FROM MEMBERS WHERE STATUS = 'GRADUATED';

4. 실전 활용 팁

4-1. 초기 데이터 설정

src/main/resources/data.sql 파일을 생성하면 애플리케이션 시작 시 자동 실행됩니다:

 
sql
-- data.sql
INSERT INTO MEMBERS (STUDENT_ID, NAME, EMAIL, DEPARTMENT, PHONE_NUMBER, ROLE, STATUS, JOIN_DATE, INTRODUCTION) VALUES
('20240001', '김한동', 'kim@handong.edu', '전산전자공학부', '010-1234-5678', 'PRESIDENT', 'ACTIVE', '2024-03-01', '안녕하세요, 회장 김한동입니다.'),
('20240002', '이한동', 'lee@handong.edu', '전산전자공학부', '010-2345-6789', 'VICE_PRESIDENT', 'ACTIVE', '2024-03-01', '부회장을 맡고 있습니다.'),
('20240003', '박한동', 'park@handong.edu', 'AI융합교육원', '010-3456-7890', 'SECRETARY', 'ACTIVE', '2024-03-15', '총무를 담당하고 있습니다.'),
('20240004', '최한동', 'choi@handong.edu', '경영경제학부', '010-4567-8901', 'MEMBER', 'ACTIVE', '2024-04-01', '열심히 활동하겠습니다!'),
('20240005', '정한동', 'jung@handong.edu', '기계제어공학부', '010-5678-9012', 'MEMBER', 'ACTIVE', '2024-04-15', '미디어 제작에 관심이 많습니다.');

INSERT INTO PROJECTS (TITLE, DESCRIPTION, PROJECT_TYPE, STATUS, START_DATE, END_DATE, LEADER_ID) VALUES
('2024 홍보 영상 제작', '학회 홍보를 위한 공식 영상 제작 프로젝트', '영상', 'IN_PROGRESS', '2024-05-01', '2024-06-30', 1),
('웹사이트 리뉴얼', '학회 공식 웹사이트 전면 개편', '웹개발', 'PLANNING', '2024-07-01', '2024-09-30', 2),
('사진전 준비', '연말 사진전시회 준비', '사진', 'PLANNING', '2024-10-01', '2024-12-20', 3);

INSERT INTO EVENTS (TITLE, DESCRIPTION, LOCATION, EVENT_DATE_TIME, EVENT_TYPE, MAX_PARTICIPANTS, CURRENT_PARTICIPANTS, ORGANIZER_ID) VALUES
('영상 편집 워크샵', 'Premiere Pro 기초부터 심화까지', '뉴턴홀 313호', '2024-05-15 14:00:00', 'WORKSHOP', 30, 15, 1),
('정기 총회', '2024년 상반기 정기 총회', '학생회관 세미나실', '2024-06-01 18:00:00', 'MEETING', 50, 0, 1),
('네트워킹 파티', '신입 회원 환영회 겸 네트워킹', '카페 한동', '2024-05-20 19:00:00', 'SOCIAL', 40, 25, 4);

-- 프로젝트 참여자 매핑
INSERT INTO PROJECT_MEMBERS (PROJECT_ID, MEMBER_ID) VALUES
(1, 1), (1, 2), (1, 4),
(2, 2), (2, 3), (2, 5),
(3, 3), (3, 4), (3, 5);

4-2. 유용한 쿼리 모음

 
sql
-- 1. 회원별 참여 프로젝트 수
SELECT m.NAME, COUNT(pm.PROJECT_ID) as PROJECT_COUNT
FROM MEMBERS m
LEFT JOIN PROJECT_MEMBERS pm ON m.ID = pm.MEMBER_ID
GROUP BY m.ID, m.NAME
ORDER BY PROJECT_COUNT DESC;

-- 2. 역할별 회원 수
SELECT ROLE, COUNT(*) as MEMBER_COUNT
FROM MEMBERS
WHERE STATUS = 'ACTIVE'
GROUP BY ROLE;

-- 3. 이번 달 예정된 이벤트
SELECT * FROM EVENTS
WHERE MONTH(EVENT_DATE_TIME) = MONTH(CURRENT_DATE)
AND YEAR(EVENT_DATE_TIME) = YEAR(CURRENT_DATE)
ORDER BY EVENT_DATE_TIME;

-- 4. 프로젝트별 참여 인원
SELECT p.TITLE, p.STATUS, COUNT(pm.MEMBER_ID) as PARTICIPANT_COUNT
FROM PROJECTS p
LEFT JOIN PROJECT_MEMBERS pm ON p.ID = pm.PROJECT_ID
GROUP BY p.ID, p.TITLE, p.STATUS;

-- 5. 참가 가능한 이벤트 (정원 미달)
SELECT TITLE, EVENT_DATE_TIME, 
       MAX_PARTICIPANTS - CURRENT_PARTICIPANTS as AVAILABLE_SEATS
FROM EVENTS
WHERE EVENT_DATE_TIME > CURRENT_TIMESTAMP
AND CURRENT_PARTICIPANTS < MAX_PARTICIPANTS
ORDER BY EVENT_DATE_TIME;

4-3. 데이터 내보내기

 
sql
-- 전체 데이터를 SQL 스크립트로 내보내기
SCRIPT;

-- 특정 테이블만 내보내기
SCRIPT SIMPLE NODATA FROM MEMBERS;

-- CSV로 내보내기
CALL CSVWRITE('~/members.csv', 'SELECT * FROM MEMBERS');

5. 문제 해결

5-1. H2 Console 접속 불가

문제: localhost:8080/h2-console 접속 시 404 에러

해결방법:

 
properties
# application.properties 확인
spring.h2.console.enabled=true

5-2. Database "testdb" not found

문제: JDBC URL이 맞지 않음

해결방법:

 
properties
# application.properties의 URL과 동일하게 입력
spring.datasource.url=jdbc:h2:mem:testdb

5-3. 테이블이 보이지 않음

문제: DDL이 실행되지 않음

해결방법:

 
properties
# JPA 설정 확인
spring.jpa.hibernate.ddl-auto=create-drop
# 또는
spring.jpa.hibernate.ddl-auto=update

5-4. 한글 깨짐

문제: 한글 데이터가 ???로 표시

해결방법:

 
properties
# URL에 인코딩 추가
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;INIT=CREATE SCHEMA IF NOT EXISTS testdb\\;SET SCHEMA testdb

6. 보안 주의사항

🚨 운영 환경에서는 H2 Console 비활성화!

 
properties
# application-prod.properties
spring.h2.console.enabled=false

프로파일별 설정

 
properties
# application-dev.properties (개발용)
spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=true

# application-prod.properties (운영용)
spring.h2.console.enabled=false

7. H2 Console 단축키

단축키기능
Ctrl + Enter 쿼리 실행
Ctrl + Space 자동완성
Ctrl + Shift + F 쿼리 포맷팅
Ctrl + H 히스토리
F5 스키마 새로고침

마무리

H2 Console은 Spring Boot 개발 시 데이터베이스를 빠르게 확인하고 테스트하는 데 매우 유용한 도구입니다. 특히 개발 초기 단계에서 엔티티 매핑이 제대로 되었는지, 관계 설정이 올바른지 확인할 때 필수적입니다.

핵심 포인트

  1. JDBC URL을 정확히 일치시켜야 합니다
  2. 개발 환경에서만 H2 Console을 활성화하세요
  3. 초기 데이터는 data.sql로 관리하면 편리합니다
  4. 복잡한 쿼리는 미리 저장해두고 사용하세요

이제 H2 Console을 활용해서 Spring Boot 개발을 더욱 효율적으로 진행해보세요! 🚀

728x90
반응형


이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.