본문으로 건너뛰기

새로운 데이터베이스 방언 추가하기

📖 Read this guide in English: Switch to EN locale using the language dropdown

이 가이드는 Jinx에 새로운 데이터베이스 방언을 추가하는 방법을 단계별로 설명합니다. Jinx는 확장 가능한 SPI(Service Provider Interface) 기반 아키텍처를 사용하여 다양한 데이터베이스를 지원합니다.

목차

  1. 방언 아키텍처 개요
  2. 단계별 구현 가이드
  3. SPI 인터페이스 구현
  4. 방언 번들 구성
  5. VisitorFactory 등록
  6. CLI 통합
  7. 테스트 작성
  8. 실제 예시: PostgreSQL 방언 추가

방언 아키텍처 개요

핵심 컴포넌트

Jinx의 방언 시스템은 다음과 같은 계층으로 구성됩니다:

DialectBundle
├── BaseDialect (기본 식별자 처리)
├── DdlDialect (DDL 생성)
├── IdentityDialect (AUTO_INCREMENT 등)
├── SequenceDialect (시퀀스 지원)
├── TableGeneratorDialect (테이블 기반 ID 생성)
└── LiquibaseDialect (Liquibase 출력)

SPI 인터페이스

// 기본 정책
public interface IdentifierPolicy {
int maxLength(); // 식별자 최대 길이 (30, 63, 64, 128 등)
String quote(String raw); // 식별자 인용 (`foo`, "foo", [foo])
String normalizeCase(String raw); // 대소문자 정규화 (Oracle → toUpperCase)
boolean isKeyword(String raw); // 예약어 확인
}

// 타입 매핑
public interface JavaTypeMapper {
JavaType map(String className);

interface JavaType {
String getSqlType(int length, int precision, int scale);
boolean needsQuotes();
String getDefaultValue();
}
}

// 값 변환
public interface ValueTransformer {
String quote(String value, JavaTypeMapper.JavaType type);
}

단계별 구현 가이드

1. 패키지 구조 생성

새로운 방언을 위한 패키지를 생성합니다:

jinx-core/src/main/java/org/jinx/migration/dialect/postgresql/
├── PostgreSqlDialect.java
├── PostgreSqlIdentifierPolicy.java
├── PostgreSqlJavaTypeMapper.java
├── PostgreSqlValueTransformer.java
├── PostgreSqlMigrationVisitor.java
└── PostgreSqlUtil.java (필요시)

2. 테스트 패키지 구조

jinx-core/src/test/java/org/jinx/migration/dialect/postgresql/
├── PostgreSqlDialectTest.java
├── PostgreSqlIdentifierPolicyTest.java
├── PostgreSqlJavaTypeMapperTest.java
├── PostgreSqlValueTransformerTest.java
└── PostgreSqlMigrationVisitorTest.java

SPI 인터페이스 구현

1. IdentifierPolicy 구현

package org.jinx.migration.dialect.postgresql;

import org.jinx.migration.spi.IdentifierPolicy;
import java.util.Set;

public class PostgreSqlIdentifierPolicy implements IdentifierPolicy {

// PostgreSQL 예약어 목록
private static final Set<String> KEYWORDS = Set.of(
"select", "from", "where", "insert", "update", "delete",
"create", "drop", "alter", "table", "column", "index",
// ... PostgreSQL 예약어들
);

@Override
public int maxLength() {
return 63; // PostgreSQL identifier limit
}

@Override
public String quote(String raw) {
if (raw == null || raw.isEmpty()) {
return raw;
}
// PostgreSQL은 큰따옴표 사용
return "\"" + raw + "\"";
}

@Override
public String normalizeCase(String raw) {
if (raw == null) return null;
// PostgreSQL은 소문자로 정규화 (대소문자 구분하지 않음)
return raw.toLowerCase();
}

@Override
public boolean isKeyword(String raw) {
return raw != null && KEYWORDS.contains(raw.toLowerCase());
}
}

2. JavaTypeMapper 구현

package org.jinx.migration.dialect.postgresql;

