본문 바로가기

back-end/spring

spring/ JPA를 이용해 json 형태의 컬럼을 RDB에서 편리하게 다루기 (hypersistence-utils)

 

프로젝트 스펙 상 메인 데이터베이스로 MySQL을 사용하고 있다. 하지만 도중에 문항에 대한 답을 매칭시켜 저장해야 하는 상황이 생겼고, 요구사항을 RDB 구조 그대로 가져가면서 충족시키기엔 문항에 대한 컬럼이 수없이 생겨야 했다. 그래서 알아보는 MySQL에 json 형식의 데이터 다루기.

 

Project Spec

  • Java 17
  • Spring 3.0.5
  • MySQL 8.0.31
  • JPA
  • hibernate 6.0.6

 

{
  "1": "첫번째 데이터",
  "2": "두번째 데이터",
  "3": "세번째 데이터",
  "4": "네번째 데이터",
  "5": "다섯번째 데이터",
  "6": "짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용짱짱긴내용"
}

 

기존에 비슷한 레거시 프로젝트에서는 위와 같은 json 형식의 데이터를 MySQL의 한 컬럼으로 저장하기 위해 List<Map<String, String>> 형태의 input data를 생성하고, ObjectMapper를 사용해 json 형식의 String을 writeValueAsString으로 생성 후 varchar 타입 컬럼에 insert하는 과정을 거치고 있었다.

 

데이터를 select해 읽어올 때는 마찬가지로 ObjectMapper로 readValue 메서드를 이용해 String을 List<Map<String, String>>의 형태로 변환하는 작업이 포함되었다.

 

이번 프로젝트에서도 json 타입을 컬럼으로 저장하려고 보니, ObjectMapper로 매번 해당 데이터들을 파싱하는 작업은 번거롭게 느껴졌고 믿음을 가지고 찾다보니 역시나 누군가 이미 해당 작업을 공수 없이 편하게 수행할 수 있는 라이브러리를 제공하고 있었다.

 


hypersistence-utils 라이브러리 주입 받기

 

GitHub - vladmihalcea/hypersistence-utils: The Hypersistence Utils library (previously known as Hibernate Types) gives you Sprin

The Hypersistence Utils library (previously known as Hibernate Types) gives you Spring and Hibernate utilities that can help you get the most out of your data access layer. - GitHub - vladmihalcea/...

github.com

 

이번 프로젝트는 JPA 기반으로 구성할 계획이었으므로 데이터베이스에서 해당 json 형식의 컬럼을 읽어오고, 반대로 json 형식의 데이터를 편하게 저장할 수 있는 것이 중요했는데 마침 딱 적당한 라이브러리가 위 라이브러리다.

 

build.gradle

먼저 주입할 라이브러리 버전 설정을 위해 현재 사용하고 있는 hibernate의 버전을 확인한다.

좌측 Project 탭에서 External Libraries 클릭 후 hibernate를 검색하면 쉽게 현재 사용하고 있는 버전을 확인할 수 있다.

 

나는 현재 6.0.6 버전을 사용 중이므로 github README에 명시된 hypersistence-utils-hibernate-60:3.3.1 버전을 implementation 해준다.

 

gradle

implementation 'io.hypersistence:hypersistence-utils-hibernate-60:3.3.1'

 


 

JSON 파싱 용 라이브러리 (jackson) 주입 받기

JSON Optional Maven Dependencies
If you are using JSON Types, then you might be interested in setting the following dependencies based on your Hibernate version:

 

JSON 타입을 제대로 parse하기 위해서는 추가적인 parsing 용 라이브러리를 주입 받아야 한다.

 

hibernate 6.0, 6.1, 6.2 기준 유효한 버전은 다음과 같다

 

gradle

implementation 'com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations'

 


Practice

이제 해당 라이브러리를 사용하기 위한 사전 설정이 끝났으니 시험용 Entity를 만들어 테스트해보자. 기본적인 JPA와 database 설정 등은 이미 준비되어 있다고 가정한다.

 

Entity 생성

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Entity(name = "TB_AROM")
public class Arom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "IDX", columnDefinition = "int(11)")
    private Integer idx;
    @Type(JsonType.class)
    @Column(name = "SCORE", columnDefinition = "longtext")
    private Map<String, Object> scores;
}

 

작동 확인용으로 간단하게 id와 json 타입을 저장할 컬럼만을 생성했다.

