JPA 사용할 때 트랜잭션 주의사항 정리

Jake Seo·2021년 6월 1일
1

엔티티의 변화가 일어나는 곳은 반드시 트랜잭션 애노테이션이 필요하다.

이건 너무나 당연한 것이다. 엔티티의 데이터가 디비에서 실제로 변화하기 위해서는 @Transactional 애노테이션이 필요하다. @Transactional 애노테이션이 없으면, 자바 메모리상에서만 데이터가 변하고 커밋이 되지 않아서 엔티티의 변경내용이 디비에 반영되지 않는다.

헷갈리지 않게 Service 레이어에서만 @Transactional을 이용하자

JPA 엔티티 데이터의 변화가 필요한 내용은 Service 객체에게 위임해서 해당 객체에서 데이터를 변화시키자.

데이터를 다른 오브젝트로 들고다니면 준영속 상태가 되어버리니 주의하자.

이번 프로젝트에서 JPA 엔티티를 SecurityContext 내부에 있는 .contextHolder()에 들고다녔는데, .contextHolder()에 있는 JPA 엔티티를 아무리 수정해도, 해당 내용은 디비나 영속성 컨텍스트에 반영되지 않는다.

또한 반대의 경우도 마찬가지이다. 영속성 컨텍스트에 있는 JPA 엔티티를 변화시켜도 SecurityContext.ContextHolder()에 있는 JPA 엔티티 객체는 영속상태가 아니기 때문에 변화가 적용되지 않는다.

이 점을 주의해야 한다.

웬만하면 스프링 시큐리티 컨텍스트에 JPA 엔티티를 들고다니지 말자

스프링 시큐리티 컨텍스트에 JPA 엔티티를 두는 것은 결국 버그를 초래하거나 개발상의 어려움을 주기가 너무 쉽다. 우리가 JPA 엔티티를 사용할 때는 당연히 영속 상태라 생각하고 사용하는데, 해당 JPA 엔티티가 준영속상태라는 것을 모르고 코딩하다보면 실수가 나기 정말 쉽다.

그래서 내 생각은 스프링 시큐리티 컨텍스트 principal에는 해당 엔티티를 구분할 수 있는 ID 개념의 문자열만 저장하는 것이 맞는 것 같다. 필요에 의해 어떤 객체도 저장할 수는 있겠지만, 절대 JPA 객체가 영속상태인것처럼 헷갈리게 저장하지 말자.

내가 만들었던 버그 재현

~29까지의 내용을 보면 이메일을 재전송하면 Account 객체에 토큰을 새로 만들고, 다시 새로운 토큰으로 인증을 해야 하는 부분이 있었다.

UserAccount 클래스의 내용

@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 클래스는 UserDetailsCredentialsContainer 인터페이스를 상속하여 구현한 구현체이다.

User 클래스의 생성자를 이용하여, UserDetails에 들어갈 객체를 생성할 수 있다.

UserDetailsService 인터페이스의 loadUserByUsername 구현 내용

@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를 꺼내서 아무리 내용을 바꿔도 디비에 반영되지 않는다.

@CurrentUser 애노테이션 내용

@Retention(RetentionPolicy.RUNTIME) // RUNTIME까지 유지되는 애노테이션
@Target(ElementType.PARAMETER) // 파라미터 타입으로 들어가는 애노테이션
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {

}

@CurrentUser 애노테이션은 SecurityContext 내부 authenticationprincipal 내부의 Account 객체를 가져온다.

@AuthenticationPrincipal 애노테이션이 Authentication.getPrincipal() 메소드를 리졸브해준다.

AccountController의 resendEmail() 메소드

@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 객체를 가져왔을 때와 @CurrentUserAccount 객체를 가져왔을 때 서로 다른 객체를 가져오게 된다.

AccountController의 checkEmailToken() 메소드의 문제

@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...() 메소드로 불러서 쓰던지 하자.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

1개의 댓글

comment-user-thumbnail
2022년 8월 16일

감사합니다! 큰 도움 되었습니다 :)

답글 달기