[BOOK_P] 메일 발송 기능 구현 및 가입 로직
설계 구성
이해를 위해 시퀀스 차트를 그려 큰 흐름을 먼저 공유하고자 합니다.
시퀀스 차트는 큰 범위의 기능을 기준으로 작성중이며, 아래 차트는 ‘회원가입’ 기능을 중심으로 합니다.
개인 감상기록과 데이터가 위주인 사이트이기에 임의의 아이디로 무작위 계정 생성이 가능한 것 보다는 실제 메일로 가입을 오픈하는 것이 좋겠다는 생각을 했습니다. 이를 위해서는 가입 시 이메일을 인증 받는 절차가 필요했습니다. 이에
- 이메일 중복 확인을 가장 먼저 거치고
- 가입 내역이 없다면 인증번호를 생성해 이메일 인증 메일을 발송
- 회원이 인증번호를 정상적으로 전달하면 가입 진행
되는 로직으로 구성했습니다.
MailSender
메일 발송 기능을 사용하기 위해 JAVA의 메일 라이브러리 jakarta.mail API를 사용했습니다.
(참고로 jakartaEE는 과거 JavaEE와 동일하며, 2018년 기준으로 명칭이 변경 됨)
라이브러리 사용을 위해 가장 먼저 의존성 주입을 진행합니다.
<!-- https://mvnrepository.com/artifact/com.sun.mail/jakarta.mail -->
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
발송메일로 사용할 계정을 셋팅해야하는데 패스워드 등 계정정보가 들어가야하기에 application.yml 파일에 별도로 셋팅했습니다.
블로그에는 대략적인 포맷 형식만 기재합니다.
mail :
host : {사용한 smtp 기준 작성}
port : {사용한 smtp 기준 작성}
userid : {메일 아이디}
username : {발송자 이름}
password : {메일 패스워드}
그리고 작성한 정보를 이용해 메일이 발송될 수 있도록 MailSender를 작성해주어야 합니다.
@Component
public class MailSender {
@Value("${mail.password}")
private String mailpasswd;
@Value("${mail.userid}")
private String FROM;
@Value("${mail.username}")
private String FROMNAME;
@Value("${mail.username}")
private String SMTP_USERNAME;
@Value("${mail.host}")
private String HOST;
@Value("${mail.port}")
private int PORT;
public void sender(String To, String Subject, Object Body) throws Exception {
Properties props = System.getProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.port", PORT);
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.ssl.trust", HOST);
props.put("mail.smtp.ssl.protocols", "TLSv1.2");
Session session = Session.getDefaultInstance(props);
MimeMessage msg = new MimeMessage(session);
msg.setFrom(new InternetAddress(FROM, FROMNAME));
msg.setRecipient(Message.RecipientType.TO, new InternetAddress(To));
msg.setSubject(Subject);
if (Body instanceof MimeMultipart) {
msg.setContent((Multipart) Body);
} else {
msg.setContent((String) Body, "text/html;charset=utf-8");
}
Transport transport = session.getTransport();
try {
transport.connect(HOST, SMTP_USERNAME, mailpasswd);
transport.sendMessage(msg, msg.getAllRecipients());
} catch (Exception ex) {
ex.printStackTrace();
} finally {
transport.close();
}
}
}
Properties props = System.getProperties(); props.put(~)
: 발송 정보를 설정하기 위한 프로퍼티스 객체 생성하고 사용할 smtp 서버 정보와 사용자 정보를 담아줍니다. (설정메서드로 추론 가능하겠지만 메일 서버 주소, port, SMTP 인증 기능 사용 여부, ssl 사용 여부 등의 정보들이다)Session session = Session.getDefaultInstance(props)
: 1번에서 설정했던 발송 정보를 주입한 Session 클래스의 인스턴스를 생성합니다MimeMessage msg = new MimeMessage(session); msg.set~
: MimeMessage는 jakartaEE의 mail 라이브러리에서 제공하는 클래스. 이를 이용해서 발송자와 메일 제목 등을 셋팅합니다.if (Body instanceof MimeMultipart) { msg.setContent((Multipart) Body); } else { msg.setContent((String) Body, "text/html;charset=utf-8"); }
: 현재는 인증번호만 발송되기에 text/html만 설정해두어도 괜찮지만, 향후 파일전송 등의 기능이 필요할 수도 있을 것 같아 미리 파일 여부를 체크하는 로직을 설정해두었습니다. MimeMultipart 클래스 역시 jakarta.mail 제공 클래스이며 Multipart 파일과 관련된 클래스입니다.Transport transport = session.getTransport(); transport.connect(HOST, SMTP_USERNAME, mailpasswd);
: smtp 정보를 가지고 와서 커넥션을 하는 역할입니다.transport.sendMessage(msg, msg.getAllRecipients());
: smtp와 연동되었다면 설정 제목과 내용들로 메일이 발송됩니다. 향후 공지 메일등이 발송되어야 한다면 특정 회원 한명이 아니라 여러명을 대상으로 메일을 보내게 될것이기에 다중수신자를 고려하여 미리.getAllRecipients()
를 작성해두었습니다.
MailService
위 mailSender를 포함해서 여러 메일발송 기능들을 작동할 때 적용될 세부 기능들이 적용된 공간인데
이 중 특이내용이 있는 부분만 공유하고자 기술합니다.
- 임시번호 생성
public String generateAuthNo(int num) {
Random rand = new Random();
String authKey = "";
for (int i = 0; i < num; i++) {
String random = Integer.toString(rand.nextInt(10));
authKey += random;
}
return authKey;
}
회원에게 전송할 임시번호를 생성하는 역할을 합니다.
현재는 모두 6자리로 생성되도록 설정해 두었으나, 보안의 문제로 향후 늘리거나 변경하고 싶을 수 있을 것같아 매개변수를 통해 자릿수를 선택할 수 있게 설계했습니다.
- 임시 비밀번호 발급
회원가입과는 상이한 페이지에서 작동되는 유저 서비스라, 아직 Controllter에서는 적용되지 않은 기능입니다.
다만 서비스단 설계하는 과정에서 업무 효율을 위해 함께 작업했기에 mailService 단을 소개하며 함께 작성합니다.
private static final char[] SPECIAL_CHARACTERS = {'!', '@', '#', '$', '%', '^', '&', '*', '(', ')'};
private static final char[] NUMERIC_CHARACTERS = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'};
private static final char[] ALL_CHARACTERS = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'!', '@', '#', '$', '%', '^', '&', '*', '(', ')'};
public String getRamdomPassword() {
return getRandPw({숫자}, SPECIAL_CHARACTERS) + getRandPw({숫자}, ALL_CHARACTERS) + getRandPw({숫자}, NUMERIC_CHARACTERS);
}
private String getRandPw(int size, char[] pwCollection) {
StringBuilder ranPw = new StringBuilder(size);
SecureRandom secureRandom = new SecureRandom();
for (int i = 0; i < size; i++) {
int selectRandomPw = secureRandom.nextInt(pwCollection.length);
ranPw.append(pwCollection[selectRandomPw]);
}
return ranPw.toString();
}
- 특수문자 배열
- 숫자 0~9까지 포함된 배열
- 숫자/문자/특수문자가 모두 포함된 배열
3가지를 만들어 두었으며 고정된 값을 가지는 배열이기에 static 영역에 final로 선언했습니다.
- 임시 비밀번호 특성상 단순 숫자나 문자의 조합이 아니라 어느정도 복잡성을 띄어야하기에
getRamdomPassword
이 호출될때 각 {숫자}의 자릿수만큼 배열에서 값을 조합해 임시 비밀번호를 만들도록 했습니다. (실제 코드에서는 조합하고자 하는 크기만큼 {숫자}의 값을 적어야함) getRandPw
가 매개변수로 넘겨준 배열에서 설정한 자릿수만큼 추출하는 역할을 할 것입니다.- 문자열의 변경이 잦을 때는 StringBuilder의 성능이 향상되기에 String 대신 StringBuilder를 사용해 구현했습니다.
- 또 난수 생성시 Math.random()은 안전성인 측면에서 좋지 않다고 들어 SecureRandom를 이용해 난수를 생성했습니다.
Math.random() vs SecureRandom
최근 자바 복습을 진행하면서 Math.random()가 암호학적인 면에서 그리 안전하지 않다는 얘기를 들었습니다.
자바를 처음 배울 때부터 난수생성 = Math.random() 로만 알고있었는데 기능을 구현하며 이 참에 익혀둘수 있는 기회라 생각해 깊게 찾아보았습니다.
Random은 사람의 눈으로는 난수로 보이기는 하지만 진짜 ‘랜덤’의 수를 반환하는 것이 아니라고 합니다.
구체적으로 이야기하자면 지정한 범위 내에서 여러가지 난수표를 만들고, 알고리즘을 통해서 난수표를 선택해 매번 다른 값을 출력해내는 구조였습니다.
어찌됐든 매번 다른 값이 출력되는데 무슨문제인가 싶겠지만 어떤 난수표를 선택하는지(이하 시드라고 표현)가 Math.random() 난수 생성의 핵심이고, 시드 생성시 시스템 시간을 입력으로 받기때문에 공격자가 시드 생성시간을 알게된다면 위험할수 있습니다. 시드값이 동일하면 리턴되는 수도 같아지기 때문입니다.
SecureRandom는 알고리즘에 시스템 시간을 받는 것이 아니라 운영체제로 부터 임의의 데이터를 받습니다.
48비트를 사용하는 Random과 달리 128비트를 사용하기 때문에 중복되는 수가 나올 확률도 현저히 적고, 알고리즘이나 테스트 역시 보다 준수합니다.
=> 난수 만들어야 하는 경우 Math.random()의 사용을 최대한 피하고 SecureRandom로 사용해야 합니다.
구현 결과
시퀀스 다이어그램에서 목표한 대로 아래 API들을 생성하였으며,
각각의 api에 요청이 들어오면 실행될 수 있도록 처리하였습니다.
- id_dupl_check (UserController) : 메일 가입가능 여부를 체크를 하는 역할
- mail_confirm (UserController) : 입력된 메일로 가입 확인용 인증번호를 발송하는 역할
- mailSender (MailService) : 메일 발송 정보 설정 및 발송 기능 실제 수행
- mailSender (MailService) : 메일 발송 정보 설정 및 발송 기능 실제 수행
- chg_pw (UserController) : 입력된 메일로 임시 비밀번호를 발송하는 역할
- mailSender (MailService)
- generateAuthNo (MailService) : 인증번호용 난수 생성 역할
- mailSender (MailService)
- getRandPw, getRamdomPassword (MailService) : 임시비밀번호 생성 역할
- email_check (UserController) : 인증번호를 검증하고 유저의 상태를 정규회원으로 변경하는 역할
- join_mail (UserController) : 입력된 정보를 검증하고 회원을 추가하는 역할
- member_del (UserController) : 로그인 정보 및 동의여부를 검증하고 회원을 탈회처리하는 역할
- member_modInfo (UserController) : 입력된 정보 값에 따라 회원 정보 및 암호를 업데이트 하는 역할
결과 및 느낀점
- 각 api 테스트를 진행하였을때 입력값에 따라 원하는 정상/오류값이 출력되는 것을 확인하였습니다.
- 메일 발송 테스트를 진행하였을때 사용중인 여러 메일 사이트들의 계정에 정상적으로 수신되는 점 확인하였습니다.
- MailSender 작업을 진행하며 smtp 개념에 대해 구체적으로 체득하게 되었습니다.
- 난수 생성시 Math클래스의 Random을 권장하지 않는다는 점은 알았으나 모호하게 인지만 하고 있었는데, 이번 계기로 정확한 문제 사유와 대처 클래스를 파악했습니다.