/* 양방향 연관관계 */@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의 정보를 조회하여 조회된 결과를 그냥 반환한다고 하면 순환 참조가 발생할 수 있게 된다
이렇게 조회된 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를 통해 의미 있는 값만 전달 반환하는 것이 바람직하다
dataclassParentDto(
val id: Long?,
val children: List<ChildDto>
)
dataclassChildDto(
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를 사용하는 경우 이를 유의하며 코드를 작성해야 한다
HttpsURLConnection은 네트워크 통신 레벨의 API로 SSL 핸드셰이크를 수행
SSLContext는 SSL 세션의 구성요소에 대한 초기화 및 관리르 수행하고 해당 객체에서 암호화 방식이나 TrustManager를 결정
TrustManagerFactory SSLContext에서 사용할 TrustManager 리스트를 생성하는 역할 수행한다 이 때, JVM "cacerts" 또는 사용자 keyStore를 통해 TrustManager 생성
X509TrustManager는 실제 서버 인증서 체인을 검증하는 역할을 수행하며 "checkServerTrusted" 메소드를 통해 서버가 제공한 인증서 체인을 검증
(RestTemplate도 내부적으로 HttpURLConnection을 사용하고 있어 인증 흐름은 동일)
Java 내 cacerts 확인 방법
cd $JAVA_HOME (JAVA가 설치된 디렉토리로 이동)
cd jre/lib/security (cacerts가 있는 위치로 이동)
# 아래 명령을 수행하면 해당 되는 alias에 대한 CA 인증서 정보를 볼 수 있다
keytool -list -v -keystore ./cacerts -storepass [password, 기본 "changeit"] -alias '[alias 명]'
해당 CA인증서를 확인함으로써 인증서의 세대 교체 등이 발생했을 때 신규 인증서를 어플리케이션에서 검증할 수 있는지 확인할 수 있다
추가
어플리케이션 실행할 때 jvm 옵션으로 아래 옵션을 추가하면 HTTPS 요청이 발생할 때 인증서, TLS 버전, 루트 인증서에 대한 정보를 로그로 확인할 수 있다
통합테스트를 할 때는 Spring 컨테이너를 통해 필요한 객체를 생성하고 의존성을 주입해야한다
그리고 이를 위해 @SpringBootTest 어노테이션을 붙인다
이를 통해 테스트가 수행될 때 SpringBootTestContextBootstrapper가 동작하면서 ContextLoader를 통해 context(ApplicationContext)를 로드하는 과정에서 캐싱된 context가 있으면 해당 컨텍스트를 재사용하게 된다
- ApplicationContext를 생성하는 비용이 크기 때문에 재사용
@SpringBootTest 동작 흐름
@SpringBootTest
↓
SpringBootTestContextBootstrapper
↓
SpringBootContextLoader.loadContext()
↓
ApplicationContext 생성
↓
의존성 주입
↓
테스트 수행
------------------
@BeforeEach 실행
↓
@Test 실행
↓
@AfterEach 실행
------------------
↓
컨텍스트 캐시 유지 또는 제거
컨텍스트를 재사용하면서 컨텍스트 로드 시점에 동작해야하는 아래 설정 함수가 동작하지 못하고 이로 인해 두번째 테스트에서 redis에 연결을 시도하였으나 실패하면서 테스트가 정상 수행되지 못한 것이다