본문으로 건너뛰기

안동민 개발노트

본문 시작

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 왕복을 연결한다

ARCHITECTURE · JDBC CONNECTION BOUNDARY

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

애플리케이션은 공통 JDBC API만 호출한다. URL이 호환 드라이버를 선택하고, 환경 구성과 비밀 정보가 연결 열기에 합류하면 드라이버가 SQL을 데이터베이스 protocol로 바꾸고 row와 metadata를 되돌린다.

Java 애플리케이션에서 JDBC 드라이버와 데이터베이스로 이어지는 연결 구조 애플리케이션의 JDBC 호출이 DriverManager와 공통 API에 도착한다. JDBC URL은 호환 드라이버를 선택하고, 환경의 풀 정책과 제한된 비밀 정보가 연결 열기에 합류한다. 선택된 드라이버가 SQL을 데이터베이스로 보내고 row와 연결 metadata를 애플리케이션 값으로 되돌린다. JDBC CALL SELECT SQL ROWS · META URL · POOL CREDENTIAL APPLICATION Repository code Connection · SQL API JDBC CONTRACT DriverManager URL을 처리할 driver 선택 Connection 공통 API 반환 VENDOR DRIVER H2 · PostgreSQL JDBC ↔ wire protocol typed value · SQLState DATABASE SQL execution auth · plan · I/O ENVIRONMENT CONFIG URL · timeout · pool SECRET SOURCE user · password BOUNDARY code는 JDBC 계약만 소유 · 환경은 endpoint policy 소유 · secret은 독립 rotation
  1. APPLICATION

    공통 JDBC API로 연결을 요청한다

    Repository 코드는 vendor protocol 대신 Connection 계약을 사용합니다.

  2. SELECT DRIVER

    URL을 처리할 수 있는 드라이버를 고른다

    환경 구성은 URL·pool·timeout을, secret source는 사용자·비밀번호를 제공합니다.

  3. OPEN

    드라이버가 데이터베이스 세션을 연다

    인증과 network protocol은 선택된 vendor driver의 책임입니다.

  4. EXECUTE

    typed value와 SQL을 데이터베이스로 보낸다

    데이터베이스는 인증·계획·잠금·I/O 뒤 결과를 반환합니다.

  5. RETURN

    row와 metadata를 Java 값으로 되돌린다

    Probe는 제품·driver·auto-commit을 읽고 소유한 연결을 닫습니다.

오류 신호도 도달 단계를 드러낸다. 드라이버 선택 실패, DNS·TCP 실패, 로그인 거절, TLS 실패를 한 종류의 credential 문제로 합치지 않는다.


이 장의 실행 기준선

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_statspost_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 장애를 생존 상태 실패로 만들어 모든 인스턴스를 동시에 재시작하지 않습니다.

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

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