[Let's Git It] 이메일 인증 공통 세팅 — SMTP, Redis, 상수

dobby·2026년 5월 2일

Let's git it BE

목록 보기
10/20

Let's Git It — WebSocket 기반 실시간 Git 명령어 학습 게임 프로젝트의 백엔드 인증 구현 시리즈입니다.
이번 글은 이메일 인증 코드 발송/검증 API 구현 전 공통 세팅을 다룹니다.


이번 단계에서 만드는 파일

이메일 인증 2개 API(발송, 검증) 모두에서 공통으로 필요한 파일들이다.

domain/auth/constants/AuthConstants.java
domain/auth/enums/AuthPurpose.java
domain/auth/repository/AuthRedisRepository.java      ← 쿨다운 메서드 추가
domain/auth/repository/AuthRedisRepositoryImpl.java  ← 쿨다운 메서드 추가
domain/auth/service/EmailService.java
domain/auth/service/EmailServiceImpl.java
application.yml                                       ← SMTP 설정 추가

Step 1 — SMTP 설정 (application.yml)

Gmail SMTP를 사용한다. .env에서 계정 정보를 주입받는 구조로 분리했다.

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}    # Gmail 앱 비밀번호 (16자리)
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true      # 587 포트는 STARTTLS 사용
            required: true
    default-encoding: UTF-8

.env에 추가:

MAIL_USERNAME=letsgitit@gmail.com
MAIL_PASSWORD=abcd efgh ijkl mnop

Gmail 앱 비밀번호 발급 경로
Google 계정 → 보안 → 2단계 인증 활성화 → https://myaccount.google.com/apppasswords
앱 이름 입력 후 만들기 → 16자리 비밀번호 발급


Step 2 — AuthPurpose enum

purpose를 String으로 받으면 오탈자가 생겨도 컴파일 단계에서 못 잡는다.
Enum으로 선언하면 잘못된 값이 들어왔을 때 Spring이 자동으로 400 처리해준다.

Auth 도메인에서만 사용하므로 global/enums/가 아닌 domain/auth/enums/에 위치시켰다.
다른 도메인에서 참조할 필요가 생기면 그때 global/enums/로 이동하면 된다.

package com.gitcat.letsgitit.domain.auth.enums;

public enum AuthPurpose {
    SIGN_UP,        // 회원가입용 이메일 인증
    PASSWORD_RESET, // 비밀번호 찾기용 이메일 인증
    WITHDRAW        // 탈퇴용 이메일 인증
}

Step 3 — AuthConstants

TTL, 최대 발송 횟수, 코드 길이 같은 값을 서비스 코드에 하드코딩하면 정책이 바뀔 때
여러 곳을 찾아 고쳐야 한다. 상수 클래스 하나에 모아두면 한 곳만 수정하면 된다.

package com.gitcat.letsgitit.domain.auth.constants;

public class AuthConstants {

    private AuthConstants() {} // 인스턴스 생성 방지

    // 인증 코드 자릿수
    public static final int AUTH_CODE_LENGTH = 6;

    // 인증 코드 만료 시간 (분)
    public static final long AUTH_CODE_TTL_MINUTES = 5;

    // 이메일 인증 완료 상태 유지 시간 (분)
    // 회원가입/비밀번호 변경 완료 전까지 유지
    public static final long EMAIL_VERIFIED_TTL_MINUTES = 30;

    // 목적별 이메일 발송 최대 횟수
    public static final long MAX_EMAIL_SEND_COUNT = 3;

    // 재발송 제한 시간 (초) — 60초 이내 재요청 차단
    public static final long EMAIL_RESEND_COOLDOWN_SECONDS = 60;

    // 인증 코드 문자셋 — O/0, I/1 혼동 방지를 위해 제외
    public static final String AUTH_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

    // Refresh Token 쿠키 이름
    public static final String REFRESH_TOKEN_COOKIE = "refreshToken";
}

Step 4 — AuthRedisRepository

이메일 인증 흐름에서 Redis에 저장하는 데이터 목록이다.

auth:code:{purpose}:{email}         → 인증 코드 (TTL: 5분)
auth:verified:{purpose}:{email}     → 인증 완료 상태 (TTL: 30분)
auth:send-count:{purpose}:{email}   → 발송 횟수 (TTL: 24시간)
auth:cooldown:{purpose}:{email}     → 재발송 쿨다운 (TTL: 60초)
auth:refresh:{email}                → Refresh Token (TTL: 7일)
auth:blacklist:{accessToken}        → 로그아웃 블랙리스트 (TTL: 토큰 잔여 시간)

