JPA, Exposed 사용후기

델리·2023년 8월 18일

Spring 학습하기

목록 보기
4/5
post-thumbnail

코틀린 + 스프링 부트를 공부하다가 JPA를 써보게 되었다. JPA는 자바 진영에서 ORM(Object-Relational Mapping) 인터페이스 모음으로, 자바 객체를 DB 테이블에 매핑해준다.

스프링, 스프링 부트를 사용하면 거의 100% JPA도 함께 사용한다고 보면 되는데... 이게 학습하다 보니 장점보단 오히려 단점..? 불편한 점들이 눈에 보이기 시작했다. 그 와중에 Ktor에도 관심이 많이 생겨 Ktor도 학습하고 있는 와중에, 코틀린을 개발한 JetBrain에서 JPA 대체제로 만든 ORM 패지키 Exposed과 그와 비슷한 다른 ORM 패키지인 Ktorm을 알게 되었고 둘다 써보고 느낀점을 말해보려 한다.

JPA

우선 JPA에 관해 이야기 해보겠다.

Java Persistence API

줄여서 JPA이라고 부르는 패키지는 SQL문이 아닌 Method를 통해 DB를 조작한다.

예를 들어,

@Entity
@Table(name="user")
class UserEntity {
	@Id
    val id : Int
}

interface UserRepository : JpaRepository<UserEntity, Int> {
	fun findById() : UserEntity?
}

이렇게 JpaRepository<엔티티 클래스,PK 타입> 인터페이스를 상속 받아 구현체를 만들면, 메소드명으로 쿼리를 만들수 있는 것이다. 기본적으로 save(),delete()등등의 기본적인 것들은 구현하지 않아도 이미 있기에 바로 가져다 쓸수 있고, 그외에는 직접 만들어야한다.

처음에는 마냥 신기하고 편했다.

메소드명만으로도 쿼리를 짤수 있다고?!

이런 생각을 하면서 공부하고 있었다. 하지만 곧 바로 한계점에 도달했다.

1. @Entity 어노테이션

첫번째 문제는 @Entity 어노테이션이였다.

@Entity
@Table(name = "user")
class User(
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id : Int,
    val name: String
)

이렇게 보면 뭐가 문제지? 할수 있는데, 분명 내가 만든 것은 엔티티다. 하지만 저 코드는 User가 단순히 엔티티가 아니라 DB 테이블이자 엔티티인 것이다. 이게 무슨말인 즉슨, 엔티티와 테이블을 각각 선언하는것이 아니라 하나의 클래스로 엔티티와 테이블을 둘다 선언하는 것이다!

' 테이블과 엔티티가 같으면 따로 안만들어서 좋은거 아냐? '

확실히 코딩할게 줄수록 시간을 아끼기에 분명 좋은 것은 사실이다. 그건 나도 공감한다. FK, Join도 @ManyToOne , @OneToMany, @OneToOne, @ManyToMany, @JoinColumn 을 사용하면 된다.

그런데....


@Entity
class Post (
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    val postId : Int,
    val title : String
)


@Entity
class Comment (
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id")
    val id : Int,
    
    @ManyToOne
    @JoinColumn(name = "post_id", referencedColumnName = "post_id")
    val postId : Post,
)

이렇게 Post 테이블과 Comment 테이블이 있을때, 만일 Post 데이터와 Post에 종속된 Comment의 갯수를 가져와야 한다고 칠때, 이 쿼리는 Post에 매핑할수 없다. 왜냐? Post에는 종속된 Comment의 갯수를 담는 컬럼 값이 없기 때문이다.

해결 방법

이럴때 어떻게 해결해야할까? 고민을 많이 했다가 다음과 같은 결론에 다다랐다. (아직 스프링 (부트) 초짜이기에 해당 방법이 안쓰는 방법일수 있다.)

@Entity
class Post (
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    val postId : Int,
    val title : String,
    @Transient
    @Column(name = "comment_count")
    val commentCount : Int
)

고민 끝에 Post에 commentCount 컬럼을 추가해서 쿼리 결과에 comment_count 컬럼이 존재하면 거기에 매핑 되도록 하는것이다. 하지만 저렇게 만들게 되면 DB에도 comment_count 컬럼이 추가되는데, 난 이걸 원하지 않아 @Transient 어노테이션을 통해, DB에서는 보이지 않도록 처리를 했다.

