핵심 요약#
H2와 MySQL은 JSON 타입 데이터를 JDBC 드라이버 레벨에서 서로 다른 Java 타입으로 매핑합니다. H2는 byte[], MySQL은 String입니다. 이 차이 때문에 MySQL 기준으로 작성한 String <->객체 Converter가 H2 테스트 환경에서 동작하지 않습니다.
문제 상황#
프로젝트를 테스트하는 도중 아래의 그림과 같이 컨버터를 찾을 수 없다는 에러사항이 발생했습니다.

저는 현재 Spring Data JDBC를 사용하는 중이며, 혹시나 컨버터가 제대로 등록되지 않았는지 체크해 보았지만 컨버터는 제대로 등록되어 있었습니다.
상황을 조금 더 구체적으로 파악하기 위해 Data JPA, Data JDBC, H2, MySQL을 번갈아 가며 살펴봤더니, 이러한 에러는 MySQL로 개발할 때는 나타나지 않았으며 H2와 연결된 테스트 코드 실행 시에만 나타났습니다.
Data JPA 를 사용하는 경우
Error attempting to apply AttributeConverter … Caused by: java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException
Data JDBC 를 사용하는 경우
No converter found capable of converting from type [byte[]] to type ~~
와 같은 에러 메시지가 나타납니다.
예시 상황을 재구성하기 위해 아래와 같은 코드를 구성해 보았습니다.
public class Member {
@Id
@Column("MEMBER_ID")
private Long id;
private String username;
private Address address;
public Member(String username, Address address) {
this.username = username;
this.address = address;
}
}// Data JPA의 컨버터와 Data JDBC의 컨버터에서
// Jackson 라이브러리의 ObjectMapper.writeValueAsString()를 이용하여
// Address <-> String 컨버팅
public class Address {
private String street;
private String city;
private String state;
private String zip;
public Address(String street, String city, String state, String zip) {
this.street = street;
this.city = city;
this.state = state;
this.zip = zip;
}
}-- Address 객체를 JSON 타입으로 저장.
CREATE TABLE MEMBER (
MEMBER_ID BIGINT PRIMARY KEY AUTO_INCREMENT,
USERNAME VARCHAR(255) NOT NULL,
ADDRESS JSON
);결론부터 이야기하면 이는 H2 데이터베이스에서 JSON 타입을 처리하는 방식이 MySQL 과 달라서 나타나는 에러였습니다.
H2와 MySQL의 JSON 타입 처리 방식#
H2의 JSON 타입 처리#
-- String 리터럴로 저장 → escaped string(JSON String) 형태로 저장됨
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('테스트이름', '{"city":"seoul", "street":"nowon"}');
-- FORMAT JSON 명시 → 깔끔한 JSON Object 형태로 저장됨
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('테스트이름', JSON '{"city":"seoul", "street":"nowon"}');

H2는 위와 같이 첫번째 쿼리를 실행하는 것으로 MEMBER_ID 3번 row와 같이 ADDRESS 컬럼에 escaped string 모양의 JSON이 저장됩니다.
(지금부터는 MEMBER_ID를 생략하고 편하게 1, 2, 3번이라 하겠습니다.)
그리고 두번째 쿼리와 같이 JSON이라는 포맷을 지정해주면 1, 2번 row와 같이 깔끔한 형식으로 저장이 됩니다.
3번과 같은 escaped string JSON 형식을 H2 에서는 JSON String 이라고 부릅니다.
H2에서는 이 동작을 다음과 같이 설명하고 있습니다.
FORMAT JSON 없이 String을 저장하면 문자열 리터럴 안에 JSON 문자열이 들어있는 형식으로 암묵적으로 변환됩니다. (’text’ -> JSON ‘“text”’).
만약 JSON 텍스트를 포함한 String 리터럴을 전달하려면, 명시적으로 FORMAT JSON을 표시해야 합니다.
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?);
-- 2번째 바인딩 변수에 FORMAT JSON 명시
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON);그리고 Data JPA, Data JDBC를 사용하여 데이터 접근 메서드를 실행하면 첫번째 쿼리와 같이 바인딩 변수 형식으로 쿼리가 날라가는 것을 본 적 있으실 겁니다.
첫번째 쿼리는 3번 row와 같이 escaped string 형태의 JSON 데이터(JSON String)로 저장됩니다.
두번째 쿼리는 바인딩 변수 뒤에 FORMAT JSON을 명시했습니다. 이 경우 1, 2번 row처럼 깔끔하게 저장됩니다.
추가로 H2 문서에는 아래와 같이 방법이 적혀있습니다.
To set a
JSONvalue withjava.lang.Stringin a PreparedStatement use aFORMAT JSONdata format (INSERT INTO TEST(ID, DATA) VALUES (?, ? FORMAT JSON)) or usesetObject(parameter, jsonText, H2Type.JSON)instead ofsetString().
요약하면, 순수 JDBC의 PreparedStatement를 직접 사용하는 경우라면 아래 두 방법으로 이 문제를 해결할 수 있습니다.
// 방법 1: SQL에 FORMAT JSON 명시
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON)"
);
ps.setString(2, jsonString);
// 방법 2: setObject()에 타입 힌트 지정
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?)"
);
ps.setObject(2, jsonString, H2Type.JSON);하지만 Spring Data JPA나 Data JDBC는 PreparedStatement를 프레임워크가 내부적으로 생성하고 파라미터 바인딩까지 관리하기 때문에, 개발자가 특정 파라미터에 대해 FORMAT JSON이나 setObject()의 타입 힌트를 직접 지정할 수 없습니다. 결국 Spring Data 환경에서는 위의 두 방법 모두 적용하기 어렵습니다.
아래의 SQL 로그를 보시면 Spring Data는 Address 값을 java.lang.String 타입으로 바인딩하고 있습니다. 이 때문에 H2에서는 escaped string 형태의 JSON String으로 저장됩니다.

