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 설정 추가
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자리 비밀번호 발급
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 // 탈퇴용 이메일 인증
}
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";
}
이메일 인증 흐름에서 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)));
}
}
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 구현으로 진행한다.
POST /email/send)POST /email/verify)