이렇게 넘어가나 했는데 문득 이런 생각이 들었다.

' 앞으로도 이런식으로 컬럼을 추가해야하고, DB에 보이지 않게 @Transient 처리를 해야하는 걸까? 차라리 엔티티와 테이블은 분리하는게 좋지 않을까? '

왜 이런 생각을 하게 된건지는 2번째 문제 때문이다.

2. JpaRepository

JpaRepository 인터페이스는 두가지 제너릭을 받는다. 하나는 @Entity 어노테이션이 붙은 테이블(이자 엔티티) 클래스, 그리고 PK의 타입이다.

그럼 문제를 보자.

interface PostRepository : JpaRepository<Post,Int> {
	//
}

이렇게 Post의 JpaRepository가 있을때, 내가 원하는 쿼리는 Post 데이터 + Post에 달린 Comment의 총갯수이다.

내가 공부가 부족해서 못만드는 걸수도 있겠지만, 메소드명으로 할수 있을까? 해서 시도 해보았지만 보아하니 메소드명으로 만들수 있는 쿼리는 순수 Post 테이블 관련만 가능한것같았다. 다른 테이블을 Join 해서 데이터를 가져오는 쿼리는 메소드명으로는 만들지는 못하는 것같았다. 그래서 @Query 어노테이션을 사용해서 커스텀 쿼리를 만들었다.

interface PostRepository : JpaRepository<Post,Int> {
	
    @Query("SELECT P.*, COUNT(C.comment_id) " +
                "FROM post P " +
                "LEFT JOIN comment C ON P.post_id = C.post_id " +
                "WHERE  P.post_id = :id " +
                "GROUP BY P.updated_at, P.post_id ",
                native = true)
    fun getPostWithCommentCount(id: Int) : // ??
}

이렇게 Post + Comment 갯수를 가져오는 커스텀 쿼리를 만들었다. 그런데...

엥? 뭘 리턴 시켜야하지?? 제너릭으로 Post를 넣었으니 Post를 리턴할텐데, Post에는 comment 갯수를 넣는 컬럼이 없잖아..!

아니나 다를까 리턴값을 Post이 맞지만 comment_count는 반영 되지 못했다. 그래서 검색하고 찾아보고 뭐 @SqlMapping..~~ 어쩌고 하는 어노테이션을 달아서 @Qeury 어노테이션에 매핑하면 되다~~ 해서 시도해보았지만 예제도 찾기 힘들고 예제 찾기가 힘들다 보니 시도 해보지도 못하고, 그렇게 스트레스 받다가 결국에 Post에 comment_count 컬럼을 추가하고 @Transient 처리를 한것이다.

그러고 계속 개발하다가 1번의 문제가 나타난 것이다. 엔티티와 테이블을 분리 할수 있다면, 어떤 커스텀 쿼리를 만들어도 그에 맞는 엔티티를 매핑하는게 너무 힘들고 불편해서 못써먹겠고 스트레스도 많이 받았다.

Exposed , Ktorm을 접하다

사실 스프링 부트와 Ktor을 동시에.. 아니 정확히는 Ktor을 학습하다가 스프링 부트를 학습했다.

Ktor는 젯브레인에서 만든 Kotlin을 사용하는 프레임워크인데, 이건 다음에 얘기하기로 하고, 젯브레인에서 만든 Exposed이란 ORM 프레임워크가 있다. Ktorm도 ORM 패지키로, 사용법은 Exposed이랑 유사하므로 Exposed 사용했던 경험에 대해 이야기 해보겠다.

Exposed는 코틀린을 위해 만들어진 프레임워크이다. Exposed에서는 2가지 방법으로 DB에 접근할수 있는데, 하나는 DSL, 하나는 DAO 방식이다. DSL은 QueryDSL와 비슷한 방법이고 DAO는 약간 sequenceOf() 이란 비슷한 느낌이 나는 방법이다.

Exposed에서 테이블을 정의하는 방법은 다음과 같다.

object UserTable : UUIDTable("user_info", "user_id") {
    val userName = varchar("user_name", 100)
}

해석을 해보자면,