그리고 이 데이터를 SELECT하여 가져와보면 아래와 같이 escaped string 리터럴이 또 한번 감싸진 것을 볼 수 있습니다.

MySQL의 JSON 타입 처리#
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES('테스트이름', '{"city":"seoul", "street":"nowon"}');
INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?);MySQL 에서 JSON 타입을 다루는 방법은 많지만, 위의 쿼리들과 같이 특정한 포맷 형식을 지정해주지 않더라도 아래의 이미지처럼 Escaped String 형식이 아닌 깔끔한 JSON 형태로 DB에 저장됩니다.
따라서 Spring Data 기술을 사용해도 문제가 없습니다.

byte[] vs String — 저장 방식이 다른 이유#

먼저, escaped 처리된 데이터도 JSON 타입이 맞습니다. 실제로 JSON이 아닌 일반적인 String(varchar)은 유효한 JSON이 아니어서 JSON 타입으로 지정한 H2의 컬럼에 INSERT가 불가능합니다.
H2에서는 JSON을 byte[] 또는 String으로 다룹니다. 그리고 저장된 결과에도 차이가 나는데, byte[] 로 H2 JSON 컬럼에 저장하면 JSON Object(escaped 되지 않은 깔끔한 형태)가 저장됩니다.
하지만, Address 객체와 같이 Jackson 라이브러리의 ObjectMapper.writeValueAsString()을 이용해서 String으로 변환하여 JSON 텍스트 형식의 리터럴을 H2 JSON 컬럼에 저장한다면 String 리터럴에 escaped 처리가 되어 저장됩니다.
이 차이가 SELECT 시점에서 Converter 에러의 직접적인 원인이 됩니다. 이제 Data JDBC와 Data JPA 각각에서 어떤 방식으로 문제가 발생하는지 살펴보겠습니다.
Data JDBC에서의 에러 원인과 해결#
에러 원인 — H2 드라이버의 byte[] 매핑#
Data JDBC는 컨버터(DB source value -> Address)를 선택할 때, 각 DB Driver에서 제공하는 타입의 포맷을 가지고 컨버터를 선택합니다. 이게 무슨 말이냐면, H2는 저장된 값과 Java Object의 매핑 방법에서 JSON 타입은 byte[] 타입으로 매핑합니다.
H2 드라이버 내부에 있는 ValueToObjectConverter를 보면, JSON 타입은 value.getBytes()를 호출하여 byte[] 타입으로 반환하는 것을 확인할 수 있습니다.

그리고 Data JDBC는 쿼리 실행 후 얻은 결과(H2 ResultSet)에서 엔티티로 변환하고자 할 때 Converter가 필요하고, 이때 적절한 Converter의 타입을 찾는 데 있어서 H2 ResultSet에 저장된 값의 타입을 힌트로 얻습니다. H2는 이 과정에서 JSON 값인 경우 byte[] 타입을 전달합니다.
그림으로 대략 표현하면 아래와 같은 흐름입니다. (실제보다 많이 단순화한 것이라 이해를 위해 간단히 참고바랍니다.)

