Spring Boot,JPA,Kotlin을 사용하며 생긴 이슈

사명기·2023년 5월 14일
1

회사에서 spring, kotlin,jpa를 사용하면서 발생한 이슈와 해결 방법 등을 공유하겠습니다.
코드는 제가 작성한 극단적인 예시 코드입니다.


문제 상황 알아보기

아래와 같은 코드가 있을 때, /update API를 호출하면 어떤 이슈가 있을까요?

코드를 한번 쭉 보시고 발생할 수 있는 문제와 원인에 대해 생각해봅시다.

@RestController
@RequestMapping("/krew/v1")
class KrewController(
   private val krewService: KrewService
) {

   @PostMapping("/update")
   fun update(
       @RequestBody request: KrewUpdateRequest
   ): String {
       val krew = krewService.findById(request.id)
       krewService.updateName(request.id, request.name)
       return "good"
   }

   data class KrewUpdateRequest(
       val id: Long,
       val name: String
   )
}

@Service
class KrewService(
   private val krewRepository: KrewRepository
) {

   fun updateName(id: Long, name: String) {
       krewRepository.updateName(id, name)
   }

   fun findById(id: Long): Krew {
       return krewRepository.findById(id).orElseThrow()
   }
}

@Repository
interface KrewRepository: JpaRepository<Krew, Long> {

   @Transactional
   @Modifying(clearAutomatically = true)
   @Query("update krew set name = :name where id = :id", nativeQuery = true)
   fun updateName(id: Long, name: String)
}

@Entity
@Table
@EntityListeners(AuditingEntityListener::class)
data class Krew(
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   val id: Long = 1L,

   @Column
   var name: String,

   @Column
   val age: Int,

   @Column
   @Convert(converter = DivisionConverter::class)
   val division: Division,

   @CreatedDate
   @Column(nullable = false, updatable = false)
   var createdAt: LocalDateTime? = null,
   
   @LastModifiedDate
   @Column(nullable = false)
   var updatedAt: LocalDateTime? = null,
) {
   class Division(
       val value: HashSet<String> = hashSetOf()
   )

   @Converter(autoApply = true)
   @Component
   class DivisionConverter: AttributeConverter<Division, String> {
       override fun convertToDatabaseColumn(attribute: Division?): String {
           return ObjectMapper().writeValueAsString(attribute)
       }

       override fun convertToEntityAttribute(dbData: String?): Division {
           return ObjectMapper().readValue(dbData, Division::class.java)
       }
   }
}



문제는 바로바로! Dirty Check로 인해 Update 쿼리가 2번 발생하는 것 입니다.
여기서 Dirty Check가 발생한 원인이 2가지가 있습니다.
첫번째 원인은 일반 class에 equals를 재정의 하지 않은 것이고, 두번째 원인은 OSIV(Open-Session-In-View)를 OFF하지 않은 것입니다.
원인들을 하나씩 살펴보도록 하겠습니다.

원인1. 일반 class에 Equals를 재정의하지 않아서

먼저, Kotlin은 일반 class와 data class가 있습니다.
data class는 equals, hashCode, copy 등의 메서드를 자동으로 만들어줍니다.

Krew class의 Division 클래스는 일반 class로 되어있습니다.
하지만 여기서 Equals를 재정의 하지 않았기 때문에 더티체킹이 발생했습니다.
더티체킹에 대해 더 자세히 알아보도록 하겠습니다.

class Division(
       val value: HashSet<String> = hashSetOf()
   )

JPA의 변경 감지(dirty check)란 엔티티의 변경사항을 데이터베이스에 자동으로 반영하는 기능입니다.

// service layer
@Transactional
fun updateAge(id: Long, age: Int) {
   val krew = findById(id)
   krew.age = age
}

위와 같은 updateAge 메서드가 있을 때 krew의 age는 새로운 age로 Update됩니다.
Repository를 통해 save를 직접 호출하지 않아도, JPA는 Dirty Check라는 기능으로 변경을 감지해 DB에 반영해주기 때문입니다.

변경을 감지하려면 기준이 필요합니다. 어떤 값을 기준으로 변경되었는지 비교를 해야하기 때문이죠.
기준은 최초 조회한 상태로 합니다. 따라서 DB에서 최초 조회한 시점의 상태와 현재 상태를 비교해서, 상태의 변경이 있으면 update합니다.

그럼 초기 상태는 어떻게 가지고 있을까요?

영속성 컨텍스트와 1차캐시

JPA의 영속성 컨텍스트 내부에는 1차 캐시가 있습니다. (1차 캐시에 관한 소개글)
1차 캐시에는 조회한 데이터들을 담아두는데, 처음 1차 캐시에 넣을 때 엔티티의 스냅샷도 같이 저장합니다.
그리고 이 스냅샷과 현재 엔티티를 비교해서 변경 감지가 일어나는 것입니다.

