[SpringBatch] 예제 구현하면서 공부하기

스프링부트 기반 인증 서버 API 만들기

구현 스펙

  • SpringBoot
    • Interceptor
  • Json Web Token(JWT)
  • Server
    • Redis
  • DB
    • MySQL

코드정리

 

SpringFramework-Sample/springboot_redis

SpringBoot & Redis. Contribute to SpringFramework-Sample/springboot_redis development by creating an account on GitHub.

github.com

  • 추후
    • SpringSecurity
      • Authentication
      • Authorization
    • Role 추가

SpringSecurity Architecture

  • 스프링 보안 인증 프로세스에 관련된 클래스 및 필터 목록 을 시연하기 위한 다이어그램

1. HttpRequest 수신

  • 스프링 시큐리티는 일련의 필터를 갖고 있다.
  • 따라서 요청이 오면 인증과 인가를 위한 필터 체인을 거치게 된다.
  • 사용자 인증 요청이 있는 경우, 인증 메커니즘/모델에 기반한 관련 인증 필터를 찾을 때까지 필터 체인을 거치게 된다.

HTTP 기본 인증 요청은 BasicAuthenticationFilter에 도달할 때까지 필터체인을 거친다.

ex)

  • HTTP 다이제스트 인증 요청DigestAuthenticationFilter에 도달할 때까지 필터 체인을 통과한다.
  • 로그인 양식 제출 요청(로그인 양식 인증 요청)이 UsernamePasswordAuthenticationFilter에 도달할 때까지 필터체인을 거친다.
  • X509 인증 요청이 X509AuthenticationFilter에 도달할 때까지 필터 체인을 통과한다.

2. 사용자 자격증명 기반 AuthenticationToken 생성

  • 관련 AuthenticationFilter에서 인증 요청을 받으면 수신된 요청에서 사용자 이름과 암호를 추출한다. (인증 메커니즘의 대부분은 사용자 이름과 암호를 요구한다.)
  • 그 후 추출된 사용자 자격 증명 기반으로 인증 객체를 생성한다.
  • 추출된 자격증명이 사용자 이름과 암호인 경우 UsernamePasswordAuthenticationToken은 사용자 이름과 비밀번호를 추출/발견하여 생성된다.

3. AuthenticationManager에게 위임하기 위한 AuthenticationToken생성

  • UsernamePasswordAuthenticationToken객체를 생성한 후에 AuthenticationManagerauthenticate 메서드를 호출하기 위해 사용된다.
  • AuthenticationManager는 단지 인터페이스이며 실제 구현체는 ProviderManager이다.
  • ProviderManager는 사용자 요청을 인증하는데 사용하기 위해 AuthenticationProvider를 설정하기 위한 목록을 갖고 있다.
  • ProviderManager는 제공된 각 AuthenticationProvider를 검토하고 전달된 Authentication 객체를 기반으로 사용자 인증을 시도한다. (UsernamePasswordAuthenticationToken)

4. AuthenticationProvider 목록으로 인증 시도

  • AuthenticationProvider는 제공된 인증 객체로 사용자 인증을 시도한다.
  • 프레임워크와 함께 제공되는 기존 Authentication Provider 중 일부
    1. CasAuthenticationProvider
    2. JaasAuthenticationProvider
    3. DaoAuthenticationProvider
    4. OpenIDAuthenticationProvider
    5. RememberMeAuthenticationProvider
    6. LdapAuthenticationProvider

5. UserDetailsService가 필요한가 ?

  • 일부 AuthenticationProvider는 사용자 이름 기반으로 사용자 세부 정보를 검색하기 위하여 UserDetailsService를 사용할 수 있다. (DaoAuthenticationProvider)

6 ~ 7. UserDetails와 User

  • UserDetailsService는 username 기반으로 UserDetail (실제 구현은 User)를 검색한다.

8. Authentication 과 AuthenticationException

  • 사용자가 인증에 성공하면 완전히 채워진 Authentication 객체가 반환된다.
  • 그렇지 않은 경우 AuthenticationException가 발생한다.
  • AuthenticationProvider 인터페이스에 따르면, 성공적으로 인증하는 경우 AuthenticationProvider는 완전히 채워진 인증 객체를 정확히 반환하거나, 예외 발생 시 AuthenticationProvider 예외를 던진다.
  • Fully populated Authentication Object란
    • authenticated - true
    • grant authorities list
    • user credentials (username only)
  • AuthenticationException가 예외 발생하는 경우, 인증 메커니즘을 지원하는 AuthenticationEntryPoint에 의해 처리된다.

