티스토리 뷰
스프링 부트 웹 애플리케이션 제작(18): OpenEntityManagerInViewFilter
Jaime.Lee 2022. 2. 2. 10:30
본 포스팅은 백기선님의 스프링과 JPA 기반 웹 애플리케이션 개발 강의를 참고하여 작성하였습니다.
소스 코드는 여기 있습니다. (commit hash: 878b1db)> git clone https://github.com/lcalmsky/spring-boot-app.git > git checkout 878b1db
ℹ️ squash merge를 사용해 기존 branch를 삭제하기로 하여 앞으로는 commit hash로 포스팅 시점의 소스 코드를 공유할 예정입니다.
Overview
지난 포스팅에서 발생한 버그(가입 후 회원인증을 했음에도 가입 날짜가 업데이트 되지 않던)의 원인을 찾아 수정해봅니다.
원인
회원 인증(로그인)시 이메일 인증 날짜를 업데이트하고, 프로필에서 가입날짜를 조회할 때 DB의 날짜를 읽어오는데, DB에 인증날짜가 없기 때문에 발생하는 상황입니다.
그렇다면 로그인을 진행했는데 왜 인증 날짜가 업데이트 되지 않았을까요?
기본적으로 스프링에는 OpenEntityManagerInViewFilter
가 등록되어있고 활성화되어있습니다.
OpenEntityManagerInViewFilter
는 JPA의 EntityManager
를 요청을 처리하는 전체 프로세스에 바인딩해주는 역할을 하는데요, 뷰가 렌더링 될때까지 영속성 컨텍스트를 유지하기 때문에 필요한 데이터를 렌더링하는 시점에 추가로 읽어올 수 있게(지연 로딩, Lazy Laoding) 해줍니다.
따라서 Entity
객체가 변경된 사항을 저장하기 위해선 트랜잭션이 종료되어야하는데 현재 소스 코드에서는 그렇게 동작하게되어있지 않습니다.
그 이유는 Controller
레이어에서 Repository
를 사용하는 Service
를 호출했는데, 해당 Service
역시 트랜잭션을 처리하도록 되어있지 않았기 때문입니다.
해결 방안
원인을 알아냈으니 해결하는 방법을 알아봅시다.
먼저 Controller
에 @Transactional
애너테이션을 추가하는 방법이 있습니다.
이메일 인증시에만 트랜잭션을 조작할 수 있게 해주면 간단히 해결되지만 설계 측면에서 좋은 방법이라고 할 순 없습니다.
Controller
가 이미 Service
의 기능을 이용하고있는데 Service
의 기능이 수정되거나, 다른 메서드에서 동일한 Service
를 호출하면서 트랜잭션 처리를 안 하게 되면 예외 상황을 항상 컨트롤해야하는 부담이 생깁니다.
반면 트랜잭션 작업이 Service
레이어에서 이루어진다면 Controller
에서 트랜잭션을 얻어서 작업을 진행해야할 때만 호출해주면 되므로 훨씬 관리가 쉽고 Service
레이어의 책임 중 하나가 DB 연동하는 과정이라고 생각하고 유사한 기능을 모두 Service
레이어에 구현하게 된다면 코드 응집도 또한 올라가게 됩니다.
Implementation
먼저 AccountController
클래스에서 트랜잭션 없이 진행했던 부분을 찾아보겠습니다.
src/main/java/io/lcalmsky/app/account/endpoint/controller/AccountController.java
@GetMapping("/check-email-token")
public String verifyEmail(String token, String email, Model model) {
Account account = accountService.findAccountByEmail(email);
if (account == null) {
model.addAttribute("error", "wrong.email");
return "account/email-verification";
}
if (!token.equals(account.getEmailToken())) {
model.addAttribute("error", "wrong.token");
return "account/email-verification";
}
account.verified(); // (1)
accountService.login(account); // (2)
model.addAttribute("numberOfUsers", accountRepository.count());
model.addAttribute("nickname", account.getNickname());
return "account/email-verification";
}
(1)에서 호출한 메서드를 Account.java 클래스에서 찾아보면,
/src/main/java/io/lcalmsky/app/account/domain/entity/Account.java
public void verified() {
this.isValid = true;
joinedAt = LocalDateTime.now();
}
트랜잭션 없이 진행한 것을 확인할 수 있습니다.
(2)에서 호출한 메서드 역시 AccountService.java에서 찾아보면,
/src/main/java/io/lcalmsky/app/account/application/AccountService.java
public void login(Account account) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(new UserAccount(account),
account.getPassword(), Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token); // AuthenticationManager를 쓰는 방법이 정석적인 방ㅇ법
}
마찬가지로 트랜잭션이 사용되지 않았습니다.
그럼 AccountController 부터 차례대로 수정해보겠습니다.
@GetMapping("/check-email-token")
public String verifyEmail(String token, String email, Model model) {
Account account = accountService.findAccountByEmail(email);
if (account == null) {
model.addAttribute("error", "wrong.email");
return "account/email-verification";
}
if (!token.equals(account.getEmailToken())) {
model.addAttribute("error", "wrong.token");
return "account/email-verification";
}
accountService.verify(account); // (1)
model.addAttribute("numberOfUsers", accountRepository.count());
model.addAttribute("nickname", account.getNickname());
return "account/email-verification";
}
- accountService의 verify라는 메서드를 호출해줍니다.
AccountController.java 전체 보기
package io.lcalmsky.app.account.endpoint.controller;
import io.lcalmsky.app.account.application.AccountService;
import io.lcalmsky.app.account.domain.entity.Account;
import io.lcalmsky.app.account.endpoint.controller.validator.SignUpFormValidator;
import io.lcalmsky.app.account.infra.repository.AccountRepository;
import io.lcalmsky.app.account.support.CurrentUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Controller
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;
private final SignUpFormValidator signUpFormValidator;
private final AccountRepository accountRepository;
@InitBinder("signUpForm")
public void initBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(signUpFormValidator);
}
@GetMapping("/sign-up")
public String signUpForm(Model model) {
model.addAttribute(new SignUpForm());
return "account/sign-up";
}
@PostMapping("/sign-up")
public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
if (errors.hasErrors()) {
return "account/sign-up";
}
Account account = accountService.signUp(signUpForm);
accountService.login(account);
return "redirect:/";
}
@GetMapping("/check-email-token")
public String verifyEmail(String token, String email, Model model) {
Account account = accountService.findAccountByEmail(email);
if (account == null) {
model.addAttribute("error", "wrong.email");
return "account/email-verification";
}
if (!token.equals(account.getEmailToken())) {
model.addAttribute("error", "wrong.token");
return "account/email-verification";
}
accountService.verify(account);
model.addAttribute("numberOfUsers", accountRepository.count());
model.addAttribute("nickname", account.getNickname());
return "account/email-verification";
}
@GetMapping("/check-email")
public String checkMail(@CurrentUser Account account, Model model) {
model.addAttribute("email", account.getEmail());
return "account/check-email";
}
@GetMapping("/resend-email")
public String resendEmail(@CurrentUser Account account, Model model) {
if (!account.enableToSendEmail()) {
model.addAttribute("error", "인증 이메일은 5분에 한 번만 전송할 수 있습니다.");
model.addAttribute("email", account.getEmail());
return "account/check-email";
}
accountService.sendVerificationEmail(account);
return "redirect:/";
}
@GetMapping("/profile/{nickname}")
public String viewProfile(@PathVariable String nickname, Model model, @CurrentUser Account account) {
Account byNickname = accountRepository.findByNickname(nickname);
if (byNickname == null) {
throw new IllegalArgumentException(nickname + "에 해당하는 사용자가 없습니다.");
}
model.addAttribute(byNickname);
model.addAttribute("isOwner", byNickname.equals(account));
return "account/profile";
}
}
AccountService도 수정해줍니다.
package io.lcalmsky.app.account.application;
import io.lcalmsky.app.account.domain.UserAccount;
import io.lcalmsky.app.account.domain.entity.Account;
import io.lcalmsky.app.account.endpoint.controller.SignUpForm;
import io.lcalmsky.app.account.infra.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional // (1)
public class AccountService implements UserDetailsService {
private final AccountRepository accountRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
public Account signUp(SignUpForm signUpForm) { // (1)
Account newAccount = saveNewAccount(signUpForm);
newAccount.generateToken();
sendVerificationEmail(newAccount);
return newAccount;
}
// 생략
@Override
@Transactional(readOnly = true) // (2)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = Optional.ofNullable(accountRepository.findByEmail(username))
.orElse(accountRepository.findByNickname(username));
if (account == null) {
throw new UsernameNotFoundException(username);
}
return new UserAccount(account);
}
public void verify(Account account) { // (3)
account.verified();
login(account);
}
}
signUp
메서드에 있던@Transactional
애너테이션을 클래스 레벨로 변경합니다. 이렇게 변경하면Service
내의 모든 메서드가 호출될 때 트랜잭션을 가지게 됩니다.- 로그인 시 조회용도로만 사용될 것이기 때문에
readyOnly
옵션을 추가합니다. Controller
에서 호출할 메서드를 추가로 구현합니다.
AccountService.java 전체 보기
package io.lcalmsky.app.account.application;
import io.lcalmsky.app.account.domain.UserAccount;
import io.lcalmsky.app.account.domain.entity.Account;
import io.lcalmsky.app.account.endpoint.controller.SignUpForm;
import io.lcalmsky.app.account.infra.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional
public class AccountService implements UserDetailsService {
private final AccountRepository accountRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
public Account signUp(SignUpForm signUpForm) {
Account newAccount = saveNewAccount(signUpForm);
newAccount.generateToken();
sendVerificationEmail(newAccount);
return newAccount;
}
private Account saveNewAccount(SignUpForm signUpForm) {
Account account = Account.builder()
.email(signUpForm.getEmail())
.nickname(signUpForm.getNickname())
.password(passwordEncoder.encode(signUpForm.getPassword()))
.notificationSetting(Account.NotificationSetting.builder()
.studyCreatedByWeb(true)
.studyUpdatedByWeb(true)
.studyRegistrationResultByWeb(true)
.build())
.build();
return accountRepository.save(account);
}
public void sendVerificationEmail(Account newAccount) {
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(newAccount.getEmail());
mailMessage.setSubject("Webluxible 회원 가입 인증");
mailMessage.setText(String.format("/check-email-token?token=%s&email=%s", newAccount.getEmailToken(),
newAccount.getEmail()));
mailSender.send(mailMessage);
}
public Account findAccountByEmail(String email) {
return accountRepository.findByEmail(email);
}
public void login(Account account) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(new UserAccount(account),
account.getPassword(), Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token); // AuthenticationManager를 쓰는 방법이 정석적인 방ㅇ법
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = Optional.ofNullable(accountRepository.findByEmail(username))
.orElse(accountRepository.findByNickname(username));
if (account == null) {
throw new UsernameNotFoundException(username);
}
return new UserAccount(account);
}
public void verify(Account account) {
account.verified();
login(account);
}
}
Test
지난 포스팅에서 테스트했던 순서와 동일하게 테스트해보겠습니다.
먼저 애플리케이션을 실행한 뒤 가입을 진행합니다.
가입 후 바로 프로필 하면으로 이동합니다.
인증하지 않았기 때문에 아직 가입 날짜가 노출되지 않습니다.
로그에서 토큰을 찾아 이메일 인증을 수행합니다.
다시 프로필을 눌러서 확인해보면 가입 시기가 노출되는 것을 확인할 수 있습니다.
여기부터는 위 본문과 상관 없는 내용입니다.
참고로 마지막 스크린샷에 url 부분이 빈 값이지만 노출되는 것을 확인할 수 있는데 이 부분도 버그입니다.
아래 클래스를 수정해주시면 나타나지 않는 것을 확인할 수 있습니다.
/src/main/java/io/lcalmsky/app/account/domain/support/ListStringConverter.java
package io.lcalmsky.app.account.domain.support;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Converter
public class ListStringConverter implements AttributeConverter<List<String>, String> {
@Override
public String convertToDatabaseColumn(List<String> attribute) {
return Optional.ofNullable(attribute)
.filter(list -> !list.isEmpty()) // 비어있을 때 아무것도 하지 않도록 수정
.map(a -> String.join(",", a))
.orElse(null);
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null) {
return Collections.emptyList();
}
return Stream.of(dbData.split(","))
.collect(Collectors.toList());
}
}
'SpringBoot > Web Application 만들기' 카테고리의 다른 글
스프링 부트 웹 애플리케이션 제작(20): 프로필 수정 (0) | 2022.02.22 |
---|---|
스프링 부트 웹 애플리케이션 제작(19): 프로필 수정 뷰 구현 (0) | 2022.02.21 |
스프링 부트 웹 애플리케이션 제작(17): 프로필 뷰 구현 (0) | 2022.01.26 |
스프링 부트 웹 애플리케이션 제작(16): 로그인 유지(RememberMe) 기능 구현 (0) | 2022.01.20 |
스프링 부트 웹 애플리케이션 제작(15): 로그인, 로그아웃 테스트 작성 (0) | 2022.01.17 |
- Total
- Today
- Yesterday
- leetcode
- 스프링부트
- intellij
- Java
- 스프링 부트 튜토리얼
- 클린 아키텍처
- Linux
- 함께 자라기 후기
- JSON
- Spring Data JPA
- QueryDSL
- 알고리즘
- 스프링 데이터 jpa
- spring boot application
- Jackson
- Spring Boot
- gRPC
- 함께 자라기
- JPA
- 헥사고날 아키텍처
- proto3
- spring boot jwt
- spring boot app
- 스프링 부트 회원 가입
- Spring Boot Tutorial
- 스프링 부트
- 스프링 부트 애플리케이션
- r
- @ManyToOne
- Spring Boot JPA
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |