JPA로 JSON Column CRUD 해보기
개요
JPA를 활용하여 JSON Column을 만들어보고 CRUD 튜토리얼을 수행해보고자 합니다.
실제 코드는 github을 참고해 주세요
RDB에서 JSON을 사용하는 이유는?
일부 비정형데이터가 예상하지 못하게 생긴 경우에는 NoSQL의 기술스택을 추가하여 사용하기보다는 RDB에 JSON 타입을 사용해 볼 수 있습니다.
JSON을 사용했을 때 단점은?
데이터를 조회 후 핸들링하기가 기존보다 복잡해진다.
JSON 타입 vs TEXT 타입
일반적으로 대용량 칼럼, 소용량 칼럼으로 비교해 보았을 때 JSON보다 TEXT 타입이 성능적으로 더 좋은 모습을 보여줍니다.
하지만 일부분을 변경하고자 해도 TEXT는 통째로 UPDATE 해야 하지만 JSON 타입은 특정 필드만 UPDATE 할 수 있고, 인덱스를 활용할 수 있는 장점이 있습니다.
보통 온라인 트랜잭션 처리 용도의 RDBMS는 가볍고 빠른 쿼리들이 매우 빈번하게 처리되는 경우가 대부분입니다.
수십 KB이상의 데이터를 한두 개의 칼럼에 저장하고 빈번하게 접근하는 것은 큰 부하를 유발합니다.
성능에 매우 민감한 DMBS 서버를 사용하고 있다면, 데이터는 최대한 콤팩트하게 유지하는 것이 좋습니다.
결론적으로 아래의 요건이 필요한 경우에는 JSON 타입을 사용하는 것이 좋습니다.
- 특정 필드만 접근이 가능해야 하는 경우
- 특정 필드만 자주 업데이트되는 경우
- 특정 필드로 인덱스 생성이 필요한 경우
조금 더 자세하게 보고 싶으신 분들은 당근 테크 블로그를 참고해 보시면 좋을 것 같습니다.
환경
- Spring Boot 3.1
- JDK 17
- MySQL with Docker
- Hibernate 6.2
docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0.22
container_name: mysql-db
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: your_database_name
MYSQL_USER: your_database_user
MYSQL_PASSWORD: your_database_password
networks:
- spring-network
networks:
spring-network:
driver: bridge
docker-compose up -d 명령어로 백그라운드로 실행
application.yml
# Database Settings
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_database_name?useSSL=false&allowPublicKeyRetrieval=true
username: your_database_user
password: your_database_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
show-sql: true
docker 파일을 기반으로 datasource 연결 설정 구성!
Hibernate-Type
Hibernate ORM에서 지원하지 않는 JSON 타입 등을 지원하는 라이브러리입니다.
해당 라이브러리를 통해 간단하게 RDB에서 JSON 타입을 JPA로 핸들링할 수 있습니다.
https://github.com/vladmihalcea/hypersistence-utils
readme 파일을 살펴보면 Hibernate 버전별로 어떤 의존성이 적합한지 가이드가 주어집니다.
나의 Hibernate가 몇 버전인지 확인할 필요가 있을 것 같습니다.
나의 Hibernate 버전 확인하기
External Libraries를 통해 확인하거나 Spring Boot 공식문서의 dependency-versions를 통해 확인할 수 있습니다.
문서 & 라이브러리를 기반으로 확인해 본 결과 Spring Boot 3.1 버전의 hibernate의 버전은 6.2.2입니다.
Hibernate-Type 필요한 Gradle 의존성 정리
//json column 활용하기 위한 hibernate-type 의존성 - hibernate 6.2 기준
implementation ("io.hypersistence:hypersistence-utils-hibernate-62:3.7.0")
implementation ("com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations")
hibernate 버전 6.2에 대한 설치 의존성입니다.
6.2 버전이 아니라면 Hibernate-Type의 Github readme를 참고해서 다른 의존성을 추가할 수 있습니다.
Application 구동 시 Create Table Query
Hibernate:
drop table if exists member
Hibernate:
create table member (
id bigint not null auto_increment,
histories json,
primary key (id)
) engine=InnoDB
json 타입의 histories column이 생긴 것을 확인할 수 있습니다!
Member Entity 만들기
import io.hypersistence.utils.hibernate.type.json.JsonType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import org.hibernate.annotations.Type
@Entity
data class Member(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Type(JsonType::class)
@Column(name = "histories", columnDefinition = "json")
val histories: MutableMap<String, String>
) {
fun addHistories(additionalHistories: List<String>) {
additionalHistories.forEach {
histories[it] = it
}
}
}
@Type 어노테이션을 활용해 주고, Map으로 만들어주었습니다.
List도 가능합니다.
MemberRepository
interface MemberRepository: JpaRepository<Member, Long> {}
MemberService
@Service
@Transactional(readOnly = true)
class MemberService(
private val memberRepository: MemberRepository,
){
fun findUserBy(id: Long): Member {
return memberRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("${id}로 회원을 조회할 수 없습니다")
}
@Transactional
fun signUp(memberHistories: List<String>): Long{
val histories = memberHistories.map { it to it }.toMap().toMutableMap()
val member = Member(histories = histories)
return memberRepository.save(member).id
}
@Transactional
fun addMemberHistories(id: Long, additionalHistories: List<String>): Member{
val member = memberRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("${id}로 회원을 조회할 수 없습니다")
member.addHistories(additionalHistories)
return memberRepository.save(member)
}
@Transactional
fun withdraw(id: Long){
memberRepository.deleteById(id)
}
}
memberRepository를 활용하여 CRUD를 수행하였습니다.
MemberController
@RestController
@RequestMapping("/members")
class MemberController(
private val memberService: MemberService,
) {
@GetMapping("/{id}")
fun getMemberById(@PathVariable id: Long): ResponseEntity<Member> {
val member = memberService.findUserBy(id)
return ResponseEntity.ok(member)
}
@PostMapping
fun signUp(@RequestBody signUpRequest: SignUpRequest): ResponseEntity<Long> {
val id = memberService.signUp(signUpRequest.histories)
return ResponseEntity.ok(id)
}
@PutMapping("/{id}/history")
fun addMemberHistories(
@PathVariable id: Long,
@RequestBody addHistoryRequest: AddHistoryRequest,
): ResponseEntity<Member> {
val member = memberService.addMemberHistories(id = id, additionalHistories = addHistoryRequest.histories)
return ResponseEntity.ok(member)
}
@DeleteMapping("/{id}")
fun withdraw(@PathVariable id: Long): ResponseEntity<Unit> {
memberService.withdraw(id = id)
return ResponseEntity.noContent().build()
}
}
REST API를 통해 CRUD를 구현하였습니다.
기타 DTO들
data class AddHistoryRequest(
val histories: List<String>,
)
data class SignUpRequest(
val histories: List<String>,
)
실제 API 호출을 통한 테스트 - curl
회원 가입
curl --location 'http://localhost:8080/members' \
--header 'Content-Type: application/json' \
--data '{
"histories": [
"wakeup", "eat", "study"
]
}'
회원 조회
curl --location 'http://localhost:8080/members/1'
회원 이력 수정
curl --location --request PUT 'http://localhost:8080/members/1/history' \
--header 'Content-Type: application/json' \
--data '{
"histories": [
"drink","sleep"
]
}'
회원 탈퇴
curl --location --request DELETE 'http://localhost:8080/members/1'
이외에도 활용할 수 있는 @Convert
이외에도 @Convert라는 어노테이션을 통해서도 json을 직렬화하는 방법을 활용해 볼 수 있습니다.
간단하게 소개하자면 엔티티의 데이터를 변환해서 데이터베이스에 저장하는 기법입니다.
참고자료
https://danawalab.github.io/spring/2022/08/05/Jpa-Json-Type.html
https://medium.com/daangn/json-vs-text-c2c1448b8b1f
https://github.com/vladmihalcea/hypersistence-utils