PostgreSQL bpchar와 Hibernate VARCHAR 검증 오류 로 인한 Spring Boot 부팅 실패 한 이야기

미생의 개발 이야기

목록 보기
83/83

PostgreSQL bpchar와 Hibernate VARCHAR 검증 오류 해결기

Spring Boot 애플리케이션을 운영 환경에 배포할 때 가장 당황스러운 순간 중 하나는, 비즈니스 로직이 아니라 애플리케이션 부팅 단계에서 바로 죽는 경우입니다.

특히 기존 PostgreSQL 스키마를 유지한 채 Spring Boot, Hibernate 버전을 올리거나 ddl-auto: validate 옵션을 활성화하면 생각보다 자주 타입 검증 오류를 만나게 됩니다.

이번 글에서는 PostgreSQL의 CHAR(1) 컬럼이 Hibernate에서는 VARCHAR(1)로 기대되면서 발생한 부팅 실패 문제를 정리하고, 단순히 에러를 없애는 수준을 넘어 실무에서 어떻게 바라봐야 하는지까지 함께 정리해보겠습니다.


1. 문제 상황

애플리케이션을 실행하자 EntityManagerFactory 초기화 단계에서 다음과 같은 에러가 발생했습니다.

Caused by: org.hibernate.tool.schema.spi.SchemaManagementException:
Schema-validation: wrong column type encountered in column [login_mark] in table [user_status];
found [bpchar (Types#CHAR)], but expecting [varchar(1) (Types#VARCHAR)]

핵심은 이 부분입니다.

found [bpchar (Types#CHAR)]
but expecting [varchar(1) (Types#VARCHAR)]

DB에는 CHAR(1)로 컬럼이 정의되어 있습니다.

login_mark CHAR(1) NOT NULL

Java 엔티티에서는 다음처럼 선언되어 있었습니다.

@Column(name = "login_mark", nullable = false, length = 1)
private String loginMark;

겉으로 보면 문제가 없어 보입니다.

DB도 길이 1입니다.
Java 엔티티도 length = 1입니다.

그런데 Hibernate는 CHAR(1)이 아니라 VARCHAR(1)을 기대하고 있었습니다. 그래서 스키마 검증 단계에서 타입 불일치로 애플리케이션 구동을 막은 것입니다.


2. 왜 이런 문제가 발생했을까?

이 문제의 본질은 단순한 길이 문제가 아닙니다.

문제는 문자열 길이가 아니라 JDBC 타입 계약입니다.

PostgreSQL에서 CHAR(n) 타입은 내부적으로 bpchar로 표현됩니다.
bpcharblank-padded char의 약자로, 고정 길이 문자열을 의미합니다.

예를 들어 CHAR(5) 컬럼에 'A'를 저장하면 PostgreSQL은 고정 길이를 맞추기 위해 뒤쪽을 공백으로 채우는 방식으로 처리합니다.

반면 Java 엔티티에서 String 필드에 단순히 @Column(length = 1)만 지정하면 Hibernate는 일반적으로 이를 가변 길이 문자열, 즉 VARCHAR(1) 계열로 해석합니다.

정리하면 다음과 같습니다.

구분실제 값
PostgreSQL 실제 컬럼 타입CHAR(1)
PostgreSQL 내부 표현bpchar
JDBC 타입Types#CHAR
Hibernate 기본 기대 타입VARCHAR(1)
Hibernate JDBC 타입Types#VARCHAR

즉, DB는 이렇게 말하고 있습니다.

이 컬럼은 고정 길이 CHAR 타입입니다.

하지만 Hibernate는 이렇게 기대하고 있었습니다.

이 필드는 String이고 length가 1이니 VARCHAR(1)이겠지요.

이 둘의 해석이 어긋나면서 ddl-auto: validate 단계에서 예외가 발생한 것입니다.


3. @Column(length = 1)만으로는 부족한 이유

많은 분들이 처음에는 이렇게 생각할 수 있습니다.

@Column(length = 1)
private String loginMark;

길이를 1로 맞췄으니 괜찮지 않을까 싶습니다.

하지만 length = 1은 말 그대로 컬럼 길이에 대한 힌트입니다.
Hibernate에게 “이 필드는 DB의 CHAR 타입으로 검증해야 한다”라고 명확히 알려주는 설정은 아닙니다.

또한 다음과 같이 columnDefinition만 추가하는 방식도 자주 사용됩니다.

@Column(name = "login_mark", columnDefinition = "char(1)")
private String loginMark;

이 설정은 DDL 생성 시에는 도움이 될 수 있습니다.
하지만 Hibernate의 스키마 검증 과정에서 기대하는 JDBC 타입까지 항상 원하는 방식으로 맞춰준다고 보기에는 부족할 수 있습니다.

운영 환경에서는 이런 애매한 설정이 문제를 키웁니다.

“테이블 생성은 되는데 검증은 실패하는 상황”이 나올 수 있기 때문입니다.


4. @JdbcTypeCode(SqlTypes.CHAR)`로 해결처리

Hibernate 6 기준으로는 @JdbcTypeCode를 사용해 해당 필드가 어떤 JDBC 타입으로 매핑되어야 하는지 명확히 지정할 수 있습니다.

수정된 코드는 다음과 같습니다.

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Entity
@Table(name = "user_status")
public class UserStatus {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JdbcTypeCode(SqlTypes.CHAR)
    @Column(
        name = "login_mark",
        nullable = false,
        columnDefinition = "char(1)"
    )
    private String loginMark = "N";

    protected UserStatus() {
    }

    public UserStatus(String loginMark) {
        this.loginMark = loginMark;
    }

    public Long getId() {
        return id;
    }

    public String getLoginMark() {
        return loginMark;
    }

    public void setLoginMark(String loginMark) {
        this.loginMark = loginMark;
    }
}

핵심은 이 부분입니다.

@JdbcTypeCode(SqlTypes.CHAR)

이 설정을 통해 Hibernate에게 다음과 같이 명확하게 알려주는 것입니다.

이 String 필드는 VARCHAR가 아니라 DB의 CHAR 타입과 매핑되어야 합니다.

그리고 columnDefinition = "char(1)"을 함께 둠으로써 DDL 관점에서도 DB 컬럼 정의를 명확히 표현할 수 있습니다.


5. 해결 후 확인해야 할 부분

해당 컬럼 하나만 고치고 끝내면 안 됩니다.

이런 문제는 보통 하나의 컬럼에서만 발생하지 않습니다.
기존 시스템에서는 다음과 같은 컬럼들이 여러 테이블에 흩어져 있는 경우가 많습니다.

use_yn
del_yn
login_yn
active_yn
status_cd
type_cd
gender_cd

특히 오래된 시스템에서는 Y/N, 0/1, A/B/C 같은 값을 CHAR(1) 또는 CHAR(2)로 관리하는 경우가 많습니다.

따라서 하나의 컬럼에서 bpchar 문제가 발생했다면 다음 작업을 같이 해야 합니다.

SELECT
    table_name,
    column_name,
    data_type,
    character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
  AND data_type = 'character';

이 쿼리로 PostgreSQL 내 CHAR 타입 컬럼을 먼저 찾고, 해당 컬럼들이 엔티티에서 어떻게 매핑되어 있는지 확인하는 것이 좋습니다.

운영 배포 직전에 하나씩 터지는 것보다, 한 번에 전체 범위를 확인하는 편이 훨씬 안전합니다.


6. ddl-auto: validate는 끄는 게 답일까?

급한 상황에서는 다음처럼 설정을 바꾸고 싶은 유혹이 생깁니다.

spring:
  jpa:
    hibernate:
      ddl-auto: none

물론 상황에 따라 임시 회피가 필요할 수는 있습니다.
하지만 근본적인 해결책은 아닙니다.

ddl-auto: validate는 애플리케이션의 엔티티 모델과 실제 DB 스키마가 일치하는지 확인하는 안전장치입니다.

이 검증이 없다면 다음과 같은 문제를 배포 후에야 발견할 수 있습니다.

  • 엔티티는 VARCHAR로 기대하는데 DB는 CHAR인 경우
  • 엔티티는 nullable을 허용하지 않는데 DB는 nullable인 경우
  • 엔티티 필드 길이와 DB 컬럼 길이가 다른 경우
  • Enum 매핑 방식과 DB 컬럼 타입이 맞지 않는 경우
  • 마이그레이션 누락으로 특정 컬럼이 존재하지 않는 경우

운영 환경에서는 애플리케이션이 뜨는 것만큼 중요한 것이, 잘못된 스키마 위에서 뜨지 않는 것입니다.

검증 오류는 귀찮은 장애물이 아니라, 더 큰 장애를 막아주는 신호에 가깝습니다.


7. 설계 관점에서 다시 생각해볼 부분

이번 문제는 @JdbcTypeCode(SqlTypes.CHAR)를 추가하면 해결됩니다.

하지만 여기서 끝내기보다는 한 번 더 생각해볼 필요가 있습니다.

이 컬럼은 정말 CHAR(1)이어야 할까요?

기존 DB를 그대로 유지해야 하는 상황이라면 현재 방식이 현실적인 해결책입니다.
하지만 신규 설계이거나 리팩토링 권한이 있다면 다른 선택지도 검토할 수 있습니다.


7-1. 단순 Y/N 값이라면 BOOLEAN 검토

컬럼이 단순히 사용 여부를 의미한다면 다음과 같은 구조보다,

use_yn CHAR(1) NOT NULL

다음과 같은 구조가 더 명확할 수 있습니다.

is_used BOOLEAN NOT NULL

Java 코드에서도 훨씬 자연스럽습니다.

@Column(name = "is_used", nullable = false)
private boolean used;

Y, N, y, n, 1, 0 같은 값이 섞이는 문제도 줄일 수 있습니다.


7-2. 상태 값이라면 Enum 검토

컬럼이 단순 여부가 아니라 여러 상태를 표현한다면 CHAR(1)보다는 Enum이 더 나은 선택일 수 있습니다.

예를 들어 다음과 같은 상태 코드가 있다고 가정해보겠습니다.

A: ACTIVE
I: INACTIVE
D: DELETED

이런 값을 문자열 한 글자로만 관리하면 코드를 읽는 사람이 매번 의미를 추적해야 합니다.

Java에서는 다음처럼 표현할 수 있습니다.

public enum UserStatusType {
    ACTIVE,
    INACTIVE,
    DELETED
}

엔티티에서는 다음처럼 사용할 수 있습니다.

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private UserStatusType status;

DB에 저장되는 값도 A, I, D가 아니라 ACTIVE, INACTIVE, DELETED처럼 의미가 드러나기 때문에 유지보수성이 좋아집니다.

물론 기존 DB와 연동해야 하거나 저장 공간, 레거시 코드 호환성 문제가 있다면 코드 값 방식을 유지해야 할 수도 있습니다.
그 경우에는 별도의 Converter를 두는 방식도 고려할 수 있습니다.

@Converter(autoApply = false)
public class UserStatusConverter implements AttributeConverter<UserStatusType, String> {

    @Override
    public String convertToDatabaseColumn(UserStatusType attribute) {
        if (attribute == null) {
            return null;
        }

        return switch (attribute) {
            case ACTIVE -> "A";
            case INACTIVE -> "I";
            case DELETED -> "D";
        };
    }

    @Override
    public UserStatusType convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return null;
        }

        return switch (dbData) {
            case "A" -> UserStatusType.ACTIVE;
            case "I" -> UserStatusType.INACTIVE;
            case "D" -> UserStatusType.DELETED;
            default -> throw new IllegalArgumentException("Unknown user status: " + dbData);
        };
    }
}

이렇게 하면 DB에는 기존 코드 값을 유지하면서도, 애플리케이션 코드에서는 의미 있는 Enum으로 다룰 수 있습니다.


8. 실무적으로 권장하는 정리 방식

이번 문제를 기준으로 정리하면 다음과 같습니다.

기존 DB의 CHAR(1) 컬럼을 유지해야 하는 경우

@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "login_mark", nullable = false, columnDefinition = "char(1)")
private String loginMark;

이 방식이 가장 직접적입니다.

Hibernate에게 JDBC 타입을 명확히 알려주고, DB 컬럼 정의도 코드에 드러낼 수 있습니다.


신규 설계가 가능한 경우

단순 여부 값이면 BOOLEAN을 우선 검토하는 것이 좋습니다.

is_login BOOLEAN NOT NULL
@Column(name = "is_login", nullable = false)
private boolean login;

상태 값이면 Enum 기반 설계를 검토하는 것이 좋습니다.

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private UserStatusType status;

레거시 코드 값과 도메인 의미를 분리하고 싶은 경우

DB에는 기존 코드 값을 유지하고, Java에서는 Enum으로 다루는 Converter 방식이 좋습니다.

@Convert(converter = UserStatusConverter.class)
@Column(name = "status_cd", nullable = false, length = 1)
private UserStatusType status;

이 방식은 레거시 DB와 도메인 모델 사이에 완충 계층을 만들어줍니다.


9. 이번 문제에서 얻은 교훈

이번 오류는 단순히 CHARVARCHAR가 다르다는 문제가 아닙니다.

더 정확히 말하면, 애플리케이션과 데이터베이스 사이의 타입 계약이 어긋난 문제입니다.

ORM은 편리하지만 마법이 아닙니다.
DB 스키마와 Java 객체 사이에는 항상 매핑 규칙이 존재합니다.

그 규칙을 명확히 하지 않으면, 로컬에서는 괜찮아 보이던 코드가 스테이징이나 운영 배포 시점에 갑자기 실패할 수 있습니다.

이번 문제를 통해 다시 확인한 점은 다음과 같습니다.

  • @Column(length = 1)은 길이 힌트이지, JDBC 타입 지정이 아닙니다.
  • PostgreSQL의 CHAR(n)은 내부적으로 bpchar로 표현됩니다.
  • Hibernate는 Java String을 기본적으로 VARCHAR 계열로 기대할 수 있습니다.
  • ddl-auto: validate는 귀찮은 옵션이 아니라 운영 안정성을 위한 안전장치입니다.
  • 기존 DB를 유지해야 한다면 타입 매핑을 명시해야 합니다.
  • 신규 설계가 가능하다면 CHAR(1)보다 BOOLEAN, VARCHAR, ENUM, Converter 구조를 검토하는 것이 좋습니다.

10. 마무리

트러블슈팅에서 중요한 것은 에러 메시지를 없애는 것만이 아니라는것을 배웠습니다.

@JdbcTypeCode(SqlTypes.CHAR)

이 한 줄로 문제는 해결할 수 있습니다.

하지만 더 중요한 것은 왜 Hibernate가 VARCHAR를 기대했는지, 왜 PostgreSQL은 bpchar를 반환했는지, 그리고 이 둘 사이의 타입 계약을 어떻게 맞춰야 하는지 알게되엇고

운영 환경에서는 작은 타입 불일치 하나가 애플리케이션 부팅 실패로 이어질 수 있었습니다.

그래서 이런 문제를 만났을 때는 단순히 “어노테이션 하나 붙이니 해결됐다”로 끝내기보다, 같은 유형의 컬럼을 전체적으로 점검하고, 앞으로의 스키마 설계 방향까지 함께 정리하는 하게되었지만

결국 좋은 트러블슈팅은 불을 끄는 데서 끝나지 않았다는것을 배웠고
다음에 같은 불이 나지 않도록 구조를 정리해야겠다는 생각이 들었습니다.

profile
공부할게 많아졌어요

0개의 댓글