9. Authentication 완료

  • AuthenticationManager는 획득한 Fully populated Authentication객체를 관련 AuthenticationFilter로 반환한다.

10. SecurityContext에서 Authentication 객체를 설정

  • 관련된 AuthenticationFilter가 획득한 Authentication 객체를 향후 필터 사용을 위하여 SecurityContext에 저장한다. (Authorization Filters를 위해 사용)
SecurityContextHolder.getContext().setAuthentication(authentication);

1. JWT(JSON Web Token) 이란?

  • JSON Web Token의 약자로 전자서명 된 URL-safe (URL로 이용할 수 있는 문자로만 구성된)의 JSON
  • 전자서명(Signature)은 JSON의 변조를 체크할 수 있게 되어 있다.
  • 속성 정보(Claims)를 JSON 데이터 구조로 표현한 토큰으로 RFC 7519 표준
  • 서버와 클라이언트 간 정보를 주고 받을 때 HttpRequest header에 JSON 토큰을 넣은 후 서버는 별도의 인증 과정없이 헤더에 포함되어 있는 JWT 정보를 통해 인증한다.
    이때 사용되는 JSON 데이터는 URL-safe 하도록 URL에 포함할 수 있는 문자만으로 만든다.
  • HMAC 알고리즘을 사용하여 비밀키 또는 RSA를 이용한 Public Key / Private Key 쌍으로 서명할 수 있다.
Base64 인코딩의 경우 "+", "/", "="이 포함되지만 JWT는 URI에서 파라미터로 사용할 수 있도록 URL-Safe 한 Base64url 인코딩을 사용한다.

2. JWT 구조

  • Claims를 userId, username으로 하고, JWA 알고리즘 SHA-256으로 암호화
jwt:
  secret: 12345678901234567890123456789000
public String createToken(Long userId, String name) {
        return Jwts.builder()
                .setHeaderParam("typ", Header.JWT_TYPE)
                .signWith(key, SignatureAlgorithm.HS256)
                .claim("userId", userId)
                .claim("userName", name)
                .setExpiration(new Date(System.currentTimeMillis() + 864000000))
                .compact();
}
  • 위 작업을 통해 생성된 토큰 구성
// Header
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
// Payload
eyJ1c2VySWQiOjEsInVzZXJOYW1lIjoic2VvayIsImV4cCI6MTU5MzUyMTU0MX0.
// signature
AWsWjhGSeAIr9d0LGdDuFbGK57iK_mdsEnxt983h_1o

[Header]

  • token의 type과 JWT를 digitally sign할 때 사용한 algorithm을 정의
  1. typ
  • 토큰의 타입을 지정 ("JWT")
  1. alg
  • 해싱 알고리즘을 지정한다.
  • 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA가 사용되며, 이 알고리즘은 토큰을 검증할 때 사용되는 signature 부분에서 사용된다.
{
    "typ" : "JWT",
    "alg" : "HS256"
}
  • 위 내용을 Base64url 인코딩 한 결과
// Header
eyJhbGciOiJIUzI1NiJ9.

[Payload]

  • JWT에 담아서 전달할 data를 정의

  • 정보의 한 "조각"을 Claim이라 부르고, name / value의 한 쌍으로 이루어져있다.

  • Claim의 종류

    • Registered Claim

      • 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기 위하여 이름이 이미 정해진 Claim

      • 모두 선택적(optional) 이다.

        1. iss: 토큰 발급자(issuer)
        2. sub: 토큰 제목(subject)
        3. aud: 토큰 대상자(audience)
        4. exp: 토큰의 만료시간(expiration)
        • 시간은 NumericDate 형식으로 되어 있어야 한다.
        • 언제나 현재 시간보다 이후로 설정되어 있어야 한다.
        1. nbf: Not Before을 의미한다.
        • 토큰의 활성 날짜와 비슷한 개념
        • NumericDate 형식으로 날짜를 지정하며, 날짜가 지나가기 전까지는 토큰이 처리되지 않는다.
        1. iat: 토큰이 발급된 시간(issued at)
        • 이 값을 사용하여 토큰의 age가 얼마나 되었는지 판단 할 수 있다 .
        1. jti: JWT의 고유 식별자
        • 주로 중복적인 처리를 방지하기 위하여 사용된다.
        • 일회용 토큰에 사용하면 유용하다.
    • Public Claim

      • 충돌이 방지된(collision-registant)이름을 가지고 있어야 한다.
      • 충돌을 방지하기 위하여 Claim 이름을 URI 형식으로 짓는다.
    • Private Claim

      • 서버 클라이언트 양측 간 협의하에 사용되는 Claim이름
      • Public Claim과는 달리 이름이 중복될 수 있으니 사용시 유의