그래서 아래와 같은 String -> Address Converter를 등록했더라도, 그것을 찾을 수 없다는 에러가 나타납니다.
// Data JDBC의 컨버터
@Slf4j
@RequiredArgsConstructor
@ReadingConverter
public class JsonToAddressConverter implements Converter<String, Address> {
private final ObjectMapper objectMapper;
@Override
public Address convert(String source) {
try {
return objectMapper.readValue(source, Address.class);
} catch (IOException e) {
log.info("JSON 타입을 Address 객체로 변경에 실패했습니다.");
throw new RuntimeException(e);
}
}
}H2는 JSON 데이터를 byte[]로 매핑하기 때문에 INSERT 시 String 타입이 아닌 byte[]로 매핑하면 JSON 타입의 읽기와 쓰기가 모두 정상적으로 동작합니다. 하지만 이 경우 MySQL에서는 INSERT할 때 에러가 나타납니다. 그리고 H2만을 위해서 JSON 타입으로 다루어질 객체를 byte[]로 바꾸는 것은 적절한 해결책이 되지 못합니다.
MySQL에서는 에러가 없는 이유#

MySQL은 이 과정에서 JSON 값인 경우 String 타입을 매핑합니다. 따라서 예외가 발생하지 않습니다. (추가로 INSERT할 때도 String 타입으로 넣어야 합니다. 그래서 byte[] 타입은 에러가 발생합니다.)
해결 방법#
아직 Data JDBC에서 이 문제가 해결되지는 못한 것 같습니다. 개인적으로 오픈소스에 기여하고 싶은 욕심이 생겨서 원인을 찾아보았지만,
- 데이터를 INSERT할 때 H2는 JSON 포맷을 지정해줘야 합니다. 아니면 byte[] 타입으로 컨버팅하여 저장해야 하는데 → MySQL은 에러 발생
- H2, MySQL Driver에서 JSON 타입을 서로 다른 포맷으로 전달합니다 → 매칭해야 하는 컨버터의 타입이 달라집니다. H2는 byte[]와 객체간 컨버팅, MySQL은 String과 객체간 컨버팅 전략을 설정해야 합니다.
이러한 여러 DB 엔진의 타입 변환 차이를 범용적으로 처리하기 위해서는 단순히 소스 코드 몇 줄 수정한다고 해결될 것이 아니었습니다.
그래서 현재 선택할 수 있는 최선의 방법은 3가지가 있습니다.
먼저, Spring Boot Profile을 분리하여 테스트 코드에서는 H2 전용 컨버터를 등록하는 방법을 적용합니다. 이 컨버터는 byte[] ↔ Address를 변환합니다.
두번째는, H2와 MySQL의 JSON 타입으로 설정한 필드를 VARCHAR 또는 TEXT 타입으로 변경합니다. TEXT 타입으로 지정한 컬럼에 JSON 형태의 문자열을 저장하는 것으로 위의 이슈를 해결할 수 있습니다. 다만 이 방법은 DB 레벨의 JSON 유효성 검증을 잃게 됩니다. JSON_EXTRACT() 같은 JSON 함수도 사용할 수 없게 되지만, 애플리케이션에서 JSON 데이터에 대한 DB 쿼리(검색, 필터링 등)를 수행할 필요가 없고 단순히 직렬화된 객체를 저장/조회하는 용도라면 이 트레이드오프는 충분히 감수할 수 있습니다.
세번째로는 Docker 컨테이너 위에서 테스트 코드를 수행하는 방법도 있습니다. TestContainers를 이용하면 Docker 컨테이너에서 MySQL DB 서버를 띄우고 그 위에서 테스트 코드가 수행됩니다. 이 방법은 운영환경과 테스트 환경을 일치시킬 수 있다는 점에서 장점이 되지만, 테스트 코드를 한번 수행하는 데 있어서 시간이 오래걸린다는 단점이 있습니다.
저의 경우는 두번째 방법을 선택했는데, 첫번째 방법인 Profile을 분리하여 H2용의 컨버터를 생성하는 방법은 추후 관리가 복잡해질 우려가 있다고 판단했습니다. 만약 DB에 JSON으로 변환해서 관리해야 할 클래스가 많아질수록 그 클래스들에 대해 모두 별도의 테스트용 컨버터를 준비해야 합니다. 프로젝트에서 JSON 컬럼에 대해 DB 레벨의 쿼리가 필요하지 않았기 때문에, 저는 H2와 MySQL 모두에게서 차이를 타협할 수 있는 TEXT 타입으로 저장하는 두번째 방법을 선택했습니다.
Data JPA에서의 에러 원인과 해결#
에러 원인 — escaped string 역직렬화 실패#
JPA는 컨버터를 선택할 때 Data JDBC와 다른 방법으로 작동합니다. Data JDBC는 연결된 DB의 Driver에게 타입을 묻는 반면, JPA는 변환하고자 하는 타입을 미리 가지고 있습니다. 그리고 그 타입에 맞게 ResultSet에서 값을 추출합니다.
그림으로 간단히 표현하면 아래와 같습니다.

