Search

[프로젝트] MariaDB JSON 타입 사용기

태그
팀 프로젝트
DB
작성 상태
작성 완료
작성일
2023/10/18
참고 링크
참고 링크 2
이전 포스트에서 편의점 할인행사 모아보기 서비스인 편킹에서 추천 알고리즘을 어떻게 설계했는지 작성했다. 오늘은 이때 사용하는 데이터들을 DB에 저장하면서 MariaDB의 JSON 타입을 사용한 것에 대한 기록을 할 것이다.

MariaDB JSON 타입

공식 문서를 보면, MariaDB에서 JSONLONGTEXT의 이명이다. 즉, 아래 두 쿼리는 동일한 테이블을 생성한다.
CREATE TABLE t (j JSON); CREATE TABLE t (j LONGTEXT);
SQL
복사
따라서 두 경우 모두 아래와 같은 INSERT 쿼리를 통해 데이터를 저장할 수 있다.
INSERT INTO t VALUES('It is not JSON');
SQL
복사
의도 그대로 JSON형태의 데이터만 저장하고 싶다면 아래 쿼리 처럼 별도의 제약 조건을 부여해야 한다.
CREATE TABLE t2 ( j JSON CHECK (JSON_VALID(j)) );
SQL
복사
사실, 이러한 것 보다는 MariaDB에서 JSON 을 활용하기 쉽도록 제공하는 함수들을 사용하는 것이 MariaDB 에서 JSON을 사용하는 핵심이라 할 수 있다. 이러한 함수들은 공식 문서에서 확인할 수 있다.

어떤 데이터가 JSON 타입을 사용하기 적합할까?

우리 서비스에 JSON 타입을 적용한 부분

관계형 데이터베이스에 데이터를 저장하기 위해서는 테이블 이라는 것을 만들어야 한다. 그리고 테이블을 만들기 위해서는 그 테이블에 어떤 정보를 저장할지, 그리고 그 정보를 구성하는 데이터는 어떤 형태를 지니는지 알아야(정해야)한다.
우리 서비스에서는 편의점 상품의 할인정보를 저장해야 하기 때문에, 편의점 상품의 정보를 저장할 필요가 있다. 따라서, 편의점 상품의 할인 정보에 어떤 것이 포함되는지 정해야 한다. 우리는 상품아이디, 이름, 분류, 판매하는 편의점 브랜드 를 포함하기로 했다. 이때 상품아이디란 이름이 같은 상품이 있을 수 있기 때문에 추가한 구분자다.
이 다음 이 데이터들을 어떤 형태로 저장할지 정해야 한다. 우리는 상품아이디 : 정수, 이름 : 문자열, 분류 : 문자열, 판매하는 편의점 브랜드 : 문자열 으로 정했다. 이를 테이블로 만들기 위해 작성한 SQL은 다음과 같다.
CREATE TABLE `item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category` varchar(255) NOT NULL, `img_url` varchar(255) NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1;
SQL
복사
만약 회원 정보를 구성하는 데이터가 늘어난다면 그에 맞게 테이블의 열을 추가하는 방식으로 확장할 수 있다. 실제로, 우리 서비스에 개인화된 추천 상품 목록 기능을 추가하면서 이전 포스트 에서 추출한 세부 분류를 추가로 저장해야 했다. 이 때, 우리는 JSON 타입을 사용했다. 최종적으로 아래와 같은 SQL로 테이블을 만들었다.
CREATE TABLE `item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category` varchar(255) NOT NULL, `img_url` varchar(255) NOT NULL, `name` varchar(255) NOT NULL, `sub_category` json DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SQL
복사

JSON을 직접 저장하는 것의 단점

현재 많은 시스템이 통신할 때 사용하는 포맷으로 JSON을 사용한다. 따라서 각 시스템 내부에 다른 시스템과의 통신에 사용할 데이터를 처음부터 JSON 으로 저장한다면 유용할 것이다. 그러나, 이 것에는 몇가지 고려할 사항이 있다.