public String createToken(Long userId, String name) {
        return Jwts.builder()
                .setHeaderParam("typ", Header.JWT_TYPE)
                .signWith(key, SignatureAlgorithm.HS256)
                .claim("userId", userId)
                .claim("userName", name)
                .setExpiration(new Date(System.currentTimeMillis() + 864000000))
                .compact();
}
  • 위 내용을 암호화한 Payload
// Payload
eyJ1c2VySWQiOjEwMDQsInVzZXJOYW1lIjoiSm9obiJ9.

[Signature]

  • JSON Web Token의 마지막 부분으로 HeaderPayload를 base64로 encode인코딩한 값을 합친 후 JWT secret key값을 encrypt한 값으로 Hash를 하여 생성
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  my-secret-key

) secret base64 encoded
  • 위 내용으로 인코딩한 결과 값
// signature
0nwaeM3fpDPvRGc64pyIp-JYNnuigCN9t_5ApVhPClQ

JWT Token 예시

  • 생성된 토큰은 HTTP 통신을 할 때 Authorization이라는 key의 value로 사용된다.
  • 일반적으로 value에 Beare이 앞에 붙여진다.
{
    "Authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInVzZXJOYW1lIjoic2VvayIsImV4cCI6MTU5MzUyMTU0MX0.AWsWjhGSeAIr9d0LGdDuFbGK57iK_mdsEnxt983h_1o"
}

JWT Decoding

3. JWT를 사용하는 상황

  • 회원 인증
    1. 사용자가 로그인을 하면, 서버는 사용자의 정보를 기반으로한 토큰을 발급
    2. 그 후, 사용자가 서버에 요청을 할 때마다 JWT를 포함하여 전달
    3. 서버는 클라이언트에서 요청을 받을 때 마다, 해당 토큰이 유효하고 인증되었는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리
    4. 서버에서는 사용자에 대한 세션을 유지할 필요가 없다.
      즉, 사용자가 로그인되어 있는지 여부를 신경 쓸 필요가 없고, 사용자가 요청을 했을 때 토큰만 확인하면 되므로 세션 관리가 필요 없어서 서버 자원과 비용을 절감할 수 있다.
  • 정보 교류
    • JWT는 두 개체 사이에서 안정정있게 정보를 교환하기에 좋은 방법
    • 정보가 서명이 되어있기 때문에 정보를 보낸이가 바뀌진 않았는지, 또 정보가 도중에 조작되지 않았는지 검증을 할 수 있다.
* UI Layer, REST API 서버를 따로 두는 경우 JWT를 사용
* 하나의 End Point가 아닌 Mobile / Web 등의 multiple EndPoint 환경이라면 통합적인 인증 / 인가 환경을 제공하기 위해 JWT를 사용
* Third Party에게 public하게 open 한 REST EndPoing가 존재하는 경우 해당 Third Party의 인증 인가를 관리하기 위해 JWT 사용

Spring Security 기반의 테스트 코드 작성하기

  • 목표

    • HelloControllerTest (페이지 접근)
    • PostsAPIControllerTest (API 호출 기능)
    • Spring Security로 인한 접근 권한에 연관된 테스트는 Security 옵션이 필요
    • 따라서 작성한 전체 테스트가 정상적으로 수행

Tasks -> verification -> test (전체 테스트)
전체 테스트 확인

  • 기존에 SpringSecurity 설정이 되어 있지 않은 상태에서 작성된 테스트 코드는 Security 적용 시 오류가 발생할 수 있다.

API 호출 및 페이지 접근 Controller 테스트 오류 해결하기