이러한 이유로 Data JPA는 Data JDBC와 다르게 컨버터를 찾을 수 없다는 에러가 나타나지 않습니다. JPA는 AttributeConverter<Address, String>에서 DB 컬럼 타입이 String임을 이미 알고 있으므로, ResultSet에서 getString()을 호출하여 값을 추출합니다.
하지만 DB로부터 읽어온 값을 String 값으로 변환 후 아래와 같은 컨버터를 사용하게 되는데,
// Data JPA의 컨버터
@Slf4j
@RequiredArgsConstructor
@Converter(autoApply = true)
public class AddressConverter implements AttributeConverter<Address, String> {
private final ObjectMapper objectMapper;
@Override
public String convertToDatabaseColumn(Address address) {
try {
return objectMapper.writeValueAsString(address);
} catch (Exception e) {
log.info("Address 타입을 Json 으로 변환할 수 없습니다.");
throw new RuntimeException(e);
}
}
@Override
public Address convertToEntityAttribute(String source) {
try {
return objectMapper.readValue(source, Address.class);
} catch (Exception e) {
log.info("Json 타입을 Address 타입으로 변환할 수 없습니다.");
throw new RuntimeException(e);
}
}
}이 과정에서 DB에서 읽어온 String 타입의 value는 아래와 같습니다.

Jackson 라이브러리의 ObjectMapper은 escaped 처리가 된 문자열을 한번 더 감싼 String 리터럴을 Address 객체로 변환하지 못합니다.

따라서 Address 객체를 생성하는 과정에서 MismatchedInputException 에러 메시지가 출력되었습니다.

추가적으로, JPA는 1, 2번 row와 같이 깔끔하게 저장되어 있는 JSON 데이터를 컨버터의 source 타입을 String으로 하던 byte[]로 하던 모두 읽어올 수 있습니다. JPA의 JdbcValueExtractor가 Converter에 선언된 타입에 따라 getString() 또는 getBytes()를 적절히 호출하고, H2 드라이버가 JSON 데이터에 대해 두 메서드 모두를 지원하기 때문입니다.
하지만 Data JDBC는 1, 2번 row와 같이 깔끔하게 저장된 JSON 데이터조차 Address ↔ String 컨버팅 전략인 경우 byte[]과의 컨버팅 전략이 아니란 이유로 컨버팅할 수 없습니다.
해결 방법 — hypersistence-utils#
JPA에서는 2021년에 MySQL, Oracle, PostgreSQL, H2와 같은 DB를 사용할 때 JSON 타입 변환을 가능하게 한 hypersistence-utils를 Vlad Mihalcea님이 만들어 주셨습니다.
@Getter
@NoArgsConstructor
@ToString
@Table(name = "MEMBER")
@Entity
public class JpaMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@Type(JsonType.class) // 이 애너테이션 하나 달아주면 H2에서도 JSON 타입으로 저장
private Address address;
public JpaMember(String username, Address address) {
this.username = username;
this.address = address;
}
}@Type(JsonType.class)를 선언하면 Hibernate가 JsonType 내부에서 직렬화/역직렬화를 처리하므로, 별도의 @Convert(converter = AddressConverter.class)를 함께 사용할 필요가 없습니다. JsonType이 각 DB 엔진에 맞는 저장/조회 전략을 내부적으로 분기 처리하기 때문입니다.
아래의 링크를 참고해 주세요!
How to map a JSON column with H2, JPA, and Hibernate
hypersistence-utils 내부 분해#
직접 디버깅으로 JsonType의 내부를 들여다보니, 핵심은 JsonJdbcTypeDescriptor 내부의 resolveJdbcTypeDescriptor() 메서드였습니다.

이 메서드는 연결된 DB의 방언(Dialect)을 감지하여 JDBC 타입 처리 전략을 분기합니다. H2Dialect인 경우 JsonBytesJdbcTypeDescriptor.INSTANCE를 반환하는데, 이것이 앞서 확인한 “H2는 byte[]로 저장해야 escaped string 없이 깔끔하게 저장된다"는 원리를 그대로 적용한 것입니다.
결국 hypersistence-utils는 DB 방언을 감지해서 H2라면 byte[] 기반 전략을, 그 외 DB라면 각 환경에 맞는 전략을 선택하는 방식으로 DB 엔진마다 다른 JSON 처리 차이를 내부에서 처리하고 있었습니다.
