1. 스레드 풀의 필요성

MySQL 서버는 멀티스레드 프로그램이다.

 

서버로 요청이 들어올 때마다 배정되는 포그라운드 스레드도 있고

계속 돌고있는 백그라운드 스레드도 있다.

 

그런데 스레드가 너무 많아지면 성능이 떨어진다.

갑자기 요청이 천개가 들어왔다고 해서 스레드를 천개나 만들어버리면, 컨텍스트 스위칭 관련 비용이 커져서 시스템 성능이 저하된다.

(자세한 문제점은 [스레드가 너무 많으면 성능이 저하되는 이유와 해결 방법] 참고)

 

그래서 멀티스레드 프로그램들은 스레드 풀(thread pool)을 이용해서  스레드의 갯수를 제한 한다.

 

2. 스레드 풀 원리

서버는 요청이 들어올 때마다 새 스레드를 생성하는 대신, 스레드 풀에게 작업을 위임한다.

 

스레드 풀은 놀고있는 스레드가 생기면 작업을 스레드에 할당하여 실행되도록 한다.

작업은 내부적으로 큐에 저장되며, 쓰레드 풀의 쓰레드들은 여기서 작업을 빼서 실행한다. 큐에 새로운 작업이 들어오면 놀고 있는 쓰레드가 큐에서 작업을 빼서 실행한다. 이를 두고 쓰레드에 작업이 할당되었다고 하며, 작업이 할당되지 않은 쓰레드들은 큐에 새 작업이 들어올 때까지 대기 상태로 유지된다. 3)

 

예를들어, 스레드풀의 스레드 갯수를 10개로 정해놨다면

갑자기 요청이 100개 들어왔을 때

처음 10개의 요청에 대해서는 스레드를 배정하고 나머지는 큐에다 넣어서 대기시킨다.

 

3. MySQL - Percona Server

MySQL 서버 엔터프라이즈 에디션은 스레드 풀 기능이 내장되어있지만

MySQL 커뮤니티 에디션에서 스레드 풀 기능을 쓰려면 플러그인을 설치해야한다.

책 Real MySQL 8.0 에서는 Percona Server라는 스레드 풀 플러그인을 소개하고 있다.

 

MySQL 서버에 스레드 풀을 설치하기만 하면 무조건 성능이 올라가는게 아니다. 스레드풀로 성능을 높이려면 셋팅을 잘 해줘야 한다.

 

♣ thread_pool_size 시스템 변수

스레드그룹의 갯수를 조정한다.

(스레드 그룹에 대해서는 바로 다음에 나오는 thread_pool_oversubscribe 설명 참고.

보통 스레드그룹은 한번에 한개의 active 스레드를 갖는다.)

일반적으로 이 값을 CPU 코어 갯수와 맞추는 것이 CPU 프로세서 친화도를 높이는데 좋다.

 

 thread_pool_oversubscribe

한개의 스레드 그룹 안에서 몇개의 스레드까지

동시에 active 상태일 수 있게할 것인지.

디폴트 값은 3이다.

 

스레드 그룹은 보통 한번에 한개의 active 스레드를 갖는데,

만약 타이머 스레드가 지연(stall)을 감지하면 스레드 그룹에 active 스레드를 추가할수 있다.

 

thread_pool_oversubscribe 변수 값이 너무 크면

스케줄링해야 할 스레드가 많아져서 스레드풀이 비효율적으로 작동할 수도 있다.

(자세한 내용은 [MariaDB 스레드 풀 시스템 및 상태변수] 의 thread_pool_oversubsribe 설명 참고)

 

 thread_pool_stall_limit

스레드 그룹은 보통 한번에 한개의 active 스레드를 갖는데

만약 타이머 스레드가 지연(stall)을 감지하면 스레드 그룹에 active 스레드를 추가할수 있다고

앞에서 설명했다.

 

만약 모든 스레드 그룹들이 각자 스레드를 처리하는 중이라면

새로운 작업이 있을 때 큐에서 대기시킬지, 아니면 스레드 그룹들 중 하나에 추가시킬지를 정해야한다.

 

이것을 정하는 것은 타이머 스레드이다.

 

타이머 스레드는 주기적으로 스레드 그룹들의 상태를 체크한다.

 

어떤 스레드 그룹의 active 스레드가

thread_pool_stall_limit 시간안에 작업을 끝내지 못하면

타이머 스레드는 지연(stall)이 일어났다고 보고