이슈 1. CustomOAuth2UserService를 찾을 수 없습니다.

  • CustomOAuth2UserService
    • 소셜로그인 관련 설정값에 따른 실행
      • 기본적으로 application.properties 파일을 src/main, src/test 모두 읽어서 사용한다.
      • src/test/resources에 application.properties가 없을 경우 src/main/resources에 있는 application.properties를 자동으로 읽는다.
      • src/main/resources/application-oauth.properties는 읽지 못한다.
      • 즉, application-oauth.properties가 test쪽에 파일이 없기때문에 발생하는 오류이다.
      • 해결책은 application.properties에 오류만 안나도록 설정 값을 임의로 넣는다.

test용 설정 파일

  • 설정 파일은 application.yml로 작성하였다.
# server setting
server:
  port: 8085

# jpa setting
spring:
  profiles:
    include: oauth # oauth 관련 properties 추가
  session:
    store-type: jdbc
  h2:
    console:
      enabled: true
  jpa:
    show_sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL5InnoDBDialect
  # Test 용 security 설정
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: test
            client-secret: test
            scope:
              - profile
              - email

이슈 2. 302 Status Code

  • PostsAPIController 테스트

    • Spring Security 설정으로 인한 인증되지 않은 사용자의 요청으로 인해 리다이렉션 응답(302 status code)을 주게 된다.
    • 이를 해결하기 위해서는 테스트 메서드에 임의로 인증된 사용자를 추가하여 API만 테스트 할 수 있도록 수정해야 한다.
  • SpringSecurity Test를 위한 spring-security-test 의존성 추가

dependencies {
    // ...
    testCompile('org.springframework.security:spring-security-test')
}
  • PostsAPIController의 테스트 메서드에 임의 사용자 인증 설정 추가
    1. @WithMockUser(roles="USER")
      • 인증된 임의 사용자를 만들어 사용
      • roles 설정으로 권한 추가가 가능
      • 해당 어노테이션으로 인하여 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 갖는다.
      • @WithMockUser가 MockMvc에만 작동하기 때문에 MockMvc에서 테스트 할 수 있도록 @Before, mvc.perform 기능을 추가
    2. @Before
      • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성
    3. mvc.perform
      • 생성된 MockMvc를 통해 API를 테스트한다.
      • 본문(Body) 영역은 문자열로 표현하기 위해서 ObjectMapper를 통해 문자열 JSON으로 변환
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsAPIControllerTest {
    // ...

    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders
                .webAppContextSetup(webApplicationContext)
                .apply(springSecurity())
                .build();
    }

    @Test
    @WithMockUser(roles="USER")
    public void testSave() throws Exception {
        // ...

        // when
        mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());

        // ...
    }

    @Test
    @WithMockUser(roles="USER")
    public void testUpdate() throws Exception {
        // ...

        mockMvc.perform(put(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());

        // ...
    }
}

이슈 3. @WebMvcTest에서 CustomOAuth2UserService을 찾을 수 없는 문제

  • HelloControllerTest의 경우 위에서 처리한 방법과는 다르다.
    • 위 이슈를 함으로써 SpringSecurity 설정은 작동했지만 @SpringBootTest와 다르게 @MockMvcTest는 CustomOAuth2UserService를 스캔하지 않는다.
    • @WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한 @ControllerAdvice, @Controller를 읽는다.
    • 즉, @Repository, @Service, @Component는 스캔 대상이 아니다.
    • SecurityConfig는 읽었지만 SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService는 읽을 수가 없어 오류가 발생했던 것이다.
    • 위 문제를 해결하기 위해서는 스캔 대상에서 SecurityConfig를 제거해야 한다.
@RunWith(SpringRunner.class)
@WebMvcTest(
        controllers = HelloController.class,
        excludeFilters = {
                @ComponentScan.Filter(
                        type = FilterType.ASSIGNABLE_TYPE,
                        classes = SecurityConfig.class
                )
        }
)
public class HelloControllerTest {

    @Test
    @WithMockUser(roles="USER")
    public void return_hello() throws Exception {

    }
    @Test
    @WithMockUser(roles="USER")
    public void return_helloDto() throws Exception {

    }
}
  • 원인 분석
    • @EnableJpaAuditing을 사용하기 위해서는 최소 하나의 @Entity 클래스가 필요하다.
    • @WebMvcTest의 경우 @Entity 클래스가 없다.
    • @EnableJpaAuditing와 @SpringBootApplication 설정을 함께 사용하였기 때문에 @WebMvcTest에서 둘다 읽게 된다.
    • 이 문제를 해결하기 위해서는 @EnableJpaAuditing을 사용하기 위한 JpaConfig 설정 클래스를 새로 작성한다.
