
bpchar와 Hibernate VARCHAR 검증 오류 해결기Spring Boot 애플리케이션을 운영 환경에 배포할 때 가장 당황스러운 순간 중 하나는, 비즈니스 로직이 아니라 애플리케이션 부팅 단계에서 바로 죽는 경우입니다.
특히 기존 PostgreSQL 스키마를 유지한 채 Spring Boot, Hibernate 버전을 올리거나 ddl-auto: validate 옵션을 활성화하면 생각보다 자주 타입 검증 오류를 만나게 됩니다.
이번 글에서는 PostgreSQL의 CHAR(1) 컬럼이 Hibernate에서는 VARCHAR(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)을 기대하고 있었습니다. 그래서 스키마 검증 단계에서 타입 불일치로 애플리케이션 구동을 막은 것입니다.
이 문제의 본질은 단순한 길이 문제가 아닙니다.
문제는 문자열 길이가 아니라 JDBC 타입 계약입니다.
PostgreSQL에서 CHAR(n) 타입은 내부적으로 bpchar로 표현됩니다.
bpchar는 blank-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 단계에서 예외가 발생한 것입니다.
@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 타입까지 항상 원하는 방식으로 맞춰준다고 보기에는 부족할 수 있습니다.
운영 환경에서는 이런 애매한 설정이 문제를 키웁니다.
“테이블 생성은 되는데 검증은 실패하는 상황”이 나올 수 있기 때문입니다.
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 컬럼 정의를 명확히 표현할 수 있습니다.
해당 컬럼 하나만 고치고 끝내면 안 됩니다.
이런 문제는 보통 하나의 컬럼에서만 발생하지 않습니다.
기존 시스템에서는 다음과 같은 컬럼들이 여러 테이블에 흩어져 있는 경우가 많습니다.
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 타입 컬럼을 먼저 찾고, 해당 컬럼들이 엔티티에서 어떻게 매핑되어 있는지 확인하는 것이 좋습니다.
운영 배포 직전에 하나씩 터지는 것보다, 한 번에 전체 범위를 확인하는 편이 훨씬 안전합니다.
ddl-auto: validate는 끄는 게 답일까?급한 상황에서는 다음처럼 설정을 바꾸고 싶은 유혹이 생깁니다.
spring:
jpa:
hibernate:
ddl-auto: none
물론 상황에 따라 임시 회피가 필요할 수는 있습니다.
하지만 근본적인 해결책은 아닙니다.
ddl-auto: validate는 애플리케이션의 엔티티 모델과 실제 DB 스키마가 일치하는지 확인하는 안전장치입니다.
이 검증이 없다면 다음과 같은 문제를 배포 후에야 발견할 수 있습니다.
VARCHAR로 기대하는데 DB는 CHAR인 경우운영 환경에서는 애플리케이션이 뜨는 것만큼 중요한 것이, 잘못된 스키마 위에서 뜨지 않는 것입니다.
검증 오류는 귀찮은 장애물이 아니라, 더 큰 장애를 막아주는 신호에 가깝습니다.
이번 문제는 @JdbcTypeCode(SqlTypes.CHAR)를 추가하면 해결됩니다.
하지만 여기서 끝내기보다는 한 번 더 생각해볼 필요가 있습니다.
이 컬럼은 정말 CHAR(1)이어야 할까요?
기존 DB를 그대로 유지해야 하는 상황이라면 현재 방식이 현실적인 해결책입니다.
하지만 신규 설계이거나 리팩토링 권한이 있다면 다른 선택지도 검토할 수 있습니다.
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 같은 값이 섞이는 문제도 줄일 수 있습니다.
컬럼이 단순 여부가 아니라 여러 상태를 표현한다면 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으로 다룰 수 있습니다.
이번 문제를 기준으로 정리하면 다음과 같습니다.
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와 도메인 모델 사이에 완충 계층을 만들어줍니다.
이번 오류는 단순히 CHAR와 VARCHAR가 다르다는 문제가 아닙니다.
더 정확히 말하면, 애플리케이션과 데이터베이스 사이의 타입 계약이 어긋난 문제입니다.
ORM은 편리하지만 마법이 아닙니다.
DB 스키마와 Java 객체 사이에는 항상 매핑 규칙이 존재합니다.
그 규칙을 명확히 하지 않으면, 로컬에서는 괜찮아 보이던 코드가 스테이징이나 운영 배포 시점에 갑자기 실패할 수 있습니다.
이번 문제를 통해 다시 확인한 점은 다음과 같습니다.
@Column(length = 1)은 길이 힌트이지, JDBC 타입 지정이 아닙니다.CHAR(n)은 내부적으로 bpchar로 표현됩니다.String을 기본적으로 VARCHAR 계열로 기대할 수 있습니다.ddl-auto: validate는 귀찮은 옵션이 아니라 운영 안정성을 위한 안전장치입니다.CHAR(1)보다 BOOLEAN, VARCHAR, ENUM, Converter 구조를 검토하는 것이 좋습니다.트러블슈팅에서 중요한 것은 에러 메시지를 없애는 것만이 아니라는것을 배웠습니다.
@JdbcTypeCode(SqlTypes.CHAR)
이 한 줄로 문제는 해결할 수 있습니다.
하지만 더 중요한 것은 왜 Hibernate가 VARCHAR를 기대했는지, 왜 PostgreSQL은 bpchar를 반환했는지, 그리고 이 둘 사이의 타입 계약을 어떻게 맞춰야 하는지 알게되엇고
운영 환경에서는 작은 타입 불일치 하나가 애플리케이션 부팅 실패로 이어질 수 있었습니다.
그래서 이런 문제를 만났을 때는 단순히 “어노테이션 하나 붙이니 해결됐다”로 끝내기보다, 같은 유형의 컬럼을 전체적으로 점검하고, 앞으로의 스키마 설계 방향까지 함께 정리하는 하게되었지만
결국 좋은 트러블슈팅은 불을 끄는 데서 끝나지 않았다는것을 배웠고
다음에 같은 불이 나지 않도록 구조를 정리해야겠다는 생각이 들었습니다.