이건 너무나 당연한 것이다. 엔티티의 데이터가 디비에서 실제로 변화하기 위해서는 @Transactional
애노테이션이 필요하다. @Transactional
애노테이션이 없으면, 자바 메모리상에서만 데이터가 변하고 커밋이 되지 않아서 엔티티의 변경내용이 디비에 반영되지 않는다.
JPA 엔티티 데이터의 변화가 필요한 내용은 Service
객체에게 위임해서 해당 객체에서 데이터를 변화시키자.
이번 프로젝트에서 JPA 엔티티를 SecurityContext
내부에 있는 .contextHolder()
에 들고다녔는데, .contextHolder()
에 있는 JPA 엔티티를 아무리 수정해도, 해당 내용은 디비나 영속성 컨텍스트에 반영되지 않는다.
또한 반대의 경우도 마찬가지이다. 영속성 컨텍스트에 있는 JPA 엔티티를 변화시켜도 SecurityContext.ContextHolder()
에 있는 JPA 엔티티 객체는 영속상태가 아니기 때문에 변화가 적용되지 않는다.
이 점을 주의해야 한다.
스프링 시큐리티 컨텍스트에 JPA 엔티티를 두는 것은 결국 버그를 초래하거나 개발상의 어려움을 주기가 너무 쉽다. 우리가 JPA 엔티티를 사용할 때는 당연히 영속 상태라 생각하고 사용하는데, 해당 JPA 엔티티가 준영속상태라는 것을 모르고 코딩하다보면 실수가 나기 정말 쉽다.
그래서 내 생각은 스프링 시큐리티 컨텍스트 principal
에는 해당 엔티티를 구분할 수 있는 ID
개념의 문자열만 저장하는 것이 맞는 것 같다. 필요에 의해 어떤 객체도 저장할 수는 있겠지만, 절대 JPA 객체가 영속상태인것처럼 헷갈리게 저장하지 말자.
~29까지의 내용을 보면 이메일을 재전송하면 Account
객체에 토큰을 새로 만들고, 다시 새로운 토큰으로 인증을 해야 하는 부분이 있었다.
@Getter
// 스프링 시큐리티의 userdetail에서 user를 가져오는 것을 잊지말자.
// 다른 라이브러리도 user라는 이름의 객체가 많아서 주의해야 한다.
public class UserAccount extends User {
private final Account account;
public UserAccount(Account account) {
super(account.getNickname(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
this.account = account;
}
}
UserAccount
클래스는 User
클래스를 상속한다. User
클래스는 UserDetails
와 CredentialsContainer
인터페이스를 상속하여 구현한 구현체이다.
User
클래스의 생성자를 이용하여, UserDetails
에 들어갈 객체를 생성할 수 있다.
@Override
public UserDetails loadUserByUsername(String emailOrNickname) throws UsernameNotFoundException {
Account account = accountRepository.findByEmail(emailOrNickname);
if(account == null) {
account = accountRepository.findByNickname(emailOrNickname);
}
if(account == null) {
throw new UsernameNotFoundException(emailOrNickname);
}
// Principal 을 만들어주면 된다.
// UserDetailsService 를 구현한 객체가 하나만 있으면, SpringSecurity 에 더이상 설정해줄 것이 없다.
// 자동으로 UserDetailsService 를 상속한 빈을 가져다 쓰게 된다.
return new UserAccount(account);
}
accountRepository
로 찾은 Account
JPA 엔티티를 UserAccount
로 넘겨주었다. 하지만 이제 여기서 @Transactional
은 끝이 나게 되고, 이 시점부터 SecurityContext
에 존재하는 principal
로서의 Account
는 영속성 엔티티가 아니게 된다. 그래서 SecurityContext
에 존재하는 Account
를 꺼내서 아무리 내용을 바꿔도 디비에 반영되지 않는다.
@Retention(RetentionPolicy.RUNTIME) // RUNTIME까지 유지되는 애노테이션
@Target(ElementType.PARAMETER) // 파라미터 타입으로 들어가는 애노테이션
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {
}
@CurrentUser
애노테이션은 SecurityContext
내부 authentication
의 principal
내부의 Account
객체를 가져온다.
@AuthenticationPrincipal
애노테이션이 Authentication.getPrincipal()
메소드를 리졸브해준다.
@GetMapping("/resend-confirm-email")
public String resendEmail(@CurrentUser Account account, Model model) {
if (account.isEmailVerified()) {
return "redirect:/";
}
model.addAttribute(account);
if (account.getResendEmailAt() != null &&
!account.canSendEmailAgain()) {
long minutes = LocalDateTime.now().until(account.getResendEmailAt().plusHours(1), ChronoUnit.MINUTES);
model.addAttribute("error", true);
model.addAttribute("message",
"이메일 재전송에 실패하였습니다. 이메일을 재전송한지 1시간 이내입니다. " + minutes + "분 후에 다시 보낼 수 있습니다.");
return "account/check-email";
}
accountService.sendAccountConfirmEmail(account);
model.addAttribute("error", false);
model.addAttribute("message", "이메일 재전송에 성공하였습니다.");
// 사실 Redirect를 시키는 것이 좋다.
// 왜냐하면 새로고침 시에 계속 재전송이 발생할 수 있기 때문에.
// 그런데 이 경우에는 어차피 1시간 검증 로직을 넣어서 상관은 없다.
return "account/check-email";
}
여기서 문제가 되는 건 resendEmail
메소드에서 @CurrentUser
를 활용해서 Account
객체를 가져온 뒤, 해당 객체를 accountService.sendAccountConfirmEmail()
메소드의 인자로 그대로 보내서이다. SecurityContext
에 들어있던 Account
객체는 JPA 영속상태가 아니기 때문에 실제 디비 데이터에 반영이 되지 않는다.
실제 디비 데이터에 반영이 되려면, @CurrentUser
로 해당 Account
를 가져오지 말고, accountRepository.findByEmail()
등으로 새로 가져와야 한다. 그래야 JPA 영속성 컨텍스트 내부에 있는 JPA 엔티티를 가져올 수 있다.
결과적으로
SecurityContext
내부에 있는Account
객체 내부의 내용만 변경되고, JPA 영속성 컨텍스트에 있는 데이터는 변경되지 않아서,accountRepository.find...()
메소드로Account
객체를 가져왔을 때와@CurrentUser
로Account
객체를 가져왔을 때 서로 다른 객체를 가져오게 된다.
@GetMapping("/check-email-token")
public String checkEmailToken(String token, String email, Model model) {
// 리포지토리를 도메인과 같은 레벨로 봄.
// 대신 서비스나 컨트롤러를 리포지토리나 도메인 엔티티에서 참조하진 않을 것 (설계 위배)
Account account = accountRepository.findByEmail(email);
if (account == null) {
model.addAttribute("error", "wrong.email");
return "account/checked-email";
}
if (!account.isValidToken(token)) {
model.addAttribute("error", "wrong.token");
return "account/checked-email";
}
accountService.completeSignUp(account);
model.addAttribute("nickname", account.getNickname());
model.addAttribute("numberOfUser", accountRepository.count());
return "account/checked-email";
}
이전
resendEmail()
메소드는SecurityContext
내부의Account
객체의 내용을 바꾸었는데,checkEmailToken()
메소드는 영속성 컨텍스트에 있는 JPA 엔티티의 내용을 바꾸었다고 생각하여, 로직이 맞지 않게 된다.
@CurrentUser
와 같은 애노테이션을 사용하는 것은 좋으나, JPA 영속성 엔티티인것처럼 헷갈리게 사용하지 말자.
엔티티와 동일한 이름의 객체를 만들지 말고, Account
말고 User
라던지, 다른 이름을 생성하거나, 아니면 Email
이나 Nickname
만 넣어놓고 Account
객체가 필요할 때는 accountRepository.find...()
메소드로 불러서 쓰던지 하자.
감사합니다! 큰 도움 되었습니다 :)