// @EnableJpaAuditing 제거
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • @EnableJpaAuditing 설정을 사용하는 JpaConfig 클래스
    • JPA Auditing 활성화
@Configuration
@EnableJpaAuditing
public class JpaConfig {}
  • 작성한 패키지 구조 및 파일 확인

  • 테스트 코드 패키지 및 파일 확인

정리하기

  • 스프링 부트 통합 개발환경 설정
  • 테스트, JPA 데이터 처리
  • Mustache를 활용한 화면 구성
  • Security와 OAuth2로 인증, 권한을 적용하여 게시판 작성

OAuth2 Naver Login

Naver API 등록

스프링 시큐리티 설정 등록

application-oauth.yml

  • 네이버에서는 스프링 시큐리티를 공식 지원하지 않으므로 설정 추가

    • CommonOAuth2Provider에서 해주던 값들도 전부 수동으로 입력
    1. user_name_attribute: response
      • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야 한다.
      • 네이버의 회원 조회시 반환되는 JSON 형태 때문이다.
      • 스프링 시큐리티에서는 하위 필드를 명시할 수가 없다.
      • 최상위 필드로 사용할 수 있는 값은 resultCode, message, response이다.
      • 최상위 필드만 user_name으로 지정이 가능하능하기 때문에 필요한 값인 response 값을 설정
  • 네이버 프로필 조회 API 호출 결과 값

    • response라는 key값의 value 값이 사용자 정보의 값이다.
{
  "resultcode": "00",
  "message": "success",
  "response": {
    "email": "openapi@naver.com",
    "nickname": "OpenAPI",
    "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
    "age": "40-49",
    "gender": "F",
    "id": "32742776",
    "name": "오픈 API",
    "birthday": "10-01"
  }
}
  • application-oauth.yml 파일 설정
# google oauth2 setting
spring:
  security:
    oauth2:
      client:
        # registration
        registration:
          # GOOGLE oauth2 setting
          google:
            client-id: {...}
            client-secret: {...}
            scope:
              - profile
              - email

          # NAVER oauth2 setting
          naver:
            client-id: {...}
            client-secret: {...}
            redirect_uri_template: {baseUrl}/{action}/oauth2/code/{registrationId}
            authorization_grant_type: authorization_code
            scope:
              - name
              - emaiL
              - profile_image
            client-name: Naver

        # provider
        provider:
            naver:
              authorization_uri: https://nid.naver.com/oauth2.0/authorize
              token_uri: https://nid.naver.com/oauth2.0/token
              user-info-uri: https://openapi.naver.com/v1/nid/me
              user_name_attribute: response

OAuthAttributes

  • OAuth의 사용자 정보를 가져올 Dto
    • Naver Attributes 추가
@Getter
public class OAuthAttributes {
    // ...