나는 hibernate 6를 사용 중이므로 hypersistence-utils 설명에 명시된 바와 같이 @Type(JsonType.class)만 해당 컬럼에 명시해주면 hypersistence-utils가 인식해 작동한다. 컬럼 타입은 longtext로 지정해주었다. 

 

 

더보기

hibernate 6


hibernate 5

 

Repository 생성

public interface AromRepository extends JpaRepository<Arom, Integer> {
}

 

Request DTO 생성

public record AromTestResult(Map<String, Object> scores) {
}

 

Request로 받을 DTO를 생성한다. 필드로 선언하지 않고 Map 형태로 선언해 여러 형태의 key, value를 받는다. record 타입으로 생성했지만 class로 생성해도 무방하다.

 

Controller 생성

@RestController
@RequiredArgsConstructor
public class AromController {
    private final AromRepository aromRepository;
    
    @PostMapping("/tests")
    public ResponseEntity saveTestScores(@RequestBody AromTestResult request) {
        Arom arom = Arom.builder()
                .scores(request.scores())
                .build();
        aromRepository.save(arom);
        return ResponseEntity.ok().build();
    }
}

 

간단하게 실제 요청과 응답을 테스트해볼 Controller를 만들었다. 앞서 생성한 DTO를 RequestBody로 받고, 상태코드 200만 반환하도록 했다.


 

데이터 저장 확인하기

 

POST /tests

{
    "scores" : 
    {
        "국어" : 100,
        "수학" : 30,
        "영어" : 92
    }
}

 

클라이언트 쪽에서 요청할 json 형태의 body다. 앞서 생성한 DTO의 Map 타입 변수명 scores를 key로 하고 value를 Map<Stirng, Object>의 형태로 보낸다.

 

 

저장 결과 확인

Controller에서 요청 DTO를 바로 Arom 엔티티의 Map<String, Object>로 선언된 scores 필드에 할당하고, jpa repository를 통해 그대로 save 해주었다. 데이터베이스를 확인해보면 문제 없이 해당 json이 잘 저장된 것을 확인할 수 있다. 

 

POST /tests: key field를 추가해 요청해보기

{
    "scores" : 
    {
        "국어" : 100,
        "수학" : 30,
        "영어" : 92,
        "물리" : 15,
        "화학" : 40
    }
}

 

DTO를 고정시키지 않고 굳이 Map으로 선언해 json 형태로 저장하려고 했던 주요 이유다.

기존 RDB 주요 컬럼들과 마찬가지로 고정된 컬럼에 고정된 값만을 저장하는 경우였다면 json 형태의 데이터를 컬럼에 저장할 일이 없었겠지만, 위와 같은 형식으로 요청 데이터가 정형화되어 있지 않은 경우를 고려해야 했다.

물론 NoSQL을 사용하는 방법도 있지만 설계된 데이터베이스 구조와 나머지 데이터들의 연관성을 고려했을 때 한 부분만을 위해서 NoSQL db를 사용하기보다는 기존 RDB에서 비정형 데이터를 다루는 것이 조금 더 효율적이라고 느껴졌기에 RDB에 json 형태를 저장하는 방식으로 구현했다.

 

저장 결과 확인

역시 마찬가지로 데이터가 잘 저장된다.


 

저장된 데이터를 가져와보기

 

Controller 추가

@GetMapping("/tests")
public ResponseEntity getTestScores(@RequestParam Integer aromIdx) {
    Arom arom = aromRepository.findById(aromIdx)
            .orElseThrow(() -> new RuntimeException("Not Found"));
    Map<String, Object> scores = arom.getScores();
    return ResponseEntity.ok(scores);
}

 

저장은 잘 되는 것을 확인했고, 이제 나중에 해당 데이터를 가져와야 하는 경우에 잘 동작하는지 확인할 Controller를 하나 만들어준다. idx를 기준으로 해당하는 엔티티를 하나 불러오고 거기서 json 형태의 컬럼이 Map으로 잘 매핑되어 나오는지 확인하는 것이 목적이다.

 

GET /tests?aromIdx=1: idx로 scores를 요청해보기

response

{
    "국어": 100,
    "수학": 30,
    "영어": 92
}

 

GET /tests?aromIdx=2: idx로 scores를 요청해보기

response

{
    "국어": 100,
    "수학": 30,
    "영어": 92,
    "물리": 15,
    "화학": 40
}

 

두 요청 모두 json 형태의 컬럼이 Map으로 잘 매핑되고, 응답으로 무리 없이 전송되는 것을 확인했다. 이제 기본적인 동작 점검은 끝났으니 나머지 작업을 진행할 수 있다!

 

 

Reference