해당 그룹에 새로운 스레드를 생성해서 추가한다.

  • 응답시간에 민감한 서비스라면 이 변수 값을 적절히 낮춰서 설정해야한다.
    왜냐하면, 스레드의 갯수가 최대일 때 (바로 다음에 설명할 thread_pool_max_threads 갯수에 도달했을 때)
    이 스레드 작업은 최소한 thread_pool_stall_limit 시간만큼 대기해야 한다.
    이 값이 크면 자리가 났는데도 괜히 좀 더 기다리게 될 수 있다.
  • 하지만 이 값을 0에 가까운 값으로 설정하는 것은 권장하지 않는다.
    이럴바엔 스레드 풀을 사용하지 않는 편이 낫다고 함.

 thread_pool_max_threads

전체 스레드 풀에 있는 스레드 갯수의 최댓값.

스레드 총 갯수는 이 값을 절대 넘을 수 없다.

 

- ppt -

4. Percona Server 선순위, 후순위 큐를 이용한 작업 순서 재배치

Percona Server의 스레드 풀 플러그인은 선순위 큐와 후순위 큐를 이용해 특정 트랜잭션이나 쿼리를 우선적으로 처리할 수 있는 기능도 제공한다.

 

그림을 통해 이해해보자.

아래 그림은 사용자로부터 요청이 유입된 순서이다.

아래는 Percona Server의 스레드 풀에서 지원하는 선순위 큐와 후순위 큐를 이용해 재배치한 작업의 순서이다.

 

 

작업 순서를 재배치함으로써 트랜잭션들이 최대한 빨리 종료될 수 있게 한다.

 


참고

1

Real MySQL 8.0 (백은빈, 이성욱)

2

스레드가 너무 많으면 성능이 저하되는 이유와 해결 방법

https://www.codeguru.com/cplusplus/why-too-many-threads-hurts-performance-and-what-to-do-about-it/

3

쓰레드 풀(Thread Pools)

https://parkcheolu.tistory.com/30

4

MariaDB 스레드 풀 시스템 및 상태변수 (thread_pool_size, thread_pool_oversubscribe)

https://runebook.dev/ko/docs/mariadb/thread-pool-system-and-status-variables/index

5

하드웨어 스레드와 소프트웨어 스레드

https://juneyr.dev/thread

6

멀티코어 프로그래밍에서 흔히 발생하는 문제 1부

https://andromedarabbit.net/멀티코어-프로그래밍에서-흔히-발생하는-문제-1부/

 

1. MySQL 구조

 

2. MySQL 엔진 구조

1. 쿼리 파서

쿼리 문장을

토큰 (MySQL이 인식할 수 있는 최소단위의 어휘나 기호) 으로 분리해

트리 구조로 만들어서

전처리기에게 전달.

SQL 문법 오류는 이 과정에서 발견되어  사용자에게 에러 메시지가 전달됨.

2. 전처리기

전달받은 파서 트리를 기반으로

테이블 이름이 존재하는지, 컬럼 이름이 존재하는지, 접근 권한이 있는지

등을 확인해서

문제가 발견되면 오류냄

3. 옵티마이저

쿼리를 최적화해서

최종 실행계획 완성

앞으로 스터디를 통해

옵티마이저가 더 나은 선택을 할 수 있게 유도하는 방법

알게되어야 한다.

4. 쿼리 실행기

스토리지 엔진이 제공하는 핸들러 API를 가지고

옵티마이저가 계획해준 작업 목록을 수행.

 

3. 스토리지 엔진

♣ 핸들러API

MySQL 엔진(의 쿼리실행기)(는) 스토리지 엔진이 제공하는 api 를 이용하여

스토리지 엔진에게 CRUD 작업을 위임한다.

이 때 스토리지 엔진이 제공하는 api를 핸들러api 라고 한다.

♣ 플러그인 모델

핸들러API 규칙을 만족하도록 구현하면

누구든지 스토리지 엔진을 만들어서

MySQL 서버에 추가해서 사용할 수 있다.

(플러그인 모델)

스토리지 엔진 외에도

전문 검색 엔진을 위한 검색어 파서,

사용자 인증 등을 위한 플러그인 등

다양한 기능들이

플러그인으로 구현되어 제공된다.

♣ MySQL이 기본적으로 제공하는 스토리지 엔진

InnoDB, MyISAM 등이 있다.

MySQL 서버에서 MySQL 엔진은 하나지만 스토리지 엔진은 여러개를 동시에 사용할 수 있다.

 

다음과 같이 특정 테이블에 특정 스토리지 엔진을 지정할 수 있다.

