토이 프로젝트 스터디 #2
- 스터디 진행 날짜 : 5/31
- 스터디 작업 날짜 : 5/28 ~ 5/31
토이 프로젝트 진행 사항
- 스프링 시큐리티 회원가입
- 스프링 시큐리티 OAuth2 소셜 로그인
- 로그
내용
소셜 로그인 공통처리
- 카카오, 네이버, 구글 소셜 로그인 적용
- 소셜 로그인마다 공통된 필드 & 메소드를 처리하고자 추상화 사용
public abstract class AbstractOAuth2Attribute {
protected Map<String, Object> attributes;
protected String attributeKey;
protected String email;
protected String nickname;
protected String profile;
public Map<String, Object> convert() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("key", attributeKey);
map.put("nickname", nickname);
map.put("email", email);
map.put("profile", profile);
return map;
}
}
- 필드
AbstractOAuth2Attribute
를 상속받을 하위 클래스에서도 필드에 접근해야 하기 때문에 접근제어자 protected
사용
convert()
DefaultOAuth2User
의 필드 attributes
의 값을 세팅하기 위해 Map<String, Object>
로 변환할 필요가 있음
- 공통으로 제공해야 할 기능이므로 상위 클래스에 정의
public class GoogleOAuth2Attribute extends AbstractOAuth2Attribute {
@Builder
private GoogleOAuth2Attribute(Map<String, Object> attributes, String attributeKey,
String email, String nickname, String profile) {
this.attributes = attributes;
this.attributeKey = attributeKey;
this.email = email;
this.nickname = nickname;
this.profile = profile;
}
public static GoogleOAuth2Attribute of(String attributeKey, Map<String, Object> attributes) {
return GoogleOAuth2Attribute.builder()
.attributes(attributes)
.attributeKey(attributeKey)
.email((String) attributes.get("email"))
.nickname((String) attributes.get("name"))
.profile((String) attributes.get("picture"))
.build();
}
}
public class KakaoOAuth2Attribute extends AbstractOAuth2Attribute {
@Builder
private KakaoOAuth2Attribute(Map<String, Object> attributes, String attributeKey,
String email, String nickname, String profile) {
this.attributes = attributes;
this.attributeKey = attributeKey;
this.email = email;
this.nickname = nickname;
this.profile = profile;
}
public static KakaoOAuth2Attribute of(String attributeKey, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return KakaoOAuth2Attribute.builder()
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.email(String.valueOf(attributes.get("id")))
.nickname((String) kakaoProfile.get("nickname"))
.profile((String) kakaoProfile.get("profile_image_url"))
.build();
}
}
public class NaverOAuth2Attribute extends AbstractOAuth2Attribute {
@Builder
private NaverOAuth2Attribute(Map<String, Object> attributes, String attributeKey,
String email, String nickname, String profile) {
this.attributes = attributes;
this.attributeKey = attributeKey;
this.email = email;
this.nickname = nickname;
this.profile = profile;
}
public static NaverOAuth2Attribute of(String attributeKey, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return NaverOAuth2Attribute.builder()
.attributes(response)
.attributeKey(attributeKey)
.email((String) response.get("email"))
.nickname((String) response.get("nickname"))
.profile((String) response.get("profile_image"))
.build();
}
}
public class OAuth2AttributeUtils {
public static AbstractOAuth2Attribute of(String provider, String attributeKey, Map<String, Object> attributes) {
switch (provider) {
case "kakao" :
return KakaoOAuth2Attribute.of("id", attributes);
case "naver" :
return NaverOAuth2Attribute.of("email", attributes);
case "google" :
return GoogleOAuth2Attribute.of(attributeKey, attributes);
default :
throw new UnSupportedSocialLoginException();
}
}
}
- 어떤
AbstractOAuth2Attribute
의 하위클래스를 반환할지 결정하는 유틸 클래스
of()
메소드의 경우 메소드 시그니처가 동일하기 때문에 공통 처리를 하고 싶었음
static
을 붙여 인스턴스 생성 없이 메소드를 호출하고 싶었기 때문에 불가능
- 더 좋은 방법은 없을지?
이메일 인증
- 회원 가입 시 랜덤한
UUID
를 생성
UUID
를 포함한 링크를 메일로 보내 링크 클릭 시 이메일 인증 완료
UUID
를 어디에 저장할 것인지?
RDB
에 저장하는 것은 좋은 선택이 아니라고 생각
- 최초 인증 시에만 사용되고 그 이후에는 사용되지 않는 값이므로
RDB
에 저장하기 적합하지 않다고 생각
RDB
에는 해당 회원이 이메일을 인증했는지 아닌지만 체크하고자 함
UUID
를 비교하기 위해 RDB
와 통신하는 것은 리소스 낭비라는 생각을 함
- 이메일 인증 시 예외처리
- 이메일 인증 메일이 스팸 처리가 된다거나 하는 이유로 삭제가 된 경우
- 이메일 중복가입은 안 되게 막을 예정
- 회원 가입 시 회원의 정보 자체는
RDB
에 저장됨
- 모종의 이유(스팸 메일 자동 삭제, 부주의 등)으로 인해 이메일 인증 메일이 삭제되었고, 메일 재발송 요청도 하지 못하는 상황이라면?
- 존재하지 않는 이메일
- 존재하지 않는 이메일인 경우에도
RDB
에는 저장됨
- 이 경우 유효하지 않은 데이터가
RDB
에 저장되기 때문에 문제가 될 수 있음
회원 가입 완료 -> 이메일 인증
이 아닌 이메일 인증 -> 회원 가입 완료
로 로직 변경 고려
UUID
저장
Redis
를 활용해 UUID
저장
- 회원 메일 인증 시에만 사용되는 정보를 굳이
RDB
에 저장할 필요가 없어짐
RDB
에서 가져오는 것 보다 효율적
@RequiredArgsConstructor
public class EmailUUIDRedisUtils {
private final RedisTemplate<String, Object> redisTemplate;
private final static String PREFIX = "verify";
private final static String SEPARATOR = ":";
public void setEmailUUID(String email, String uuid) {
redisTemplate.opsForValue().set(getRedisKey(email), uuid, Duration.ofMinutes(30L));
}
public String getEmailUUID(String email) {
return (String) redisTemplate.opsForValue().get(getRedisKey(email));
}
public void deleteEmailUUID(String email) {
redisTemplate.delete(getRedisKey(email));
}
private String getRedisKey(String email) {
return PREFIX + SEPARATOR + email;
}
}
- 단순한 문자열만을 저장할 것이기 때문에
RedisTemplate
사용
key / value
= verify:이메일 / uuid
로 저장하도록 설정
- 해당
uuid
의 유효시간은 30분으로 설정
public void sendMail(String email) {
String uuid = UUID.randomUUID().toString();
emailUUIDRedisUtils.deleteEmailUUID(email);
mailSender.sendMail(email, uuid);
emailUUIDRedisUtils.setEmailUUID(email, uuid);
}
- 메일 전송 시 재전송되는 경우를 고려해 메일 전송 전
Redis
에서 uuid
삭제
public boolean verifyUUID(String email, String uuid) {
return uuid.equals(emailUUIDRedisUtils.getEmailUUID(email));
}
- 이후 이메일 인증 시
Redis
에서 값을 꺼내와 비교
이메일 인증 시 예외처리
- 스케쥴러를 통해 인증 메일 유효 시간(30분)이 지나면
RDB
에 저장된 회원 정보를 자동으로 삭제하고자 함
- 메일이 발송된 시간을 기준으로 처리
- 스프링 스케쥴러의 경우 특정 시간 혹은 특정 기간동안 반복되는 기능이라 적합하지 않다고 판단
quartz
사용
public void sendMail(String email) {
mailUtils.sendMail(email);
initEmailVerifyScheduler(email);
}
private void initEmailVerifyScheduler(String email) {
try {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("accountService", accountService);
jobDataMap.put("emailUUIDRedisUtils", emailUUIDRedisUtils);
jobDataMap.put("email", email);
EmailJobRequest jobRequest = EmailJobRequest.builder()
.jobDataMap(jobDataMap)
.jobName("mail delete target : " + email)
.startDateAt(LocalDateTime.now().plusMinutes(1))
.repeatCount(0)
.build();
JobKey jobKey = new JobKey(jobRequest.getJobName(), "DEFAULT");
if (!emailScheduleService.isJobExists(jobKey)) {
emailScheduleService.addJob(jobRequest, EmailJob.class);
} else {
throw new ScheduleCannotAddJobException();
}
} catch (Exception e) {
throw new ScheduleException();
}
}
- 인증 메일 전송 후
Job
추가
Job
에서 사용할 기능을 JobDataMap
에 담아서 전달
ApplicationContext
에서 꺼내올 수도 있겠지만, 등록한 스프링 빈을 단순 조회하기 위해 ApplicationContext
를 Job
에 DI
하는 것보다 낫다고 판단
public class EmailJob extends QuartzJobBean {
private AccountService accountService;
private EmailUUIDRedisUtils emailUUIDRedisUtils;
@Override
@Transactional
protected void executeInternal(JobExecutionContext context) {
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
String email = (String) jobDataMap.get("email");
accountService = (AccountService) jobDataMap.get("accountService");
emailUUIDRedisUtils = (EmailUUIDRedisUtils) jobDataMap.get("emailUUIDRedisUtils");
accountService.deleteAccountByEmail(email);
emailUUIDRedisUtils.deleteEmailUUID(email);
}
}
Job
동작 시 RDB
에 저장된 회원 정보와 Redis
에 저장된 인증용 uuid
삭제
- 여러 번 사용하는
Job
이라면 각 필드에 대해 null
체크 후 할당하겠지만, 해당 Job
은 단 한 번만 실행되기 때문에 무조건 할당하도록 함
Log
- 콘솔에서 로그를 확인할 뿐만 아니라, 로그 타입에 따라 각기 다른 파일로 분류하고자 함
logback-spring.xml
을 통해 조금 더 상세한 설정을 하고자 함
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<property name="LOG_PATH" value="/log"/>
<property name="LOG_ALL" value="log_all"/>
<property name="LOG_DB" value="log_db" />
<property name="LOG_ERR" value="log_err"/>
<property name="LOG_PATTERN" value="[%5level] [%d{yyyy-MM-dd HH:mm:ss}] [%thread] [%logger{0}:%line] :: %msg%n"/>
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_ALL}.%d{yyyy-MM-dd}_%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<appender name="ERR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>error</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_ERR}.%d{yyyy-MM-dd}_%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<appender name="DB_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_DB}.%d{yyyy-MM-dd}_%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<logger name="org.hibernate.SQL" level="DEBUG">
<appender-ref ref="DB_FILE"/>
<appender-ref ref="ERR_FILE"/>
</logger>
<logger name="org.hibernate.tool.hbm2ddl" level="DEBUG">
<appender-ref ref="DB_FILE"/>
<appender-ref ref="ERR_FILE"/>
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE">
<appender-ref ref="DB_FILE"/>
<appender-ref ref="ERR_FILE"/>
</logger>
<logger name="com.project.board" level="ERROR">
<appender-ref ref="ERR_FILE"/>
</logger>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ALL_FILE"/>
</root>
</configuration>
스터디 내용
소셜 로그인 공통처리
public abstract class AbstractOAuth2Attribute {
protected Map<String, Object> attributes;
protected String attributeKey;
protected String email;
protected String nickname;
protected String profile;
public Map<String, Object> convert() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("key", attributeKey);
map.put("nickname", nickname);
map.put("email", email);
map.put("profile", profile);
return map;
}
}
static
을 공통처리할 수 있는 방법을 찾지 못함
회원 도메인
email
/ social_email
과 같이 구분하지 않고 하나의 email
로 처리
enum
을 통해 해당 회원의 타입(일반회원/소셜회원) 구분
SignatureException
- 팀원이 진행한
JWT
과정 중 SignatureException
발생
- 원인은
setSigningKey()
메소드에서 매개변수에 String
타입으로 진행했기 때문
setSigningKey(secretKey.getBytes())
로 해결