Pingu
영차영차! Backend

Spring Mail로 구축하는 SMTP 이메일 전송 시스템

2026년 1월 25일
7개 태그
Spring Mail
SMTP
이메일 전송
JavaMailSender
Gmail
템플릿
Kotlin

Spring Mail로 구축하는 SMTP 이메일 전송 시스템

들어가며

"이메일 보내는 거 그냥 API 호출하면 되는 거 아니에요?"

처음에는 그렇게 생각했습니다. 하지만 실제로 구현해보니 생각보다 고려할 요소가 많았습니다. HTML 형식의 이메일, 템플릿 관리, 파라미터 치환, SMTP 설정, 에러 처리... 단순해 보였던 이메일 전송이 생각보다 복잡했습니다.

저는 notification-service에서 Spring Mail과 JavaMailSender를 활용하여 SMTP 프로토콜로 이메일을 전송하는 시스템을 구축했습니다. 특히 템플릿 기반 이메일 시스템을 구현하면서 겪은 고민들과 해결 방법을 공유하려고 합니다.

문제 상황: 단순한 이메일 전송의 한계

초기 시도: 단순 텍스트 이메일

처음에는 단순하게 텍스트 이메일만 보내면 될 거라고 생각했습니다:

// 단순한 텍스트 이메일
fun sendSimpleEmail(to: String, subject: String, body: String) {
    val message = javaMailSender.createMimeMessage()
    val helper = MimeMessageHelper(message)
    helper.setTo(to)
    helper.setSubject(subject)
    helper.setText(body)
    javaMailSender.send(message)
}

하지만 실제 요구사항은 더 복잡했습니다:

  1. HTML 형식 이메일: 텍스트만으로는 부족, HTML로 예쁘게 꾸며야 함
  2. 템플릿 관리: 같은 형식의 이메일을 여러 번 보내야 함
  3. 동적 내용: 사용자 이름, 링크 등 동적으로 변경되는 내용
  4. 다중 수신자: 여러 명에게 동시에 전송
  5. 에러 처리: 전송 실패 시 재시도 및 로깅

해결책: Spring Mail 기반 이메일 시스템

전체 아키텍처

다이어그램 로딩 중...

핵심 구성 요소

  1. EmailNotificationProvider: 이메일 전송 로직 담당
  2. JavaMailSender: Spring Mail의 핵심 인터페이스
  3. EmailTemplateRepository: 데이터베이스에서 템플릿 조회
  4. MimeMessageHelper: HTML 이메일 생성 지원

Spring Mail 설정

Spring Mail

Spring Mail

의존성 추가

build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-mail")
}

Spring Boot Starter Mail을 추가하면 자동으로 JavaMailSender가 설정됩니다.

SMTP 설정

application.properties:

# Gmail SMTP 설정
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your-email@gmail.com
spring.mail.password=your-app-password

# SMTP 프로토콜 설정
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

수동 설정 (MailConfig)

자동 설정이 작동하지 않을 경우를 대비해 수동 설정도 구현했습니다:

@Configuration
class MailConfig {

    @Bean
    fun javaMailSender(
        @Value("\${spring.mail.host:smtp.gmail.com}") host: String,
        @Value("\${spring.mail.port:587}") port: Int,
        @Value("\${spring.mail.username:}") username: String,
        @Value("\${spring.mail.password:}") password: String,
    ): JavaMailSender {
        val mailSender = JavaMailSenderImpl()
        mailSender.host = host
        mailSender.port = port
        mailSender.username = username
        mailSender.password = password
        
        val props = mailSender.javaMailProperties
        props["mail.transport.protocol"] = "smtp"
        props["mail.smtp.auth"] = "true"
        props["mail.smtp.starttls.enable"] = "true"
        props["mail.smtp.starttls.required"] = "true"
        props["mail.smtp.connectiontimeout"] = "5000"
        props["mail.smtp.timeout"] = "5000"
        props["mail.smtp.writetimeout"] = "5000"
        
        return mailSender
    }
}

