본문 바로가기

JDBC 데이터베이스 연결

JDBC 드라이버가 URL을 해석해 Connection을 여는 과정을 이해하고, Java 25·H2로 게시판의 실제 연결·스키마 계약을 검증합니다.

웹에서 회원가입이나 게시글 등록을 처리해도 서버를 다시 시작할 때 데이터가 사라진다면 실제 서비스로 쓰기 어렵습니다.

데이터를 계속 보관하려면 애플리케이션이 데이터베이스에 SQL을 보내고 결과를 받아야 합니다.

JDBC(Java Database Connectivity)는 이 일을 위한 Java 표준 인터페이스입니다.

처음에는 다음 네 역할만 구분하면 됩니다.

이름입문자에게 필요한 뜻
데이터베이스회원과 게시글을 지속해서 보관하고 SQL로 읽고 쓰는 프로그램
JDBC 드라이버Java의 공통 JDBC 호출을 H2·PostgreSQL 같은 데이터베이스의 통신 방식으로 바꾸는 라이브러리
Connection애플리케이션과 데이터베이스 사이에 열린 한 세션
PreparedStatement / ResultSet값을 분리해 전달하는 SQL 구문 / 조회 결과를 한 행씩 읽는 커서

가장 작은 흐름은 연결 열기 → SQL 준비 → 실행 및 결과 읽기 → 연결 닫기입니다.

URL로 사용할 드라이버를 선택하고, 선택된 드라이버는 구성과 비밀 정보를 사용해 데이터베이스 세션을 엽니다.

URL이 드라이버를 고르고 JDBC 계약이 SQL 왕복을 연결한다

URL/인증 정보로 driver·Connection을 얻어 metadata 관찰 뒤 닫는 자원 상태이다.

환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

URL로 드라이버를 선택하고 연결의 준비 상태를 읽는다

직접 연결하는 Probe는 URL과 user·password로 Connection을 열고 metadata를 읽은 뒤 닫습니다. DataSource의 풀 정책은 별도 경계이며, H2의 호환 모드는 운영 DB 검증을 대신하지 않습니다.

URL로 드라이버를 선택하고 연결의 준비 상태를 읽는다환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.
URL · userpassword
연결 관계URL · user · password → DriverManager · 적합한 driver
DriverManager적합한 driver
연결 관계DriverManager · 적합한 driver → Connection 열기
Connection 열기
연결 관계Connection 열기 → metadata 읽기
metadata 읽기
연결 관계metadata 읽기 → 불변 identity
불변 identity
연결 관계불변 identity → Connection 닫기
Connection 닫기
입력부터 연결 닫기까지 여섯 단계원 desktop과 같은 여섯 노드 다섯 진행 간선: URL user password→DriverManager 드라이버 선택→Connection 열기→metadata 읽기→불변 identity 복사→Connection 닫기. 별도 schema readiness probe를 이 진행 간선으로 대체하지 않는다.
URL · userpassword
연결 관계URL · user · password → DriverManager · 적합한 driver
DriverManager적합한 driver
연결 관계DriverManager · 적합한 driver → Connection 열기
Connection 열기
연결 관계Connection 열기 → metadata 읽기
metadata 읽기
연결 관계metadata 읽기 → 불변 identity
불변 identity
연결 관계불변 identity → Connection 닫기
Connection 닫기

JDBC CONTRACT

등록된 드라이버 중 URL에 맞는 구현

DriverManager는 서비스 제공자 방식으로 드라이버를 찾고 URL을 처리할 수 있는 구현에 연결을 요청합니다. 애플리케이션은 공통 JDBC API를 사용합니다. pool-size 같은 풀 정책은 DriverManager의 입력 계약이 아닙니다.

ACTUAL H2 FIXTURE

제품·드라이버·자동 커밋을 확인

제공된 Probe test는 제품 H2, H2 JDBC Driver 이름, 비어 있지 않은 제품·드라이버 버전과 기본 autoCommit=true를 확인합니다. try-with-resources로 소유한 Connection을 닫습니다.

ACTUAL SCHEMA VERSION

준비 여부는 공유 metadata에서 읽는다

