Skip to content

Commit

Permalink
멤버 초대 이메일 발송 서비스 설계 및 고려 (#146)
Browse files Browse the repository at this point in the history
* feat: 멤버초대 컨트롤러, 서비스 틀 구축

* feat: UUID 초대토큰 생성

* feat: ses 서비스 도입

* feat: 이메일 초대 토큰 키-값 레디스에 저장

* feat: 이메일 초대 토큰 키-값 레디스에 저장

* feat: 비동기 적용 및 외부서비스 장애 발생 시 처리 로직 구현

* feat: 타임아웃 설정

* refactor: 이메일 스레드 개수

* refactor: 딜레이 1초는 너무 길기때문에 500ms로 줄임
  • Loading branch information
minsang-alt authored Dec 24, 2024
1 parent 1087f94 commit 7fab1bf
Show file tree
Hide file tree
Showing 16 changed files with 476 additions and 6 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ dependencies {

// mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.amazonaws:aws-java-sdk-ses:1.12.408'


// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
Expand Down
67 changes: 67 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/AmazonSESService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package dynamicquad.agilehub.email;

import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
import com.amazonaws.services.simpleemail.model.Body;
import com.amazonaws.services.simpleemail.model.Content;
import com.amazonaws.services.simpleemail.model.Destination;
import com.amazonaws.services.simpleemail.model.Message;
import com.amazonaws.services.simpleemail.model.SendEmailRequest;
import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.issue.aspect.Retry;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IContext;

@Service
@RequiredArgsConstructor
public class AmazonSESService implements SMTPService {

private final AmazonSimpleEmailService amazonSimpleEmailService;
private final TemplateEngine htmlTemplateEngine;

@Value("${aws.ses.from}")
private String from;

@Override
@Async("emailExecutor")
@Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500)
public CompletableFuture<Void> sendEmail(String subject, Map<String, Object> variables, String... to) {
// Amazon SES를 이용한 이메일 발송
return CompletableFuture.runAsync(() -> {
try {
String content = htmlTemplateEngine.process("invite", createContext(variables));
SendEmailRequest request = createSendEmailRequest(subject, content, to);

amazonSimpleEmailService.sendEmail(request);
} catch (Exception e) {
throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT);
}
});


}

private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) {
return new SendEmailRequest()
.withDestination(new Destination().withToAddresses(to))
.withMessage(new Message()
.withBody(new Body()
.withHtml(new Content().withCharset("UTF-8").withData(content)))
.withSubject(new Content().withCharset("UTF-8").withData(subject)))
.withSource(from);
}

private IContext createContext(Map<String, Object> variables) {
Context context = new Context();
context.setVariables(variables);

return context;
}
}
28 changes: 28 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/InvitationController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dynamicquad.agilehub.email;

import dynamicquad.agilehub.global.auth.model.Auth;
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/invitations")
public class InvitationController {

private final InvitationService invitationService;

@PostMapping
public ResponseEntity<Void> inviteMember(@Auth AuthMember authMember,
@RequestBody ProjectInviteRequestDto.SendInviteMail sendInviteMail) {
invitationService.sendInvitation(authMember, sendInviteMail);
return ResponseEntity.ok().build();
}


}
12 changes: 12 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/InvitationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dynamicquad.agilehub.email;

import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto;

public interface InvitationService {
void sendInvitation(AuthMember authMember, ProjectInviteRequestDto.SendInviteMail sendInviteMail);

//초대토큰 유효한지 확인
void validateInvitation(String inviteToken);
}

128 changes: 128 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package dynamicquad.agilehub.email;

import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.global.util.RandomStringUtil;
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto.SendInviteMail;
import dynamicquad.agilehub.project.service.MemberProjectService;
import dynamicquad.agilehub.project.service.ProjectQueryService;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class InvitationServiceImpl implements InvitationService {

private final MemberProjectService memberProjectService;
private final RedisTemplate<String, String> redisTemplate;
private final SMTPService smtpService;
private final ProjectQueryService projectQueryService;

// 초대 코드 저장 key prefix
private static final String KEY_PREFIX = "i:";

// 초대 코드 상태 저장 key prefix
private static final String STATUS_PREFIX = "status:";

// 초대 코드 만료 시간
private static final int EXPIRATION_MINUTES = 10;

@Override
public void sendInvitation(AuthMember authMember, SendInviteMail sendInviteMail) {
validateMember(authMember, sendInviteMail.getProjectId());
// 진행 중인 초대가 있는 지 확인
String email = validateMail(sendInviteMail);

String token = generateInviteToken();
storeInviteToken(token, sendInviteMail);
storeInvitationStatus(email, InvitationStatus.PENDING);

sendEmail(sendInviteMail, token);
}

private String validateMail(SendInviteMail sendInviteMail) {
String email = sendInviteMail.getEmail();
if (hasActiveInvitation(email)) {
throw new GeneralException(ErrorStatus.ALREADY_INVITATION);
}
else if (isEmailServiceDown(email)) {
// 이메일 서비스가 고장나 있을 때
throw new GeneralException(ErrorStatus.EMAIL_SEND_FAIL);
}
return email;
}

private boolean isEmailServiceDown(String email) {
String statusKey = STATUS_PREFIX + email;
String status = (String) redisTemplate.opsForValue().get(statusKey);
return status != null && (InvitationStatus.isFailed(status));
}

private void storeInvitationStatus(String email, InvitationStatus invitationStatus) {
String statusKey = STATUS_PREFIX + email;
// 10분 동안 이메일 상태 유지
redisTemplate.opsForValue().set(statusKey, invitationStatus.name(), EXPIRATION_MINUTES, TimeUnit.MINUTES);
}

private boolean hasActiveInvitation(String email) {
String statusKey = STATUS_PREFIX + email;
String status = redisTemplate.opsForValue().get(statusKey);
return status != null && (InvitationStatus.isPendingOrSending(status));
}

private void storeInviteToken(String token, SendInviteMail sendInviteMail) {
String tokenBase64 = RandomStringUtil.uuidToBase64(token);
String key = KEY_PREFIX + tokenBase64;

Map<String, Object> fields = new HashMap<>();
fields.put("p", String.valueOf(sendInviteMail.getProjectId())); // projectId -> p
fields.put("u", "0"); // used 상태

redisTemplate.opsForHash().putAll(key, fields);
redisTemplate.expire(key, EXPIRATION_MINUTES, TimeUnit.MINUTES);
}


private void sendEmail(SendInviteMail sendInviteMail, String token) {
final String projectName = projectQueryService.findProjectById(sendInviteMail.getProjectId()).getName();

Map<String, Object> variables = new HashMap<>();
variables.put("inviteCode", token);
variables.put("projectName", projectName);

smtpService.sendEmail("AgileHub 초대 메일", variables, sendInviteMail.getEmail())
.thenRun(() -> {
storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.SENT);
log.info("이메일 전송 완료");
})
.exceptionally(e -> {
log.error("이메일 전송 실패", e);
storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED);
return null;
});
}