이렇게 하면 user 테이블에 대한 CRUD는 innoDB 스토리지 엔진이 처리하게 된다.

create table user(
  id bigint,
  name varchar(50),
  age int
) engine=innodb;

 

 

 

 

이전글에서 구현했던 OAuth2UserService # loadUser() 가 실행이 완료되면
세션 방식 로그인에서는 시큐리티 세션(의 Authentication 객체)에 사용자 정보가 들어가게 됩니다.
(JWT 방식에서는... 별 일 없는 것 같습니다.)

여기까지 수행에 성공하면
AuthenticationSuccessHandler 타입 객체의 onAuthenticationSuccess 가 실행됩니다.

(이렇게 실행되게 하기 위해서
AuthenticationSuccessHandler 타입 객체를 만들고
oauth2 로그인 방식의 successHandler로 등록해주면 됩니다. 이것은 나중에 하도록 하겠습니다.)

인증 과정이 여기까지 진행되었다면, 이제 우리 서버는 토큰을 만들어서 브라우저에게 내려주는 일만 남았습니다.
이 작업을 AuthenticationSuccessHandler # onAuthenticationSuccess() 메서드에서 수행하도록 구현하겠습니다.

1. SimpleUrlAuthenticationSuccessHandler 상속

package com.yelim.security.config;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;

public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) {

    }
}

 

2. TokenProvider 구현

JWT 토큰과 TokenProvider에 대해서는 다른 글에서 자세히 다루도록 하겠습니다.

package com.yelim.security;

import com.yelim.security.config.AppProperties;
import com.yelim.security.domain.UserPrincipal;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import java.security.Key;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component
public class TokenProvider {

    private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private Long tokenExpirationMsec;
    private Key tokenSecretKey;

    public TokenProvider(AppProperties appProperties) {
        this.tokenSecretKey = encodeTokenSecret(appProperties.getAuth().getTokenSecret());
        this.tokenExpirationMsec = appProperties.getAuth().getTokenExpirationMsec();
    }

    public String createToken(Authentication authentication) {
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + tokenExpirationMsec);

        return Jwts.builder()
            .setSubject(Long.toString(userPrincipal.getId()))
            .setIssuedAt(new Date())
            .setExpiration(expiryDate)
            .signWith(tokenSecretKey, SignatureAlgorithm.HS512)
            .compact();
    }

    public Long getUserIdFromToken(String token) {
        Claims claims = Jwts.parser()
            .setSigningKey(tokenSecretKey)
            .parseClaimsJws(token)
            .getBody();

        return Long.parseLong(claims.getSubject());
    }

    public boolean validateToken(String authToken) {
        try {
            Jwts.parser()
                .setSigningKey(tokenSecretKey)
                .parseClaimsJws(authToken);
            return true;
        } catch (SignatureException ex) {
            logger.error("Invalid JWT signature");
        } catch (MalformedJwtException ex) {
            logger.error("Invalid JWT token");
        } catch (ExpiredJwtException ex) {
            logger.error("Expired JWT token");
        } catch (UnsupportedJwtException ex) {
            logger.error("Unsupported JWT token");
        } catch (IllegalArgumentException ex) {
            logger.error("JWT claims string is empty.");
        }
        return false;
    }

    private Key encodeTokenSecret(String tokenSecret) {
        return Keys.hmacShaKeyFor(tokenSecret.getBytes());
    }
}

 

3. 구현

백엔드가 토큰을 내려줄 때 부터

토큰이 브라우저에 저장되기까지의

flow를 그림으로 그려보았습니다.

이 그림처럼 동작하게 만들어보겠습니다.

 

브라우저가 http://localhost:3000/oauth2/redirect?token=토큰내용 으로 redirect 하도록

302 응답을 내려줍니다.

 

package com.yelim.security.config;

import com.yelim.security.TokenProvider;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;
    private final AppProperties appProperties;

    public OAuth2AuthenticationSuccessHandler(TokenProvider tokenProvider, AppProperties appProperties) {
        this.tokenProvider = tokenProvider;
        this.appProperties = appProperties;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException {
        String url = makeRedirectUrl(tokenProvider.createToken(authentication));

        if (response.isCommitted()) {
            logger.debug("응답이 이미 커밋된 상태입니다. " + url + "로 리다이렉트하도록 바꿀 수 없습니다.");
            return;
        }
        getRedirectStrategy().sendRedirect(request, response, url);
    }

    private String makeRedirectUrl(String token) {
        return UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect")
            .queryParam("token", token)
            .build().toUriString();
    }
}

 

