본문 바로가기
개발/기타

JSON을 통째로 RDB에 넣어버리기

by 카펀 2023. 3. 26.

회사 업무 중에 겪은 일이 꽤 재밌는 경험이었다고 생각하여, 글로 기록하여 남깁니다.

모든 코드 및 내용은 예시를 위해 별도로 작성하였습니다.

목차

0. 개요

1. 1:N 구조가 과연 최선인가?

2. JSON을 테이블에 그대로 넣는다면?

3. 코드로 작성해보자

4. jacksonObjectMapper

5. 마무리 및 테스트

6. 요약

0. 개요

어떤 계층형 데이터 구조를 설계해야 한다고 칩시다. 예를 들어서, LOL 프로게이머 한 명에 대해, n명의 평가자가 남긴 코멘트를 기록하는 구조를 만든다고 가정해 보겠습니다. 즉 1명의 선수에 대해 n개의 코멘트가 따라올 수 있는, 1:N 구조입니다.

선수는 아래 정보를 가집니다.

  • 이름
  • 닉네임
  • 소속 팀
  • 최근 수상 내역
  • 우승 횟수

평가자의 코멘트는 아래 정보를 가집니다.

  • 평가자명
  • 한줄 코멘트
  • 평가점수 (개인 기량)
  • 평가점수 (팀 플레이)
  • 평가점수 (장래성)
  • 다음 라운드 진출 동의 여부
  • 평가 날짜

일반적으로 이러한 데이터를 RDB를 통해 관리한다면, 테이블을 두 개 만들고, 코멘트 정보에 해당 선수 id를 기록하는 등등의 방식으로 설계하겠죠? 

대충 그려본 erd diagram

하지만 아래와 같은 조건을 추가한다면, 위의 구조가 여전히 최선일까요?

  • 선수와 평가자의 수가 한정되어 있음. 선수로 등록되는 사람의 수는 많아야 50명 내외이며, 평가자는 해설, 캐스터 등 관계자에 한하므로 10명 내외가 존재함.
  • 평가 항목이 수시로 변경될 수 있음. 예를 들어, 현재는 없지만 추후 'Player of the Game 선정 여부'라는 항목이 추가될 수도 있고, 평가점수 항목이 3개에서 2개로 줄어들 수도 있음.

이런 상황에서, 어떤 구조로 가져가는 것이 좋을지에 대해 동료들과 토론을 해 보고, 내린 결론을 정리하였습니다.

1. 1:N 구조가 과연 최선인가?

장단점을 고려했을 때, 테이블을 2개로 나누면 아래와 같은 우려점이 생깁니다.

  • 테이블을 2개로 나누어서 얻는 이점이 뚜렷하지 않음. 선수 50명, 코멘트는 50 * 10 = 500개인데, 굳이 계층 구조로 관리해서 얻는 이점이 없음.
  • 평가 항목이 변화될 때마다 코멘트를 관리하는 테이블에 대해 DDL을 다시 실행해야 함. Column을 추가하거나 삭제해야 하는데, 실제 운영 환경의 변경을 위해 매번 DBA를 통해 실행한다는 것은 굉장히 번거로운 일이 될 것임
  • Column을 추가한 경우에는 기존 데이터들은 해당 Column에 어떤 값을 넣어야 하는지가 문제점이 됨.

약간의 배경 상황을 소개하면, 저희 내부 admin tool에서 사용할 기능을 설계하는 것이었기 때문에, 흔히 말하는 '교과서에서 소개하는 방법'이 능사인 상황은 아니었습니다.

맨 처음에는 2가지의 안을 두고 고민했습니다. 데이터의 수가 적으니 테이블 하나에 몰아 넣자는 의견과, 앞서 언급한 바와 같이 테이블 2개를 가지고 1:N 구조로 설계하자는 의견이었습니다.

테이블 하나에 넣는 것은 일종의 비정규화를 하자는 의견이었습니다. 따라서 데이터 정합성 등에 대한 우려가 존재했고, Column 변경에 유연하게 대응하기 어렵다는 한계점 역시 그대로였습니다.

 

그러던 중, 한 분이 고민 끝에 새로운 방식을 제안하셨습니다.