이메일 전송 구현

EmailNotificationProvider

이메일 전송의 핵심 로직을 담당하는 Provider입니다:

@Component
class EmailNotificationProvider(
    private val javaMailSender: JavaMailSender,
    private val emailTemplateRepository: EmailTemplateRepository,
) : NotificationChannelProvider {

    override fun send(request: NotificationRequest): Boolean {
        return try {
            val recipient = request.recipient as? NotificationRecipient.EmailRecipient
                ?: throw IllegalArgumentException("Invalid recipient type for Email")

            logger.info("이메일 전송 시작: notificationId=${request.notificationId}, email=${recipient.email}")

            // 템플릿 또는 직접 내용 처리
            val (subject, htmlBody, textBody) = when (val content = request.content) {
                is NotificationContent.Direct -> {
                    // 직접 내용 전송
                    Triple(
                        content.title ?: "알림",
                        content.body,
                        content.body,
                    )
                }
                is NotificationContent.Template -> {
                    // 템플릿 기반 전송
                    val template = emailTemplateRepository.findByTemplateId(content.templateId)
                        ?: throw IllegalArgumentException("Email template not found: ${content.templateId}")

                    val subject = replaceParameters(template.subject, content.parameters)
                    val htmlBody = replaceParameters(template.htmlContent, content.parameters)
                    val textBody = template.textContent?.let { replaceParameters(it, content.parameters) }
                        ?: htmlBody.replace(Regex("<[^>]+>"), "") // HTML 태그 제거

                    Triple(subject, htmlBody, textBody)
                }
            }

            sendEmail(
                to = recipient.email,
                toName = recipient.emailName,
                subject = subject,
                htmlBody = htmlBody,
                textBody = textBody,
            )

            logger.info("이메일 전송 성공: notificationId=${request.notificationId}")
            true
        } catch (e: Exception) {
            logger.error("이메일 전송 실패: notificationId=${request.notificationId}", e)
            false
        }
    }

    private fun sendEmail(
        to: String,
        toName: String?,
        subject: String,
        htmlBody: String,
        textBody: String,
    ) {
        val message: MimeMessage = javaMailSender.createMimeMessage()
        val helper = MimeMessageHelper(message, true, "UTF-8")

        // 수신자 설정 (이름 포함)
        val toAddress = if (toName != null) {
            InternetAddress(to, toName, "UTF-8")
        } else {
            InternetAddress(to)
        }
        helper.setTo(toAddress)

        // 제목 설정
        helper.setSubject(subject)

        // 본문 설정 (HTML과 텍스트 모두)
        helper.setText(textBody, htmlBody)

        // 이메일 발송
        javaMailSender.send(message)
    }
}

핵심 포인트

  1. MimeMessageHelper 사용

    • MimeMessageHelper(message, true, "UTF-8")
    • true: 멀티파트 메시지 지원 (HTML + 텍스트)
    • "UTF-8": 한글 지원
  2. HTML과 텍스트 모두 제공

    • HTML: 이메일 클라이언트가 HTML을 지원하는 경우
    • 텍스트: HTML을 지원하지 않는 경우 대체
  3. 수신자 이름 설정

    • InternetAddress(to, toName, "UTF-8")로 이름 포함

템플릿 기반 이메일 시스템

문제: 하드코딩된 이메일 내용

처음에는 이메일 내용을 코드에 직접 작성했습니다:

// 나쁜 예: 하드코딩
fun sendWelcomeEmail(email: String, name: String) {
    val body = """
        안녕하세요 $name님,
        
        환영합니다!
        
        로그인: https://example.com/login
    """.trimIndent()
    
    sendEmail(email, "환영합니다", body)
}

문제점:

  • 내용 변경 시 코드 수정 필요
  • 여러 언어 지원 어려움
  • 디자이너가 직접 수정 불가능

해결책: 데이터베이스 기반 템플릿