OAuth2UserService 의
OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) 메서드는
서드파티에 사용자 정보를 요청할 수 있는 access token 을 얻고나서 실행됩니다.

이 때 access token과 같은 정보들이 oAuth2UserRequest 파라미터에 들어있습니다.

이 메서드가 할 일은 다음과 같습니다.

  1. access token을 이용해 서드파티 서버로부터 사용자 정보를 받아온다.
  2. 해당 사용자가 이미 회원가입 되어있는 사용자인지 확인한다.
    만약 회원가입이 되어있지 않다면, 회원가입 처리한다.
    만약 회원가입이 되어있다면, 프로필사진URL 등의 정보를 업데이트한다.
  3. UserPrincipal 을 return 한다.
    세션 방식에서는 여기서 return한 객체가 시큐리티 세션에 저장된다.
    하지만 JWT 방식에서는 저장하지 않는다.
    (JWT 방식에서는 인증&인가 수행시 HttpSession을 사용하지 않을 것이다.)


1. 커스텀 OAuth2UserService 클래스 생성

DefaultOAuth2UserService를 상속해서 만들 것입니다.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    /**
     * 서드파티 접근을 위한 accessToken까지 얻은다음 실행된다.
     */
    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) {
        return null;
    }
}

 

2. super.loadUser(oAuth2UserRequest)

처음에 loadUser 메서드의 역할을 1 ~ 3번으로 정의했었는데요.
그 중 1번이었던 "access token을 이용해 서드파티 서버로부터 사용자 정보를 받아온다" 기능을 넣을 것입니다.

아주 쉽습니다. super.loadUser() 를 호출하기만하면 됩니다.
DefaultOAuth2UserService 클래스의 loadUser() 메서드에 이 기능이 구현되어있기 때문입니다.

@Service  
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) {
        // accessToken으로 서드파티에 요청해서 사용자 정보를 얻어옴
        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);

        return null;
    }
}

 

3. 사용자 정보 validation

앞서 OAuth2User 타입 객체로 전달받은 사용자 정보를
간단하게 validation 해주도록 하겠습니다.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) {
        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);

        validateAttributes(oAuth2User.getAttributes());

        return null;
    }

    private void validateAttributes(Map<String, Object> attributes) {
        if (!attributes.containsKey("email")) {
            throw new IllegalArgumentException("서드파티의 응답에 email이 존재하지 않습니다!!!");
        }
    }
}

 

4. 자동 회원가입 / 회원정보 갱신

처음에 정의했던 loadUser 메서드의 역할 2번을 구현해볼 것입니다.

회원가입 한 적이 있는지 확인해서
회원가입한 적 없으면 자동으로 회원가입을 시키고,
회원가입한 적 있으면 정보갱신을 하도록 구현....하려고 했으나 제 테스트 프로젝트에서는 갱신할 정보가 없네요.
프로필사진 url 같은걸 쓴다면 이런걸 갱신해주면 됩니다.

package com.yelim.security.service;

import com.yelim.security.domain.Provider;
import com.yelim.security.domain.User;
import com.yelim.security.domain.UserPrincipal;
import com.yelim.security.repository.UserRepository;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Service;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) {
        Map<String, Object> attributes = super.loadUser(oAuth2UserRequest).getAttributes();
        Provider provider = Provider.from(oAuth2UserRequest.getClientRegistration().getRegistrationId());

        validateAttributes(attributes);

        registerIfNewUser(attributes, provider);
        
        return null;
    }

    private void validateAttributes(Map<String, Object> userInfoAttributes) {
        if (!userInfoAttributes.containsKey("email")) {
            throw new IllegalArgumentException("서드파티의 응답에 email이 존재하지 않습니다!!!");
        }
    }

    private void registerIfNewUser(Map<String, Object> userInfoAttributes, Provider provider) {
        String email = (String) userInfoAttributes.get("email");

        Optional<User> optionalUser = userRepository.findByEmailAndProvider(email, provider);

        if (optionalUser.isPresent()) {
            return;
        }
        User user = new User(null, email, "대충 아무 텍스트", provider);
        userRepository.save(user);
    }
}

.

5. UserPrincipal 리턴

loadUser 메서드는 OAuth2User 타입을 return 합니다.

 

만약 인증/인가를 세션 방식으로 구현하면,

return 한 OAuth2User 객체가 시큐리티 세션에 저장됩니다.


JWT 방식으로 구현할 경우 세션을 사용하지 않으므로 세션에 저장하지는 않습니다.

 

하지만 그렇다고해서 지금처럼 null 을 리턴하면 안됩니다.