2. JSON을 테이블에 그대로 넣는다면?

아래와 같은 새로운 구조를 고민해 볼 수 있습니다.

참고로 이 아이디어는 제가 처음에는 썩 내키지 않았던 의견입니다 ㅋㅋ

  • 이름
  • 닉네임
  • 소속 팀
  • 최근 수상 내역
  • 우승 횟수
  • 코멘트

앞서 언급한 코멘트 내용을 하나의 column 내에 몰아 넣는 것입니다!

코멘트에 JSON 형식의 문자열을 저장하고, BE 단에서 데이터를 불러올 때는 JSON을 풀어서 사용하고, 저장할 때는 다시 JSON으로 변환하여 저장하는 것이지요.

이 방법대로라면 Column 변경에도 유연하게 대응할 수 있습니다. JSON에서 필드값을 추가/제거/수정하기만 하면 되니까요! (물론 이에 대응하는 FE/BE 단에서의 코드 역시 수정이 필요하지만, 수정해야 하는 범위가 '저희 팀 내부'로 좁아졌다는 데서 의의가 있었습니다. 적어도 변경을 위해 외부 (DBA 등에게)에 요청을 할 필요는 없어졌으니까요.

 

최종적으로 테이블 (lck_player_comments)는 아래와 같은 구조를 가지게 되었습니다.

Column Datatype
id bigdecimal
name varchar
nickname varchar
team varchar (enum)
latest_win varchar, nullable
win_count int
comments varchar

comments에 들어가는 JSON은 아래 형식이 되었습니다.

Column Datatype
name varchar
single_comment varchar
score_personal int
score_teamplay int
score_potential int
is_next_round boolean
comment_date varchar

3. 코드로 작성해보자

위에서 설계한 내용을 토대로, Spring/Kotlin 환경에서 이를 제공할 수 있는 코드를 작성하였습니다.

전체 코드는 GitHub Repository에서 확인하실 수 있습니다. 이 글을 위해서 생성한 레포입니다!

 

먼저 entity를 설계하였습니다.

@Entity
@Table(name = "player_comments")
class PlayerComments(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "player_comments_id")
    val playerCommentsId: Long,

    @Column
    val name: String,

    @Column
    val nickname: String,

    @Column
    val team: String,

    @Column(name = "latest_win")
    val latestWin: String?,

    @Column(name = "win_count")
    val winCount: Int,

    @Column(columnDefinition = "text")
    val comments: String,
)

가장 핵심인 부분은 제일 마지막의 'comments' 필드입니다.

앞에서 얘기했던 comments 내용이 json 형식으로 해당 필드에 들어가게 됩니다.

 

여기를 통해 가져온 데이터를, 우리의 API에서 응답하는 형태로 바꾸기 위해 response 객체를 하나 만들었습니다.

객체 구조는 아래와 같습니다.

data class PlayerCommentsResponse(
    val playerCommentsId: Long,
    val name: String,
    val nickname: String,
    val team: String,
    val latestWin: String?,
    val winCount: Int,
    val comments: List<Comment>
) {
    companion object {
        fun from(comment: PlayerComments): PlayerCommentsResponse {
            with(comment) {
                return PlayerCommentsResponse(
                    playerCommentsId,
                    name,
                    nickname,
                    team,
                    latestWin,
                    winCount,
                    comments = Comment.from(comments)
                )
            }
        }
    }
}

여기서도 주목할 점은 제일 마지막의 comments입니다.

DB 상에 저장된 값은 json 형식의 String 값인데, 우리는 이 값을 Comment라는 형태의 객체에 담고, Comment의 목록을 List에 담아 리턴할 것입니다.

그래서 PlayerCommentsResponse.from 메소드를 보시면, comments = Comment.from(comments)로 되어 있습니다. 해당 json 값을 Comment의 팩토리 메소드를 통해 List<Comment>로 변환하는 것입니다.

 