키 형식을 도메인:목적:식별자로 통일해 충돌을 방지했다.

package com.gitcat.letsgitit.domain.auth.repository;

import com.gitcat.letsgitit.domain.auth.enums.AuthPurpose;

public interface AuthRedisRepository {

    // ===================== 이메일 인증 코드 =====================
    void saveEmailCode(String email, String purpose, String code);
    String getEmailCode(String email, String purpose);
    void deleteEmailCode(String email, String purpose);

    // ===================== 이메일 인증 완료 상태 =====================
    void saveEmailVerified(String email, String purpose);
    boolean isEmailVerified(String email, String purpose);
    void deleteEmailVerified(String email, String purpose);

    // ===================== 이메일 발송 횟수 =====================
    long incrementEmailSendCount(String email, String purpose);
    long getEmailSendCount(String email, String purpose);

    // ===================== Refresh Token =====================
    void saveRefreshToken(String email, String refreshToken);
    String getRefreshToken(String email);
    void deleteRefreshToken(String email);

    // ===================== Access Token 블랙리스트 =====================
    void addToBlacklist(String accessToken, long remainingExpirationMs);
    boolean isBlacklisted(String accessToken);

    // ===================== 재발송 쿨다운 =====================
    void saveCooldown(String email, AuthPurpose purpose);
    boolean hasCooldown(String email, AuthPurpose purpose);
}

구현체는 authStringRedisTemplate을 주입받아 사용한다.
@RequiredArgsConstructor는 @Qualifier를 지원하지 않아 생성자를 직접 작성했다.

package com.gitcat.letsgitit.domain.auth.repository;

import java.util.concurrent.TimeUnit;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;

import com.gitcat.letsgitit.domain.auth.constants.AuthConstants;
import com.gitcat.letsgitit.domain.auth.enums.AuthPurpose;

@Repository
public class AuthRedisRepositoryImpl implements AuthRedisRepository {

    private final StringRedisTemplate authStringRedisTemplate;

    public AuthRedisRepositoryImpl(
        @Qualifier("authStringRedisTemplate")
        StringRedisTemplate authStringRedisTemplate) {
        this.authStringRedisTemplate = authStringRedisTemplate;
    }

    // ===================== 키 생성 규칙 =====================

    private String emailCodeKey(String email, String purpose) {
        return "auth:code:" + purpose + ":" + email;
    }

    private String emailVerifiedKey(String email, String purpose) {
        return "auth:verified:" + purpose + ":" + email;
    }

    private String emailSendCountKey(String email, String purpose) {
        return "auth:send-count:" + purpose + ":" + email;
    }

    private String refreshTokenKey(String email) {
        return "auth:refresh:" + email;
    }

    private String blacklistKey(String accessToken) {
        return "auth:blacklist:" + accessToken;
    }

    private String cooldownKey(String email, AuthPurpose purpose) {
        return "auth:cooldown:" + purpose.name() + ":" + email;
    }

    // ===================== 이메일 인증 코드 =====================

    @Override
    public void saveEmailCode(String email, String purpose, String code) {
        authStringRedisTemplate.opsForValue()
            .set(emailCodeKey(email, purpose), code,
                AuthConstants.AUTH_CODE_TTL_MINUTES, TimeUnit.MINUTES);
    }

    @Override
    public String getEmailCode(String email, String purpose) {
        return authStringRedisTemplate.opsForValue().get(emailCodeKey(email, purpose));
    }

    @Override
    public void deleteEmailCode(String email, String purpose) {
        authStringRedisTemplate.delete(emailCodeKey(email, purpose));
    }

    @Override
    public void saveEmailVerified(String email, String purpose) {
        authStringRedisTemplate.opsForValue()
            .set(emailVerifiedKey(email, purpose), "1",
                AuthConstants.EMAIL_VERIFIED_TTL_MINUTES, TimeUnit.MINUTES);
    }

    @Override
    public boolean isEmailVerified(String email, String purpose) {
        return Boolean.TRUE.equals(
            authStringRedisTemplate.hasKey(emailVerifiedKey(email, purpose)));
    }

    @Override
    public void deleteEmailVerified(String email, String purpose) {
        authStringRedisTemplate.delete(emailVerifiedKey(email, purpose));
    }

    // ===================== 이메일 발송 횟수 =====================