이메일 템플릿을 데이터베이스에 저장하고, 파라미터를 동적으로 치환합니다.

EmailTemplate 엔티티

@Entity
@Table(name = "email_templates")
data class EmailTemplateEntity(
    @Id
    @Column(name = "id", length = 26)
    val id: String,

    @Column(name = "template_id", nullable = false, unique = true, length = 100)
    val templateId: String,

    @Column(name = "subject", nullable = false, length = 500)
    val subject: String,

    @Column(name = "html_content", nullable = false, columnDefinition = "TEXT")
    val htmlContent: String,

    @Column(name = "text_content", columnDefinition = "TEXT")
    val textContent: String?,

    @Column(name = "created_at", nullable = false)
    val createdAt: Instant,

    @Column(name = "updated_at", nullable = false)
    val updatedAt: Instant,
)

템플릿 예시

teacher_account_creation 템플릿:

INSERT INTO email_templates (
    id, 
    template_id, 
    subject, 
    html_content, 
    text_content,
    created_at,
    updated_at
) VALUES (
    '01ARZ3NDEKTSV4RRFFQ69G5FAV',
    'teacher_account_creation',
    '{{organizationName}} 선생님 계정이 생성되었습니다',
    '
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <style>
            body { font-family: Arial, sans-serif; }
            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
            .button { background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
        </style>
    </head>
    <body>
        <div class="container">
            <h1>안녕하세요, {{name}}님!</h1>
            <p>{{organizationName}}의 선생님 계정이 생성되었습니다.</p>
            <p>아래 정보로 로그인하실 수 있습니다:</p>
            <ul>
                <li>이메일: {{email}}</li>
                <li>비밀번호: {{password}}</li>
            </ul>
            <p>
                <a href="{{loginUrl}}" class="button">로그인하기</a>
            </p>
        </div>
    </body>
    </html>
    ',
    '
    안녕하세요, {{name}}님!

    {{organizationName}}의 선생님 계정이 생성되었습니다.

    아래 정보로 로그인하실 수 있습니다:
    - 이메일: {{email}}
    - 비밀번호: {{password}}

    로그인: {{loginUrl}}
    ',
    NOW(),
    NOW()
);

파라미터 치환

템플릿의 {{key}} 형식을 실제 값으로 치환합니다:

private fun replaceParameters(template: String, parameters: Map<String, String>): String {
    var result = template
    parameters.forEach { (key, value) ->
        // {{key}} 형식으로 치환
        result = result.replace("{{$key}}", value)
        // ${key} 형식도 지원 (선택)
        result = result.replace("\${$key}", value)
    }
    return result
}

사용 예시:

val template = "안녕하세요, {{name}}님!"
val parameters = mapOf("name" to "홍길동")
val result = replaceParameters(template, parameters)
// 결과: "안녕하세요, 홍길동님!"

실제 사용: 선생님 계정 생성 이메일

lms-service에서 발행:

val request = NotificationRequest(
    notificationId = UlidCreator.getUlid().toString(),
    channel = NotificationChannel.EMAIL,
    recipient = NotificationRecipient.EmailRecipient(
        email = email,
        emailName = name,
    ),
    content = NotificationContent.Template(
        templateId = "teacher_account_creation",
        parameters = mapOf(
            "name" to name,
            "email" to email,
            "password" to password,
            "organizationName" to organizationName,
            "loginUrl" to loginUrl,
        ),
    ),
    purpose = "TEACHER_ACCOUNT_CREATION",
    priority = "HIGH",
    sourceService = "lms-service",
)

notificationPublisher.publishNotification(request)

notification-service에서 처리:

  1. Pub/Sub에서 메시지 수신
  2. templateId로 데이터베이스에서 템플릿 조회
  3. parameters로 템플릿의 {{key}} 치환
  4. JavaMailSender로 이메일 전송

HTML 이메일 작성 팁

1. 인라인 스타일 사용

이메일 클라이언트는 외부 CSS를 지원하지 않으므로, 인라인 스타일을 사용해야 합니다:

<!-- 나쁜 예: 외부 스타일 -->
<head>
    <link rel="stylesheet" href="styles.css">
</head>
<div class="container">...</div>

<!-- 좋은 예: 인라인 스타일 -->
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
    ...
</div>

2. 테이블 레이아웃 사용

Flexbox나 Grid는 지원하지 않는 클라이언트가 많으므로, 테이블 레이아웃을 사용합니다:

<table width="100%" cellpadding="0" cellspacing="0">
    <tr>
        <td style="padding: 20px;">
            <h1>제목</h1>
            <p>내용</p>
        </td>
    </tr>
</table>

3. 이미지 사용 시 주의

이미지는 절대 URL을 사용하고, alt 텍스트를 제공해야 합니다:

<!-- 나쁜 예: 상대 경로 -->
<img src="/images/logo.png">

<!-- 좋은 예: 절대 URL + alt -->
<img src="https://example.com/images/logo.png" alt="로고">

4. 반응형 디자인

모바일에서도 잘 보이도록 미디어 쿼리를 사용합니다:

<style>
    @media only screen and (max-width: 600px) {
        .container {
            width: 100% !important;
            padding: 10px !important;
        }
    }
</style>

Gmail SMTP 설정

App Password 생성

Gmail을 SMTP 서버로 사용하려면 App Password가 필요합니다:

  1. Google 계정 설정 → 보안
  2. 2단계 인증 활성화
  3. App Password 생성
  4. 생성된 비밀번호를 spring.mail.password에 설정

주의사항:

  • 일반 비밀번호가 아닌 App Password를 사용해야 함
  • App Password는 16자리 공백으로 구분된 문자열

SMTP 포트 선택

Gmail SMTP는 여러 포트를 지원합니다:

포트프로토콜설명
587STARTTLS권장 (TLS 암호화)
465SSL/TLSSSL 암호화
25일반대부분 차단됨

권장: 587 포트 사용

  • STARTTLS로 암호화
  • 대부분의 방화벽에서 허용
  • 표준 SMTP 포트

설정 예시

spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your-email@gmail.com
spring.mail.password=xxxx xxxx xxxx xxxx  # App Password

spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true

에러 처리 및 재시도

일반적인 에러 상황

  1. 인증 실패

    • 잘못된 사용자명/비밀번호
    • App Password 미사용
  2. 연결 실패

    • 네트워크 문제
    • 방화벽 차단
    • SMTP 서버 다운
  3. 전송 실패

    • 잘못된 이메일 주소
    • 스팸 필터 차단
    • 할당량 초과

에러 처리 구현

override fun send(request: NotificationRequest): Boolean {
    return try {
        // 이메일 전송 로직
        sendEmail(...)
        true
    } catch (e: AuthenticationFailedException) {
        logger.error("SMTP 인증 실패: ${e.message}", e)
        // 인증 실패는 재시도 불가능
        false
    } catch (e: MailSendException) {
        logger.error("이메일 전송 실패: ${e.message}", e)
        // 네트워크 오류 등은 재시도 가능
        false
    } catch (e: Exception) {
        logger.error("예상치 못한 오류: ${e.message}", e)
        false
    }
}

Pub/Sub과의 통합

notification-service는 Pub/Sub에서 메시지를 받아 처리하므로, 실패 시 자동 재시도가 가능합니다:

protected fun createMessageHandler(): MessageHandler {
    return MessageHandler { message ->
        var ackMessage: BasicAcknowledgeablePubsubMessage? = null

        try {
            // 메시지 처리
            processNotificationUseCase.process(request)
            
            // 성공 시 ACK
            ackMessage?.ack()
        } catch (e: Exception) {
            logger.error("이메일 전송 실패", e)
            // 실패 시 NACK (재시도 가능)
            ackMessage?.nack()
        }
    }
}

재시도 동작:

  • NACK 시 Pub/Sub이 자동으로 재전송
  • Ack Deadline(60초) 내에 ACK가 없으면 자동 재전송
  • 최대 재시도 횟수 설정 가능

실제 사용 사례

사례 1: 선생님 계정 생성 이메일

템플릿:

<h1>안녕하세요, {{name}}님!</h1>
<p>{{organizationName}}의 선생님 계정이 생성되었습니다.</p>
<p>이메일: {{email}}</p>
<p>비밀번호: {{password}}</p>
<p><a href="{{loginUrl}}">로그인하기</a></p>

발행:

NotificationContent.Template(
    templateId = "teacher_account_creation",
    parameters = mapOf(
        "name" to "홍길동",
        "email" to "hong@example.com",
        "password" to "temp1234",
        "organizationName" to "에이스 학원",
        "loginUrl" to "https://ace.lms.s-class.com/login",
    ),
)

실제 전송된 이메일 예시:

선생님 계정 생성 이메일 예시

선생님 계정 생성 이메일 예시

결과:

  • HTML 형식의 예쁜 이메일 전송
  • 템플릿 변경 시 코드 수정 불필요
  • 여러 조직에 동일한 템플릿 재사용

사례 2: 결제 성공 알림

템플릿:

<h1>결제가 완료되었습니다</h1>
<p>주문 번호: {{orderId}}</p>
<p>결제 금액: {{amount}}원</p>
<p>상품: {{description}}</p>

발행:

NotificationContent.Template(
    templateId = "payment_success",
    parameters = mapOf(
        "orderId" to "ORD-123456",
        "amount" to "50000",
        "description" to "프리미엄 패키지",
    ),
)

성능 최적화

1. 비동기 전송

이메일 전송은 시간이 걸리므로 비동기로 처리합니다:

@Async
fun sendEmailAsync(request: NotificationRequest) {
    emailNotificationProvider.send(request)
}

설정:

@Configuration
@EnableAsync
class AsyncConfig {
    @Bean
    fun taskExecutor(): ThreadPoolTaskExecutor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 5
        executor.maxPoolSize = 10
        executor.queueCapacity = 100
        executor.setThreadNamePrefix("email-")
        executor.initialize()
        return executor
    }
}