Comment는 하나의 코멘트에 대한 정보를 가지는 객체입니다. 구조는 아래와 같습니다.

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class Comment(
    val name: String,
    val singleComment: String,
    val scorePersonal: Int,
    val scoreTeamplay: Int,
    val scorePotential: Int,
    val isNextRound: Boolean,
    val commentDate: LocalDateTime
) {
    companion object {
        fun from(comments: String): List<Comment> {
            return jsonToComments(comments)
                .map {
                    Comment(
                        it.name,
                        it.singleComment,
                        it.scorePersonal,
                        it.scoreTeamplay,
                        it.scorePotential,
                        it.isNextRound,
                        it.commentDate
                    )
                }
        }

        private fun jsonToComments(comments: String): List<Comment> {
            val mapper = jacksonObjectMapper()
            return mapper.readValue(
                comments,
                mapper.registerModule(JavaTimeModule()).typeFactory.constructCollectionType(
                    List::class.java,
                    Comment::class.java
                )
            )
        }
    }
}

data class Comment의 각 property는 json 내의 각 필드에 대응합니다. 따라서 앞서 얘기한대로, comment 내의 필드가 변경된다면 (추가, 삭제 등), Comment 객체의 property 역시 수정되어야 합니다.

jsonToComments라는 메소드를 주목해 주세요. json을 List<Comment>로 변경하는 로직은 이 안에 있습니다.

4. jacksonObjectMapper

조금 더 자세히 보겠습니다.

jacksonObjectMapper는 Java 객체를 json으로 serialize 하거나, json을 Java 객체로 deserialize하도록 도와 주는 라이브러리입니다. (자세한 소개는 여기에)

만약 json이 하나의 Comment에 대응하는 내용이라면 아래와 같이 작성할 수 있습니다.

objectMapper.readValue(comments, Comment::class.java)

우리가 가진 json 값은 List<Comment>로 deserialize 하기를 원하기 때문에, 아래와 같이 작성해 줍니다.

objecMapper.readValue(
    comments,
    mapper.typeFactory.constructCollectionType(
        List::class.java,
        Comment::class.java
    )
)

여기서 한 가지 더 처리가 필요합니다.

우리가 가진 Comment의 property 중에는 시간을 나타내는 LocalDateTime 값이 존재하는데요.

이는 Java 8에서 추가되었던 data type이고, jacksonObjectMapper에서는 이를 직접 변환하지 못하는 문제가 있습니다.

LocalDate, LocalDateTime은 default 조건에서는 지원되지 않는다.

다행히도 에러 메세지에 어떤 module을 추가하라고 안내해 주죠? 조금 더 친절하게 제가 설명드리겠습니다.

앞의 코드를 보시면 registerModule 어쩌고 하는 코드가 있습니다.

정확히는, registerModule(JavaTimeModule())을 추가해 주면 됩니다. jackson-datatype-jsr310은 Spring Boot 2.0부터 기본으로 포함되었다고 합니다. (출처: 꽑꽑 -  자바 DateTime 역직렬화 실패!)

objecMapper.readValue(
    comments,
    mapper.registerModule(JavaTimeModule()).typeFactory.constructCollectionType(
        List::class.java,
        Comment::class.java
    )
)

이렇게 처리해 주면 시간값까지 문제 없이 deserialize가 가능합니다.

5. 마무리 및 테스트

앞서 PlayerCommentsResponse를 살펴 보았습니다. 나머지는 일반적인 CRUD 구조와 비슷합니다.

 

먼저 repositiory를 작성하였습니다.

interface CommentRepository : JpaRepository<PlayerComments, Long> {
    fun findByPlayerCommentsIdIn(playerIds: List<Long>): List<PlayerComments>
}

JPA를 통해 데이터를 가져옵니다.

기본적인 ID를 통한 조회 외에도, player ID를 복수로 조회할 경우에 대응하는 메소드를 추가하였습니다.

 

참고로 MySQL에서 in 절은 자주 사용되지는 않지만, 성능 개선이 생각보다 다양한 경우에 가능합니다. 자세한 내용은 여기서!

 

다음은 Service입니다.

@Service
class CommentService(
    private val commentRepository: CommentRepository
) {
    @Transactional(readOnly = true)
    fun comments(playerCommendIds: List<Long>?): List<PlayerComments> {
        return when {
            playerCommendIds.isNullOrEmpty() -> commentRepository.findAll()
            else -> commentRepository.findByPlayerCommentsIdIn(playerCommendIds)
        }
    }
}