JSON 데이터를 이용해 검색하는 것의 효율성

JSON 데이터의 특정 키를 이용해 검색해야 한다고 하자. 데이터베이스가 이를 수행하려면 반드시 JSON을 파싱해야 한다. 만일, 각 행에 저장된 JSON 의 포맷이 항상 동일하다면 인덱스와 마찬가지로 자료구조를 이용해 최적화 할 수 있을것이다. 하지만 각 행에 저장된 JSON의 포맷이 동일하지 않다면 결국 각 행을 전체 순회 하면서 JSON을 파싱해야 할 것이다. 파싱하는 것은 비용이 꽤 큰 작업이기 때문에 JSON 을 검색의 키워드로 사용한다면 속도가 느려질 것이다.

JSON을 생성하는 데 필요한 비용

JSON 데이터를 생성하는 것에도 비용이 필요하다. 특히, 사용자의 어떤 행위시마다 JSON 데이터를 시스템이 직접 생성해야 한다면, 그 행위가 빈번하게 발생하는 경우 큰 비용이 발생할 수 있다.

과연 유용했을까?

우리 서비스에서는 상품의 특성 정보는 하루에 한번만 업데이트 되기 때문에 이를 위한 JSON 생성 비용은 적지만, 추천 목록을 생성하기 위해 매번 JSON 데이터를 이용해 검색해야 하기 때문에 이 부분에서 비효율이 발생한다. 게다가 다른 시스템 사이의 통신에 상품 특성 정보를 활용하지 않기 때문에 이 부분에서의 이점도 없다.
전혀 유용하지 못했다. 테이블로 만드는 것이 좋았을 것이다.

Spring Boot와 MariaDB JSON

비단 Spring 뿐 아니라 여러 언어와 프레임워크 진영에서 관계형 데이터베이스와 연동을 ORM을 이용한다. ORM 역시 결국 어떤 방식으로든 SQL을 생성해 DBMS 와 통신해야 한다. 또한, ORM은 가능한 많은 DBMS와 쉽게 연동할 수 있어야 한다. 따라서, 특정 DBMS에만 제공하는 SQL을 ORM이 작성하게 하는 것은 쉬운 일이 아니다.

Spring Data JPA와 MariaDB JSON

Spring Data JPA에서 사용하는 Hibernate역시 MariaDB 의 JSON 타입을 기본적으로 지원하지 않는다. 따라서 별도의 라이브러리를 추가하여 이를 사용해야 한다.
이 마저 완벽한 것이 아니라, MariaDB 에서 제공하는 함수들을 사용하는 쿼리를 Hibernate 가 자동으로 생성하지 않기 때문에 @Query 어노테이션을 사용해 직접 네이티브 쿼리를 작성해야 했다.
@Query(nativeQuery = true, value = "(select * from promotion_info p join item i on i.id = p.item_id " + "where start_date = :startDate and end_date = :endDate " + "and JSON_EXISTS(sub_category, :sub1) = true order by rand() limit 6) " + "union all " + "(select * from promotion_info p join item i on i.id = p.item_id " + "where start_date = :startDate and end_date = :endDate " + "and JSON_EXISTS(sub_category, :sub2) = true order by rand() limit 4) " + "order by rand()") List<PromotionInfo> findBySubCategoryBasedPersonalizeData(@Param("sub1") String sub1, @Param("sub2") String sub2, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate);
Java
복사
또한 비즈니스 로직을 작성하는 Service 영역에서 JSON을 파싱하는 코드가 추가되었다. 이 역시 코드를 복잡하게 만드는 원인이 되었다.
Map<String, Object> recommendData = memberProfile.getRecommendData(); List<String> categoryWithSub = new ArrayList<>(recommendData.keySet()); categoryWithSub = categoryWithSub.stream() .filter(s -> !s.contentEquals("recent_items")) .sorted(Comparator.comparingInt(o -> (int) recommendData.get(o))) .limit(2) .map(s -> "$."+s.split("-")[1]) .collect(Collectors.toList());
Java
복사