    public static OAuthAttributes of(
            String registrationId
            , String userNameAttributeName
            , Map<String, Object> attributes) {
        if("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profileImage"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    // ...
}

index.mustache

  • login button

    1. /oauth2/authorization/naver

      • 네이버 로그인 URL은 application-oauth.yml에 등록한 redirect_uri_template 값에 맞춰서 자동으로 등록된다.
      • /oauth2/authorization/ 까지는 고정, 마지막 Path만 각 소셜 로그인 코드를 사용
      • naver가 마지막 Path가 된다.
{{>layout/header}}

    <h1>스프링 부트로 시작하는 웹 서비스 No. 3</h1>

    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
                {{#userName}}
                    Logged in as: <span id="user">{{userName}}</span>
                    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
                {{/userName}}
                {{^userName}}
                    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                    <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
                {{/userName}}
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->

    </div>

{{>layout/footer}}

네이버 로그인 확인

이슈

  • 기존 설정된 application-oauth.yml 파일에 naver 설정을 추가하였더니 안됨
  • 다시 properties 파일로 변경하니 정상적으로 됨, 확인해봐야 하는 문제

세션 관리 하기

  • 현재 서비스에서는 어플리케이션을 재실행하면 로그인이 풀리게 된다.

    • 이유는 세션이 내장 톰캣의 메모리에 저장되기 때문이다.
    • 기본적으로 세션은 실행되는 WAS(Web Application Server)의 메모리에서 저장되고 호출된다.
    • 메모리에 저장되다보니 내장 톰캣처럼 어플리케이션 실행 시 실행되는 구조에서 항상 초기화가 된다.
    • 즉, 배포할 때마다 톰캣이 재시작된다.
    • 만약 2대 이상의 서버에서 서비스하는 경우 톰캣마다 세션 동기화 설정을 해야 한다.
  • 실제 현업에서 사용하는 3가지 세션 저장소

    1. 톰캣 세션 사용
      • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식
      • 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요
    2. MySQL과 같은 데이터베이스를 세션 저장소로 사용
      • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
      • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있다.
      • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용
    3. Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용
      • B2C 서비스에서 가장 많이 사용하는 방식
      • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요

Spring Session 설정

의존성 추가

  • build.gradle 내에 spring-session-jdbc 의존성 추가
dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile("org.projectlombok:lombok")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("com.h2database:h2")
    compile('org.springframework.boot:spring-boot-starter-mustache')
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
    compile("org.springframework.session:spring-session-jdbc")

    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}

properties 파일 내에 추가

  • 세션 저장소를 jdbc로 선택하도록 코드 추가
# jpa setting
spring:
  profiles:
    include: oauth # oauth 관련 properties 추가
  session:
    store-type: jdbc

Session 설정 테스트

  • h2-console 내에 테이블 추가 확인
    • jpa로 인하여 세션 테이블이 자동생성
    • spring_session
    • spring_session_attributes
    • 한 개의 세션 등록 확인
    • 여전히 H2 데이터베이스 기반의 스프링 부트 프로젝트이기 때문에 나중에 AWS의 RDS 서비스를 사용하는 경우 적용확인 가능

  • 로그인 후 테이블 내용 확인

spring_session 테이블
spring_session_attributes 테이블

어노테이션 기반으로 개선하기

  • 반복되는 같은 코드 해결하기
    • 커스텀 어노테이션(@LoginUser) 만들기
    • ArgumentResolver

커스텀 어노테이션 생성

  • 세션 처리를 위한 @LoginUser 어노테이션 생성
    1. @Target(ElementType.PARAMETER)
      • 어노테이션이 생성될 수 있는 위치를 지정
      • PARAMETER로 지정하므로써 메서드의 파라미터로 선언된 객체에서만 사용될 수 있다.
      • 이 외에도 클래스 선언시 사용할 수 있는 TYPE 등이 있다.
    2. @Interface
      • 해당 파일을 어노테이션 클래스로 지정
      • LoginUser라는 이름을 가진 어노테이션을 사용할 수 있다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

LoginUserArgumentResolver

  • ArgumentResolver 생성

    • HandlerMethodArgumentResolver 인터페이스를 구현한 클래스
    • 조건에 맞는 경우 메서드가 있다면, HandlerMethodArguementResolver의 구현체가 지정한 값으로 해당 메서드의 파리미터로 넘길 수 있다.
    1. supportParameter()
      • 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
      • 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환
    2. resolveArgument()
      • 파라미터에 전달할 객체를 생성
      • 해당 코드에서는 세션에서 객체를 가져온다.
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter
            , ModelAndViewContainer mavContainer
            , NativeWebRequest webRequest
            , WebDataBinderFactory binderFactory) throws Exception {

        return httpSession.getAttribute("user");
    }
}

ArgumentResolver 설정

  • WebConfig
    • 스프링에서 LoginUserArgumentResolver를 인식할 수 있도록 WebMvcConfigurer를 구현한 WebConfig 클래스에 설정한다.
    • WebMvcConfigurer의 addArgumentResolvers()를 추가한다.
    • 다른 HandlerMethodArgumentResolver가 필요한 경우 같은 방식으로 추가한다.
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolver) {
        argumentResolver.add(loginUserArgumentResolver);
    }
}

Controller 수정

  • 세션을 사용하는 부분 수정

    • @LoginUser를 파라미터로 받아 사용할 수 있도록 수정
    1. @LoginUser SessionUser user
      • 기존 (SessionUser) httpSession.getAttribute("user")로 가져오던 세션 정보 값을 개선
      • 다른 컨트롤러에서 세션이 필요한 경우 @LoginUser를 사용하여 세션 정보를 가져올 수 있다.
@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

        if(user !=  null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }

    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);
        return "posts-update";
    }
}

어노테이션 기반으로 변경하여 다시 세션 체크

  • 글 작성 테스트

  • 글 등록 확인

+ Recent posts