playerCommentIds는 조회하고자 하는 player의 id를 담은 List입니다.

이 값은 필수가 아니기 때문에 null일 수 있습니다. Service에서는 null이거나 list가 비어 있는 경우에는 전체를 조회하고, 그렇지 않은 경우에는 (id가 존재하는 경우에는) 해당 id를 조회하여 return합니다.

 

마지막으로 요청과 처리를 위한 Controller입니다.

@RequestMapping("/api")
@RestController
class CommentController(
    private val commentService: CommentService
) {
    @GetMapping("/comments")
    fun playerComments(
        @RequestParam("playerId", required = false) playerId: List<Long>?,
    ): List<PlayerCommentsResponse> {
        return commentService.comments(playerId)
            .map { PlayerCommentsResponse.from(it) }
    }
}

Service를 통해 데이터를 repository로 부터 가져오고, 이를 응답하는 형태에 맞추고자 PlayerCommentsResponse의 팩토리 메소드를 통해 변환한 결과를 리턴합니다.

앞서 언급한 바와 같이, playerId는 0개 이상이 존재할 수 있으며, 이를 Service에 전달합니다.

 

코드를 작성했다면 당연히 테스트를 통해 검증을 해야겠죠?

오늘 소개한 핵심 로직은 json -> Comment deserialization입니다. 이 부분에 대한 단위 테스트를 진행하였습니다.

 

먼저 테스트에 사용할 stub 객체입니다.

object CommentsStub {
    private val FIRST_NAME = "침착맨"
    private val FIRST_SINGLE_COMMENT = "대체 불가능한 차세대 유망주"
    private val FIRST_SCORE_PERSONAL = 10
    private val FIRST_SCORE_TEAMPLAY = 8
    private val FIRST_SCORE_POTENTIAL = 10
    private val FIRST_IS_NEXT_ROUND = true
    private val FIRST_COMMENT_DATE = "2022-08-02T14:00:00"

    private val SECOND_NAME = "주호민"
    private val SECOND_NAME_SINGLE_COMMENT = "아직은 지켜봐야 하는 단계"
    private val SECOND_NAME_SCORE_PERSONAL = 9
    private val SECOND_NAME_SCORE_TEAMPLAY = 10
    private val SECOND_NAME_SCORE_POTENTIAL = 9
    private val SECOND_IS_NEXT_ROUND = false
    private val SECOND_COMMENT_DATE = "2022-08-04T19:00:00"

    val COMMENTS_OBJECT_STUB = listOf(
        Comment(
            FIRST_NAME,
            FIRST_SINGLE_COMMENT,
            FIRST_SCORE_PERSONAL,
            FIRST_SCORE_TEAMPLAY,
            FIRST_SCORE_POTENTIAL,
            FIRST_IS_NEXT_ROUND,
            LocalDateTime.parse(FIRST_COMMENT_DATE)
        ),
        Comment(
            SECOND_NAME,
            SECOND_NAME_SINGLE_COMMENT,
            SECOND_NAME_SCORE_PERSONAL,
            SECOND_NAME_SCORE_TEAMPLAY,
            SECOND_NAME_SCORE_POTENTIAL,
            SECOND_IS_NEXT_ROUND,
            LocalDateTime.parse(SECOND_COMMENT_DATE)
        )
    )

    val COMMENTS_JSON_STUB = """
        [
            {
                "name" : "$FIRST_NAME",
                "single_comment" : "$FIRST_SINGLE_COMMENT",
                "score_personal" : $FIRST_SCORE_PERSONAL,
                "score_teamplay" : $FIRST_SCORE_TEAMPLAY,
                "score_potential" : $FIRST_SCORE_POTENTIAL,
                "is_next_round" : $FIRST_IS_NEXT_ROUND,
                "comment_date" : "$FIRST_COMMENT_DATE"
            },
            {
                "name" : "$SECOND_NAME",
                "single_comment" : "$SECOND_NAME_SINGLE_COMMENT",
                "score_personal" : $SECOND_NAME_SCORE_PERSONAL,
                "score_teamplay" : $SECOND_NAME_SCORE_TEAMPLAY,
                "score_potential" : $SECOND_NAME_SCORE_POTENTIAL,
                "is_next_round" : $SECOND_IS_NEXT_ROUND,
                "comment_date" : "$SECOND_COMMENT_DATE"
            }
        ]
    """.trimIndent()
}