schema_version=1에 기대값1이면 READY, 기대값2이면 NOT_READY입니다. DB 값을2로 바꾸고 기대값1로 읽으면 DEGRADED입니다. 연결 성공과 스키마의 준비 상태는 다른 판단입니다.

CONFIG / SECRETS

SQL 구조·환경·비밀 정보의 소유자

SQL과 JDBC 사용은 코드가, URL·풀 크기·시간 정책은 환경 설정이, user·password는 제한한 비밀 정보 공급원이 소유합니다. 실제 Probe의 Properties에는 user·password만 전달합니다.

H2 / PRODUCTION SCOPE

메모리 DB와 호환 모드의 경계

DB_CLOSE_DELAY=-1은 마지막 연결을 닫아도 메모리 DB를 프로세스 수명 동안 유지합니다. MODE=PostgreSQL은 일부 문법 호환 설정입니다. 운영 제품의 타입·격리·쿼리 계획·driver 오류는 별도 검사합니다.

DIAGNOSIS / ADDITIONAL

도달한 단계에서 실패를 좁힌다

적합한 driver 없음, DNS, TCP, DB 로그인, TLS는 서로 다른 원인입니다. 풀 획득 timeout과 driver 통신 timeout도 분리합니다. 운영 readiness endpoint와 상세 정보 공개 정책은 이 fixture와 별도 구현입니다.

환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.
  • 원 desktop과 같은 여섯 노드 다섯 진행 간선: URL user password→DriverManager 드라이버 선택→Connection 열기→metadata 읽기→불변 identity 복사→Connection 닫기. 별도 schema readiness probe를 이 진행 간선으로 대체하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1URL이 드라이버를 고르고 JDBC 계약이 SQL 왕복을 연결한다
26schema_version=1에 기대값 1이면 READY, 기대값 2이면 NOT_READY입니다. DB 값을 2로 바꾸고 기대값 1로 읽으면 DEGRADED입니다. 연결 성공과 스키마의 준비 상태는 다른 판단입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
드라이버 선택

URL·인증 정보로 DriverManager가 적합한 드라이버를 고릅니다.

입력: JDBC URL · 선택: driver연결을 열 구현이 정해집니다.
Connection 관찰

Connection을 열어 probe가 metadata를 읽습니다.

자원: Connection · 자료: metadata드라이버 연결의 실제 관측 자료를 얻습니다.
close와 별도 health

probe 연결을 닫고 schema 버전·제품 health는 별도 비교입니다.

연결: close · health: 별도 schema_metadata 검사자원 종료와 업무 schema 판정을 분리합니다.

URL이 드라이버를 고르고 JDBC 계약이 SQL 왕복을 연결한다

URL/인증 정보로 driver·Connection을 얻어 metadata 관찰 뒤 닫는 자원 상태이다.

환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

URL로 드라이버를 선택하고 연결의 준비 상태를 읽는다

직접 연결하는 Probe는 URL과 user·password로 Connection을 열고 metadata를 읽은 뒤 닫습니다. DataSource의 풀 정책은 별도 경계이며, H2의 호환 모드는 운영 DB 검증을 대신하지 않습니다.

URL로 드라이버를 선택하고 연결의 준비 상태를 읽는다환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.
URL · userpassword
연결 관계URL · user · password → DriverManager · 적합한 driver
DriverManager적합한 driver
연결 관계DriverManager · 적합한 driver → Connection 열기
Connection 열기
연결 관계Connection 열기 → metadata 읽기
metadata 읽기
연결 관계metadata 읽기 → 불변 identity
불변 identity
연결 관계불변 identity → Connection 닫기
Connection 닫기
입력부터 연결 닫기까지 여섯 단계원 desktop과 같은 여섯 노드 다섯 진행 간선: URL user password→DriverManager 드라이버 선택→Connection 열기→metadata 읽기→불변 identity 복사→Connection 닫기. 별도 schema readiness probe를 이 진행 간선으로 대체하지 않는다.
URL · userpassword
연결 관계URL · user · password → DriverManager · 적합한 driver
DriverManager적합한 driver
연결 관계DriverManager · 적합한 driver → Connection 열기
Connection 열기
연결 관계Connection 열기 → metadata 읽기
metadata 읽기
연결 관계metadata 읽기 → 불변 identity
불변 identity
연결 관계불변 identity → Connection 닫기
Connection 닫기

