회원인증절차
해당 프로젝트는 UI는 제공하지 않는 API 서버 구축 프로젝트라 인증절차를 글로 표현하면 다음과 같다.
1. 이메일을 입력한다.
2. 인증번호메일을 발송한다.
3. 메일을 확인한 후 인증번호를 입력한다.
4. 인증번호가 맞으면 해당 메일의 인증이 된다.
5. 그 이후에 회원가입 절차를 진행한다.
구글메일인증
구글메일계정을 이용해 메일을 전달하기 때문에 전달용 메일을 만들기 위한 절차가 필요하다.
우선 `Google 계정관리`라는 메뉴에 들어가 `앱`을 검색하면 다음화면과 같이 `앱 비밀번호`가 뜨게 된다.
그럼 다음과 같이 화면이 나오게되는데 `앱 이름`을 입력하고 `만들기`를 누르면 `패스워드`를 제공해 준다. 그 패스워드를 어디에다 기록해 둔 다음 후에 SpringBoot 메일세팅에 활용할 예정이다.
그런 다음 계정 Gmail 화면으로 들어간다. `설정`창을 열어보면 `전달 및 POP/IMAP` 메뉴를 클릭한다. 그리고 다음 그림의 표시처럼 선택하고 `변경사항 저장`을 누른다. 이렇게 까지 하면 전송이메일 세팅이 끝이 난다.
SpringBoot 메일전송 세팅 (v3.3.6)
세팅
springboot에서 메일을 보내기 위해선 우선 `build.gradle`에 다음 라이브러리를 추가해야한다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-mail'
...
}
그런 다음 application.yml 파일을 세팅해줘야한다.
spring:
mail:
host: smtp.gmail.com #이메일 전송에 사용할 메일 서버의 호스트 이름으로 Gmail을 활용한다.
port: 587 #Gmail 포트로 default값
username: abcdea12345@gmail.com #위에서 설정한 메일주소를 입력한다.
password: looo uooo qooo gooo #위에서 세팅 후 받은 패스워드를 입력한다.
properties: # 말그대로 세팅정보로 후에 Config 설정에서 사용된다.
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
auth-code-expiration-millis: 1800000 # 30 * 60 * 1000 == 30분
Spring Boot에서 이메일 전송을 설정하기 위한 application.yml 파일의 각 항목이 의미하는 내용을 아래와 같다.
1. 메일 서버 설정
spring:
mail:
host: smtp.gmail.com
- host: 이메일 전송에 사용할 메일 서버의 호스트 이름이다. 여기서는 Gmail의 SMTP 서버를 사용한다.
port: 587
- port: SMTP 서버와 통신하기 위한 포트 번호이다. Gmail은 일반적으로 587번 포트를 TLS통신에 사용한다.
2. 메일 인증 계정 정보
username: abcdea12345@gmail.com
- username: 이메일 전송에 사용할 계정의 이메일 주소이다. 위에서 설정한 Gmail계정이어야 한다.
password: looo uooo qooo gooo
- password: Google 계정에서 발급받은 앱 비밀번호를 입력한다.
3. SMTP 속성
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
- auth: true: SMTP 서버 인증이 필요함을 나타낸디.
- starttls.enable: true: TLS를 사용하여 보안 연결을 활성화한다.
- starttls.required: true: TLS 연결이 필수임을 지정한다.
사실 정확히 어떤 역할을 하는지는 모르겠다.
4. 타임아웃 설정
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
- connectiontimeout: SMTP 서버에 연결하는 데 허용되는 최대 시간(밀리초)이다. 여기서는 5초로 설정
- timeout: 서버에서 응답을 기다리는 최대 시간(밀리초)이다. 5초로 설정
- writetimeout: SMTP 명령을 전송할 때의 최대 대기 시간(밀리초)이다. 5초로 설정
5. 인증 코드 만료 시간
auth-code-expiration-millis: 1800000
- auth-code-expiration-millis: 인증 코드가 만료되기까지의 시간(밀리초)이다. 30분(30 * 60 * 1000 = 1800000 밀리초)으로 설정
Config 설정
Spring에서 위와 같은 EmailConfig 설정을 사용하는 이유는 `JavaMailSender`를 설정하고 애플리케이션에서 이메일 전송을 손쉽게 처리하기 위해서이다. 이메일 전송은 외부 SMTP 서버와 통신하는 작업이므로 세부적인 SMTP 설정이 필요하다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
@Configuration
public class EmailConfig {
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Value("${spring.mail.properties.mail.smtp.starttls.enable}")
private boolean starttlsEnable;
@Value("${spring.mail.properties.mail.smtp.starttls.required}")
private boolean starttlsRequired;
@Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
private int connectionTimeout;
@Value("${spring.mail.properties.mail.smtp.timeout}")
private int timeout;
@Value("${spring.mail.properties.mail.smtp.writetimeout}")
private int writeTimeout;
@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
mailSender.setDefaultEncoding("UTF-8");
mailSender.setJavaMailProperties(getMailProperties());
return mailSender;
}
private Properties getMailProperties() {
Properties properties = new Properties();
properties.put("mail.smtp.auth", auth);
properties.put("mail.smtp.starttls.enable", starttlsEnable);
properties.put("mail.smtp.starttls.required", starttlsRequired);
properties.put("mail.smtp.connectiontimeout", connectionTimeout);
properties.put("mail.smtp.timeout", timeout);
properties.put("mail.smtp.writetimeout", writeTimeout);
return properties;
}
}
후에 `JavaMailSender`을 생성자 주입받아 메일을 보낼 것이기 때문에 그것을 위한 설정이라고 생각하면 된다.
구현코드
위에서 Bean 등록한 `JavaMailSender`을 생성자주입받아 메일전송 코드를 구현 하였다.
package com.firstcomesystem.domain.users.service;
import com.firstcomesystem.common.exception.MailSendingException;
import com.firstcomesystem.common.util.AuthCodeGenerator;
import com.firstcomesystem.common.util.EmailType;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class MailSenderServiceImpl implements MailSenderService {
private final JavaMailSender javaMailSender;
@Override
public void sendMail(String email, String authCode) {
EmailType emailType = EmailType.SIGNUP;
String subject = emailType.getSubject();
String content = emailType.getContent(authCode);
MimeMessage message = javaMailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message,true,"utf-8");
helper.setTo(email); // customer email
helper.setSubject(subject); // email title
helper.setText(content,true); // content, html: true
javaMailSender.send(message);
} catch (MessagingException e) {
throw new MailSendingException();
}
}
}
1. EmailType
EmailType은 이메일 템플릿이라 생각하면 된다. 용도의 따라 메일의 정의되는 HTML이 있기 때문에 그것을 정의해 둔 enum이라고 생각하면 된다.
다음과 같은 코드구성을 가지면 제목과 본문 내용으로 구성되어 있다.
import lombok.Getter;
@Getter
public enum EmailType {
SIGNUP("회원 가입을 위한 이메일입니다!",
"이메일을 인증하기 위한 절차입니다.<br><br>인증 번호는 <b>{authCode}</b> 입니다.<br>회원 가입 폼에 해당 번호를 입력해주세요.");
private final String subject;
private final String content;
EmailType(String subject, String content) {
this.subject = subject;
this.content = content;
}
public String getContent(String authCode) {
return content.replace("{authCode}", authCode);
}
}
이 프로젝트에는 메일을 보내는 게 한 군데뿐이라 다음과 같이 하드 하게 타입을 박아서 사용하였다.
EmailType emailType = EmailType.SIGNUP;
`
2. MimeMessage
JavaMail API를 사용하여 HTML 이메일, 첨부 파일, 이미지 삽입 등 복잡한 이메일을 전송할 때 사용하는 클래스이다. 우린 HTML형태의 메일전송이 이뤄져야 하므로 해당 클래스를 사용하였다.
MimeMessageHelper는 MimeMessage의 복잡한 설정(HTML, 첨부 파일, 이미지 등)을 간단히 도와주는 유틸리티 클래스이다.
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message,true,"utf-8");
helper.setTo(email); // customer email
helper.setSubject(subject); // email title
helper.setText(content,true); // content, html: true
3. send()
javaMailSender.send(message);
Redis 사용
`redis`를 사용한 이유를 인증메일의 TTL을 추가해 휘발성데이터로 만들기 위해서이다.
세팅
우선 나 같은 경우 docker-compose.yml을 사용하여 redis설정을 하였다. docker-compose 설정의 경우 다양하게 설정할 수 있으니 참고만 바라며, redis를 도커 컨테이너에 설치하여 사용하였다고 생각하면 될 것 같다.
services:
my-cache-server:
image: redis
ports:
- 6379:6379
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 10
[...]
application.yml 설정
spring:
data:
redis:
host: localhost
port: 6379
intellij 실행설정이라 `localhost`로 설정되어 있으며, 만약 docker에서 jar로 springboot를 빌드해야 한다면 docker-container 네이밍을 사용해야 되는 것을 알고 있다.
SpringBoot 3 버전 이후엔 `spring.data.redis` 형태로 변경되었다. 기존에는 `spring.redis`형태로 선언되었다.
구현
구현의 경우 redisRepository와 그걸 이용해 service로직을 구현하는 코드로 구성된다.
`Spring Data Redis`의 `RedisTemplate`을 활용해 Redis와 통신하는 `Repository`구현코드이다. `JpaRepository`를 상속받은 `Repository`와 비슷한 역할을 한다고 생각하면 된다.
import com.firstcomesystem.domain.users.repository.AuthCodeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
@Component
public class RedisAuthCodeRepository implements AuthCodeRepository {
private final RedisTemplate<String, String> redisTemplate;
@Override
public void saveAuthCode(String email, String authCode, long expirationTime) {
redisTemplate.opsForValue().set(email, authCode, expirationTime, TimeUnit.SECONDS);
}
@Override
public String getAuthCode(String email) {
return redisTemplate.opsForValue().get(email);
}
@Override
public void deleteAuthCode(String email) {
redisTemplate.delete(email);
}
}
해당 Repository를 이용해 만든 Service코드이다.
import com.firstcomesystem.domain.users.repository.AuthCodeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class AuthCodeService {
private final AuthCodeRepository authCodeRepository;
private static final long EXPIRATION_TIME = 300L; // 5분
public void saveAuthCode(String email, String authCode) {
authCodeRepository.saveAuthCode(email, authCode, EXPIRATION_TIME);
}
public String getAuthCode(String email) {
return authCodeRepository.getAuthCode(email);
}
public void deleteAuthCode(String email) {
authCodeRepository.deleteAuthCode(email);
}
}
최종코드
최종적으로 아래와 같은 코드구성을 갖게 된다.
import com.firstcomesystem.common.util.AuthCodeGenerator;
import com.firstcomesystem.domain.users.dto.UserCommand;
import com.firstcomesystem.domain.users.dto.UserInfo;
import com.firstcomesystem.domain.users.service.AuthCodeService;
import com.firstcomesystem.domain.users.service.MailSenderService;
import com.firstcomesystem.domain.users.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class UserFacade {
private final UserService userService;
private final MailSenderService mailSenderService;
private final AuthCodeService authCodeService;
[...]
// 인증코드 전송
public void requestAuthCode(String email) {
// 1. 이미 가입된 메일인지 체크
// 2. 인증메일 발송
// 3. Redis에 메일과 인증번호 저장
userService.checkEmailAvailability(email);
String authCode = AuthCodeGenerator.generateAuthCode();
mailSenderService.sendMail(email, authCode);
authCodeService.saveAuthCode(email, authCode);
}
// 인증코드 인증
public boolean verifyAuthCode(String email, String inputAuthCode) {
String savedAuthCode = authCodeService.getAuthCode(email);
if (savedAuthCode == null || !savedAuthCode.equals(inputAuthCode)) {
return false;
}
authCodeService.deleteAuthCode(email);
return true;
}
[...]
}