아무래도 테스트를 하려는 데이터이다 보니 내용이 좀 많아 보일 수 있는데요.

간단히 요약하자면,

  • FIRST_, SECOND_로 시작하는 값들이 실제 Comment / json 내의 필드에 들어갈 값
  • 위 값을 가지고 두 개의 comment를 가지는 list를 생성
  • 마찬가지로, 두 개의 comment에 대응하는 값을 가지는 json 문자열을 생성
  • 테스트 코드에서는, json 값을 역직렬화 후 comment에 대응하는지 확인

이렇게 만든 의도가 이해가 되시나요?

테스트 코드에서는 실제 테스트 로직에만 집중하고, 테스트에 사용할 데이터는 이곳에서 일괄적으로 관리하기 위함입니다.

 

이어서 테스트 코드입니다.

class CommentTest {
    @Test
    fun `JSON을 객체로 변환한 내용은 Comment와 같다`() {
        // given
        val comments = COMMENTS_OBJECT_STUB
        val jsonComments = COMMENTS_JSON_STUB

        // when
        val converted = Comment.from(jsonComments)

        // then
        assertThat(converted.size).isEqualTo(comments.size)
        assertThat(converted[0]).isEqualTo(comments[0])
        assertThat(converted[1]).isEqualTo(comments[1])
    }
}

comments와 jsonComments를 가져옵니다. jsonComments는 앞서 만든 팩토리 메소드를 통해 deserialize 하여 List<Comment> 형태로 만듭니다.

당연히 size도 같아야 하고, 각 comment의 내용도 같아야 하므로 이를 검증합니다.

Test success

다행히 테스트도 잘 통과하네요 ㅎㅎ

 

마지막으로, 실제 API가 잘 동작하는지 확인해 보았습니다.

앞서 설정할 때, 프로젝트를 실행하면 DB는 H2를 사용하도록 했었죠?

실제 데이터를 하나 INSERT 하고, API를 호출하여 결과를 확인하겠습니다.

 

INSERT문은 아래와 같습니다.

insert into player_comments(
    name, nickname, team, latest_win, win_count, comments
)values (
    '정규호', 'katfun', 'kgames', null, 0,
    '[{"name" : "침착맨","single_comment" : "대체 불가능한 차세대 유망주","score_personal" : 10,"score_teamplay" : 8,"score_potential" : 10,"is_next_round" : true,"comment_date" : "2022-08-02T14:00:00"},{"name" : "주호민","single_comment" : "아직은 지켜봐야 하는 단계","score_personal" : 9,"score_teamplay" : 10,"score_potential" : 9,"is_next_round" : false,"comment_date" : "2022-08-04T19:00:00"}]'
)

앞서 stub에서 만든 내용을 포함하여 INSERT 합니다.

 

다음은 API를 호출해 봅니다.

http 파일에 아래와 같이 작성 후 실행하였습니다.

### local player comments 전체 조회
GET http://localhost:8080/api/comments

http response

API도 잘 호출되는 것을 확인하였습니다!

이제 우리가 설계한 구조가 정상적으로 동작하는 것을 확인하였습니다.

6. 요약

이 구조 역시 모든 상황에서 정답은 아닙니다.

다만 앞서 소개한 바와 같이 column의 변경이 잦고, 데이터의 갯수가 많지 않은 상황에서는, DB 테이블을 1개만 가져가면서도 효과적으로 관리할 수 있는 좋은 방법이라고 생각합니다.

이런 경우에 JSON - Object 직렬화/역직렬화가 이루어지는 과정까지 알아보는 재밌는 경험이었습니다.

 

다시 말씀드리지만 모든 코드는 GitHub에서 볼 수 있으니 필요하시면 참고해 주세요!

댓글