Table 이름이 user_info이고, PK의 컬럼명은 user_id로, PK의 타입이 UUID이다. 또한 varchar(100)타입의 user_name 컬럼도 가지고 있다.

이걸 처음 봤을때 드는 생각은..
간편하다!

Table 종류
PK로 자주 쓰는 타입별로 Table 템플릿이 이미 있다..!

이중에 원하는 템플릿이 없거나, 두개의 컬럼을 PK로 사용하고 싶을 경우

object TestTable : Table("test") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 100)
    
    override val primaryKey = PrimaryKey(id, name)
}

이렇게 커스텀도 가능하다.

FK 설정하는 것도 매우 쉬웠다.

object TestTable : Table("test") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 100)
    val fk = reference("fk", UserTable.id)
    
    override val primaryKey = PrimaryKey(id, name)
}

.reference()를 사용하면 어떤 테이블의 컬럼을 FK로 연결할껀지 지정할수 있다. 이 얼마나 간편한가..!

더욱 더 맘에 들었던 점은...

Table과 Entity가 분리되어 있다..!

Exposed에서는 Entity라고 부르지 않고 Dao라고 부르는 것같지만 어쨋든 Table과 Entity(Dao)가 분리 되어 있다..!

다음 코드를 보자

// Table
object UserTable : UUIDTable("user_info", "user_id") {

    val snsId = varchar("sns_id", 100)
    val email = varchar("email", 100)
    val userName = varchar("user_name", 100)
    val islandName = varchar("island_name", 100)
    val introduction = text("introduction")
    val loginType = enumerationByName<LoginType>("login_type", 100)

}

// Dao
class UserEntity(
    userId: EntityID<UUID>,
) : UUIDEntity(userId) {
    companion object : UUIDEntityClass<UserEntity>(UserTable)

    var snsId by UserTable.snsId
    var email by UserTable.email
    var userName by UserTable.userName
    var islandName by UserTable.islandName
    var introduction by UserTable.introduction
    var loginType by UserTable.loginType

}

보면 Table와 UserEntity는 분리 되어 있다는 것을 알수 있다.

UserEntity의 변수들은 UserTable의 컬럼들이 매핑 되어 있는 것을 볼수 있는데, 커스텀 쿼리를 작성하여 UserEntity를 생성할수 없을때 ResultRaw 클래스로 반환되서 Map에서 key를 통해 value에 접근하는 방법으로 값을 추출하여 원하는 데이터 클래스로 바로 변환 할수 있다.

이렇게 해서 나는 Spring Boot + JPA 조합을 사용하면서 마주한 문제들을 Ktor + Exposed을 사용하면서 해결했다.

그래서 개인적으로 이런 결론에 도달했다.

JPA

  • Spring이란 거대 프레임워크와 함께 사용하여 공부에 도움이 되는 자료가 많다.
  • 단일 테이블에 쿼리를 요청할때 메소드 이름짓기로 간편하게 요청이 가능하다.
  • 복잡한 쿼리, 커스텀 쿼리에는 복잡한 작업들이 필요하다.

Exposed & Ktorm

  • JPA에 비해 자료가 부족하다. 하지만 사용법이 간편하다.
  • 단일 테이블에 하는 요청은 Entity의 sequenceof()를 사용하면 더 간편하게 할수 있고, 그외에 복잡한 쿼리, 커스텀 쿼리는 DSL + ResultRow을 사용하여 간편하게 모델로 변환할수 있다.
  • 코틀린 환경에서만 사용할수 있기에 코틀린의 장점들을 사용할수 있다.

Jpa.. 아무리 코틀린이 JPA와 상성이 안좋다고는 하지만 자바를 사용하여 개발한다 해도, 커스텀 쿼리를 처리하는 부분 때문에 별로 사용하고 싶지 않을 것같다.

Exposed는 코프링 또는 Ktor을 사용할때 좋으니 앞으로 개인 프로젝트에서 Ktor를 사용하면 Exposed이나 Ktorm 둘중에 하나를 사용할것같고, 코프링이면 Exposed만 사용할것 같다.

profile
아키텍트를 꿈꾸는 주니어 백엔드 개발자

1개의 댓글

comment-user-thumbnail
2023년 8월 18일

정보에 감사드립니다.

답글 달기