Entity와 스냅샷 비교방법

조금 더 구체적으로 알아보면...
Entity와 스냅샷을 비교할 땐, Objects의 equals 메서드를 사용합니다.
이 때, equals를 재정의 하지 않았기 때문에 주소 비교를 진행하고 주소가 달라서 false를 반환하게 됩니다. 따라서 Dirty Check가 발생해 전체 컬럼에 대한 Update 쿼리가 발생합니다.
(참고: 더티 체킹이 발생하면 전체 컬럼 Update 쿼리가 발생)

당연한 말이겠지만 변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용됩니다.

1차 원인 정리

1차 캐시의 Entity와 복사본의 equals 비교에서 변경사항을 감지하는데,
division class에 equals를 override하지 않았기 때문에 주소 비교를 진행해 False를 반환.
따라서 더티 체킹으로 인해 Update 쿼리가 발생한다!

더티 체킹이란 엔티티의 변경사항을 데이터베이스에 자동으로 반영하는 기능. 더티 체킹은 엔티티와 스냅샷을 Objects.equals 비교를 통해 확인

더티체킹이 일어나지 않는 엔티티 상태는

  • detach된 엔티티 (준영속)
  • DB에 반영되기 전 처음 생성된 엔티티 (비영속)

@Transactional 내에서 단순 select를 진행했을 때에도 더티체킹으로 인한 update 쿼리가 발생하므로,
더티 체킹이 의심될 땐 엔티티 내의 일반 class에 equals를 재정의 했는지 확인하자.



원인2. OSIV가 OFF이기 때문에

이번에도 OSIV(Open-Session-In-View)에 대한 개념부터 알아보겠습니다.

OSIV는 영속성 컨텍스트의 생존 범위를 Spring Filter단까지 늘리는 것입니다.

먼저, Spring에서 OSIV를 사용하지 않는 경우입니다.

OSIV-OFF

OSIV가 OFF인 경우에는 트랜잭션의 생명주기와 영속성 컨텍스트의 생명 주기가 같습니다.

OSIV를 사용하는 경우입니다.

OSIV-ON

요청이 들어올 때, 영속성 컨텍스트를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지합니다. 따라서 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지합니다.
Spring의 OSIV 디폴트 설정은 true입니다.

Controller단을 보면 findById를 통해 krew를 조회합니다. 이 때, krew는 영속성 컨텍스트에 들어가게 됩니다.
그리고 updateName 메서드 내부의 repository.updateName을 만나서 더티체킹이 발생합니다. 이 상황에서도 더티체킹이 발생하는 원인은 equals를 재정의하지 않았기 때문입니다.

osiv-on

그럼 OSIV를 끄면 어떻게 될까요?

osiv-off

OSIV를 끄면, 더티체킹이 발생하지 않습니다.
controller에서 조회 후 영속성 컨텍스트는 비워지고, krew는 준영속 상태가 됩니다. 따라서 이후 krewService.updateName을 호출하면 더티체킹이 발생하지 않고 native query만 호출하고 끝나게 됩니다.


2차 원인 정리

OSIV는 영속성 컨텍스트의 생존범위를 filter단까지 늘리는 것이다.

controller에서 find 했을 때, 엔티티는 영속 상태가 되었기 때문에. update 시에 더티체킹이 발생했다.

osiv를 사용하면 다음과 같은 이점이 있다.

  • 컨트롤러 이상의 레벨에서도 영속성 컨텍스트를 사용하므로,
  • 1차 캐시를 잘 활용할 수 있고
  • 지연로딩(lazy loading)을 presentation layer에서도 가능하게 한다.

하지만 제가 생각하기엔 OSIV를 사용하면 큰 문제점이 있습니다

DB 커넥션 풀에서 한번 가져온 커넥션을 API 응답을 반환할 때까지 가지고 있는 것입니다.
한 API에서 커넥션을 활용 후, 커넥션이 필요 없는 비지니스 로직(HTTP 호출 등)을 진행하면… 그동안 커넥션이 놀게됩니다. 이로 인해 트래픽이 몰렸을 때, DB의 자원을 잘 활용하지 못하고 커넥션을 못가져와 time out이 발생할 수 있습니다.

그럼 어떻게 하는게 좋을까요..?
제가 내린 결론은 아래와 같습니다.

트래픽이 많은 서비스에서는 OSIV를 꺼서, db connection을 잘 활용할 수 있도록 해주자.

트래픽이 적은 어드민성 서비스에만 필요하면 키도록 하자.

0개의 댓글