Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1b3551e
:sparkles: feat: Spring μ„œλ²„μ—μ„œ 이메일 전솑을 μœ„ν•œ build.gradle μ˜μ‘΄μ„± μΆ”κ°€
ojy0903 Jan 26, 2026
dcefec3
:sparkles: feat: 이메일 μΈμ¦μ½”λ“œλ₯Ό μ €μž₯ν•˜κΈ° μœ„ν•œ RedisUtil 클래슀 μΆ”κ°€
ojy0903 Jan 26, 2026
4de758a
:sparkles: feat: 이메일 인증관련 Request, Response DTO μΆ”κ°€
ojy0903 Jan 26, 2026
11123c3
:sparkles: feat: 이메일 인증 μ²˜λ¦¬ν•˜λŠ” EmailService 클래슀 μΆ”κ°€
ojy0903 Jan 26, 2026
5e0259f
:sparkles: feat: 이메일 인증 κ΄€λ ¨ μ˜ˆμ™Έμ²˜λ¦¬ μ½”λ“œ UserErrorCode에 μΆ”κ°€
ojy0903 Jan 26, 2026
2b8e818
:sparkles: feat: κΈ°μ‘΄ νšŒμ›κ°€μž… λ‘œμ§μ—μ„œ 이메일 μΈμ¦λ˜μ–΄μ•Ό κ°€μž… κ°€λŠ₯ν•˜λ„λ‘ μˆ˜μ •
ojy0903 Jan 26, 2026
22101f5
:sparkles: feat: μΈμ¦μ½”λ“œ 전솑 API, μΈμ¦μ½”λ“œ 검증 API μΆ”κ°€(/api/users/...)
ojy0903 Jan 26, 2026
db7cdc4
:sparkles: feat: SecurityConfig μ—μ„œ 둜그인 인증 없이 이메일 인증 μ ‘κ·Ό κ°€λŠ₯ν•˜λ„λ‘ μˆ˜μ •
ojy0903 Jan 26, 2026
852dc4c
:bug: fix: test 디렉터리 application.yml μ„€μ • μΆ”κ°€ -> λΉŒλ“œ ν…ŒμŠ€νŠΈ 톡과 λͺ©μ 
ojy0903 Jan 28, 2026
dbb18f7
:bug: fix: test 디렉터리 application.yml μ‚­μ œ
ojy0903 Jan 28, 2026
da68b56
:sparkles: feat: .gitignore μ—μ„œ application.yml λ¬΄μ‹œ μ„€μ • 주석 처리 & .env λ¬΄μ‹œ …
ojy0903 Jan 28, 2026
5634c1c
:sparkles: feat: application.yml μΆ”κ°€, .env ν˜•μ‹ μ˜ˆμ‹œ 파일 .env.example μΆ”κ°€
ojy0903 Jan 28, 2026
ba60c4f
:sparkles: feat: application.yml JWT μ˜΅μ…˜ μ£Όμ„μ²˜λ¦¬
ojy0903 Jan 28, 2026
f9f14e7
:green_heart: build: ci.yml λ‚΄ env(ν™˜κ²½λ³€μˆ˜λͺ…) μˆ˜μ •
jinnieusLab Jan 28, 2026
b96059d
:green_heart: build: ci.yml λ‚΄ jwt secret μž„μ˜ 인코딩 κ°’μœΌλ‘œ μˆ˜μ •
jinnieusLab Jan 28, 2026
a8a1b59
:recycle: refactor: 이메일 인증 μš”μ²­ DTO λ₯Ό κΈ°μ‘΄ EmailRequest, EmailVerifyReque…
ojy0903 Jan 28, 2026
6b3dfec
:truck: rename: UserErrorCode λ‚΄λΆ€ USER_400_N μ½”λ“œ 숫자 μ •λ ¬
ojy0903 Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ dependencies {
//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.0'

//Email
implementation 'org.springframework.boot:spring-boot-starter-mail'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.whereyouad.WhereYouAd.domains.user.application.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record EmailRequest(
@NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Email(message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")
String email
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.whereyouad.WhereYouAd.domains.user.application.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record EmailVerifyRequest(
@NotBlank(message = "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Email(message = "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")
String email,
@NotBlank
String authCode
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.whereyouad.WhereYouAd.domains.user.application.dto.response;

public record EmailSentResponse(
String message, //μΈμ¦μ½”λ“œλ₯Ό μ΄λ©”μΌλ‘œ μ „μ†‘ν–ˆμŠ΅λ‹ˆλ‹€.
String email, //μ „μ†‘ν•œ 이메일
long expireIn //λ§Œλ£Œμ‹œκ°„ (500L -> 500초)
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.whereyouad.WhereYouAd.domains.user.domain.service;

import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse;
import com.whereyouad.WhereYouAd.domains.user.exception.UserSignUpException;
import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode;
import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository;
import com.whereyouad.WhereYouAd.global.utils.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Transactional
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {

private final JavaMailSender emailSender;
private final RedisUtil redisUtil;
private final UserRepository userRepository;

//application.yml 적용 ν•„μš”
@Value("${spring.mail.username}")
private String senderEmail;

//μΈμ¦μ½”λ“œ 이메일 λ°œμ†‘ 둜직
public EmailSentResponse sendEmail(String toEmail) {

if (userRepository.existsByEmail(toEmail)) { //이미 ν•΄λ‹Ή μ΄λ©”μΌλ‘œ μƒμ„±ν•œ 계정이 있으면
throw new UserSignUpException(UserErrorCode.USER_EMAIL_DUPLICATE); //이메일 쀑볡 μ˜ˆμ™Έ(νšŒμ›κ°€μž… μ‹œ μ‚¬μš©ν–ˆλ˜ μ˜ˆμ™Έ)
}

//μΈμ¦μ½”λ“œ μž¬μ „μ†‘ 둜직 -> 이미 Redis 에 ν•΄λ‹Ή 이메일 μΈμ¦μ½”λ“œκ°€ μžˆμ„μ‹œ μ‚­μ œ
String redisKey = "CODE:" + toEmail;
if (redisUtil.getData(redisKey) != null) {
redisUtil.deleteData(redisKey);
}

String authCode = createCode(); //μΈμ¦μ½”λ“œ 생성

if (isTestEmail(toEmail)) {
//ν…ŒμŠ€νŠΈμš© κ°€μ§œ 이메일은 μ„œλ²„ 둜그둜만 μΈμ¦μ½”λ“œλ₯Ό 보여주기
//μ‹€μ œ 이메일 λ°œμ†‘ X
//μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 이메일 μ£Όμ†Œλ‘œ 계속 이메일을 보내면 μΈμ¦μ½”λ“œ μ „μ†‘μš© 이메일 계정이 슀팸처리 될 κ°€λŠ₯μ„± μ‘΄μž¬ν•˜μ—¬ 둜그둜 남기기
log.warn("{} 이메일 -> ν…ŒμŠ€νŠΈ or 개발용 κ°€μ§œ 이메일 μž…λ‹ˆλ‹€.", toEmail);
log.warn("[TEST λͺ¨λ“œ] μ‹€μ œ λ°œμ†‘μ„ κ±΄λ„ˆλœλ‹ˆλ‹€.");
log.warn("[TEST λͺ¨λ“œ] μˆ˜μ‹ μž: {}", toEmail);
log.warn("[TEST λͺ¨λ“œ] μΈμ¦μ½”λ“œ: {}", authCode);

} else { //μ‹€μ œ μ‘΄μž¬ν•˜λŠ” 이메일이라면

try {
//μ‹€μ œ 인증 μ½”λ“œκ°€ λ‹΄κΈ΄ 이메일 전솑
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail);
message.setSubject("whereyouad νšŒμ›κ°€μž… 인증번호");
message.setText("인증번호: " + authCode);
message.setFrom(senderEmail);

emailSender.send(message); //λ§Œμ•½ μ‹€μ œ μ‘΄μž¬ν•˜λŠ” 이메일인데 μ‚¬μš©μžκ°€ μ˜€νƒ€λ₯Ό λƒˆλ‹€λ©΄
} catch (MailException e) { //μ˜ˆμ™Έ λ°œμƒ
throw new UserSignUpException(UserErrorCode.USER_EMAIL_NOT_VALID); //톡합 응닡 처리 μ˜ˆμ™Έλ‘œ λ°˜ν™˜
}

}

//Redis에 μ €μž₯ (Key: "CODE:이메일", Value: "123456", μœ νš¨μ‹œκ°„: 300초(5λΆ„))
//ν…ŒμŠ€νŠΈ 계정도 인증은 ν•΄μ•Όν•˜λ‹ˆ Redis 에 μ½”λ“œκ°€ μ €μž₯ λ˜μ–΄μ•Ό 함.
//ν…ŒμŠ€νŠΈ κ³„μ •μ˜ 인증은 μ„œλ²„ 둜그λ₯Ό 톡해 μΈμ¦μ½”λ“œλ₯Ό μ–»μ–΄ μž…λ ₯.
redisUtil.setDataExpire("CODE:" + toEmail, authCode, 60 * 5L);

return new EmailSentResponse("μΈμ¦μ½”λ“œλ₯Ό μ΄λ©”μΌλ‘œ μ „μ†‘ν–ˆμŠ΅λ‹ˆλ‹€.", toEmail, 300L);
}

//μΈμ¦μ½”λ“œ 검증 λ©”μ„œλ“œ
public void verifyEmailCode(String email, String inputCode) {
//ν•΄λ‹Ή 이메일 κ°’μœΌλ‘œ Redis μ—μ„œ 쑰회
String key = "CODE:" + email;
String savedCode = redisUtil.getData(key);

//λ§Œμ•½ μΈμ¦μ½”λ“œκ°€ μ—†κ±°λ‚˜ 잘λͺ» μž…λ ₯ν–ˆλ‹€λ©΄,
if (savedCode == null || !savedCode.equals(inputCode)) {
throw new UserSignUpException(UserErrorCode.USER_EMAIL_AUTH_INVALID); //μ˜ˆμ™Έ λ°œμƒ(BAD_REQUEST)
}

//μ •μƒμ μœΌλ‘œ μΈμ¦μ½”λ“œλ₯Ό μž…λ ₯ν–ˆλ‹€λ©΄,
//κΈ°μ‘΄ Redis 에 μ €μž₯된 데이터λ₯Ό μ§€μš°κ³ ,
redisUtil.deleteData(key);
//"ν•΄λ‹Ή 이메일이 μ •μƒμ μœΌλ‘œ μΈμ¦λ˜μ—ˆλ‹€" λŠ” 값을 λ‹€μ‹œ Redis 에 μ €μž₯ -> 이후 νšŒμ›κ°€μž…(signup) λ‚΄λΆ€ λ‘œμ§μ—μ„œ ν™œμš©
redisUtil.setDataExpire("VERIFIED:" + email, "TRUE", 60 * 60L); //1μ‹œκ°„ λ’€ Expire
}

//ν…ŒμŠ€νŠΈμš© 이메일은, "test" 둜 μ‹œμž‘ν•˜κ±°λ‚˜, "example.com" 으둜 λλ‚˜μ•Ό ν•œλ‹€.
private boolean isTestEmail(String email) {
return email.startsWith("test") || email.endsWith("example.com");
}

//λ¬΄μž‘μœ„ μΈμ¦μ½”λ“œ κ°’ 생성
private String createCode() {
return String.valueOf((int)(Math.random() * (900000)) + 100000);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.whereyouad.WhereYouAd.global.utils;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class RedisUtil {

private final StringRedisTemplate template;

//Redis 에 데이터 μ €μž₯(μœ νš¨μ‹œκ°„ μ„€μ •)
public void setDataExpire(String key, String value, long duration) {
ValueOperations<String, String> valueOperations = template.opsForValue();
Duration expireDuration = Duration.ofSeconds(duration);
valueOperations.set(key, value, expireDuration);
}

//Redis μ—μ„œ 데이터 κΊΌλ‚΄κΈ°(Value κΊΌλ‚΄κΈ°)
public String getData(String key) {
ValueOperations<String, String> valueOperations = template.opsForValue();
return valueOperations.get(key);
}

//Redis μ—μ„œ 데이터 μ§€μš°κΈ°(key κ°’ 기반)
public void deleteData(String key) {
template.delete(key);
}
}