환경 : AWS ec2 인스턴스에서 서버 배포 + Spring Data JPA(hibernate) + MySQL
어떤 오류?
2022-09-02 06:59:34.637 ERROR 35806 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.lucky7.stackoverflow.question.entity.Question.comment, could not initialize proxy - no Session] with root cause
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.lucky7.stackoverflow.question.entity.Question.comment, could not initialize proxy - no Session
at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:614) ~[hibernate-core-5.6.10.Final.jar!/:5.6.10.Final]
요약하자면 Lazy(지연) Initialization(초기화) 오류이다.
프록시를 초기화할 수 없다고 나오며 no Session이라고 세션이 없다는 로그가 보인다.
오류가 발생하는 이유?
일단 Spring Data JPA의 Lazy 전략에 대한 이해가 부족하였고 영속성 컨텍스트의 생명주기에 대해 알지 못하여서 발생하는 문제였다.
간단하게 정리해보자면 이렇다.
서비스 단에 @Transactional을 붙여놓은 상태여서 메서드가 종료되면 Hibernate의 Session도 함께 종료되어 영속성 컨텍스트에서 사라진다.
영속성 컨텍스트는 보통 트랜잭션과 생명주기를 같이한다. → service 호출 끝나고 controller로 돌아가면 영속성 상태가 끝난다.
jpa의 효율 문제 때문에 엔티티가 List나 객체로 참조하고있는 부분을 전부 쿼리문을 날려서 채워놓진 않고 일단 프록시 객체로 채워놓은 이후 나중에 getter를 사용하면 쿼리를 보내서 실제 데이터로 채운다.
Question에 조회하는 요청 시 owner나 comment, answer 에 실제 데이터를 쿼리를 날려서 저장하는 것이 아닌 프록시 객체로 채워진다.(stub 데이터라고 보면 됨) -> 프록시 객체로 채워진 상태에서 Service 메서드 호출이 끝나면 영속성 컨텍스트의 관리대상에서 제외되기 때문에 이후엔 gatter를 사용해도 쿼리문을 날려서 해당 객체를 실제 데이터로 채우지 않는다.
QuestionService에 @Transactional 애너테이션 붙어있음 → controller에서 service의 메서드 호출한 순간부터 트랜젝션 생성 → 서비스 메서드 끝나면서 트랜젝션 종료됨 → mapper에서 question객체를 dto 객체로 변환시켜주면서 owner, comment, answer 객체를 불러오는 쿼리 보냄 → 영속성 컨텍스트에 존재하지 않아서 프록시 객체를 실제 객체로 채우지 못함 → LazyInitializationException !
어떤 문서를 작성하는 것이 좋고 어떤 방식으로 하면 좋은지 프론트, 백엔드 모두 이해할 수 있도록 안내를 받았다.
프론트엔드 개발자가 작성해야하는 문서에 대해서는 몰랐는데 화면정의서라는 것을 처음으로 알게 되었다.
백엔드에서 작성해야할 문서는 API 명세서와 데이터베이스 명세서이다.
어제 작성을 마친 사용자 요구사항 정의서를 토대로API 명세서를 먼저 작성하였다.
처음엔 API 명세서는 프론트 엔드 팀원분들과 같이 작성을 하다가 백엔드에서 작성하고 넘기는게 효율적일 것 같다는 의견이 나와서 백엔드 팀원분과 같이 작성했다.
API 명세서를 반드시 Spring Rest Docs를 사용할 필요는 없고 데이터베이스 명세서도 반드시 ERD로 만들 필요는 없다고 해서 문서화작업을 최대한 빠르게 마치고 코딩으로 넘어가기 위해서 간단하게 필수기능만 작성하기로 했다.
데이터베이스 명세서는 API 명세서의 데이터를 토대로 작성하는 거라서 다른 팀원분이 ERDcloud 라는 쉽고 직관적인 사이트를 알려주셨다. 내가 알던 dbdiagram.io는 테이블과 칼럼, 속성 등을 sql문으로 작성해야 했는데...ERDcloud는 너무 편했다. 엔티티를 그리는 것도 편하고 작업을 개인이 아닌 팀으로 설정하면 팀원을 추가해서 같이 작업할 수 있다! 데이터베이스 명세서는 백엔드 다른 팀원분이 맡아서 해주신다하셔서 너무 감사했다. (안그래도 할일이 밀린데다 오랜 회의로 피로가 많이 누적되 있었다.) 그리고 몇 시간전에 완성된 것을 확인했다. 😄
프론트엔드 팀원분들도 화면정의서를 필수기능 위주로 간단하게 작성해주셨다. 사실 굉장히 빠르게 마치셔서 놀랐다. 우리 팀의 프론트 분들의 작업속도가 빠른 편인 것으로 보인다. ㅎ
다행히 문서 작업이 예상보다 굉장히 빠르게 끝났다!!!
우리의 목표는 이번주 내로 끝내는 거였는데 시작한지 몇일 안된 오늘 (수요일)에 끝났다..!ㅠㅠ 팀원분들 만세ㅠㅠ
👉🏻 5명으로 시작해서 몇일만에 4명으로 줄었다..ㅠ
저번주 금요일에 프로젝트 팀이 정해지고 이번주부터 제대로 프로젝트에 돌입했는데 팀원 한분이 자리를 비우고 회의에도 참석하지 않았는데 하차하신다는 연락을 받았다.ㅠㅠ 사정이 있어서 그런거지만 아쉬운 건 어쩔 수가 없었다. 다른 팀에선 1명이 하차해서 공중분해 되서 다 다른 팀으로 배정되었다는 얘기를 들었는데... 우리는 다행히 한명이 하차해도 백엔드 2명, 프론트엔드 2명으로 다른 팀과 인원차이가 안나기 때문에 그대로 진행할 수 있었다.
Rest Docs 기능 활성을 위해 build.gradle 파일에 코드를 추가하고 build 하니까 자꾸만 찾을 수 없다고 떠서 이번 기회에 build.gradle 에 추가해야 하는 코드를 정리해 놓으려고 한다.
버전 정보는 바뀔 수 있으므로 불러오기에 실패할 경우 버전을 체크하자.
plugins {
id 'org.springframework.boot' version '2.7.2'
id 'io.spring.dependency-management' version '1.0.12.RELEASE'
id 'org.asciidoctor.jvm.convert' version '3.3.2'
id 'java'
}
group = 'com.wisejade' // initializr에서 프로젝트 생성시 설정한 group이 자동으로 들어감
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets")) // 추가해야하는 코드
}
configurations {
asciidoctorExtensions // asciidoctorExtensions를 사용하기 위해 추가
}
dependencies {
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // mockmvc 사용을 위해 추가
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' // asciidoctor
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Spring data jpa 사용을 위해 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.mapstruct:mapstruct:1.5.2.Final' // mapstruct 사용을 위해 추가
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final' // mapstruct 사용을 위해 추가
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.google.code.gson:gson' // gson은 Json을 String으로 쉽게 바꿔줌
}
tasks.named('test') { // 추가해야하는 코드
outputs.dir snippetsDir
useJUnitPlatform()
}
tasks.named('asciidoctor') { // 추가해야하는 코드
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
task copyDocument(type: Copy) { // 생성된 rest docs를 static/docs로 자동으로 copy
dependsOn asciidoctor
from file("${asciidoctor.outputDir}")
into file("src/main/resources/static/docs")
}
build { // task build 할 때마다 copy
dependsOn copyDocument
}
bootJar {
dependsOn copyDocument
from("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
Spring Security의 Filters를 사용하지 않을 경우 AuthenticationManager를 사용하지 않고 SecurityContextHolder에 직접 설정하면 된다.
AuthenticationManager의 가장 많이 사용하는 구현체는 ProviderManager이다.
🍰 ProviderManager
AuthenticationManager의 가장 일반적인 구현체이다.
ProviderManager는 동작을 AuthenticationProvider List에 위임한다.
모든 AuthenticationProvider는 인증을 성공 or 실패 or 결정을 내릴 수 없는 지를 판단하고 다운스트림에 있는 AuthenticationProvider가 결정하게 할 수 있다.
설정한 AuthenticationProvider들 중 어떤 것도 인증할 수 없다면, ProviderNotFoundException 오류와 함께 인증이 실패한다.
ProviderNotFoundException : ProviderManager가 전달된 Authentication 유형을 지원하도록 구성되지 않았음을 나타낸다.
AuthenticationProvider마다 각자 맡은 인증을 수행한다.
ProviderManager에 인증을 수행할 AuthenticatinoProvider가 없을 경우, 부모 AuthenticationManager 구성을 허용한다.
부모는 어떤 타입의 AuthenticationManager든 가능하지만 주로 ProviderManager의 인스턴스이다.
여러 개의 SecurityFilterChain 인스턴스가 공통의 인증(같은 부모 AuthenticationManager)을 가지지만 다른 인증 매커니즘(다른 ProviderManager 인스턴스)일 경우 같은 부모 AuthenticationManager를 여러 ProviderManager가 공유할 수 있다.
ProviderManager는 인증 이후의 리턴된 Authentication 객체의 모든 민감한 credential 정보는 제거하려고 시도한다.
password와 같은 정보가 HttpSession에 필요 이상으로 오래 남아있는 것을 방지하기 위해서이다.
하지만 Authentication에 cache의 객체(ex. UserDetails)에 대한 참조가 포함되어 있는 경우 credential 정보를 모두 제거하면 캐시된 값에 대해 더 이상 인증할 수 없는 문제가 생길 수 있다.
해결책은 캐시 구현 또는 반환된 Authentication 객체를 생성하는 AuthenticationProvider에서 먼저 객체의 복사본을 만드는 것이다.
또 다른 해결책으로는 ProviderManager의 eraseCredentialsAfterAuthentication property를 비활성화 하는것이다.
🍰 AuthenticationProvider
다수의 AuthenticationProvider를 ProviderManager에 주입할 수 있다.
각각의 AuthenticationProvider는 특정 유형의 인증을 수행한다.
ex) DaoAuthenticationProvider : username/password 기반의 인증을 지원한다.
JwtAuthenticationProvider : JWT 토큰 인증을 지원한다.
🍰 Request Credentials with AuthenticationEntryPoint
AuthenticationEntryPoint는 클라이언트의 자격증명 요청에 대한 응답 HTTP를 보내는데 사용된다.
또한 클라이언트에 자격증명을 요청할 때 사용된다.
AuthenticationEntryPoint의 구현체는 로그인 페이지로 리디렉션하거나 WWW-Authenticate header를 전송하는 것을 수행한다.
🍰 AbstractAuthenticationProcessingFilter
사용자의 credentials를 인증하기 위한 기초 Filter로 사용된다.
credentials이 인증되기 전에, Spring Security는 일반적으로 AuthenticationEntryPoint를 사용하여 credentials을 요청한다.