import org.jinx.migration.spi.JavaTypeMapper;

public class PostgreSqlJavaTypeMapper implements JavaTypeMapper {

@Override
public JavaType map(String className) {
return switch (className) {
case "java.lang.String" -> new PostgreSqlStringType();
case "int", "java.lang.Integer" -> new PostgreSqlIntegerType();
case "long", "java.lang.Long" -> new PostgreSqlBigIntType();
case "java.math.BigDecimal" -> new PostgreSqlDecimalType();
case "boolean", "java.lang.Boolean" -> new PostgreSqlBooleanType();
case "java.time.LocalDate" -> new PostgreSqlDateType();
case "java.time.LocalDateTime" -> new PostgreSqlTimestampType();
case "java.util.UUID" -> new PostgreSqlUuidType();
case "byte[]" -> new PostgreSqlByteArrayType();
// ... 기타 타입들
default -> new PostgreSqlStringType(); // 기본값
};
}

// 내부 타입 클래스들
private static class PostgreSqlStringType implements JavaType {
@Override
public String getSqlType(int length, int precision, int scale) {
return length > 0 ? "VARCHAR(" + length + ")" : "TEXT";
}

@Override
public boolean needsQuotes() { return true; }

@Override
public String getDefaultValue() { return null; }
}

private static class PostgreSqlIntegerType implements JavaType {
@Override
public String getSqlType(int length, int precision, int scale) {
return "INTEGER";
}

@Override
public boolean needsQuotes() { return false; }

@Override
public String getDefaultValue() { return "0"; }
}

private static class PostgreSqlBigIntType implements JavaType {
@Override
public String getSqlType(int length, int precision, int scale) {
return "BIGINT";
}

@Override
public boolean needsQuotes() { return false; }

@Override
public String getDefaultValue() { return "0"; }
}

private static class PostgreSqlUuidType implements JavaType {
@Override
public String getSqlType(int length, int precision, int scale) {
return "UUID";
}

@Override
public boolean needsQuotes() { return false; }

@Override
public String getDefaultValue() { return null; }
}

// ... 기타 타입 클래스들
}

3. ValueTransformer 구현

package org.jinx.migration.dialect.postgresql;

import org.jinx.migration.spi.JavaTypeMapper;
import org.jinx.migration.spi.ValueTransformer;

public class PostgreSqlValueTransformer implements ValueTransformer {

@Override
public String quote(String value, JavaTypeMapper.JavaType type) {
if (value == null) {
return "NULL";
}

if (type.needsQuotes()) {
// 문자열 타입: 작은따옴표로 감싸고 이스케이프
return "'" + value.replace("'", "''") + "'";
} else {
// 숫자, 불린 등: 그대로 반환
return value;
}
}
}

4. 메인 Dialect 클래스 구현

package org.jinx.migration.dialect.postgresql;

import org.jinx.migration.AbstractDialect;
import org.jinx.migration.spi.JavaTypeMapper;
import org.jinx.migration.spi.ValueTransformer;
import org.jinx.migration.spi.dialect.*;
import org.jinx.model.*;

