7.18(목) 백엔드 회의에서 API 명세서를 바탕으로 역할 분배를 했다. 어떻게 나눌까 하다가 최대한 동시에 작업할 수 있도록 도메인 별로 5~7개 정도씩 나눴고, 나는 그중에서 회원 도메인을 담당하게 됐다. 크게 보면 회원가입과 로그인, 로그아웃, 회원 정보 조회 API를 구현해야 했다.
소셜 로그인(애플)에서 이메일 인증 방식을 사용한 회원가입으로 바꾸기로 해서 구글과 GitHub에 관련된 내용을 검색해 봤다. 이미 로그인을 구현해 본 제아가 공유해 준 본인의 블로그 글도 참고하면서 진행했다.
개발 환경은 아래와 같다.
- Java 17
- Spring Boot 3.3.1
- Gradle
- IntelliJ(IDE)
- MySQL(Main DB), Redis(Sub DB)
Redis
보통 이메일 인증 방식을 사용할 때 인증 코드를 입력하는 데에 시간 제한을 두는 경우가 많다. 한 번 쓰이고 마는 인증 코드를 DB에 영구적으로 저장하면 사용자가 얼마 없을 때는 괜찮지만, 사용자가 10만 명이라면 코드도 10만 개를 저장해둬야 하고 이는 서버에 부담을 주게 된다. 따라서 TTL(만료 시간)을 설정할 수 있는 Redis를 Sub DB로 사용해 인증 코드를 저장하기로 했다.
Redis에 대한 추가적인 설명은 아래 링크와 관련된 글들을 보면 확인할 수 있다.
SMTP
AWS에서 설명하는 내용은 아래와 같다.
SMTP는 Simple Mail Transfer Protocol의 약자로, 인터넷을 통해 이메일 메시지를 보내고 받는 데 사용되는 통신 프로토콜이다. 메일 서버 및 기타 메시지 전송 에이전트(MTA)는 SMTP를 사용하여 메일 메시지를 보내고, 받고, 중계한다.
https://aws.amazon.com/ko/what-is/smtp/
Gmail로 이메일을 보내기 위해 [Google 계정 설정 > 보안 > 2단계 인증 > 앱 비밀번호]에서 앱 비밀번호를 만들어야 한다.
[만들기]를 클릭하면 아래와 같은 화면이 뜨는데, 이 화면이 꺼지면 따로 확인할 수 없기 때문에 16자리 비밀번호를 따로 적어놔야 한다. (보안 문서에 따로 적어두는 것이 좋다.)
이후 [Gmail 설정 > 전달 및 POP/IMAP]을 아래 사진처럼 설정하면 Google SMTP와 관련된 모든 설정은 끝난다.
IMAP를 사용하도록 설정해야 Spring Boot(다른 클라이언트)에서 Gmail에 접근해 이메일을 보낼 수 있다. 발신 메일(SMTP) 서버의 host는 smtp.gmail.com이고 포트 번호는 587이다.
코드
1. 의존성 및 라이브러리 추가
build.gradle
아래 의존성을 새로 추가하고 Gradle Reload 버튼을 눌러야 적용할 수 있다.
dependencies {
...
// spring mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
}
application.yml
spring:
mail:
host: smtp.gmail.com
port: 587
username: kej431003@gmail.com # 아래 앱 비밀번호를 생성한 이메일 입력
password: ${MAIL_PASSWORD} # 앱 비밀번호; Spring Boot 프로젝트 환경변수에 저장
properties:
mail:
smtp:
auth: true # SMTP 서버에 인증해야 하는 경우 true로 지정(Gmail은 필수)
starttls: # TLS = 데이터를 암호화해 안전한 전송을 보장하는 프로토콜
enable: true
required: true
timeout: 5000 # 클라이언트가 SMTP 서버로부터 응답을 대기해야 하는 시간(msec)
data:
redis:
host: localhost
port: 6379
cache:
type: redis # Redis를 캐시로 활용할 때 설정; Redis를 설치하고 redis-cli.exe를 실행해야 함
HaruchiApplication.java
Spring Security 의존성을 추가하게 되면 swagger 초기 화면에 아이디와 비밀번호를 입력하라는 창이 뜨게 된다. 매번 입력하기엔 번거로워서 아래 주석 처리한 코드 뒤에 exclude 옵션을 넣어 Security의 자동 설정 정보 class를 제외하게 했다.
//@SpringBootApplication
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
@EnableJpaRepositories
@EnableJpaAuditing
@EnableScheduling
public class HaruchiApplication {
public static void main(String[] args) {
SpringApplication.run(HaruchiApplication.class, args);
}
}
2. Config(설정 정보) 추가
EmailConfig.java
위의 application.yml에서 설정한 변수들을 @Value 어노테이션으로 가져와 JavaMailSenderImpl 객체를 구현한다. javaMailSender 빈을 통해 메일을 보낼 수 있다.
@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.timeout}")
private int timeout;
@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 props = new Properties();
props.put("mail.smtp.auth", auth);
props.put("mail.smtp.starttls.enable", starttlsEnable);
props.put("mail.smtp.starttls.required", starttlsRequired);
props.put("mail.smtp.timeout", timeout);
return props;
}
}
RedisConfig.java
Lettuce라는 라이브러리를 활용해 Redis 연결을 관리하는 객체를 생성하고, Redis 서버에 대한 정보(host, port)를 설정한다. Redis를 캐시로 쓰진 않을 거기 때문에 캐시 매니저는 따로 생성하지 않았다.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
// lettuce
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
// Redis에 Key와 Value를 저장할 때 String으로 직렬화(변환)해서 저장하는 template 생성
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
Redis 캐싱 관련 자료는 아래 링크를 보면 확인할 수 있다.
3. 회원가입 API 구현 (+이메일 인증 요청 및 검증 API 구현)
Mail 관련 class를 따로 만들지 않고 Member 관련 class에서 처리하도록 구현했다.
MemberApiController
MemberRequestDTO로 들어오는 값들을 MemberService에서 처리하고 MemberConverter를 통해 MemberResponseDTO로 바꾼 뒤, 응답을 통일시키기 위해 ApiResponse에 담아 클라이언트에게 전송한다. Controller의 파라미터에 있는 @Valid 어노테이션과 Service 단에서 오류를 체크하도록 하고, 오류가 발생하면 ApiResponse 형식으로 클라이언트에게 전송한다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "member", description = "회원 관련 API")
public class MemberApiController {
private final MemberService memberService;
@PostMapping("/signup")
@Operation(summary = "회원가입 API", description = "이메일 인증으로 회원가입을 진행하는 API (액세스 토큰 필요 없음)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4005", description = "존재하지 않는 회원입니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MONTHBUDGET4001", description = "한 달 예산이 존재하지 않습니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
})
public ApiResponse<MemberResponseDTO.MemberJoinResultDTO> join(@Valid @RequestBody MemberRequestDTO.MemberJoinDTO request) throws Exception {
Member member = memberService.joinMember(request);
memberService.connectToDayBudget(member.getId());
return ApiResponse.onSuccess(MemberConverter.toJoinResultDTO(member));
}
@PostMapping("/signup/password")
@Operation(summary = "비밀번호 2차 확인 API", description = "회원이 입력한 비밀번호와 확인용 비밀번호를 비교하는 API (액세스 토큰 필요 없음)")
@Parameters({
@Parameter(name = "password", description = "회원가입을 진행할 비밀번호"),
@Parameter(name = "checkPassword", description = "확인용 비밀번호")
})
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4006", description = "비밀번호가 일치하지 않습니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
})
public ApiResponse<MemberResponseDTO> verifyPassword(@RequestParam("password") String password,
@RequestParam("checkPassword") String verifyPassword) throws Exception {
memberService.checkPassword(password, verifyPassword);
return ApiResponse.onSuccess(null);
}
@PostMapping("/signup/email")
@Operation(summary = "이메일 인증 요청 API", description = "이메일에 인증 번호를 보내는 API (액세스 토큰 필요 없음)")
@Parameters({
@Parameter(name = "email", description = "인증을 받을 메일 주소")
})
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4002",description = "이미 있는 이메일입니다."),
})
public ApiResponse<MemberResponseDTO> sendEmail(@Email(message = "이메일 형식이 올바르지 않습니다.")
@RequestParam("email") String email) throws Exception {
memberService.sendSimpleMessage(email);
return ApiResponse.onSuccess(null);
}
@PostMapping("/signup/email/verify")
@Operation(summary = "이메일 인증 확인 API", description = "이메일 인증 번호를 확인하는 API (액세스 토큰 필요 없음)")
@Parameters({
@Parameter(name = "email", description = "인증을 받은 메일 주소"),
@Parameter(name = "code", description = "받은 인증 코드")
})
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4004",description = "인증 번호가 일치하지 않습니다.")
})
public ApiResponse<MemberResponseDTO> verifyEmail(@Email(message = "이메일 형식이 올바르지 않습니다.")
@RequestParam("email") String email,
@RequestParam("code") String code) throws Exception {
String authCode = memberService.getVerificationCode(email);
memberService.verificationEmail(code, authCode);
return ApiResponse.onSuccess(null);
}
}
MemberService
회원가입을 진행, 이메일 인증 요청 처리, 비밀번호 확인 등을 처리하는 class로, 오류 처리도 함께 진행한다.
- Redis에 인증 번호를 저장할 때, 프론트엔드에서 설정해둔 시간보다 10초에서 30초 정도 길게 저장해야 화면단에서 지연이 발생해도 정상적으로 인증 번호 입력을 처리할 수 있다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final MonthBudgetRepository monthBudgetRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final RedisTemplate redisTemplate;
public static int code;
private final MonthBudgetService monthBudgetService;
private final DayBudgetRepository dayBudgetRepository;
// 회원가입
@Transactional
public Member joinMember(MemberRequestDTO.MemberJoinDTO request) throws Exception {
LocalDate today = LocalDate.now();
Member newMember = MemberConverter.toMember(request);
newMember.encodePassword(passwordEncoder.encode(request.getPassword()));
//회원가입 시 monthBudget 생성
MonthBudget monthBudget = MonthBudgetConverter.toMonthBudgetWithMonth(request.getMonthBudget(), today.getYear(), today.getMonthValue());
monthBudget.setMember(newMember);
monthBudgetRepository.save(monthBudget);
return memberRepository.save(newMember);
}
//dayBudget 생성
@Transactional
public void connectToDayBudget(Long memberId) {
List<DayBudget> dayBudgets = monthBudgetService.distributeDayBudgets(memberId);
dayBudgetRepository.saveAll(dayBudgets);
}
// 비밀번호 확인
@Transactional
public void checkPassword(String password, String verifyPassword) {
if (!password.equals(verifyPassword)) {
throw new MemberHandler(ErrorStatus.PASSWORD_NOT_MATCH);
}
}
// 이메일 중복 체크
@Transactional
public void checkDuplicatedEmail(String email) throws Exception {
Optional<Member> member = memberRepository.findByEmail(email);
if (member.isPresent()) {
throw new MemberHandler(ErrorStatus.EXISTED_EMAIL);
}
}
// 인증 번호를 전송할 메세지 생성
@Transactional
public MimeMessage createMessage(String to) throws Exception {
checkDuplicatedEmail(to);
MimeMessage message = mailSender.createMimeMessage();
code = (int)(Math.random() * 90000) + 100000;
message.setFrom(new InternetAddress("haruchi@haruchi.com", "Haruchi_Admin"));
message.addRecipients(Message.RecipientType.TO, to);
message.setSubject("Haruchi 회원가입 이메일 인증");
String msg = "";
msg += "<h3>" + "Haruchi 이메일 인증 번호입니다." + "</h3>";
msg += "<h1>" + code + "</h1>";
msg += "<h3>" + "감사합니다." + "</h3>";
message.setText(msg, "utf-8", "html");
return message;
}
// 이메일 인증 번호 전송
@Transactional
public void sendSimpleMessage(String to) throws Exception {
MimeMessage message = createMessage(to);
try {
mailSender.send(message);
saveVerificationCode(to, String.valueOf(code));
} catch (MailException es) {
es.printStackTrace();
throw new IllegalArgumentException();
}
}
// 이메일 인증 번호 redis에 저장
public void saveVerificationCode(String email, String code) {
redisTemplate.opsForValue().set("emailVerify" + email, code, 130, TimeUnit.SECONDS);
}
// 이메일 인증 번호 redis에서 얻기
public String getVerificationCode(String email) {
return (String) redisTemplate.opsForValue().get("emailVerify" + email);
}
// 인증 번호로 이메일 인증
public void verificationEmail(String code, String savedCode) throws Exception {
if (!code.equals(savedCode)) {
throw new MemberHandler(ErrorStatus.EMAIL_VERIFY_FAILED);
}
}
}
회원가입은 한달예산과 닉네임, 이메일, 비밀번호를 입력받아 Member Entity로 바꿔 DB에 저장한다. 이때 비밀번호를 그냥 DB에 저장하면 DB가 노출되는 경우 보안상 문제를 일으킬 수 있기 때문에 BCryptPasswordEncoder를 통해 암호화해 저장하도록 구현했다. BCryptPasswordEncoder는 아래 PasswordEncoder Interface를 구현한 class로, BCrypt 해시 함수를 이용해 비밀번호를 암호화한다. DB에 저장된 암호화된 비밀번호와 입력한 비밀번호가 일치하는지 확인하는 matches() 메서드도 지원하기 때문에 간편하게 사용할 수 있다.
public interface PasswordEncoder {
// 비밀번호 암호화
String encode(CharSequence rawPassword);
// 입력한 비밀번호와 DB에 저장된 암호화된 비밀번호를 비교해 T/F 반환
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
아래와 같이 4부터 31까지의 숫자를 넣어 암호화 강도를 설정할 수 있다. 아무 숫자도 넣지 않았을 때 기본 강도는 10이다.
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(17);
}
PasswordEncoder에 대한 추가 정보는 아래 링크에서 확인할 수 있다.
MemberRequestDTO
클라이언트에게 받을 정보들의 틀이 되는 class로, class 안에 static class를 생성해 세부적인 기능별로 DTO를 나눈다. static class의 필드에 @NotNull, @Positive, @Length 등의 노테이션을 붙이면 Controller의 @Valid 어노테이션을 통해 제약 조건대로 값이 들어왔는지 검증할 수 있다.
public class MemberRequestDTO {
@Getter
public static class MemberJoinDTO {
@NotNull(message = "한달예산은 필수 입력 값입니다.")
@Positive(message = "0이 넘는 값을 입력해야합니다.")
private Long monthBudget;
@NotBlank(message = "이름은 필수 입력 값입니다.")
@Length(max = 5)
@Pattern(
regexp = "^[ㄱ-ㅎㅏ-ㅣ가-힣]{1,5}$",
message = "이름은 1~5자의 한글로만 이루어져야 합니다."
)
private String name;
@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Pattern(
regexp = "\\w+@\\w+\\.\\w+(\\.\\w+)?",
message = "이메일 형식이 올바르지 않습니다."
)
private String email;
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
@Pattern(
regexp = "^[a-zA-Z0-9~!@#$%^&*()]{8,30}",
message = "비밀번호는 영어 대소문자, 숫자, 특수 문자로 구성돼야 합니다."
)
@Length(min = 8, max = 30)
private String password;
}
MemberResponseDTO
클라이언트에게 전송할 정보들의 틀이 되는 class로, 객체를 편하게 만들기 위해 Lombok의 @Builder을 이용했다. 클라이언트가 회원가입을 요청하면, 생성된 회원의 번호와 생성 날짜를 전송해 주기 위해 코드를 아래처럼 작성했다.
public class MemberResponseDTO {
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class MemberJoinResultDTO {
Long memberId;
LocalDateTime createdAt;
}
}
MemberConverter
Converter는 DTO를 Entity로 만들고 Entitiy를 DTO로 만드는 역할을 한다.
@RequiredArgsConstructor
public class MemberConverter {
private static final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public static Member toMember(MemberRequestDTO.MemberJoinDTO request) {
return Member.builder()
.monthBudget(request.getMonthBudget())
.name(request.getName())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.build();
}
public static MemberResponseDTO.MemberJoinResultDTO toJoinResultDTO(Member member) {
return MemberResponseDTO.MemberJoinResultDTO.builder()
.memberId(member.getId())
.createdAt(LocalDateTime.now()).build();
}
}
4. Swagger 테스트 결과
참고자료