스프링 시큐리티 코드 어딘가에서 이 객체에 접근하기 때문에

그 부분이 실행될 때 Null Pointer Exception이 발생합니다.

 

package com.yelim.security.service;

import com.yelim.security.domain.Provider;
import com.yelim.security.domain.User;
import com.yelim.security.domain.UserPrincipal;
import com.yelim.security.repository.UserRepository;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Service;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) {
        Map<String, Object> attributes = super.loadUser(oAuth2UserRequest).getAttributes();
        Provider provider = Provider.from(oAuth2UserRequest.getClientRegistration().getRegistrationId());

        validateAttributes(attributes);

        User user = registerIfNewUser(attributes, provider);

        return UserPrincipal.create(user, attributes);
    }

    private void validateAttributes(Map<String, Object> userInfoAttributes) {
        if (!userInfoAttributes.containsKey("email")) {
            throw new IllegalArgumentException("서드파티의 응답에 email이 존재하지 않습니다!!!");
        }
    }

    private User registerIfNewUser(Map<String, Object> userInfoAttributes, Provider provider) {
        String email = (String) userInfoAttributes.get("email");

        Optional<User> optionalUser = userRepository.findByEmailAndProvider(email, provider);

        if (optionalUser.isPresent()) {
            return optionalUser.get();
        }
        User user = new User(null, email, "대충 아무 텍스트", provider);
        return userRepository.save(user);
    }
}

 

이제 OAuth2UserService 구현이 끝났습니다!

 

5. 테스트코드

도대체 OAuth2UserService 는 테스트코드를 어떻게 짜야할까요?...

언젠가 알게되면 꼭 정리하겠습니다....

SecurityContext

세션 방식으로 로그인 구현시, 스프링 시큐리티는
세션 공간 안에
시큐리티 세션이라는 자신만의 공간을 갖는다.


SecurityContext 객체를 Http Session 에 저장해서

스프링 시큐리티 전용 세션 쓰는듯.


JWT 토큰 방식으로 구현시

SecurityContext를 세션에 저장하지 않는다. 즉,
하나의 요청을 처리할 때 SecurityContext 하나가 생성되고

응답이 끝나면 버려진다.

 

더보기

SecurityContextHolder 클래스에는

SecurityContextHolderStrategy 타입 객체가 들어있고

SecurityContextHolderStrategy 타입 객체에는

SecurityContext 가 들어있다.

 

strategy 라는게 

SecurityContext를 HttpSession에 저장할건지, 아니면 뭐 다른방식으로 저장할건지 그런것같다.

(SecurityContext 저장 방식에 대한 전략인 것 같다는 소리임)

 

 

Authentication 객체UserDetails & OAuth2User

SecurityContext 에는
들어갈 수 있는 객체 타입이 정해져있다.
그것은 Authentication 타입이다.

Authentication 타입 객체에는 User 정보를 저장한다.
Authentication 안에도 저장할 수 있는 객체의 타입이 정해져있다.
그것은 UserDetails 타입과 OAuth2User 타입이다.
이 둘 중 하나여야, Authentication 객체 안에 저장할 수 있다.

서드파티 없이 회원가입-로그인 프로세스를 직접 구현한 경우 UserDetails 타입 객체를 저장하게 된다.
소셜로그인 구현시에는 OAuth2User 타입 객체를 저장하게 된다.

 

UserDetails & OAuth2User

세션 방식에서 사용자가 로그인 할 경우
소셜로그인이 아닌 방식으로 로그인한 사용자 정보는 UserDetails 타입 객체로,

소셜로그인으로 로그인한 사용자 정보는 OAuth2User 타입 객체로
세션에 저장하게 된다.


그리고 이후에, 만약 내정보 조회 API 를 만든다면


소셜로그인 사용자 정보 조회 기능은

세션에서 OAuth2User 타입 객체를 꺼내서 써야하고

 

일반로그인 사용자 정보 조회 기능에서는

세션에서 UserDetails 타입 객체를 꺼내서 써야한다.

 

이렇게 구현하려면

똑같은 "사용자 정보 조회 API"를

로그인 방식에 따라서 두개 마련해야 하는데,

이건 좀 너무 코드가 별로다.

 

근데 방법이 있다. 바로!!
UserDetails와 OAuth2User를 둘 다 implement 하는 클래스를 만드는 것이다!

사실 JWT 방식에서는
소셜로그인한 사용자도 필터에서 UserDetails 로 저장하거나,
일반로그인한 사용자를 필터에서 OAuth2User 로 저장하거나
이런식으로도 가능할 것 같다.