public class PostgreSqlDialect extends AbstractDialect
implements SequenceDialect, LiquibaseDialect {

public PostgreSqlDialect() {
super();
}

@Override
protected JavaTypeMapper initializeJavaTypeMapper() {
return new PostgreSqlJavaTypeMapper();
}

@Override
protected ValueTransformer initializeValueTransformer() {
return new PostgreSqlValueTransformer();
}

// BaseDialect 구현
@Override
public String quoteIdentifier(String raw) {
return "\"" + raw + "\"";
}

// DdlDialect 구현
@Override
public String openCreateTable(String tableName) {
return "CREATE TABLE " + quoteIdentifier(tableName) + " (\n";
}

@Override
public String closeCreateTable() {
return "\n);";
}

@Override
public String getCreateTableSql(EntityModel entity) {
// CREATE TABLE 로직 구현
// MySQL 예시를 참고하여 PostgreSQL 문법에 맞게 구현
}

@Override
public String getColumnDefinitionSql(ColumnModel column) {
StringBuilder sb = new StringBuilder();

String javaType = column.getConversionClass() != null ?
column.getConversionClass() : column.getJavaType();
JavaTypeMapper.JavaType mappedType = javaTypeMapper.map(javaType);

String sqlType;
if (column.getSqlTypeOverride() != null && !column.getSqlTypeOverride().trim().isEmpty()) {
sqlType = column.getSqlTypeOverride().trim();
} else {
sqlType = mappedType.getSqlType(column.getLength(), column.getPrecision(), column.getScale());
}

sb.append(quoteIdentifier(column.getColumnName())).append(" ").append(sqlType);

if (!column.isNullable()) {
sb.append(" NOT NULL");
}

// PostgreSQL은 SERIAL 타입 지원
if (shouldUseSerial(column.getGenerationStrategy())) {
// SERIAL 타입은 이미 sqlType에 포함됨
}

if (column.getDefaultValue() != null) {
sb.append(" DEFAULT ").append(valueTransformer.quote(column.getDefaultValue(), mappedType));
}

return sb.toString();
}

// SequenceDialect 구현 (PostgreSQL은 시퀀스 지원)
@Override
public String getCreateSequenceSql(SequenceModel sequence) {
StringBuilder sb = new StringBuilder();
sb.append("CREATE SEQUENCE ").append(quoteIdentifier(sequence.getSequenceName()));

if (sequence.getInitialValue() > 1) {
sb.append(" START WITH ").append(sequence.getInitialValue());
}

if (sequence.getAllocationSize() > 1) {
sb.append(" INCREMENT BY ").append(sequence.getAllocationSize());
}

sb.append(";\n");
return sb.toString();
}

@Override
public String getDropSequenceSql(SequenceModel sequence) {
return "DROP SEQUENCE IF EXISTS " + quoteIdentifier(sequence.getSequenceName()) + ";\n";
}

@Override
public String getAlterSequenceSql(SequenceModel newSeq, SequenceModel oldSeq) {
// PostgreSQL 시퀀스 변경 로직
return "";
}

// PostgreSQL 특화 메서드들
private boolean shouldUseSerial(GenerationStrategy strategy) {
return strategy == GenerationStrategy.IDENTITY || strategy == GenerationStrategy.AUTO;
}

@Override
public int getMaxIdentifierLength() {
return 63; // PostgreSQL limit
}

// ... 기타 DdlDialect 메서드들 구현
}

방언 번들 구성

DatabaseType 열거형에 추가

// jinx-core/src/main/java/org/jinx/migration/DatabaseType.java
public enum DatabaseType {
MYSQL,
POSTGRESQL, // 새로 추가
// ... 기타 DB들
}

DialectBundle 생성 헬퍼

// PostgreSQL 방언 번들 생성 예시
public static DialectBundle createPostgreSqlBundle() {
PostgreSqlDialect dialect = new PostgreSqlDialect();

return DialectBundle.builder(dialect, DatabaseType.POSTGRESQL)
.sequence(dialect) // PostgreSQL은 시퀀스 지원
.liquibase(dialect) // Liquibase 지원
.build();
}

VisitorFactory 등록

VisitorFactory.java에 새로운 방언 케이스를 추가합니다:

// jinx-core/src/main/java/org/jinx/migration/VisitorFactory.java
public final class VisitorFactory {
public static VisitorProviders forBundle(DialectBundle bundle) {
var db = bundle.databaseType();
var ddl = bundle.ddl();

switch (db) {
case MYSQL -> {
// 기존 MySQL 코드
}
case POSTGRESQL -> { // 새로 추가
Supplier<TableVisitor> tableV =
() -> new PostgreSqlMigrationVisitor(null, ddl);

Function<DiffResult.ModifiedEntity, TableContentVisitor> contentV =
me -> new PostgreSqlMigrationVisitor(me, ddl);

// PostgreSQL은 시퀀스 지원
Optional<Supplier<SequenceVisitor>> seqV = bundle.sequence().map(seqDialect ->
(Supplier<SequenceVisitor>) () -> new PostgreSqlSequenceVisitor(seqDialect)
);

// TableGenerator는 옵션 (PostgreSQL은 시퀀스를 선호)
var tgOpt = bundle.tableGenerator().map(tgDialect ->
(Supplier<TableGeneratorVisitor>) () -> new PostgreSqlTableGeneratorVisitor(tgDialect)
);

return new VisitorProviders(tableV, contentV, seqV, tgOpt);
}
default -> throw new IllegalArgumentException("Unsupported database type: " + db);
}
}
}