JDBC CONTRACT

등록된 드라이버 중 URL에 맞는 구현

DriverManager는 서비스 제공자 방식으로 드라이버를 찾고 URL을 처리할 수 있는 구현에 연결을 요청합니다. 애플리케이션은 공통 JDBC API를 사용합니다. pool-size 같은 풀 정책은 DriverManager의 입력 계약이 아닙니다.

ACTUAL H2 FIXTURE

제품·드라이버·자동 커밋을 확인

제공된 Probe test는 제품 H2, H2 JDBC Driver 이름, 비어 있지 않은 제품·드라이버 버전과 기본 autoCommit=true를 확인합니다. try-with-resources로 소유한 Connection을 닫습니다.

ACTUAL SCHEMA VERSION

준비 여부는 공유 metadata에서 읽는다

schema_version=1에 기대값1이면 READY, 기대값2이면 NOT_READY입니다. DB 값을2로 바꾸고 기대값1로 읽으면 DEGRADED입니다. 연결 성공과 스키마의 준비 상태는 다른 판단입니다.

CONFIG / SECRETS

SQL 구조·환경·비밀 정보의 소유자

SQL과 JDBC 사용은 코드가, URL·풀 크기·시간 정책은 환경 설정이, user·password는 제한한 비밀 정보 공급원이 소유합니다. 실제 Probe의 Properties에는 user·password만 전달합니다.

H2 / PRODUCTION SCOPE

메모리 DB와 호환 모드의 경계

DB_CLOSE_DELAY=-1은 마지막 연결을 닫아도 메모리 DB를 프로세스 수명 동안 유지합니다. MODE=PostgreSQL은 일부 문법 호환 설정입니다. 운영 제품의 타입·격리·쿼리 계획·driver 오류는 별도 검사합니다.

DIAGNOSIS / ADDITIONAL

도달한 단계에서 실패를 좁힌다

적합한 driver 없음, DNS, TCP, DB 로그인, TLS는 서로 다른 원인입니다. 풀 획득 timeout과 driver 통신 timeout도 분리합니다. 운영 readiness endpoint와 상세 정보 공개 정책은 이 fixture와 별도 구현입니다.

환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 환경의 JDBC URL과 인증 정보로 DriverManager가 적합한 JDBC 드라이버를 선택하고 Connection을 연다. Probe는 metadata를 읽고 연결을 닫는다. 별도 DatabaseHealth는 실제 schema_metadata 버전과 제품을 기대값과 비교한다.
  • 원 desktop과 같은 여섯 노드 다섯 진행 간선: URL user password→DriverManager 드라이버 선택→Connection 열기→metadata 읽기→불변 identity 복사→Connection 닫기. 별도 schema readiness probe를 이 진행 간선으로 대체하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1URL이 드라이버를 고르고 JDBC 계약이 SQL 왕복을 연결한다
26schema_version=1에 기대값 1이면 READY, 기대값 2이면 NOT_READY입니다. DB 값을 2로 바꾸고 기대값 1로 읽으면 DEGRADED입니다. 연결 성공과 스키마의 준비 상태는 다른 판단입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
드라이버 선택

URL·인증 정보로 DriverManager가 적합한 드라이버를 고릅니다.

입력: JDBC URL · 선택: driver연결을 열 구현이 정해집니다.
Connection 관찰

Connection을 열어 probe가 metadata를 읽습니다.

자원: Connection · 자료: metadata드라이버 연결의 실제 관측 자료를 얻습니다.
close와 별도 health

probe 연결을 닫고 schema 버전·제품 health는 별도 비교입니다.

연결: close · health: 별도 schema_metadata 검사자원 종료와 업무 schema 판정을 분리합니다.

이 장의 실행 기준선

ch9-1부터 ch9-8까지의 예제를 한 프로젝트에 모아 실행할 수 있도록 루트 빌드 계약은 이 문서에서 한 번만 정의합니다.