private void validateMember(AuthMember authMember, long projectId) {
memberProjectService.validateMemberInProject(authMember.getId(), projectId);
memberProjectService.validateMemberRole(authMember, projectId);
}

private String generateInviteToken() {
return RandomStringUtil.generateUUID();
}


@Override
public void validateInvitation(String inviteToken) {

}
}
26 changes: 26 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/InvitationStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dynamicquad.agilehub.email;

public enum InvitationStatus {
PENDING, // 초기 상태
SENDING, // 발송 중
SENT, // 발송 완료
RETRY, // 재시도 대기
FAILED // 최종 실패
;


// string을 전달하면 enum으로 변환
public static InvitationStatus fromString(String status) {
return InvitationStatus.valueOf(status.toUpperCase());
}

public static boolean isPendingOrSending(String status) {
InvitationStatus invitationStatus = fromString(status);
return invitationStatus == PENDING || invitationStatus == SENDING;
}

public static boolean isFailed(String status) {
InvitationStatus invitationStatus = fromString(status);
return invitationStatus == FAILED;
}
}
8 changes: 8 additions & 0 deletions src/main/java/dynamicquad/agilehub/email/SMTPService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dynamicquad.agilehub.email;

import java.util.Map;
import java.util.concurrent.CompletableFuture;

public interface SMTPService {
CompletableFuture<Void> sendEmail(String subject, Map<String, Object> variables, String... to);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dynamicquad.agilehub.global.config;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
Expand All @@ -16,14 +17,17 @@ public class AsyncConfig {
@Bean(name = "emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(50);
executor.setCorePoolSize(4); // IO 바운드 작업: 코어수 * 2
executor.setMaxPoolSize(44); // CorePoolSize * (1 + 대기시간/서비스시간)
executor.setQueueCapacity(80); // (MaxPoolSize - CorePoolSize) * 2
executor.setThreadNamePrefix("email-");
executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE);
executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS);
// 거부 정책 설정
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

executor.initialize();

return executor;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dynamicquad.agilehub.global.config;

import com.amazonaws.ClientConfiguration;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DefaultSESConfig {
@Value("${aws.ses.accessKey}")
private String accessKey;

@Value("${aws.ses.secretKey}")
private String secretKey;

@Bean
public AmazonSimpleEmailService amazonSimpleEmailService() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonSimpleEmailServiceClientBuilder.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withClientConfiguration(new ClientConfiguration()
.withConnectionTimeout(3000)
.withSocketTimeout(5000)
.withRequestTimeout(10000))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dynamicquad.agilehub.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;

@Configuration
public class TemplateConfig {
@Bean
public TemplateEngine htmlTemplateEngine(SpringResourceTemplateResolver springResourceTemplateResolver) {
TemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(springResourceTemplateResolver);

return templateEngine;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ public enum ErrorStatus implements BaseStatus {
// Email Error
EMAIL_NOT_SENT(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_5001", "이메일이 정상적으로 송신되지 않았습니다."),
INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."),
ALREADY_INVITATION(HttpStatus.BAD_REQUEST, "EMAIL_4002", "이미 진행 중인 초대가 있습니다"),
EMAIL_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_4003", "이메일 서비스에 문제가 발생했습니다. 나중에 다시 시도해주세요."),

// Optimistic Lock Exception
OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."),
OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER(HttpStatus.LOCKED, "LOCK_4001", "이슈 번호 생성 실패. 다시 시도해주세요.");


private final HttpStatus httpStatus;
private final String code;
private final String message;
Expand Down
Loading

0 comments on commit 7fab1bf

Please sign in to comment.