하지만 일반로그인한 사용자를 OAuth2User로 저장하는건 의미상 이상하고
소셜로그인한 사용자를 UserDetails 로 저장하는것도
registrationId (google인지 naver인지 이런거) 같은 일부정보가 누락될 수도 있을 것 같아서 그다지 좋아보이지 않는다.


따라서 나는 JWT 방식으로 구현하고 있지만 이렇게 진행할 것이다.

 

구현

더보기
package com.yelim.security.domain;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.lang.Nullable;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

public class UserPrincipal implements OAuth2User, UserDetails {

    private User user;
    private List<GrantedAuthority> authorities;
    private Map<String, Object> oauthUserAttributes;

    private UserPrincipal(User user, List<GrantedAuthority> authorities,
            Map<String, Object> oauthUserAttributes) {
        this.user = user;
        this.authorities = authorities;
        this.oauthUserAttributes = oauthUserAttributes;
    }

    /**
     * OAuth2 로그인시 사용
     */
    public static UserPrincipal create(User user, Map<String, Object> oauthUserAttributes) {
        return new UserPrincipal(user, List.of(() -> "ROLE_USER"), oauthUserAttributes);
    }

    public static UserPrincipal create(User user) {
        return new UserPrincipal(user, List.of(() -> "ROLE_USER"), new HashMap<>());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return String.valueOf(user.getEmail());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return Collections.unmodifiableMap(oauthUserAttributes);
    }

    @Override
    @Nullable
    @SuppressWarnings("unchecked")
    public <A> A getAttribute(String name) {
        return (A) oauthUserAttributes.get(name);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.unmodifiableList(authorities);
    }

    @Override
    public String getName() {
        return String.valueOf(user.getEmail());
    }
}
  • getUsername(), getName()
    User를 authentication 할 때 사용할 username을 return.
    만약 이메일로 authentication 할 경우 username 으로 email을 리턴하게하면 된다.
  • getAttributes()
    우리 서버에서 구글 access token 을 얻은 다음
    이 access token 으로 구글한테 사용자 정보를 알려달라고 요청하면
    구글에서 응답을 하는데
    이 때 사용자 정보가 attributes 안에 담아져서 온다.
    attribues가 Map 형식이니까 { "email" : "yelim@gmail.com" } 이런식으로!
더보기
package com.yelim.security.domain;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.GrantedAuthority;

class UserPrincipalTest {

    private User user;

    @BeforeEach
    void setUp() {
        user = new User(1L, "Kim98", "testPassword");
    }

    @Test
    void create() {
        UserPrincipal userPrincipal = UserPrincipal.create(user);

        assertThat(userPrincipal.getName()).isEqualTo(user.getEmail());
    }

    /**
     * OAuth2 로그인시 사용
     */
    @Test
    void createWithAttributes() {
        // given
        Map<String, Object> oauthUserAttributes = new HashMap<>();
        oauthUserAttributes.put("email", "test@email.com");

        UserPrincipal userPrincipal = UserPrincipal.create(user, oauthUserAttributes);

        // when
        Map<String, Object> attributes = userPrincipal.getAttributes();

        // then
        assertThat(userPrincipal.getName()).isEqualTo(user.getEmail());
        assertThat(attributes).hasSize(1);
        assertThat(attributes.get("email")).isEqualTo(oauthUserAttributes.get("email"));
    }

    @Test
    void getAttribute() {
        // given
        Map<String, Object> oauthUserAttributes = new HashMap<>();
        oauthUserAttributes.put("email", "test@email.com");

        UserPrincipal userPrincipal = UserPrincipal.create(user, oauthUserAttributes);

        // when
        Object email = userPrincipal.getAttribute("email");

        // then
        assertThat((String) email).isEqualTo("test@email.com");
    }

    @Test
    void getPassword() {
        UserPrincipal userPrincipal = UserPrincipal.create(user);

        assertThat(userPrincipal.getPassword()).isEqualTo(user.getPassword());
    }

    @Test
    void getUsername() {
        UserPrincipal userPrincipal = UserPrincipal.create(user);

        assertThat(userPrincipal.getUsername()).isEqualTo(user.getEmail());
    }

    @Test
    void getAuthorities() {
        // given : UserPrincipal 객체가 만들어져 있다.
        UserPrincipal userPrincipal = UserPrincipal.create(user);

        // when : authorities 를 조회한다.
        Collection<? extends GrantedAuthority> authorities = userPrincipal.getAuthorities();
        Optional<? extends GrantedAuthority> first = authorities.stream().findFirst();

        // then : authorities 가 조회된다.
        assertThat(first.isPresent()).isTrue();
        assertThat(first.get()).isInstanceOf(GrantedAuthority.class);
        assertThat(first.get().getAuthority()).isEqualTo("ROLE_USER");
    }

