Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[YS-330] refactor: 이메일 전송 기능 AWS SES로 마이그레이션 #121

Merged
merged 9 commits into from
Feb 25, 2025
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
implementation("software.amazon.awssdk:s3:2.20.59")
implementation("software.amazon.awssdk:ses:2.20.100")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.3")
implementation("org.springframework.boot:spring-boot-starter-aop")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.dobby.backend.infrastructure.config

import com.dobby.backend.infrastructure.config.properties.SESProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.ses.SesClient

@Configuration
class SESClientConfig(
private val sesProperties: SESProperties
) {
@Bean
fun sesClient(): SesClient {
return SesClient.builder()
.region(Region.of(sesProperties.region.static))
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(
sesProperties.credentials.accessKey,
sesProperties.credentials.secretKey
)
)
)
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dobby.backend.infrastructure.config.properties

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "aws.ses")
data class SESProperties (
val email: Email,
val region: Region,
val credentials: Credentials
) {
data class Email(
val sender: String
)

data class Region(
val static: String
)

data class Credentials(
val accessKey: String,
val secretKey: String
)
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,45 @@
package com.dobby.backend.infrastructure.gateway.email

import com.dobby.backend.domain.gateway.email.EmailGateway
import com.dobby.backend.infrastructure.config.properties.EmailProperties
import com.dobby.backend.infrastructure.config.properties.SESProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Component
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.ses.SesClient
import software.amazon.awssdk.services.ses.model.*

@Component
class EmailGatewayImpl(
private val emailProperties: EmailProperties,
private val mailSender: JavaMailSender
private val sesClient: SesClient,
private val sesProperties: SESProperties
) : EmailGateway {

override suspend fun sendEmail(to: String, subject: String, content: String, isHtml: Boolean) {
withContext(Dispatchers.IO) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 SesClient는 동기 API를 제공하기 때문에 withContext(Dispatchers.IO)를 사용한 것이 적절해 보입니다.

그런데 제가 찾아보니 AWS에서 비동기 클라이언트인 SesAsyncClient를 제공해서, 이를 활용하면 코루틴 없이도 자연스럽게 비동기 처리가 가능할 거라 기대해요! 이렇게 되면 서비스 전체적으로 코루틴을 줄이고 AWS SDK의 비동기 기능을 최대한 활용할 수 있어 깔끔해진다고 생각하는데 어떻게 생각하시나요? 🤔

Copy link
Member Author

@chock-cho chock-cho Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 제가 AWS에서 SesAsyncClient를 제공해준다는 점을 몰랐었습니다!
관련하여 반영하기 위해 더 찾아보니, SesAsyncClientwhenComplete을 사용하면, 코루틴의 launch{} 함수와 같은 효과(비동기 스레드 풀로 관리) AWS SDK의 내부 비동기 실행 모델을 최대한 활용할 수 있다고 하네요!

오히려 해당 경우에서 코루틴의 launch{} 를 사용하면, SES SDK 내부 구조와 호환성이 맞지 않아 예외가 잡히지 않는다고 합니다 👀!
(제가 코루틴의 launch{}whenComplete 방식으로 개선해보니 두 경우 모두 비슷하게 약 800-900ms 정도의 응답시간이 소요되더라구요☺️)

지수님의 꼼꼼한 리뷰 덕에 고칠 수 있었어요! 감사합니다! ✨

try {
val message = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, false, "UTF-8")
val body = if(isHtml) {
Body.builder().html(Content.builder().data(content).build()).build()
} else {
Body.builder().text(Content.builder().data(content).build()).build()
}

helper.setTo(to)
helper.setSubject(subject)
helper.setText(content, isHtml)
val request = SendEmailRequest.builder()
.source(sesProperties.email.sender)
.destination(Destination.builder().toAddresses(to).build())
.message(
Message.builder()
.subject(Content.builder().data(subject).build())
.body(body)
.build()
)
.build()

sesClient.sendEmail(request)

mailSender.send(message)
} catch (ex: Exception) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이제 AWS SES라는 서드파티 서비스를 사용하는 거니 예외를 아래와 같은 형식으로 두 번 잡는 건 어떨까요?

} catch (ex: SesException) {
        throw IllegalStateException("AWS SES 오류 발생: ${ex.awsErrorDetails()?.errorMessage()}")
} catch (ex: Exception) {
        throw IllegalStateException("이메일 발송 실패: ${ex.message}")
}

throw IllegalStateException("이메일 발송 실패: ${ex.message}")
}
}
}
}

10 changes: 10 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,15 @@ cloud:
access-key: test
secret-key: test

aws:
ses:
email:
sender: test
region:
static: test
credentials:
access-key: test
secret-key: test

discord:
webhook-url: test
Loading