728x90
320x100
🎯 오늘의 목표
- 회원가입 전, 이메일 인증 구현
⚙️ 진행한 작업
- 회원가입 전, 이메일 인증 구현
🛠️ 개발내용
📌 전체 흐름
[1] 이메일 입력 후 인증 요청 (POST /api/email/verify-request)
└ Redis에 토큰 저장 + 이메일 발송
[2] 인증 메일 링크 클릭 (GET /api/email/verify?token=xxx)
└ Redis에서 토큰 확인 → 인증 완료 → DB 저장 or 업데이트
[3] 회원가입 요청 (POST /api/users)
└ DB에 이메일 인증된 사용자만 가입 허용
📌 Redis, Mail 관련 의존성 추가 (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
📌 application.yml
spring:
mail:
host: smtp.gmail.com
port: 587
username: [gmail 주소]
password: [앱 비밀번호]
properties:
mail:
smtp:
auth: true
starttls:
enable: true
data:
redis:
host: localhost
port: 6379
- 지메일 주소와 앱 비밀번호는 메일 인증을 할 때 발신자 메일을 무엇으로 할 것인지에 따른 설정임.
- 앱 비밀번호는 기존의 메일 비밀번호가 아니라 따로 생성해줘야 함.
[구글 이메일의 앱 비밀번호 만들기] (구글 메일일 경우)
1. 구글 계정 로그인 후 https://myaccount.google.com/security 로 접속하여 상단에 '앱 비밀번호'를 검색하여 들어가줌.
- 앱 이름을 편하게 지정해주면, 16자리의 비밀번호가 뜨는데 그게 앱 비밀번호가 되고 그 비밀번호를 작성하면 됨.
📌 이메일 인증관련 엔티티 생성 (EmailVerification.java)
package ddururi.bookbookclub.domain.emailverification.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class EmailVerification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private boolean verified;
private LocalDateTime verifiedAt;
public void markAsVerified() {
this.verified = true;
this.verifiedAt = LocalDateTime.now();
}
}
- 이메일 인증상태를 영구적으로 저장하기 위해 엔티티를 하나 더 생성해줌.
📌 이메일 인증관련 레파지토리 생성 (EmailVerificationRepository.java)
package ddururi.bookbookclub.domain.emailverification.repository;
import ddururi.bookbookclub.domain.emailverification.entity.EmailVerification;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface EmailVerificationRepository extends JpaRepository<EmailVerification, Long> {
Optional<EmailVerification> findByEmail(String email);
boolean existsByEmailAndVerifiedIsTrue(String email);
}
- 이메일 인증상태를 영구적으로 저장한 데이터를 조회하기 위한 생성.
📌 EmailVerificationService.java
package ddururi.bookbookclub.domain.emailverification.service;
import ddururi.bookbookclub.domain.emailverification.entity.EmailVerification;
import ddururi.bookbookclub.domain.emailverification.repository.EmailVerificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class EmailVerificationService {
private final RedisTemplate<String, String> redisTemplate;
private final JavaMailSender mailSender;
private final EmailVerificationRepository emailVerificationRepository;
public void sendVerificationEmail(String email) {
String token = UUID.randomUUID().toString();
String redisKey = "email:verify:" + token;
redisTemplate.opsForValue().set(redisKey, email, 10, TimeUnit.MINUTES);
String link = "http://localhost:8080/api/email/verify?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("[북북클럽] 이메일 인증");
message.setText("이메일 인증을 위해 아래 링크를 클릭해주세요:\n" + link);
mailSender.send(message);
}
public boolean verifyEmail(String token) {
String redisKey = "email:verify:" + token;
String email = redisTemplate.opsForValue().get(redisKey);
if (email == null) return false;
redisTemplate.delete(redisKey);
Optional<EmailVerification> optional = emailVerificationRepository.findByEmail(email);
EmailVerification verification = optional
.map(existing -> {
existing.markAsVerified();
return existing;
})
.orElseGet(() -> EmailVerification.builder()
.email(email)
.verified(true)
.verifiedAt(LocalDateTime.now())
.build());
verification.markAsVerified();
emailVerificationRepository.save(verification);
return true;
}
public boolean isEmailVerified(String email) {
return emailVerificationRepository.existsByEmailAndVerifiedIsTrue(email);
}
}
📌 이메일 인증 관련 API 생성 (EmailVerificationController)
package ddururi.bookbookclub.domain.emailverification.controller;
import ddururi.bookbookclub.domain.emailverification.service.EmailVerificationService;
import ddururi.bookbookclub.global.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/email")
@RequiredArgsConstructor
public class EmailVerificationController {
private final EmailVerificationService emailVerificationService;
@PostMapping("/verify-request")
public ResponseEntity<ApiResponse<Void>> requestEmailVerification(@RequestParam String email) {
emailVerificationService.sendVerificationEmail(email);
return ResponseEntity.ok(ApiResponse.success(null, "이메일 인증 메일이 발송되었습니다."));
}
@GetMapping("/verify")
public ResponseEntity<ApiResponse<String>> verifyEmail(@RequestParam String token) {
boolean result = emailVerificationService.verifyEmail(token);
if (result) {
return ResponseEntity.ok(ApiResponse.success("이메일 인증이 완료되었습니다."));
} else {
return ResponseEntity.badRequest().body(ApiResponse.fail("유효하지 않거나 만료된 인증 링크입니다."));
}
}
}
📌 [기타 수정] SercurityConfig.java
테스트 하기 전, 시큐리티 제외 범위에 추가해줌. 그래야 403 에러가 안남.
package ddururi.bookbookclub.global.config;
import ddururi.bookbookclub.global.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/users/login",
"/api/users",
"/api/users/check-email",
"/api/users/check-nickname",
"/api/email/**" //추가
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}
📌 Redis 실행
1. Redis 실행
터미널에서 아래 명령어로 Redis 실행함.
docker run --name bookbook-redis -p 6379:6379 -d redis
- --name bookbook-redis: 컨테이너 이름
- -p 6379:6379: 내 컴퓨터 6379포트를 컨테이너의 Redis 포트에 연결
- -d redis: 백그라운드에서 redis 이미지 실행
처음 실행할 땐 redis 이미지를 자동으로 다운로드함 (인터넷 필요)
2. 정상 실행 확인
docker ps
(실제 출력 화면)
📌 도커란? (+ 도커 설치방법)
도커(Docker) (정의, 사용하는 이유, 설치법)
도커(Docker)란?개발환경을 통째로 포장해서 어디서든 똑같이 실행되게 도와주는 도구개발, 테스트, 배포까지 전 과정을 더 쉽고 빠르게, 일관되게 만들어주는 도구내 컴퓨터에서 잘 되던 프로그
ddururiiiiiii.tistory.com
📌 이메일 인증에 Redis를 사용하는 이유
항목 | 설명 |
1. 인증 토큰은 임시 데이터 | 인증 링크는 일정 시간(예: 10분)만 유효해야 함 |
2. TTL 지원 | Redis는 key마다 유효 시간(TTL)을 쉽게 설정할 수 있음 |
3. 빠른 읽기/쓰기 | DB보다 훨씬 빠름 → 실시간 토큰 검증에 유리 |
4. 보안 | 토큰은 민감 정보 → 짧게 보관하고 바로 삭제하는 구조가 적절 |
5. 재사용 방지 | 한 번 인증에 사용된 토큰은 바로 삭제 (1회용) |
Redis는 "빠르고, 임시적이고, 자동 삭제되는" 인증 토큰 저장소로 최적화된 도구
📌 이메일 인증에 DB를 사용하는 이유
항목 | 설정 |
1. 영구 저장 필요 | 어떤 이메일이 인증되었는지 기록으로 남겨야 함 |
2. 회원가입 조건 검증 | 이메일 인증된 사용자만 회원가입 가능하게 하기 위해 |
3. 재요청/재가입 차단 | 이메일 인증 여부를 다시 확인할 수 있어야 함 |
4. 관리자 조회 | 누가 인증했는지, 언제 인증했는지 추적 가능 |
5. 통계, 로깅 | 인증 성공률 분석, 이메일 인증 패턴 분석 등에 활용 가능 |
DB는 "영구적이고 조회 가능한 상태 저장소"로써 최종 인증 여부를 보관하는 역할
[Postman으로 테스트]
POST http://localhost:8080/api/email/verify-request
email : your_email@gmail.com
아래와 같이 메일이 발송되고 메일 안에 URL을 클릭하면 이메일 인증이 완료됨.
테스트 중, 반대로 인증이 안된 이메일을 했을 때는 403 에러가 터져버리는 문제 발생.
그냥 403에러가 터저버리는 이유는
우리는 예외를 403 또는 400으로 감싸서 JSON 응답으로 주고 싶은데, 현재는 아무도 이 예외를 잡지 않고 있어서 Spring이 알아서 처리하다 보니 결과적으로 Postman에서는 그냥 HTML 403/500 에러 페이지가 보이는 상황.
- UserService.signup() 메서드 내부에서 throw new IllegalStateException("이메일 인증이 완료되지 않았습니다.") 를 던졌는데
- 이 예외가 전역 예외 처리(@ControllerAdvice 등)*서 처리되지 않아서 응답으로 JSON 에러가 안 나오고, 서블릿 레벨에서 500 에러 로그가 찍히고 있음.
✅ 해결 방법: 전역 예외 처리 추가
📁 GlobalExceptionHandler.java
package ddururi.bookbookclub.global.exception;
import ddururi.bookbookclub.global.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalState(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail(e.getMessage()));
}
}
다시 테스트해보면 아래와 같이 JSON 형태로 에러 메시지가 응답되는것을 확인할 수 있음.
728x90
320x100
'💻 뚝딱뚝딱 > 북북클럽' 카테고리의 다른 글
[개발일지 #008] 로그인 구현3 (refreshToken 재발급, AccessToken 블랙리스트 기능) (0) | 2025.04.23 |
---|---|
[개발일지 #007] 로그인 구현2 (refreshToken 도입) / 로그아웃 (0) | 2025.04.23 |
[개발일지 #005] 로그인 구현 (Feat.JWT 기반 인증) (0) | 2025.04.22 |
[개발일지 #004] 회원 정보 수정 API 구현 (0) | 2025.04.22 |
[개발일지 #003] 회원(User) 도메인 회원가입 API 구현 및 테스트 (0) | 2025.04.22 |