    @Override
    public long incrementEmailSendCount(String email, String purpose) {
        String key = emailSendCountKey(email, purpose);
        Long count = authStringRedisTemplate.opsForValue().increment(key);

        // 처음 증가 시(count == 1)에만 TTL 설정
        if (count != null && count == 1) {
            authStringRedisTemplate.expire(key, 24, TimeUnit.HOURS);
        }

        return count != null ? count : 0L;
    }

    @Override
    public long getEmailSendCount(String email, String purpose) {
        String value = authStringRedisTemplate.opsForValue()
            .get(emailSendCountKey(email, purpose));
        return value != null ? Long.parseLong(value) : 0L;
    }

    // ===================== Refresh Token =====================

    @Override
    public void saveRefreshToken(String email, String refreshToken) {
        authStringRedisTemplate.opsForValue()
            .set(refreshTokenKey(email), refreshToken, 7, TimeUnit.DAYS);
    }

    @Override
    public String getRefreshToken(String email) {
        return authStringRedisTemplate.opsForValue().get(refreshTokenKey(email));
    }

    @Override
    public void deleteRefreshToken(String email) {
        authStringRedisTemplate.delete(refreshTokenKey(email));
    }

    // ===================== Access Token 블랙리스트 =====================

    @Override
    public void addToBlacklist(String accessToken, long remainingExpirationMs) {
        authStringRedisTemplate.opsForValue()
            .set(blacklistKey(accessToken), "1",
                remainingExpirationMs, TimeUnit.MILLISECONDS);
    }

    @Override
    public boolean isBlacklisted(String accessToken) {
        return Boolean.TRUE.equals(
            authStringRedisTemplate.hasKey(blacklistKey(accessToken)));
    }

    // ===================== 재발송 쿨다운 =====================

    @Override
    public void saveCooldown(String email, AuthPurpose purpose) {
        authStringRedisTemplate.opsForValue()
            .set(cooldownKey(email, purpose), "1",
                AuthConstants.EMAIL_RESEND_COOLDOWN_SECONDS, TimeUnit.SECONDS);
    }

    @Override
    public boolean hasCooldown(String email, AuthPurpose purpose) {
        return Boolean.TRUE.equals(
            authStringRedisTemplate.hasKey(cooldownKey(email, purpose)));
    }
}

Step 5 — EmailService

SMTP 발송 로직을 AuthService에 직접 넣지 않고 별도 서비스로 분리했다.
나중에 발송 방식이 Gmail → AWS SES로 바뀌어도 AuthService는 수정할 필요가 없다.

package com.gitcat.letsgitit.domain.auth.service;

public interface EmailService {

    void sendAuthCode(String toEmail, String code, long ttlMinutes);
}
package com.gitcat.letsgitit.domain.auth.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import com.gitcat.letsgitit.global.exception.BusinessException;
import com.gitcat.letsgitit.global.exception.ErrorCode;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements EmailService {

    private final JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String fromEmail;

    @Override
    public void sendAuthCode(String toEmail, String code, long ttlMinutes) {
        try {
            MimeMessage message = mailSender.createMimeMessage();

            // true = multipart, "UTF-8" = 한글 깨짐 방지
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(fromEmail);
            helper.setTo(toEmail);
            helper.setSubject("[Let's Git It] 이메일 인증 코드");
            helper.setText(buildEmailBody(code, ttlMinutes), true); // true = HTML

            mailSender.send(message);

        } catch (MessagingException | MailException e) {
            log.error("이메일 발송 실패. 수신자: {}", toEmail, e);
            throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED);
        }
    }

    private String buildEmailBody(String code, long ttlMinutes) {
        return """
            <div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto;">
                <h2 style="color: #4A90D9;">Let's Git It 이메일 인증</h2>
                <p>아래 인증 코드를 입력해주세요.</p>
                <div style="background-color: #f4f4f4; padding: 20px; text-align: center;
                            font-size: 32px; font-weight: bold; letter-spacing: 8px;
                            border-radius: 8px; color: #333;">
                    %s
                </div>
                <p style="color: #888; font-size: 13px; margin-top: 16px;">
                    인증 코드는 %d분간 유효합니다.
                </p>
            </div>
            """.formatted(code, ttlMinutes);
    }
}

다음 단계

공통 세팅 완료. 이제 각 API 구현으로 진행한다.

  • 3-1: 이메일 인증 코드 발송 API (POST /email/send)
  • 3-2: 이메일 인증 코드 검증 API (POST /email/verify)
profile
느리게 한걸음

0개의 댓글