2. 연결 풀링

JavaMailSender는 기본적으로 연결 풀링을 지원하지 않습니다. 대용량 전송이 필요한 경우:

  • Apache Commons Email: 연결 풀링 지원
  • Spring Integration Mail: 배치 처리 지원
  • 전용 이메일 서비스: SendGrid, AWS SES 등

3. 배치 전송

여러 이메일을 한 번에 전송:

fun sendBatchEmails(requests: List<NotificationRequest>) {
    val messages = requests.map { request ->
        createMimeMessage(request)
    }
    javaMailSender.send(*messages.toTypedArray())
}

보안 고려사항

1. 비밀번호 관리

나쁜 예:

spring.mail.password=my-password  # 하드코딩

좋은 예:

spring.mail.password=${GMAIL_APP_PASSWORD}  # 환경 변수

또는 Secret Manager 사용:

@Value("\${spring.cloud.gcp.secretmanager.secret-name}")
private val secretName: String

// Secret Manager에서 비밀번호 가져오기

2. 이메일 주소 검증

수신자 이메일 주소를 검증합니다:

fun isValidEmail(email: String): Boolean {
    val pattern = "^[A-Za-z0-9+_.-]+@(.+)$".toRegex()
    return pattern.matches(email)
}

3. 스팸 방지

