토이 프로젝트 스터티 #2

appti·2022년 5월 31일
0

토이 프로젝트 스터디 #2

  • 스터디 진행 날짜 : 5/31
  • 스터디 작업 날짜 : 5/28 ~ 5/31

토이 프로젝트 진행 사항

  • 스프링 시큐리티 회원가입
    • 이메일 인증
    • Redis
  • 스프링 시큐리티 OAuth2 소셜 로그인
  • 로그
    • AOP
    • 파일 저장

내용

소셜 로그인 공통처리

  • 카카오, 네이버, 구글 소셜 로그인 적용
    • 소셜 로그인마다 공통된 필드 & 메소드를 처리하고자 추상화 사용
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에서 꺼내올 수도 있겠지만, 등록한 스프링 빈을 단순 조회하기 위해 ApplicationContextJobDI하는 것보다 낫다고 판단
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())로 해결
profile
안녕하세요

0개의 댓글