글을 쓰게 된 이유
동기, 비동기를 이론으로 공부하고 실제로 사용해 보아야겠다는 생각이 들어서 Spring에서는 비동기 처리를 어떻게 하는지 궁금하여 학습하게 됐습니다. 비동기 처리의 대표적인 예시 중 하나인 이메일 인증을 Spring을 사용하여 처리해 보도록 하겠습니다.
dependency
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mail' //메일 관련 의존성
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
이메일 인증 방식
제가 사용한 이메일 인증 방식은 이메일에 오는 링크를 누르면 인증이 완료되는 방식입니다.
그래서 사용자가 이메일 인증 신청 -> 이메일 도착 -> 이메일 링크 클릭 -> 이메일 인증 완료 로직을 가지게 됩니다.
yml 설정
spring:
mail:
host: smtp.gmail.com
port: 587
username: //이메일
password: //app 비밀번호
properties:
mail:
smtp:
auth: true
starttls:
enable: true
저는 구글 이메일을 사용하였고, 구글 이메일 인증을 받는 방법은 사이트를 참고하시면 됩니다.
중요한 부분은 password에 구글 이메일 비밀번호가 아닌 “앱 비밀번호”설정 시 발급받은 app 비밀번호입니다.
Entity
@Entity
@Getter
@NoArgsConstructor
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private boolean emailAuth; //이메일 인증 여부
public Account(final String email, final boolean emailAuth) {
this.email = email;
this.emailAuth = emailAuth;
}
/**
* 이메일 인증 완료 시 해당 메서드 호출
*/
public void accessEmailAuth() {
emailAuth = true;
}
}
@Entity
@Getter
@NoArgsConstructor
public class EmailAuth {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
//인증을 여러 개 받을 경우 어떤 인증 요청인지 구분하기 위한 column
private String authToken;
@Column(columnDefinition = "TINYINT(1)")
private boolean isExpired;
private LocalDateTime expiredDate;
@Builder
public EmailAuth(final String email, final String authToken, final boolean isExpired) {
this.email = email;
this.authToken = authToken;
this.isExpired = isExpired;
this.expiredDate = LocalDateTime.now().plusMinutes(5L);
}
public void expire() {
isExpired = true;
}
}
EmailAuth는 email 인증 요청 시 인증에 대한 정보입니다. authToken은 인증을 여러 개 받을 경우 어떤 인증 요청인지 구분하기 위한 column으로 UUID를 사용합니다.
isExpired는 해당 이메일 인증이 인증 완료되면 true로 변경하여 파기합니다.
인증 메일 요청 biz logic
사용자 인증 요청
//controller
@PostMapping("/register")
public Account register(@RequestBody AccountRegisterRequest request) {
return accountService.registerAccount(request);
}
사용자는 email을 통해 request를 합니다.
@Transactional
public Account registerAccount(AccountRegisterRequest requestDto) {
final String email = requestDto.getEmail();
//이메일 인증 생성
final EmailAuth emailAuth = emailAuthRepository.save(
EmailAuth.builder()
.email(email)
.authToken(UUID.randomUUID().toString()) //UUID를 통해 어떤 인증인지 구분
.isExpired(false)
.build(
));
final Account account = accountRepository.save(new Account(email, false));
//메일 전송
emailAuthService.send(email, emailAuth.getAuthToken());
return account;
}
Service layer에서는 사용자의 이메일을 통해 이메일 인증을 생성하고 메일을 전송합니다.
인증 메일 전송
@Service
@RequiredArgsConstructor
public class EmailAuthService {
private final JavaMailSender javaMailSender;
public void send(String email, String authToken) {
MimeMessage mail = javaMailSender.createMimeMessage();
try {
MimeMessageHelper mailHelper = new MimeMessageHelper(mail, true, "UTF-8");
mailHelper.setFrom("sender");
mailHelper.setTo(email);
mailHelper.setSubject("회원가입 이메일 인증");
mailHelper.setText("<http://localhost:8080/api/confirm?email=>" + email + "&authToken=" + authToken, true);
} catch (Exception e) {
e.printStackTrace();
}
javaMailSender.send(mail); //실제 메일 전송
}
}
Java가 메일 인증에 대해 제공해주는 JavaMailSender 인터페이스를 사용합니다.
MiMeMessage, MimeMessageHelper를 통해 사용자에게 보낼 이메일을 작성하고,
setText()를 통해 인증 완료 요청을 보낼 URL을 함께 전송합니다.
인증 메일 승인 biz logic
앞서 인증 메일 전송을 통해 사용자에게 메일이 갑니다.
사진과 같이 구글 메일에 링크가 도착하게 됩니다.
승인 요청
@GetMapping("/confirm")
public EmailAuthResponse confirm(@ModelAttribute EmailAuthRequest request) {
return emailService.confirmEmail(request);
}
도착한 이메일에 있는 URL을 사용자가 클릭하게 되면 해당 컨트롤러에 요청을 보내게 됩니다.
@Transactional
public EmailAuthResponse confirmEmail(EmailAuthRequest requestDto) {
final String email = requestDto.getEmail();
final String authToken = requestDto.getAuthToken();
final EmailAuth emailAuth = emailAuthRepository.findWaitingConfirmedEmail(email,
authToken,
LocalDateTime.now())
.get();
final Account account = accountRepository.findByEmail(email).get();
account.accessEmailAuth();
emailAuth.expire();
return new EmailAuthResponse(email, authToken);
}
그리고 요청을 대기하고 있는 EmailAuth를 찾아서 해당 EmailAuth를 파기하고, account 메일 인증을 true로 변경해 줍니다.
문제점
하지만 여기서 문제점이 있습니다.
구글 이메일 인증을 보내는 것은 구글 이메일 api를 통해서 요청하는 것으로 외부 api를 사용하기 때문에 시간이 오래 걸립니다.
postman을 통해 요청을 보낼 시 약 4초 정도 가량 걸리게 됩니다.
동기 요청을 보내게 되면 구글 메일을 보내고 응답 값이 올 때까지 기다려야 합니다. 그렇다면 어떤 문제점이 발생할 수 있을까요?
- 사용자는 4초 동안 아무 반응이 없는 브라우저를 보며 불편함을 느낄 수 있음
- 만약 구글 메일 서버에 이상이 있을 경우 응답 값이 오지 않기 때문에 계속해서 기다려야 하는 상황 발생
- 트랜잭션이 길어져서 DB 커넥션을 계속 물고 있어서 성능에 좋지 않음
그래서 이메일 인증 요청을 비동기로 보내고 나서, emailAuth라는 값을 받고 사용자가 링크를 클릭할 경우 다시 emailAuth를 true로 변경해 줍니다.
@Async 적용
Spring에서 제공하는 @EnableAsync, @Async를 통해서 비동기 요청을 할 수 있습니다.
@Service
@RequiredArgsConstructor
@EnableAsync
public class EmailAuthService {
private final JavaMailSender javaMailSender;
@Async
public void send(String email, String authToken) {
MimeMessage mail = javaMailSender.createMimeMessage();
try {
MimeMessageHelper mailHelper = new MimeMessageHelper(mail, true, "UTF-8");
mailHelper.setFrom("sender");
mailHelper.setTo(email);
mailHelper.setSubject("회원가입 이메일 인증");
mailHelper.setText("<http://localhost:8080/api/confirm?email=>" + email + "&authToken=" + authToken, true);
} catch (Exception e) {
e.printStackTrace();
}
javaMailSender.send(mail);
}
}
앞서 send와 같은 코드입니다. 다만 @Async, @EnableAsync를 통해 비동기 요청을 할 수 있게 해 줍니다.
시간이 4초에서 0.2초로 감소된 것을 알 수 있습니다.
@Async?
@Async를 사용한 방법에도 문제점이 있습니다. @Async의 기본 설정은 SimpleAsyncTaskExecutor로 thread를 재사용하지 않습니다.
즉, @Async 붙은 메서드를 호출하게 되면 매번 새로운 스레드를 생성하여 할당하게 됩니다.
요청마다 새로운 스레드를 생성하여 할당하기보다는 톰캣처럼 스레드 풀을 만들어서 스레드를 할당해 주는 게 나아 보입니다.
Configuration을 통해 TaskExecutor 빈을 주입하여 스레드 풀 사이즈를 늘려줄 수 있습니다.
또는 yml 파일로도 수정할 수 있습니다.
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=5
spring.task.execution.pool.queue-capacity=100
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); //유휴 상태일 경우에도 풀에 유지할 스레드 수
executor.setMaxPoolSize(10); //스레드풀에서 최대로 사용 가능한 스레드 개수 지정
executor.setQueueCapacity(100); //스레드 풀에 의해 실행되기 전, 작업을 보관하는데 사용되는 대기열의 용량
executor.setThreadNamePrefix("java-saeng"); //스레드 풀에서 생성된 스레드의 이름 prefix
executor.initialize(); //스레드 풀을 초기화하기 위해 호출
return executor;
}
}
configuration을 통해서 Spring이 비동기 처리를 할 때 이제 해당 빈의 설정 정보에 따라 스레드 풀을 형성하게 됩니다.
스레드 풀에 최대로 저장 가능한 스레드 개수는 10개, 유휴 상태일 경우에도 풀에 유지할 스레드 개수는 10개입니다.
테스트를 통해서 정말 10개 스레드까지만 사용되는지 확인해 보겠습니다.
Test
@Async
public void asyncTest() {
log.info("async Test");
}
@Test
void testAsync() {
for (int i = 0; i < 20; i++) {
emailAuthService.asyncTest();
}
}
20번을 수행해도, 100번을 수행해도 10개의 스레드만 사용되는 것을 알 수 있습니다.
만약 CorePoolSize를 4개로 설정하면 어떻게 될까요?
4개의 스레드만 사용되는 것을 알 수 있습니다.
@Async는 왜 사용하는 건가요?
Async 어노테이션이 붙은 메서드는 TaskExecutor에 의해 비동기적으로 실행되야 한다 라는 뜻을 가지고 있습니다.
그래서 @Async가 붙은 메서드는 void 또는 java의 Future를 반환하는 메서드에서 사용할 수 있습니다.
즉, 메서드에 스레드가 할당되어 백그라운드에서 실행되고, 실행이 완료되고 반환 값이 존재한다면 Future 객체에서 get()을 통해 값을 얻을 수 있습니다.
따로 스레드에 할당받아서 백그라운드에서 실행되기 때문에 실행되는 만큼 애플리케이션 퍼포먼스를 높일 수 있습니다.
사용 시 주의해야 할 점
@Async 어노테이션의 공식문서를 보면 Advisor 가 있습니다. 그러면 해당 어노테이션은 AOP로 동작하는구나를 알 수 있습니다.
그래서 @Async는 @Transactional과 같이 AOP로 동작하기 때문에 AOP로 동작할 경우의 주의사항과 같습니다.
예를 들어 spring에서는 AOP를 적용할 때, CGLIB Proxy를 사용하게 되는데, 이는 상속을 통해서 구현됩니다.
- private method는 오버라이딩 될 수 없기 때문에 AOP가 동작하지 않고, 내부 호출을 할 때도 AOP가 동작하지 않게 됩니다.
REFERENCES
https://spring.io/guides/gs/async-method/
https://dzone.com/articles/effective-advice-on-spring-async-part-1
'Spring' 카테고리의 다른 글
Method Arguments 들의 동작 방법 (0) | 2023.04.25 |
---|---|
Spring ExceptionHandler, ResponseStatus 동작 과정 (2) | 2023.04.22 |
HikariCP란 무엇일까? (0) | 2022.10.27 |
@Scheduled를 사용하여 API를 주기적으로 호출 (0) | 2022.06.07 |
MapStruct (0) | 2022.05.30 |