스팸으로 분류되지 않도록:

  • SPF 레코드 설정: 도메인에 SPF 레코드 추가
  • DKIM 서명: 이메일 서명 추가
  • DMARC 정책: 이메일 인증 정책 설정
  • 발신자 주소: 신뢰할 수 있는 발신자 주소 사용

테스트

단위 테스트

@SpringBootTest
class EmailNotificationProviderTest {
    
    @MockBean
    private lateinit var javaMailSender: JavaMailSender
    
    @Autowired
    private lateinit var emailNotificationProvider: EmailNotificationProvider
    
    @Test
    fun `이메일 전송 성공`() {
        // Given
        val request = NotificationRequest(
            notificationId = "test-123",
            channel = NotificationChannel.EMAIL,
            recipient = NotificationRecipient.EmailRecipient(
                email = "test@example.com",
                emailName = "테스트",
            ),
            content = NotificationContent.Direct(
                title = "테스트",
                body = "테스트 내용",
            ),
        )
        
        // When
        val result = emailNotificationProvider.send(request)
        
        // Then
        assertThat(result).isTrue()
        verify(javaMailSender).send(any(MimeMessage::class.java))
    }
}

통합 테스트

실제 SMTP 서버 없이 테스트:

@SpringBootTest
@TestPropertySource(properties = [
    "spring.mail.host=localhost",
    "spring.mail.port=1025",  // GreenMail 테스트 서버
])
class EmailIntegrationTest {
    // GreenMail을 사용한 통합 테스트
}