CLI 통합

MigrateCommand.javaresolveDialects 메서드에 추가합니다:

// jinx-cli/src/main/java/org/jinx/cli/MigrateCommand.java
private DialectBundle resolveDialects(String name) {
return switch (name.toLowerCase()) {
case "mysql" -> {
MySqlDialect mysql = new MySqlDialect();
yield DialectBundle.builder(mysql, DatabaseType.MYSQL)
.identity(mysql)
.tableGenerator(mysql)
.build();
}
case "postgresql", "postgres" -> { // 새로 추가
PostgreSqlDialect postgres = new PostgreSqlDialect();
yield DialectBundle.builder(postgres, DatabaseType.POSTGRESQL)
.sequence(postgres) // 시퀀스 지원
.liquibase(postgres) // Liquibase 지원
.build();
}
default -> throw new IllegalArgumentException("Unsupported dialect: " + name);
};
}

테스트 작성

1. 단위 테스트

각 컴포넌트별로 단위 테스트를 작성합니다:

// PostgreSqlDialectTest.java
@Test
@DisplayName("PostgreSQL 컬럼 정의 SQL 생성")
void testColumnDefinitionSql() {
PostgreSqlDialect dialect = new PostgreSqlDialect();

ColumnModel column = ColumnModel.builder()
.columnName("username")
.javaType("java.lang.String")
.length(255)
.isNullable(false)
.build();

String sql = dialect.getColumnDefinitionSql(column);
assertThat(sql).isEqualTo("\"username\" VARCHAR(255) NOT NULL");
}

@Test
@DisplayName("PostgreSQL 시퀀스 생성 SQL")
void testCreateSequenceSql() {
PostgreSqlDialect dialect = new PostgreSqlDialect();

SequenceModel sequence = SequenceModel.builder()
.sequenceName("user_id_seq")
.initialValue(1)
.allocationSize(1)
.build();

String sql = dialect.getCreateSequenceSql(sequence);
assertThat(sql).isEqualTo("CREATE SEQUENCE \"user_id_seq\";\n");
}

2. 통합 테스트

// PostgreSqlIntegrationTest.java
@Test
@DisplayName("PostgreSQL 전체 마이그레이션 생성 테스트")
void testFullMigrationGeneration() {
// 엔티티 모델 생성
EntityModel entity = createTestEntity();

// 방언 번들 생성
DialectBundle bundle = createPostgreSqlBundle();

// 마이그레이션 생성
MigrationGenerator generator = new MigrationGenerator(bundle);
DiffResult diff = createTestDiff(entity);

String sql = generator.generateSql(diff);

// 생성된 SQL 검증
assertThat(sql).contains("CREATE TABLE");
assertThat(sql).contains("VARCHAR");
assertThat(sql).doesNotContain("AUTO_INCREMENT"); // PostgreSQL은 SERIAL 사용
}

3. 필수 테스트 커버리지

반드시 다음 영역들을 테스트해야 합니다:

  1. 식별자 정책 테스트

    • 최대 길이 제한
    • 인용 문법
    • 대소문자 정규화
    • 예약어 검증
  2. 타입 매핑 테스트

    • 모든 Java 타입의 SQL 타입 매핑
    • 길이, 정밀도, 스케일 처리
    • 기본값 처리
  3. DDL 생성 테스트

    • CREATE TABLE
    • ALTER TABLE (ADD/DROP/MODIFY COLUMN)
    • 제약조건 (PK, FK, UNIQUE, CHECK)
    • 인덱스
  4. 특화 기능 테스트

    • 시퀀스 (지원하는 경우)
    • Identity/Serial 컬럼
    • TableGenerator (필요한 경우)

