(1) gradle root 프로젝트에서 사용할 plugin들을 정의 java: java를 빌드하기 위한 plugin으로 코드 컴파일, test 수행, jar파일 생성 등의 역할 수행 org.springframework.boot: spring boot 어플리케이션 실행을 위한 필수적인 플러그인 io.spring.dependency-management: spring boot의 의존성 버전을 관리하는 플러그인으로 의존성의 버전을 명시하지 않아도 권장되는 버전으로 의존성 사용되도록 만들어준다
(2) 프로젝트에서 사용되는 java의 버전을 명시하여 특정 버전으로 프로젝트가 컴파일되고 실행되도록 하기 위함
(3) allprojects는 루트 및 서브 프로젝트에 공통적으로 적용하는데 사용 group, version property를 지정하고 프로젝트에서 mavenCentral 저장소를 사용하도록 설정
(4) subprojects는 모든 서브 프로젝트에 공통적으로 적용하는데 사용(루트 프로젝트는 적용안됨) 서브 프로젝트에서 사용할 plugin 및 의존성 등을 설정 tasks.test(gradle)에서 기존적으로 제공하는 테스트 작업에 JUnit5를 사용하여 테스트가 실행되도록 설정
Sentinel 방식은 말 그대로 보초로서 Redis서버(Master, Replica)에 대한 모니터링을 수행하고 장애가 발생했을 때 Failover를 수행함으로서 가용성을 보장하는 구성이다
Sentinel의 역할
모니터링/장애 조치 1) Redis서버에 지속적으로 Ping을 보내어 Redis서버가 alive 상태인지 확인 2) Redis서버에 장애가 발생하는 경우 Sentinel이 이를 감지하고 다른 센티넬들에게 이를 공유하고 정족수(quorum)이상의 Sentinel들이 down으로 판단한 경우 실질적 down으로 판단 3) 리더 Sentinel이 선출되고 리더 Sentinel에 의해 장애 Redis서버에 대해 Failover 수행(단, Master에 대해서만 Failover 수행하며 Replica에 대해서는 Failover 수행하지 않음)
구성 정보 제공 Sentinel은 Redis 노드를 감시하고 마스터 노드에 대한 정보를 제공하는 역할을 한다 Sentinel로 구성된 Redis르 Client가 연결할 때는 Sentinel을 통해 구성 정보를 받아야 한다(Failover가 발생했을 때 Master가 변경되기 때문)
Sentinel구성은 Sentinel이 장애가 발생하여 quorum(정족수)를 미달하는 경우 Failover가 정상적으로 이루어지지 않을 수 있기 때문에 Sentinel에 대한 모니터링이 중요하다
2) Cluster
Cluster 방식은 확장성 및 고가용성을 갖춘 방식으로 데이터를 분산(Sharding)하고 자동으로 Failover를 지원하는 방식이다
데이터 분산 16384개의 Hash Slot으로 분할된 키를 각 마스터가 담당하여 처리한다
장애조치 Master 별로 1개 이상의 Replica를 가지고 Master가 장애로 감지되면 Replica중 하나가 Master로 승격된다 이때, Cluster 내 다수의 노드가 동의 하는 경우 장애로 간주 된다(quorum 기준 이상)
노드 간 통신 Cluster 노드 간에는 gossip protocol로 서로 통신하고 주기적으로 상태 정보를 교환 한다 이를 통해 각 노드는 Cluster 내 다른 노드 정보를 알고 있다
노드별로 슬롯을 담당하고 있는 형태인 Cluster는 노드가 추가되는 경우 리벨런싱 작업이 필요하고 이는 자동으로 이루어지지 않는다
JPA를 사용하면서 발생할 수 있는 N+1 문제는 어플리케이션의 성능을 저하시킬 수 있는 문제 이다
이 문제에 대해 발생하는 이유와 해결 방법에 대해 알아보고자 한다
N+1 문제 그리고 발생 원인
최초 실행한 한 개의 쿼리 + N개의 쿼리라고하여 N+1 문제라고 한다
JPA의 FetchType이 Lazy인 경우 발생 하는 것이다 (기본은 FetchType Eager)
N+1 문제는 하나의 쿼리를 실행 후, 조회된 엔티티에 대한 연관된 엔티티를 조회하기 위해 추가로 N개의 쿼리가 실행되는 것을 말한다
FetchType이 Lazy인 것이 자체가 문제가 되는 것은 아니다
Lazy로 연관 엔티티를 조회하는 경우 불필요한 데이터를 조회하지 않아도 되는 이점이 있다
그러나 이를 잘못 사용하여 연관된 데이터가 많은 엔티티에 접근하게 되면 해당 연관 엔티티의 수 만 큼 N번 조회가 발생하게 된다
이러한 N번의 조회에 의해 아래와 같은 문제점이 발생하게 되고 어플리케이션의 성능 저하가 발생할 수 있다
1. 쿼리 수가 급격히 증가
: 연관 엔티티가 100개면 총 101개의 쿼리가 실행된다
2. DB 커넥션 낭비
: 하나의 커넥션을 오래 점유하기 때문에 커넥션 풀 고갈 위험이 있다
N+1 문제가 발생할 수 있는 예시
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener::class)
class Order(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
.......
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST])
val orderItems: MutableList<OrderItem> = mutableListOf(),
.......
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedDate
val modifiedAt: Instant? = null
)
@Entity
@Table(name = "orderitems")
class OrderItem(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(nullable = false)
val productId: Long? = null,
.......
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedDate
val modifiedAt: Instant? = null
)
위 예시를 보면 Order 안에 연관 엔티티로 OrderItem이 있는 것을 볼 수 있다
만약 Order에 연관된 OrderItem이 1000개 라고 가정하고 Order 엔티티를 조회한 후 OrderItem 엔티티에 접근하게 되면
총 1000 + 1으로 1001 번의 쿼리가 수행되게 된다
해결 방법
1. Fetch Eager 사용
JPA는 기본적으로 연관 관계에 대해 Fetch Eager를 기본으로 한다
Eager를 사용하게 되면 조회할 때 연관 엔티티도 함께 조회를 하게 되는데, 연관 엔티티를 함께 조회하기 때문에 N+1문제가 발생하지 않는다
하지만 실제 필요하지 않은 엔티티도 함께 조회된다
2. Fetch Join 활용
Fetch Join을 하게되면 필요한 연관 엔티티를 한 쿼리로 조회할 수 있다
@Query("SELECT o FROM Order o JOIN FETCH o.items")
fun findOrders(): List<Order>
3. EntityGraph 활용
Fetch Join 과 비교해서 단순화된 방법이다
명시적으로 Eager로 수행되도록 하는 방식 이다
@EntityGraph(attributePaths = ["items"])
@Query("SELECT o FROM Order o")
fun findOrders(): List<Order>
4. DTO Projection 활용
필요한 컬럼의 데이터만 조회하는 방법이다
data class OrderInfoDto (
val orderId: Long,
val itemName: String
)
@Query("""
SELECT new com.example.dto.OrderInfoDto(o.orderId, i.itemName)
FROM Order o
JOIN o.items i
""")
fun findOrderItem(): List<OrderInfoDto>
위 코드처럼 필요한 항목의 데이터만 조회할 수 있다
Dto의 package 경로 전체를 기입해야 하기 때문에 작성해야하는 구문이 길어질 수 있다
data class OrderInfoDto (
val orderId: Long,
val itemNames: List<String>
)
아래와 같은 Dto의 구성으로 List 항목으로 데이터를 조회할 수 는 없다
따라서 단일 항목으로 조회하고 조회 이후에 별도 가공을 해야한다
N+1 문제를 해결하기 위한 다양한 방법이 존재하는데 상황에 맞게 적절한 방법을 사용할 필요가 있다
이러한 연관관계를 지정하는 경우 N+1문제가 발생하는 경우 때문에 아래와 같이 FetchType을 Lazy로 한다
(물론 JPA에서는 Eager가 기본)
연관관계의 FetchType을 Lazy로 한 경우 조회 기능을 개발할 때 아래와 같이 LazyInitializationException 가 발생하는 경우가 있다
LazyInitializationExceptionorg.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
kr.co.kimga.order.domain.entity.order.Order.orderItems:
could not initialize proxy - no Session at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:635)
at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:219)
at org.hibernate.collection.spi.AbstractPersistentCollection.readSize(AbstractPersistentCollection.java:150)
at org.hibernate.collection.spi.PersistentBag.size(PersistentBag.java:353)
at kotlin.collections.CollectionsKt__IterablesKt.collectionSizeOrDefault(Iterables.kt:39)
at kr.co.kimga.order.infrastructure.service.order.OrderService.findOrderDetails(OrderService.kt:82)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
/* 양방향 연관관계 */
@Entity
data class Customer(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val name: String,
@OneToMany(mappedBy = "customer")
val orders: List<Order> = mutableListOf()
)
@Entity
data class Order(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val orderDate: String,
@ManyToOne
@JoinColumn(name = "customer_id")
val customer: Customer
)
이 처럼 Customer(고객)가 여러 Order(주문)을 가지는 경우에 대해 필요 시 양방향 연관 관계를 설정할 수 있다
그렇다면 이러한 상황에서 Customer의 정보를 조회하여 조회된 결과를 그냥 반환한다고 하면 순환 참조가 발생할 수 있게 된다
@Service
class CustomerService(
private val customerRepository: CustomerRepository
) {
fun getCustomerById(id: Long): Customer? =
customerRepository.findById(id).orElseThrow(EntityNotFoundException("Customer not found"))
}
@Repository
interface CustomerRepository : JpaRepository<Customer, Long>
이렇게 조회된 Customer를 별도의 DTO 객체로 변환하거나 하지 않고 Service를 지나 Controller에서 반환된다면 JSON으로 변환될 때 순환 참조를 맛볼 수 있다
그렇다면 왜 JSON으로 변환하는 과정에서 순환 참조가 발생하게 되는 걸까?
2. 이유
그 이유를 알아보기 위해서는 객체를 JSON으로 변환해주는 라이브러리를 살펴볼 필요가 있다
Spring에서는 @RestController가 있는 Controller에서 객체를 반환하면 JSON 형식으로 변환되어 응답이 나가게 된다
이러한 것은 Spring 내부에 있는 Message Converter(HttpMessageConverter)가 우리의 객체를 Http 메시지로 변환하는 과정을 통해 이루어 진다
Controller에서 반환된 객체는 ReturnValue Handler에 의해 처리가 되고,
반환된 객체는 Jackson Message Coverter에 의해 처리가 되고 내부에 ObjectMapper에 의해 객체가 json으로 serialize 되는 과정에서 객체 내부의 필드에 대해 재귀 호출이 발생하고 이 과정에서 양방향 연관관계가 있는 객체에 대해 순환 참조가 발생하게 되고 StackOverFlow Exception이 발생하게된다
3. 해결 방법
1) @JsonIgnore
@Entity
class Child(
@Id
@GenerateValue
val id: Long? = null,
@ManyToOne
@JoinColumn(name = "parent_id")
@JsonIgnore
val parent: Parent
)
JsonIgnore 어노테이션을 사용하면 직렬화를 할 때 해당 변수에 대한 직렬화를 제외하게 되고 순환 참조가 일어나지 않는다
단, 이러한 경우 해당 변수에 대한 값은 직렬화 되지 못하여 반환된 값에서 제외 된다
2) DTO활용 (가장 적절한 방법)
데이터를 반환할 때는 DTO를 통해 의미 있는 값만 전달 반환하는 것이 바람직하다
data class ParentDto(
val id: Long?,
val children: List<ChildDto>
)
data class ChildDto(
val id: Long?,
val parentId: Long?
)
fun Parent.toDto(): ParentDto = ParentDto(
id = this.id,
children = this.children.map {it.toDto()}
)
fun Child.toDto(): ChildDto = ChildDto(
id = this.id,
parentId = this.parent.id
)
이 처럼 원하는 데이터만 DTO에 포함하여 반환하면 순환참조를 제어할 수 있고, 진짜 필요한 값만 반환할 수 있기 때문에 가장 추천되는 방법이다
위에서 설명한 것 외에도 순환 참조가 발생하는 케이스들이 존재하기 때문에 JPA를 사용하는 경우 이를 유의하며 코드를 작성해야 한다