오늘은 일찍 일어나서 스프링 심화주차 강의 security 부분을 듣고 코드도 직접 짜보고 lv4 과제를 했다.
1. Error: org.hibernate.PersistentObjectException: detached entity passed to persist
코드를 실행시키는데 위와 같은 에러가 발생했다. persist가 나온걸로 봐선 영속성 컨텍스트 문제가 아닌가라고 생각했는데 찾아보니 역시나 맞았다.
발생원인:
- JPA에서 @ManytoOne 에 cascade 옵션을 persist로 지정해줘서 에러 발생
- cascade 옵션을 ALL(또는 persist)로 처리 할 경우 부모가 save() 될 때 영속성이 detached(분리)되어 persist(지속)되지 않기 때문에 에러 발생
해결방법:
- JOIN 어노테이션에 cascade 옵션 변경
- JPA의 CASCADE옵션은 영속성 전이를 제공한다. 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.
- cascadetype 종류:
- CascadeType.RESIST
엔티티를 생성하고, 연관 엔티티를 추가하였을 때 persist() 를 수행하면 연관 엔티티도 함께 persist()가 수행된다. 만약 연관 엔티티가 DB에 등록된 키값을 가지고 있다면 detached entity passed to persist Exception이 발생한다. - CascadeType.MERGE:
트랜잭션이 종료되고 detach 상태에서 연관 엔티티를 추가하거나 변경된 이후에 부모 엔티티가 merge()를 수행하게 되면 변경사항이 적용된다.(연관 엔티티의 추가 및 수정 모두 반영됨) - CascadeType.REMOVE
삭제 시 연관된 엔티티도 같이 삭제됨 - CascadeType.DETACH
부모 엔티티가 detach()를 수행하게 되면, 연관된 엔티티도 detach() 상태가 되어 변경사항이 반영되지 않는다. - CascadeType.ALL
모든 Cascade 적용
- CascadeType.RESIST
2. 영속성 컨텍스트, @Transactional
@Getter
@NoArgsConstructor
@Entity(name = "users")
public class User {
....
@OneToMany
List<Folder> folders = new ArrayList<>();
...
}
@PostMapping("/folders")
public List<Folder> addFolders(
@RequestBody FolderRequestDto folderRequestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
List<String> folderNames = folderRequestDto.getFolderNames();
return folderService.addFolders(folderNames, userDetails.getUser());
}
@Transactional
public List<Folder> addFolders(List<String> folderNames, User user) {
// User user = userRepository.findByUsername(name).orElseThrow(
// () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
// );
// 입력으로 들어온 폴더 이름을 기준으로, 회원이 이미 생성한 폴더들을 조회합니다.
List<Folder> existFolderList = folderRepository.findAllByUserAndNameIn(user, folderNames);
List<Folder> folderList = new ArrayList<>();
for (String folderName : folderNames) {
// 이미 생성한 폴더가 아닌 경우만 폴더 생성
if (!isExistFolderName(folderName, existFolderList)) {
Folder folder = new Folder(folderName, user);
folderList.add(folder);
}
}
return folderRepository.saveAll(folderList);
}
이렇게 코드를 짜면 다음과 같은 에러가 발생한다.
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: failed to lazily initialize a collection of role: com.sparta.myselectshop.entity.User.folders, could not initialize proxy - no Session; nested exception is com.fasterxml.jackson.databind.JsonMappingException: failed to lazily initialize a collection of role: com.sparta.myselectshop.entity.User.folders, could not initialize proxy - no Session (through reference chain: java.util.ArrayList[0]->com.sparta.myselectshop.entity.Folder["user"]->com.sparta.myselectshop.entity.User["folders"])]
한마디로 이 에러는 LazyInitializationError라고도 하는데 영속성 컨텍스트 관련 에러이다.
위의 user 엔티티를 보면 folders에 @OneToMany 어노테이션을 달아주고 있는데 이 어노테이션의 FetchType의 디폴트 값은 lazy이다. fetchtype이 lazy이면 그 필드가 실제로 사용되기 전까지는 proxy 객체를 만들어서 대신 넣어주다가 나중에 디비 조회를 한다거나 실제로 사용을 하면 그때서야 진짜 객체를 만들어서 디비에 저장한다.
우리는 controller 부분에서 @AuthenticationPrincipal 이 어노테이션으로 로그인한 유저 정보를 userDetails에 담아 User를 get해서 service쪽으로 넘겨준다. 그럼 service 쪽에서 user를 매개변수로 받는데, 이때, user는 db에서 가져온 테이블이 아니라 그냥 하나의 객체일 뿐이다. 그 이유는 @Transactional 때문인데 로그인한 유저이름이로 유저를 찾아서 context에 저장할 때랑 지금 user를 context에서 찾는건 다른 Transaction이기 때문이다. 그래서 Folder를 생성자로 folderName이랑 user를 넣어서 생성할 때 user안의 folders 속성은 초기화도 안 된 상태이다(lazy fetchtype이기 때문). 그래서 에러가 난다.
3. 인증 요청할 때 디폴트 httpmethod는 POST이다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
//기본 설정인 session 방식은 사용하지 않고 jwt 방식을 사용하기 위한 설정
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests().antMatchers("/api/user/**").permitAll()
.antMatchers("/api/search").permitAll()
.antMatchers("/api/shop").permitAll()
.anyRequest().authenticated()
//JWT 인증/인가를 사용하기 위한 설정
.and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
http.formLogin().loginPage("/login-page").permitAll();
return http.build();
}
@GetMapping("/login-page")
public ModelAndView loginPage() {
return new ModelAndView("login");
}
@ResponseBody
@PostMapping("/login")
public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
userService.login(loginRequestDto, response);
return "success";
}
지금 이 코드는 커스턴로그인 페이지를 login-page로 요청 보내서 login.html를 반환한다.
만약 여기서 커스텀로그인 페이지 url을 다음과 같이 적고 controller 쪽에서도 이 url로 변경하면 에러가 난다.
http.formLogin().loginPage("/login").permitAll();
@GetMapping("/login")
public ModelAndView loginPage() {
return new ModelAndView("login");
}
@ResponseBody
@PostMapping("/login")
public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
userService.login(loginRequestDto, response);
return "success";
}
결국 controller 부분에 login으로 연결되는 url이 GetMapping과 PostMapping 2가지가 있는데 인증 요청의 경우 디폴트가 Post로 연결되기 때문에 에러가 난다. url 이름을 바꿔줘서 제대로 ModelAndView를 반환하게 하자!
-끄읕-