
프로젝트를 만지다 보면 “왜 또 안 되냐” 싶은 순간이 한 번이 아니라 줄줄이 온다.
특히 Kotlin + Spring Boot + JPA + PostgreSQL 조합에서는, 에러가 한 번 시작되면 전혀 다른 문제처럼 보여도 사실은 각 계층에서 다른 종류의 실패가 순서대로 드러나는 경우가 많다.
이번에 겪은 흐름도 딱 그랬다.
처음에는 Kotlin 컴파일 데몬이 죽었고, 그다음에는 Hibernate 스키마 검증에서 막혔고, 마지막에는 ObjectMapper 빈이 없어서 애플리케이션이 뜨지 않았다.
얼핏 보면 전부 별개 사고처럼 보이지만, 로그를 차근히 뜯어보면 원인과 레이어가 꽤 분명하게 갈린다.
처음 마주친 건 Kotlin 컴파일 실패였다.
그런데 내용을 보면 문법 에러나 타입 에러가 아니라 이런 쪽이었다.
Daemon compilation failedCould not close incremental cachesStorage ... is already registered이런 메시지는 대체로 소스가 틀려서가 아니라 Kotlin daemon과 incremental cache가 충돌한 상황이다.
쉽게 말해, 이전 빌드 흔적을 정리하지 못했는데 새 빌드가 또 들어오면서 캐시 파일 등록이 꼬인 거다.
이때 흔한 실수는 경고를 범인으로 보는 거다.
로그 위쪽에 deprecated 경고가 잔뜩 찍혀 있으니 거기에 시선이 가는데, 실제로 애플리케이션을 멈춘 건 경고가 아니라 캐시 정리 실패였다.
이런 경우 우선순위는 명확하다.
build 캐시 삭제.gradle까지 정리clean build즉, 이 단계에서 중요한 건 “왜 문법이 틀렸지?”가 아니라
“지금 이건 코드 문제냐, 빌드 인프라 문제냐”를 먼저 구분하는 것이다.
캐시 문제를 넘기고 나니 이번에는 애플리케이션 부팅이 JPA에서 멈췄다.
핵심 로그는 이거였다.
tb_banner.reflct_at 컬럼의 타입이 실제 DB에서는 bpchar, 즉 char(1)인데, Hibernate는 varchar(1)로 기대하고 있었다.
이건 자주 나오는 함정이다.
엔티티에서는 보통 이렇게 쓴다.
@Column(name = "reflct_at", length = 1)
var reflctAt: String? = null
개발자는 “1글자니까 됐지”라고 생각하지만, Hibernate는 이걸 varchar(1) 쪽으로 해석할 수 있다.
반면 PostgreSQL의 실제 컬럼은 char(1)이면 내부적으로 bpchar로 보인다.
결국 Hibernate의 validate 단계에서 “타입 다름” 판정이 나고, SessionFactory 생성 자체가 실패한다.
여기서 중요한 포인트는 이거다.
이건 단순 예외가 아니라 엔티티 정의와 실제 스키마 간 계약 위반이다.
즉, 해결도 둘 중 하나뿐이다.
varchar(1)로 바꾸거나char(1)로 명시해서 맞추거나예를 들어 레거시 테이블에서 Y/N 플래그 컬럼을 오래 써왔다면, 오히려 엔티티 쪽을 아래처럼 맞추는 게 더 자연스럽다.
@Column(name = "reflct_at", columnDefinition = "char(1)")
var reflctAt: String? = null
여기서 많이 하는 나쁜 선택이 하나 있다.
ddl-auto=validate가 거슬린다고 검증을 꺼버리는 것이다.
그건 문제를 해결하는 게 아니라 경고등을 검은 테이프로 가리는 행동에 가깝다.
부팅은 될 수 있어도, 운영에서 더 골치 아픈 형태로 돌아온다.
스키마 문제를 넘기고 나면 또 다른 실패가 나왔다.
MbrdJdbcEditorDocumentRepository 생성자에서 ObjectMapper를 주입받는데, 해당 타입의 빈을 찾지 못한다는 에러였다.
이건 앞선 두 문제와 결이 다르다.
즉, 이제는 애플리케이션 레이어에서 DI 구성 자체가 맞지 않는 상태다.
특히 이 상황은 Spring Boot 4 환경이라면 더 민감하다.
코드에서는 com.fasterxml.jackson.databind.ObjectMapper를 요구하고 있는데, 현재 환경의 JSON 구성과 맞지 않거나, 관련 의존성/자동설정이 예상대로 올라오지 않으면 이런 식으로 빈이 비게 된다.
겉으로는 “ObjectMapper 하나 없네?”처럼 보여도, 실제로는 다음 둘 중 하나일 가능성이 크다.
이 단계에서 해야 할 질문은 단순하다.
ObjectMapper를 어떤 패키지로 import하고 있는가?즉, 이 문제는 “빈 하나 수동 등록하면 끝”일 수도 있지만, 제대로 보면 프레임워크 버전 업 과정에서 생긴 호환성 문제일 가능성이 높다.
실무에서 로그가 길어지면 사람은 쉽게 지친다.
위에서부터 읽다가 스택트레이스에 파묻히고, 중간쯤 가면 멘탈이 먼저 꺼진다.
하지만 대부분의 장애는 로그가 길어서 어려운 게 아니라, 어느 레이어의 실패인지 구분하지 못해서 어렵다.
이번 흐름만 봐도 딱 나뉜다.
Kotlin daemon / incremental cache 충돌
Hibernate schema validation 실패
Spring bean 생성 실패
이렇게 나누고 보면, 사실 한 번에 세 문제를 동시에 푸는 게 아니다.
순서대로 하나씩 제거하면 된다.
이런 식으로 접근하면 로그가 길어도 안 쫄린다.
로그는 장문의 협박문이 아니라, 그냥 범인이 자기 위치를 순서대로 불고 있는 거다.
이번 흐름에서 얻을 수 있는 교훈은 꽤 분명하다.
앞의 에러를 해결해야 뒤의 에러가 보인다.
처음 문제를 덮어놓고 전체를 한 번에 해석하려고 하면 머리만 아프다.
deprecated 경고가 많아도, 실제 부팅 실패 원인은 전혀 다른 데 있을 수 있다.
char(1)과 varchar(1)도 Hibernate 입장에서는 다른 계약이다.
특히 validate 모드에서는 더 냉정하다.
Spring Boot 메이저 버전 변경은 단순 문법 수정이 아니라,
자동설정, 라이브러리 버전, 주입 가능한 타입까지 영향을 준다.
개발하다 보면 “왜 이래?”가 절로 나오는 순간이 있다.
그런데 로그를 제대로 읽으면, 대부분의 장애는 감정 문제가 아니라 구조 문제다.
이걸 구분하는 순간, 긴 로그도 덜 무섭다.
결국 중요한 건 에러 메시지를 많이 읽는 게 아니라, 어느 계층에서 실패했는지 먼저 분류하는 습관이다.
한마디로 정리하면 이렇다.
빌드 실패는 코드 탓이 아닐 수 있고, 부팅 실패는 DB 탓일 수 있고, 마지막엔 설정 탓일 수 있다.
로그는 시끄럽지만, 원인은 생각보다 정직하다.