    @Test
    void getAuthorities_whenOAuth2Login() {
        // given
        Map<String, Object> oauthUserAttributes = new HashMap<>();
        oauthUserAttributes.put("email", "test@email.com");

        UserPrincipal userPrincipal = UserPrincipal.create(user, oauthUserAttributes);

        // when : authorities 를 조회한다.
        Collection<? extends GrantedAuthority> authorities = userPrincipal.getAuthorities();
        Optional<? extends GrantedAuthority> first = authorities.stream().findFirst();

        // then : authorities 가 조회된다.
        assertThat(first.isPresent()).isTrue();
        assertThat(first.get()).isInstanceOf(GrantedAuthority.class);
        assertThat(first.get().getAuthority()).isEqualTo("ROLE_USER");
    }

    @Test
    void getName() {
        // given
        Map<String, Object> oauthUserAttributes = new HashMap<>();
        oauthUserAttributes.put("email", "test@email.com");

        UserPrincipal userPrincipal = UserPrincipal.create(user, oauthUserAttributes);

        // when & then
        assertThat(userPrincipal.getName()).isEqualTo(user.getEmail());
    }

    @Test
    void getAttributes() {
        // given
        Map<String, Object> oauthUserAttributes = new HashMap<>();
        oauthUserAttributes.put("email", "test@email.com");

        UserPrincipal userPrincipal = UserPrincipal.create(user, oauthUserAttributes);

        // when
        Map<String, Object> attributes = userPrincipal.getAttributes();

        // then
        assertThat(attributes).hasSize(1);
        assertThat(attributes.get("email")).isEqualTo(oauthUserAttributes.get("email"));
    }

    @Test
    void isAccountNonExpired() {
    }

    @Test
    void isAccountNonLocked() {
    }

    @Test
    void isCredentialsNonExpired() {
    }

    @Test
    void isEnabled() {
    }
}

 


참고

Authentication 객체가 가질 수 있는 2가지 타입

https://youtu.be/7hrw03uzQHE

우리의 서버는

인증에 성공한 클라이언트(브라우저)에게 토큰을 만들어 줄 것입니다.

 

이 토큰을 만들기 위해 필요한 정보를 정의해봅시다.

 

1. application.yml 에 다음내용 추가

app:
  auth:
    tokenSecret: mymymymymymymymymymymymymymymymymymymymymy
    tokenExpirationMsec: 864000000
  • 토큰시크릿과 토큰유효시간은 마음대로하면 됩니다.
    단, tokenSecret의 길이가 충분히 길어야합니다. 그렇지 않으면 키 길이가 짧다, 안전하지 않다 이러면서 예외가 빵빵 터질 것입니다.

 

2. AppProperties

앞에서 정의한 토큰 정보를

자바 코드에서 가져다 쓰기 위해

AppProperties를 작성합니다.

package com.yelim.security.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app")
public class AppProperties {

    private final Auth auth = new Auth();

    public static class Auth {

        private String tokenSecret;
        private long tokenExpirationMsec;

        public String getTokenSecret() {
            return tokenSecret;
        }

        public void setTokenSecret(String tokenSecret) {
            this.tokenSecret = tokenSecret;
        }

        public long getTokenExpirationMsec() {
            return tokenExpirationMsec;
        }

        public void setTokenExpirationMsec(long tokenExpirationMsec) {
            this.tokenExpirationMsec = tokenExpirationMsec;
        }
    }

    public Auth getAuth() {
        return auth;
    }
}

 

3. Enable Configuration Properties

main 메서드가 있는 클래스에

@EnableConfigurationProperties(AppProperties.class) 를 붙여줍니다.

@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)    // 이거 추가
public class SecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}

 

4. AppPropertiesTest

앞에서 구현한 코드가 잘 동작하는지 테스트해보겠습니다.

package com.yelim.security.config;

import static org.assertj.core.api.Assertions.assertThat;

import com.yelim.security.config.AppProperties.Auth;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableConfigurationProperties(value = AppProperties.class)
@TestPropertySource("classpath:application.yml")
class AppPropertiesTest {

    @Autowired
    private AppProperties appProperties;