Gradle 9.5.1과 Java 25를 사용하고 Spring Boot 4.1.1 BOM과 JUnit 6.0.3을 고정합니다.

settings.gradle.kts
rootProject.name = "jdbc-transaction-contracts"
build.gradle.kts
plugins {
    java
}

group = "board"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(enforcedPlatform(
        "org.springframework.boot:spring-boot-dependencies:4.1.1"))
    implementation("org.springframework.boot:spring-boot-starter-jdbc")

    testImplementation(enforcedPlatform(
        "org.springframework.boot:spring-boot-dependencies:4.1.1"))
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("com.h2database:h2")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")

    constraints {
        testImplementation("org.junit.jupiter:junit-jupiter") {
            version { strictly("6.0.3") }
        }
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
    options.release = 25
    options.compilerArgs.add("-parameters")
}

tasks.test {
    useJUnitPlatform()
}
구성 요소고정 버전고정 위치
Gradle9.5.1실행 환경
Java25toolchain·release
Spring Boot4.1.1enforcedPlatform BOM
JUnit6.0.3strict constraint

starter-jdbc가 Spring JDBC, transaction API, HikariCP를 한 계약으로 제공하므로 같은 모듈을 직접 중복 선언하지 않습니다.

H2는 실행 가능한 통합 테스트에서 JDBC 드라이버 타입을 직접 사용할 수 있도록 test compile classpath에 둡니다.


JDBC 드라이버 탐색

드라이버는 데이터베이스마다 다르지만 애플리케이션은 같은 JDBC 인터페이스를 사용합니다.

DriverManager.getConnection(...)에 URL을 전달하면 DriverManager가 그 URL을 이해하는 드라이버를 찾아 Connection을 엽니다.

현대 JDBC 드라이버 JAR은 서비스 제공자 설정으로 구현을 자동 등록하므로 오래된 예제처럼 Class.forName을 직접 호출하지 않습니다.

src/main/java/board/jdbc/JdbcConnectionProbe.java
package board.jdbc;

import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Objects;
import java.util.Properties;

public final class JdbcConnectionProbe {
    public DatabaseIdentity connect(
            String url,
            String username,
            String password
    ) throws SQLException {
        Objects.requireNonNull(url, "url");
        Objects.requireNonNull(username, "username");
        Objects.requireNonNull(password, "password");

        var properties = new Properties();
        properties.setProperty("user", username);
        properties.setProperty("password", password);

        try (var connection = DriverManager.getConnection(url, properties)) {
            var metadata = connection.getMetaData();
            return new DatabaseIdentity(
                    metadata.getDatabaseProductName(),
                    metadata.getDatabaseProductVersion(),
                    metadata.getDriverName(),
                    metadata.getDriverVersion(),
                    connection.getAutoCommit());
        }
    }

    public record DatabaseIdentity(
            String product,
            String productVersion,
            String driver,
            String driverVersion,
            boolean autoCommit
    ) {
        public DatabaseIdentity {
            requireText(product, "product");
            requireText(productVersion, "productVersion");
            requireText(driver, "driver");
            requireText(driverVersion, "driverVersion");
        }

        private static void requireText(String value, String name) {
            if (value == null || value.isBlank()) {
                throw new IllegalArgumentException(name + " required");
            }
        }
    }
}

연결 타임아웃을 임의의 connection property 이름으로 넘기면 드라이버가 조용히 무시할 수 있습니다.

DriverManager.setLoginTimeout은 프로세스 전역 상태이므로 공유 애플리케이션의 요청별 정책으로 사용하지 않습니다.

Spring Boot와 연결 풀을 쓸 때는 풀 획득 시간과 드라이버 네트워크 시간을 서로 다른 경계에서 설정합니다.


데이터베이스 URL과 배포 입력

H2 메모리 URL jdbc:h2:mem:board;DB_CLOSE_DELAY=-1은 프로세스 안에 이름 붙은 데이터베이스를 만들고 마지막 연결 뒤에도 그 프로세스가 끝날 때까지 유지합니다.

PostgreSQL URL은 호스트, 포트, 데이터베이스, TLS 파라미터를 담습니다.