실제 예시: PostgreSQL 방언 추가

다음은 PostgreSQL 방언을 추가하는 실제 단계별 체크리스트입니다:

단계 1: 기본 구조 생성

# 1. 패키지 디렉토리 생성
mkdir -p jinx-core/src/main/java/org/jinx/migration/dialect/postgresql
mkdir -p jinx-core/src/test/java/org/jinx/migration/dialect/postgresql

# 2. 기본 클래스 파일들 생성
touch jinx-core/src/main/java/org/jinx/migration/dialect/postgresql/PostgreSqlDialect.java
touch jinx-core/src/main/java/org/jinx/migration/dialect/postgresql/PostgreSqlJavaTypeMapper.java
touch jinx-core/src/main/java/org/jinx/migration/dialect/postgresql/PostgreSqlValueTransformer.java
touch jinx-core/src/main/java/org/jinx/migration/dialect/postgresql/PostgreSqlMigrationVisitor.java

단계 2: 인터페이스 구현

  1. PostgreSqlJavaTypeMapper 구현
  2. PostgreSqlValueTransformer 구현
  3. PostgreSqlDialect 메인 클래스 구현
  4. PostgreSqlMigrationVisitor 구현

단계 3: 시스템 통합

  1. DatabaseType.POSTGRESQL 추가
  2. VisitorFactory에 PostgreSQL 케이스 추가
  3. MigrateCommand.resolveDialects()에 추가

단계 4: 테스트 작성

  1. 각 클래스별 단위 테스트 작성
  2. 통합 테스트 작성
  3. 실제 PostgreSQL DDL 검증

단계 5: 문서화

  1. 지원 DB 목록에 PostgreSQL 추가
  2. 사용법 문서 업데이트
  3. 이 기여 가이드 업데이트

주의사항

필수 고려사항

  1. 예약어 처리: 각 DB의 예약어 목록을 정확히 파악
  2. 식별자 길이: DB별 최대 식별자 길이 제한 준수
  3. 타입 매핑: Java 타입과 SQL 타입의 정확한 매핑
  4. 문법 차이: CREATE TABLE, ALTER TABLE 등의 문법 차이
  5. 제약조건: PK, FK, UNIQUE, CHECK 제약조건 문법
  6. 특화 기능: 시퀀스, 자동 증가 컬럼 등의 지원 여부

테스트 필수사항

  1. DDL 문법 검증: 생성된 SQL이 실제 DB에서 실행 가능한지 확인
  2. 마이그레이션 테스트: 실제 스키마 변경 시나리오 테스트
  3. 역호환성: 기존 코드에 영향을 주지 않는지 확인
  4. 에러 케이스: 잘못된 설정이나 지원하지 않는 기능에 대한 에러 처리

성능 고려사항

  1. 지연 초기화: 무거운 리소스는 필요할 때만 초기화
  2. 캐싱: 자주 사용되는 정보는 캐싱
  3. 메모리 효율성: 불필요한 객체 생성 최소화

새로운 데이터베이스 방언을 추가하는 것은 복잡하지만, Jinx의 SPI 아키텍처 덕분에 체계적으로 접근할 수 있습니다. 이 가이드의 단계를 따라하면서 기존 MySQL 구현체를 참고하면 성공적으로 새로운 방언을 추가할 수 있습니다.

기여 시 반드시 기억할 점:

  • 모든 변경사항에 대한 포괄적인 테스트 작성
  • 실제 데이터베이스에서 DDL 검증
  • 문서화 업데이트
  • 코드 리뷰 요청
  • CI 단계에서 모든 테스트가 통과되지 않는다면 머지는 거절됩니다

여러분의 기여가 Jinx를 더욱 강력한 도구로 만들어 줄 것입니다!