    @Test
    void getAuth() {
        Auth auth = appProperties.getAuth();

        assertThat(auth.getTokenSecret()).isEqualTo("mymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymymym");
        assertThat(auth.getTokenExpirationMsec()).isEqualTo(864000000);
    }
}

 

테스트가 통과했습니다. yml 에서 토큰 관련 정보를 잘 가져오고 있습니다.

1. dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.10.7'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.10.7'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.10.7'
implementation 'org.springframework.security:spring-security-oauth2-client'

 

2. application.yml에 다음 내용 추가

application.yml 에 다음 내용 추가

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            clientId: slkdjflsdjflsdjflsdlfkjsldf
            clientSecret: sdlfkjsldkfjsldflskdjflskd
            redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
            scope:
              - email
#              - profile
  • client id
    구글 oauth 콘솔에서 앱 만들기를 해서 얻은 클라이언트 ID
  • client secret
    구글 oauth 콘솔에서 앱 만들기를 해서 얻은 클라이언트 secret
  • redirect uri
    구글 oauth 콘솔의 승인된 리다이렉트 URI
    이전글에서 설명했으니 참고

    oauth 진행과정 중간에
    해당 URL에 code를 붙인 요청이
    내 서버로 들어오게 된다.
  • registrationId
    google, naver, kakao, facebook 등이 있다.
  • scope
    내 서버가 서드파티로부터 얻어올 정보 목록.

 

 

 

구글 콘솔에서 oauth 앱을 생성해봅시다.

구글 API 콘솔에서 새 프로젝트를 생성합니다.

 

 

 

OAuth 클라이언트 ID를 만들어줍니다.

 

 

 

OAuth 동의화면부터 채우라고 나오네요.

 

여기서 필수적인것만 채워주고

다시  OAuth 클라이언트 ID 만들기를 시도하겠습니다.

 

저는 누구나 소셜로그인을 통해 사용할 수 있는 웹 사이트를 만들것이기 때문에 [외부]를 선택했습니다.

 

OAuth 동의화면 페이지에서는

앱 도메인, 앱 로고, 승인된 도메인, 뭐시기뭐시기 등 물어보는게 많은데

필수 정보(앱이름, 사용자 지원 이메일, 개발자 연락처 정보 이메일)만 채우고 다음으로 넘어가겠습니다.

 

 

 

사용자의 어떤 정보를 얻고자하는지 선택합니다.

이메일만 선택하겠습니다.

 

 

테스트 사용자로는 저만 등록했습니다.

 

 

 

이제 다시 OAuth 클라이언트 ID 만들기를 시도해보겠습니다.

 

어플리케이션 유형 : 브라우저로 접속하는 웹사이트를 만들것이기 때문에 웹 애플리케이션을 선택합니다.

 

승인된 자바스크립트 원본

제 경우 프론트엔드를 http://localhost:3000 으로 사용하고있기 때문에

http://localhost:3000 을 추가해줬습니다.

 

 

승인된 리다이렉션 URI 란?

저는 처음에 이게 뭔지 이해하기가 어려웠는데요.

승인된 리다이렉션 URI가 뭔지 이해하려면 OAuth2의 인증 과정을 알아야합니다.

 

OAuth2 인증 과정

OAuth2 인증 과정을 그림으로 그려보겠습니다.

PPT로 그렸는데

자리가 부족해서 여러장에 걸쳐서 그렸습니다.

더보기

승인된 리다이렉션 URI는 바로 이것입니다!

 

 

아무튼 oauth2 인증 과정을 마저 이어서 봅시다!

 

 

7번과정까지 무사히 끝나면

우리의 서버는

구글 서버의 사용자 정보에 접근하기 위한

access token 을 갖게됩니다.

 

이 상태에서

DefaultOAuth2UserService 서비스의

OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) 메서드가 실행됩니다.

 

그래서 승인된 리다이렉션 URI란?

앞서 등장한 접은글 ▶ 여기서 잠깐! 승인된 리다이렉션 URI 에서 이해가 되셨나요?!

 

정리해보겠습니다.

 

그림의 3번 과정에서

브라우저가 구글서버로 인증 요청을 보낼 때

리다이렉션 URI 를 파라미터로 보냅니다.

 

그러면 구글 서버는

사용자 인증 성공시

요청에서 리다이렉션 URI 파라미터를 확인하여

승인된 리다이렉션 URI 인 경우

code와 함께 해당 URI 로 리다이렉트하도록

302 응답을 내려줍니다.

 

만약 요청의 리다이렉션 URI가

승인된 리다이렉션 URI가 아닌경우

오류가 발생합니다.

 

 

+ Recent posts