토이 프로젝트 스터디 #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())로 해결