서로 닮아 보여도 옵션 이름과 기본값은 공급자 문서를 확인해야 합니다.

입력 경계포함하는 값변경 주기
Source codeJDBC 인터페이스 사용과 고정 SQL애플리케이션 배포
Environment configURL·pool 크기·timeout환경별 설정 배포
Secret source사용자·비밀번호권한을 제한한 독립 rotation
src/main/resources/application.properties
spring.datasource.url=jdbc:h2:mem:board;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
spring.datasource.hikari.pool-name=board-pool
spring.datasource.hikari.maximum-pool-size=12
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=2000
spring.datasource.hikari.validation-timeout=1000
spring.datasource.hikari.max-lifetime=1740000
spring.datasource.hikari.keepalive-time=120000

MODE=PostgreSQL은 H2를 PostgreSQL로 만드는 설정이 아닙니다.

일부 문법 호환성을 높일 뿐 트랜잭션 격리, 타입, 인덱스, 쿼리 계획기 차이를 없애지 않습니다.

소스 코드와 로그에 남기는 URL에는 비밀번호를 포함하지 않습니다.

비밀 정보 관리자 또는 배포 환경에서 인증 정보를 주입하고 구성 덤프에서 가립니다.


장 전체 실행 스키마

회원 게시판의 저장 계약은 앞 장의 CreatePostCommand와 같은 이름과 제한을 사용합니다.

member_id, published_on, client_request_id를 다른 이름으로 다시 감싸지 않고 command에서 persistence까지 그대로 전달합니다.

daily_post_stats와 post_event_outbox는 뒤의 트랜잭션 문서에서 사용하지만 chapter-wide fixture가 한 번에 초기화되도록 여기에서 함께 정의합니다.

src/main/resources/schema.sql
create table schema_metadata (
    singleton_key integer primary key,
    schema_version integer not null,
    constraint ck_schema_metadata_singleton
        check (singleton_key = 1),
    constraint ck_schema_metadata_version
        check (schema_version > 0)
);

insert into schema_metadata(singleton_key, schema_version)
values (1, 1);

create table members (
    id bigint generated by default as identity primary key,
    email varchar(255) not null,
    password_hash varchar(255) not null,
    name varchar(60) not null,
    active boolean not null default true,
    daily_character_limit integer not null default 10000,
    constraint ck_members_id check (id > 0),
    constraint uq_members_email unique (email),
    constraint ck_members_daily_limit
        check (daily_character_limit > 0)
);

create table posts (
    id bigint generated by default as identity primary key,
    member_id bigint not null,
    title varchar(80) not null,
    content varchar(720) not null,
    published_on date not null,
    client_request_id varchar(64) not null,
    created_at timestamp with time zone not null,
    version bigint not null default 0,
    constraint ck_posts_id check (id > 0),
    constraint ck_posts_title
        check (char_length(trim(title)) between 1 and 80),
    constraint ck_posts_content
        check (char_length(trim(content)) >= 1
            and char_length(content) <= 720),
    constraint ck_posts_client_request
        check (regexp_like(
            client_request_id,
            '^[A-Za-z0-9_-]{8,64}$')),
    constraint ck_posts_version check (version >= 0),
    constraint fk_posts_member
        foreign key (member_id) references members(id),
    constraint uq_posts_member_request
        unique (member_id, client_request_id),
    constraint uq_posts_outbox_identity
        unique (id, member_id, client_request_id)
);

create table daily_post_stats (
    member_id bigint not null,
    published_on date not null,
    total_characters integer not null default 0,
    constraint pk_daily_post_stats
        primary key (member_id, published_on),
    constraint ck_daily_post_stats_total
        check (total_characters >= 0),
    constraint fk_daily_post_stats_member
        foreign key (member_id) references members(id)
);

create table post_event_outbox (
    id bigint generated by default as identity primary key,
    post_id bigint not null,
    member_id bigint not null,
    occurred_at timestamp with time zone not null,
    client_request_id varchar(64) not null,
    published_at timestamp with time zone,
    constraint ck_post_event_outbox_id check (id > 0),
    constraint ck_post_event_outbox_request
        check (regexp_like(
            client_request_id,
            '^[A-Za-z0-9_-]{8,64}$')),
    constraint fk_post_event_outbox_post_identity
        foreign key (post_id, member_id, client_request_id)
        references posts(id, member_id, client_request_id),
    constraint uq_post_event_outbox_post unique (post_id)
);

