
굉장히 오랜만에 올리는 포스트이다. 3월 말서부터 새로운 프로젝트를 시작해 약 2달간 진행하였고 며칠 전 완전히 종료되었다. 한동안은 해당 프로젝트를 하면서 알게 되고 해결한 것들에 대해 포스팅 해보려 한다.
우선 오늘 포스팅할 내용은 프로젝트 초반 DB설계를 할 때 겪었던 일로 MySQL 데이터로 List를 넣고 싶어 구현했던 방법에 관한 내용이다.

위의 ERD와 같이 recipe_file 테이블은 recipe 테이블의 primary key인 recipe_no를 foreign key로 갖는다. 원래 기획대로라면 recipe 테이블과 recipe_file 테이블은 One to Many의 관계를 가지게 되는데 추후 사진 부분을 구현할 때 번거로움이 있을 거 같아 recipe_file_oname 컬럼과 recipe_file_sname 컬럼을 List로 관리하는 건 어떨까? 라고 생각하였다. 즉 이렇게 되면 One to Many가 아닌 One to One 관계를 갖게 된다. 다만 문제가 하나 있는데 MySQL에서는 List에 맞는 데이터 타입이 없다는 것이다. 이를 해결한 방법은 아래에서 소개하도록 하겠다.
해결 방법으로 사용한 것은 AttributeConverter, @Converter, @Convert이다.
AttributeConverter는 인터페이스로 엔티티의 값을 원하는 형태로 DB에 저장하거나 DB에 저장된 값을 원하는 형태로 엔티티 필드에 바인딩할 때 사용한다.
package jakarta.persistence;
public interface AttributeConverter<X, Y> {
Y convertToDatabaseColumn(X var1);
X convertToEntityAttribute(Y var1);
}
AttributeConveter에는 두 가지 메서드가 있다.
이 AttributeConverter를 구현하는 구현체를 만들고 위의 두 메소드를 Override하면 된다. 또한 이 구현체에 @Converter 어노테이션을 달아주면 Entity의 값과 DB의 값을 원하는 형태로 변환할 수 있다.
아래 코드는 프로젝트에서 사용한 코드이다.
// common/StringListConverter.java
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> entityData) {
try {
return mapper.writeValueAsString(entityData);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
try {
return mapper.readValue(dbData, List.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
DB에 값을 저장할 때는 convertToDatabaseColumn 메소드를 오버라이드하여 ObjectMapper의 writeValueAsString를 통해 Java 객체를 String으로 직렬화(Serialization) 하였고
DB에서 값을 읽어올 때는 convertToEntityAttribute 메소드를 오버라이드하여 ObjectMapper의 readValue를 통해 String을 Java 객체로 역직렬화(Deserialization) 하였다.
이 Custom Converter를 적용할 엔티티의 필드에 @Convert 어노테이션을 이용해 적용시켜주면 된다.
아래 코드와 같이 @Convert 어노테이션을 적어주고 사용할 converter를 명시해주면 된다.
필자는 recipe_file_oname 컬럼과 recipe_file_sname 컬럼뿐만 아니라 레시피 재료(recipe_stuff) 컬럼 또한 엔티티에서는 List<String> 타입으로 사용하고, DB에는 String 타입으로 저장하고 싶어 해당 컬럼에도 적용하였다.
// domain/Recipe.java
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Recipe {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "recipe_no")
private Long recipeNo;
.
.
.
@Column(name = "recipe_stuff")
@Convert(converter = StringListConverter.class)
private List<String> recipeStuff;
@ManyToOne
@JoinColumn(name = "user_no")
private User user;
@OneToMany(mappedBy = "recipe", cascade = CascadeType.REMOVE)
private List<RecipeFile> recipeFiles;
.
.
.
}
//RecipeFile.java
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RecipeFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "recipe_file_no")
private Long recipeFileNo;
@Column(name = "recipe_file_oname", columnDefinition = "TEXT")
@Convert(converter = StringListConverter.class)
private List<String> recipeFileOname;
@Column(name = "recipe_file_sname", columnDefinition = "TEXT")
@Convert(converter = StringListConverter.class)
private List<String> recipeFileSname;
@ManyToOne
@JoinColumn(name = "recipe_no")
private Recipe recipe;
}
Postman을 통해 데이터를 넣어보고 조회하여 원하는대로 변환이 이루어지는지 확인해본다.
recipe_stuff, recipe_file_oname, recipe_file_sname에 List<String> 형태로 된 데이터를 작성하여 넣어준다.

DB를 확인해보면 다음과 같이 String으로 변환되어 recipe_stuff는 varchar(255) 타입으로, recipe_file_oname과 recipe_file_sname은 Entity 필드에서 지정한 TEXT 타입으로 데이터가 들어간 것을 볼 수 있다.

이번엔 recipe_stuff에 List<String> 형태가 아닌 String 형태로 데이터를 넣어본다.

에러가 발생한 것을 볼 수 있고 IntelliJ를 확인해보면 다음과 같은 에러 메시지가 찍힌 것을 볼 수 있다.
Exception [Err_Msg]: JSON parse error: Cannot construct instance of `java.util.ArrayList` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('당근')
Exception [Err_Where]: org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:406)
Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of `java.util.ArrayList` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('당근')]
위에서 생성한 레시피를 조회하면 어떻게 조회되는지도 확인해본다.

List 형태로 조회되는 것을 확인할 수 있다.
https://khdscor.tistory.com/39
https://effortguy.tistory.com/483
https://velog.io/@dnrwhddk1/Spring-Converter-%EC%82%AC%EC%9A%A9