모니터링 및 로깅

전송 성공/실패 추적

override fun send(request: NotificationRequest): Boolean {
    val startTime = System.currentTimeMillis()
    
    return try {
        sendEmail(...)
        val duration = System.currentTimeMillis() - startTime
        
        logger.info(
            "이메일 전송 성공: notificationId=${request.notificationId}, " +
            "email=${recipient.email}, duration=${duration}ms"
        )
        true
    } catch (e: Exception) {
        logger.error(
            "이메일 전송 실패: notificationId=${request.notificationId}, " +
            "email=${recipient.email}, error=${e.message}",
            e
        )
        false
    }
}

메트릭 수집

이메일 전송 통계를 수집합니다:

  • 전송 성공률
  • 평균 전송 시간
  • 실패 원인별 통계
  • 시간대별 전송량

결론

Spring Mail과 JavaMailSender를 활용하여 SMTP 이메일 전송 시스템을 구축하면서 얻은 것들:

달성한 목표

  1. 템플릿 기반 이메일

    • 데이터베이스에서 템플릿 관리
    • 코드 수정 없이 이메일 내용 변경 가능
    • 파라미터 치환으로 동적 내용 생성
  2. HTML 이메일 지원

    • MimeMessageHelper로 HTML + 텍스트 모두 제공
    • 다양한 이메일 클라이언트 호환성
  3. 안정적인 전송

    • Pub/Sub과 통합하여 자동 재시도
    • 에러 처리 및 로깅
  4. 확장 가능한 구조

    • Provider 패턴으로 채널별 독립적 구현
    • 새로운 템플릿 추가가 간단함

배운 교훈

"단순해 보이는 기능도 실제로는 복잡하다"

이메일 전송은 단순해 보였지만, HTML 형식, 템플릿 관리, 에러 처리 등을 고려하면 생각보다 복잡했습니다. 하지만 Spring Mail이 대부분의 복잡성을 추상화해주어, 핵심 로직에만 집중할 수 있었습니다.

"템플릿 관리는 데이터베이스가 최선"

이메일 내용을 코드에 하드코딩하는 것보다, 데이터베이스에 템플릿을 저장하고 파라미터를 치환하는 방식이 훨씬 유연했습니다. 디자이너가 직접 템플릿을 수정할 수 있고, 여러 언어 지원도 쉬워졌습니다.

다음 단계

현재 구조로도 충분히 잘 작동하고 있지만, 더 나아가려면:

  1. 이메일 서비스 전환: Gmail SMTP → SendGrid, AWS SES 등
  2. 템플릿 에디터: 웹 UI에서 템플릿 직접 편집
  3. A/B 테스트: 여러 템플릿 버전 테스트
  4. 이메일 추적: 열람률, 클릭률 추적

댓글

?