애플리케이션 검증과 같은 범위를 DB 제약 조건이 마지막으로 방어합니다.

제약 조건 이름은 예외 변환과 마이그레이션에서 추적 가능하게 안정적으로 정합니다.

운영 환경에서는 시작할 때마다 테이블을 다시 만드는 대신 Flyway나 Liquibase 같은 마이그레이션 도구로 버전을 관리합니다.


Java 25·H2 연결과 준비 상태 확인

연결 확인은 제품·드라이버·기본 자동 커밋을 읽고 즉시 자원을 닫습니다.

준비 상태는 연결한 제품과 공유 schema_metadata에서 직접 읽은 스키마 버전을 평가합니다.

src/main/java/board/jdbc/DatabaseHealth.java
package board.jdbc;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.Objects;

import javax.sql.DataSource;

public record DatabaseHealth(
        Status status,
        String product,
        int schemaVersion
) {
    public DatabaseHealth {
        Objects.requireNonNull(status, "status");
        if (product == null || product.isBlank() || schemaVersion < 1) {
            throw new IllegalArgumentException("invalid database health");
        }
    }

    public static DatabaseHealth inspect(
            DataSource dataSource,
            String expectedProduct,
            int expectedSchemaVersion
    ) throws SQLException {
        Objects.requireNonNull(dataSource, "dataSource");
        if (expectedProduct == null || expectedProduct.isBlank()
                || expectedSchemaVersion < 1) {
            throw new IllegalArgumentException("invalid expected database");
        }

        try (Connection connection = dataSource.getConnection()) {
            String actualProduct = connection.getMetaData()
                    .getDatabaseProductName();
            int actualSchemaVersion = readSchemaVersion(connection);
            Status status;
            if (!actualProduct.equals(expectedProduct)
                    || actualSchemaVersion < expectedSchemaVersion) {
                status = Status.NOT_READY;
            } else if (actualSchemaVersion > expectedSchemaVersion) {
                status = Status.DEGRADED;
            } else {
                status = Status.READY;
            }
            return new DatabaseHealth(
                    status, actualProduct, actualSchemaVersion);
        }
    }

    private static int readSchemaVersion(Connection connection)
            throws SQLException {
        try (var statement = connection.prepareStatement("""
                select schema_version
                  from schema_metadata
                 where singleton_key = 1
                """);
             var result = statement.executeQuery()) {
            if (!result.next()) {
                throw new SQLException("schema metadata row missing");
            }
            int version = result.getInt(1);
            if (result.wasNull() || version < 1 || result.next()) {
                throw new SQLException("invalid schema metadata");
            }
            return version;
        }
    }

    public enum Status {
        READY, DEGRADED, NOT_READY
    }
}
src/test/java/board/jdbc/JdbcConnectionProbeTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;

import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

class JdbcConnectionProbeTest {
    @Test
    void H2_identity와_schema_준비_상태를_구별한다()
            throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL(
                "jdbc:h2:mem:probe;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        new ResourceDatabasePopulator(
                new ClassPathResource("schema.sql")).execute(dataSource);

        var identity = new JdbcConnectionProbe().connect(
                dataSource.getURL(),
                "sa",
                "");

        assertThat(identity.product()).isEqualTo("H2");
        assertThat(identity.driver()).contains("H2 JDBC Driver");
        assertThat(identity.productVersion()).isNotBlank();
        assertThat(identity.driverVersion()).isNotBlank();
        assertThat(identity.autoCommit()).isTrue();

        assertThat(DatabaseHealth.inspect(
                dataSource, "H2", 1).status())
                .isEqualTo(DatabaseHealth.Status.READY);
        assertThat(DatabaseHealth.inspect(
                dataSource, "H2", 2).status())
                .isEqualTo(DatabaseHealth.Status.NOT_READY);

        try (var connection = dataSource.getConnection();
             var statement = connection.createStatement()) {
            assertThat(statement.executeUpdate("""
                    update schema_metadata
                       set schema_version = 2
                     where singleton_key = 1
                    """)).isEqualTo(1);
        }

        var newer = DatabaseHealth.inspect(dataSource, "H2", 1);
        assertThat(newer.status())
                .isEqualTo(DatabaseHealth.Status.DEGRADED);
        assertThat(newer.schemaVersion()).isEqualTo(2);
    }
}

버전 문자열 전체에는 결합하지 않고 제품 이름과 비어 있지 않은 driver metadata를 확인합니다. 준비 상태의 버전은 테스트가 실행한 공유 schema.sql의 단일 metadata 행에서 관찰합니다.

운영 진단에서는 실제 URL의 호스트를 마스킹한 형태, 드라이버 버전, 풀 이름을 시작 로그 한 번에 남기면 잘못된 데이터베이스 연결을 빠르게 찾을 수 있습니다.


연결 실패 진단

모든 SQLException을 인증 정보 오류라고 가정하지 않습니다.

신호가 나타난 단계부터 역으로 범위를 좁힙니다.

실패 신호도달 단계먼저 볼 값
적합한 드라이버 없음로컬 드라이버 선택의존성·URL 접두사
알 수 없는 호스트DNS호스트 철자·리졸버
연결 거부TCP 엔드포인트포트·서버 리스너
인증 실패DB 로그인사용자·비밀 정보·역할
TLS 핸드셰이크암호화 전송CA·호스트 이름·프로토콜

재시도는 원인을 가려서는 안 됩니다.

잘못된 비밀번호를 무한 재시도하면 계정 잠금과 시작 지연을 만듭니다.

일시 네트워크 실패만 제한된 백오프로 재시도하고 구성 오류는 빠르게 실패시킵니다.

검증 환경도 서로 다른 증거를 맡습니다.

검증 계층확인하는 증거
H2 repository test빠른 binding·mapping·resource scope
Production dialect suite실제 SQL 문법·타입·격리 수준
Production driver suiteSQLState·vendor code·timeout
Migration smoke test운영과 같은 schema version·권한

연습 문제

준비 상태와 생존 상태를 분리한 운영 엔드포인트를 설계하세요.

준비 상태는 새 트래픽을 받을 수 있는지, 생존 상태는 프로세스를 재시작해야 하는지 답합니다.

일시 DB 장애를 생존 상태 실패로 만들어 모든 인스턴스를 동시에 재시작하지 않습니다.

외부 응답에는 준비 여부만 제공하고 상세 드라이버·스키마 정보는 인증된 운영 엔드포인트나 로그에 제한합니다.

같은 연결 성공을 네 가지 준비 상태로 나누기 기대 제품은 H2, 기대 스키마 버전은 2라고 정합니다. 연결과 schema_metadata 조회가 성공한 결과가 A: H2/2, B: H2/1, C: H2/3, D: 다른 제품/3일 때 위 DatabaseHealth.inspect의 반환 상태를 각각 구하세요. 연결 획득 자체가 실패한 E를 이 네 결과 중 하나로 바로 반환할 수 있는지도 판단하세요.

분기 순서와 운영 판단의 풀이

A는 READY, B는 NOT_READY, C는 DEGRADED, D는 NOT_READY입니다. D는 버전이 높아도 첫 분기의 제품 불일치가 먼저 적용됩니다. E는 SQLException으로 호출자에게 전달되므로 이 함수가 DatabaseHealth 상태를 반환한 경우와 구분합니다.

이는 예제 함수의 분기 계약입니다. 높은 버전을 가진 C에 새 트래픽을 허용할지는 호환성 정책을 별도로 정해야 합니다. E도 준비 상태를 실패로 보고할 수 있지만 DB 연결 장애만으로 애플리케이션 프로세스를 반드시 재시작해야 한다고 판단하지는 않습니다. 공개 응답에는 연결 문자열이나 자격 증명을 넣지 않습니다.

다음 문서에서는 연결 성공 뒤 Connection, PreparedStatement, ResultSet 세 자원의 소유권과 종료 순서를 다룹니다.