// 에드센스


1. 구현하고자 하는 것

 

인증은 Firebase에서,

인가는 Spring Security로,

구성하려고 한다.

이후 Controller에서 @PreAuthorize를 통해서 요청을 제한하고자 한다.

즉, Firebase에서 발급한 JWT를 Spring Security에서 사용하기.

 

인증 - 유저가 누구인지 확인하는 절차

인가 - 유저가 요청한 요청(request)을 실행할 수 있는지를 확인하는 절차

 

 

 

 


2. 현재까지 구현된 것

nodejs로 구현한 회원가입 로직에 firebase-admin를 사용하여 Firebase에 유저 생성 및 권한등록을 해주었다.

 

 

import * as admin from 'firebase-admin';

export const createFirebaseUser = (email: string, password: string, name: string) => {
  return admin.auth().createUser({
    email: email,
    password: password,
    displayName: name,
    disabled: false,
  });
};

firebase-admin 라이브러리로 firebase에 새로운 user를 등록해 주었고

 

 

import * as admin from 'firebase-admin';

export const setRole = (uid: string, role: string) => {
  let claims = {
      group: role,
  };
  admin.auth().setCustomUserClaims(uid, claims)
};

firebase-admin 라이브러리로 { group: role } 이라는 customUserClaim을 생성해주었다.

이것 자체로 권한으로써의 역할을 하진 않고 그저 사용자 설정 클레임일 뿐이다.

 

여기까지가 현재 진행 상태였고,

우리의 목표는 Spring에서 이 클레임을 사용자의 ROLE로 인식되게 하는 것이다.

 

 

 

 

 

 


3. JwtFilter 구현

    @PostMapping("")
    @PreAuthorize("hasAnyRole('admin', 'partner')")
    fun postEvent(@Valid @RequestBody req: PostEventRequest): ResponseEntity<Any> {
        println("postEvent")
        return ResponseEntity.status(201).body(object {
            val data = eventService.createOneEvent(req)
        })
    }

이렇게 컨트롤러에 @PreAuthorize 어노테이션을 통해서 사용자의 접근을 걸러낼 수 있어야한다.

저 컨트롤러로 들어가는 request에서 권한을 어떻게 추출하여 걸러낼 수 있을까?

 

  1. Firebase JWT에서 group을 추출하여 role을 얻어낸다.
  2. 이 role을 UserDetails 객체를 만들고 넣어준다.
  3. UserDetails 객체를 SecurityContextHolder에 등록해준다.

이 로직을 Filter로 구현하여 Filter를 등록해주자.

 

참고로, @PreAuthorize 안에서 다양한 표현식을 사용할 수 있다.

https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html#el-common-built-in

 

15. Expression-Based Access Control

There are some built-in expressions which are specific to method security, which we have already seen in use above. The filterTarget and returnValue values are simple enough, but the use of the hasPermission() expression warrants a closer look. The Permiss

docs.spring.io

 

 

1. Firebase JWT로부터 Custom Claim 추출하기

val accessToken = request.getHeader("Authorization").split(" ")[1]

JWT 토큰이 Header에 Authorization: Bearer {jwt token} 형식으로 요청되어지기에 공백을 기준으로 뒷 덩어리를 가져온다.

 

 

 

2. JWT Token 검증하기

val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
val uid: String = decodedToken.uid
val role: String = decodedToken.claims["group"] as String

Firebase Admin를 통해서 verify한다.

시간이 만료되었거나 잘못된 토큰일 경우 FirebaseAuthException이나 IllegalArgumentException을 뱉는다.

 

 

 

3. SecurityContextHolder에 UserDetails 객체를 생성하여 등록하기

val userDetails: UserDetails =
    User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
val authentication = UsernamePasswordAuthenticationToken(
    userDetails, null, userDetails.authorities
)
SecurityContextHolder.getContext().authentication = authentication

 

 

위 3단계를 합치고 예외 몇 가지를 지정해준 JwtFilter 코드는 다음과 같다.

class JwtFilter: OncePerRequestFilter() {

    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            val accessToken =
                request.getHeader("Authorization").split(" ")[1]
            val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
            val uid: String = decodedToken.uid
            val role: String = decodedToken.claims["group"] as String
            val userDetails: UserDetails =
                User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
            val authentication = UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.authorities
            )
            SecurityContextHolder.getContext().authentication = authentication
        } catch (e: FirebaseAuthException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: IllegalArgumentException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: NoSuchElementException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"USER_NOT_FOUND\"}")
            return
        } catch (e: NullPointerException) {
            response.status = HttpStatus.SC_BAD_REQUEST
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"AUTHORIZATION_HEADER_NOT_FOUND\"}")
            return
        } catch (e: Exception) {
            e.printStackTrace()
            return
        }
        filterChain.doFilter(request, response)
    }
}

 

 

 

 


4. Filter 등록

이렇게 구현한 Filter를 Spring Security의 Filter Chain에 등록해준다.

 

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
class WebSecurityConfig: WebSecurityConfigurerAdapter(){

    @Throws(java.lang.Exception::class)
    override fun configure(web: WebSecurity) {
        web.ignoring()
            .antMatchers("/")
            .antMatchers("/ping")
    }

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http
            .cors().disable()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/v1/**").authenticated().and()
            .addFilterBefore(
                JwtFilter(),
                UsernamePasswordAuthenticationFilter::class.java
            )
            .exceptionHandling()
            .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    }
}

 

@PreAuthorize가 동작하지 않으면 다음을 추가해주자

@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)

 


5. 동작확인

만료된 JWT Token으로 접근을 시도하면 401을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

권한에 맞지 않는 api에 접근을 시도하면 403을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

 

 

 

 

 

참고:

 

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring()
    	.antMatchers("/")
        .antMatchers("/ping");
}

@Override
public void configure(HttpSecurity http) throws Exception {
     http.csrf().disable()
        .authorizeRequests()
        .antMatchers("/v1/**").authenticated();
}

개발하며 위처럼 파라미터가 다른 두 configure를 재정의해서 사용할 일이 있었다.

 

 

HttpSecurity 패턴은 보안처리

WebSecurity 패턴은 보안예외처리 (정적리소스나 health check)

 

WebSecurity 패턴은 HttpSecurity패턴이 정의되어야 사용할 수 있다.

 

 

 

configure (WebSecurity)

  • antMatchers에 파라미터로 넘겨주는 endpoints는 Spring Security Filter Chain을 거치지 않기 때문에 '인증' , '인가' 서비스가 모두 적용되지 않는다.
  • 또한 Security Context를 설정하지 않고, Security Features(Secure headers, CSRF protecting 등)가 사용되지 않는다.
  • Cross-Site-Scripting(XSS), content-sniffing에 대한 endpoints 보호가 제공되지 않는다.
  • 일반적으로 로그인 페이지, public 페이지 등 인증, 인가 서비스가 필요하지 않은 endpoint에 사용한다.

 

configure (HttpSecurity)

  • antMatchers에 있는 endpoint에 대한 '인증'을 무시한다.
  • Security Filter Chain에서 요청에 접근할 수 있기 때문에(요청이 security filter chain 거침) 인증, 인가 서비스와 Secure headers, CSRF protection 등 같은 Security Features 또한 사용된다.
  • 취약점에 대한 보안이 필요할 경우 HttpSecurity 설정을 사용해야 한다.

 

 

 

 

참고:

더보기

이전에 구현했던 JWT 로그인에 Refresh Token을 추가해보았다.

전체 코드는 여기에 올렸다.


1. Refresh Token이란

지난 포스팅에선 Access 토큰만 사용하여 인터셉터가 적용된 자원에 접근해보았다. 

하지만 이 Access 토큰을 사용하여 요청과 응답을 받는 과정에서 우리는 이 토큰을 탈취당할 수 있다.

토큰은 [헤더]+[페이로드]+[비밀키]로 암호화 되어있지만 성능 좋은 컴퓨터로 비밀키를 알아내면 우리는 모든 정보와 권한을 빼앗기에 된다.

 

이를 보완하고자 Refresh 토큰의 개념이 사용된다.

  • Access 토큰의 유효기간은 매우 짧게
  • Refresh 토큰의 유효기간은 길게

해주는 것이 포인트다.

 

 

사용자는 Access 토큰을 통해서만 자원에 접근이 가능하고 Refresh 토큰은 소용이 없다.

그럼 언제 Refresh 토큰을 사용하는가?

유효기간이 짧은 Access 토큰이 만료가 되면 Refresh 토큰을 확인하여 검증 후 Access 토큰을 재발급해준다.

 

 

즉,

  • Access 토큰을 통해서만 자원에 접근이 가능, But 유효기간이 매우 짧다(탈취를 당해도 이미 사용할 수 없는 상태)
  • Refresh 토큰은 유효기간이 길기에 탈취당할 수도 있지만 Refresh 토큰은 오직 Access 토큰을 재발급하는 용도(Refresh 토큰 자체로는 별 쓸모가 없다.)

 

 

 

 


2. 인증 과정

 

위 사진은 로그인으로 설명이 돼있는데 회원가입으로 설명하는 것이 흐름을 알아보기에 조금 더 좋을 것 같아서 조금 바꿔서 설명하겠다.

 

  1. 사용자 회원가입 정보 입력.
  2. 유효한 데이터가 들어왔다면 회원가입 처리(DB 등록)
  3. 사용자 정보와 권한이 들어가 있는 Access 토큰과 Refresh 토큰 발급 (이때 Refresh 토큰은 DB에 저장한다.)
  4. 클라이언트는 두 종류의 토큰을 받는다.
  5. 이후 사용자가 데이터를 요청할 때마다 Access 토큰을 동봉하여 보낸다.
  6. 서버는 사용자로부터 전달된 Access 토큰이 유효한지만 판단한다(어자피 사용자의 권한과 정보는 토큰에 자체적으로 있다.)
  7. Access 토큰이 유효하면 사용자의 요청을 처리해서 반환해준다.
  8. 이때, 유효기간이 짧은 Access 토큰이 만료됐다고 해보자.
  9. 사용자는 만료된 Access 토큰으로 데이터 요청을 보낸다.
  10. 서버에서는 토큰에 대한 유효성 검사를 통해 만료된 토큰임을 확인한다.
  11. 클라이언트에게 "너의 토큰은 만료되었으니 갱신하기위해 Refresh 토큰을 보내라" 라고 응답한다.
  12. 클라이언트는 Access 토큰 재발급을 위해 Access 토큰과 Refresh 토큰을 전송한다.
  13. 전달받은 Refresh 토큰이 그 자체로 유효한지 확인하고, 3번에서 DB에 저장해 두었던 원본 Refresh 토큰과도 비교하여 같은지 확인한다.
  14. 유효한 Refresh 토큰이면 Access 토큰을 재발급 해준다.
  15. 만약 Refresh 토큰도 만료됐다면 로그인을 다시하고 Access 토큰과 Refresh 토큰을 새로 발급해준다.

 

더 간단한 흐름도로 확인하면 다음과 같다.

 

 

 

Refresh 토큰을 통해

  • Access 토큰의 유효기간을 짧게 잡을 수 있다.(탈취 방지)
  • Access 토큰의 유효기간에 짧음에도 불구하고 Refresh 토큰(유효기간 김)이 만료될때까지 추가적인 로그인을 안해도 된다. 마치 세션이 유지되는 것 처럼.

 

 

또 이러한 JWT 방식은

  • 서버가 다수 존재하는 환경에서 유용하다. 세션을 사용하면 모든 서버에서 세션 내용을 공유해야 하기 때문
  • 또한 매 요청시마다 DB 조회를 안하고 토큰 자체만으로 사용자의 정보와 권한을 알 수 있기에 병목현상을 방지한다.

 

 

 

 


3. 구현

구조

 

 

 

우리의 목표는 이 API에 접근하는 것

    @PostMapping("/user/test")
    public Map userResponseTest() {
        Map<String, String> result = new HashMap<>();
        result.put("result","success");
        return result;
    }

 

 

 

이는 인터셉터로 막혀있다

@Component
@RequiredArgsConstructor
public class JwtTokenInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {

        System.out.println("JwtToken 호출");
        String accessToken = request.getHeader("ACCESS_TOKEN");
        System.out.println("AccessToken:" + accessToken);
        String refreshToken = request.getHeader("REFRESH_TOKEN");
        System.out.println("RefreshToken:" + refreshToken);

        if (accessToken != null && jwtTokenProvider.isValidAccessToken(accessToken)) {
            return true;
        }

        response.setStatus(401);
        response.setHeader("ACCESS_TOKEN", accessToken);
        response.setHeader("REFRESH_TOKEN", refreshToken);
        response.setHeader("msg", "Check the tokens.");
        return false;
    }
}

 

 

 

회원가입을 하면 Access 토큰과 Refresh 토큰 발행

    // JWT 토큰 생성
    public String createAccessToken(String userId) {
        Claims claims = Jwts.claims();// JWT payload 에 저장되는 정보단위
        claims.put("userId", userId);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) // 만료시간 설정
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

클라이언트에서 이렇게 토큰을 받는다.

 

 

 

Access 토큰을 통해 데이터 요청

잘 받아졌다.

시간이 지나 Access 토큰이 만료되면 

아무 응답도 받지 못함과 동시에 토큰이 만료됐음을 확인할 수 있다.

 

 

 

Refresh 토큰을 사용하여 Access 토큰 재발급

Access 토큰은 재발급 된 거, Refresh 토큰은 기존의 것. 근데 Refresh 토큰도 매번 재발급 해주면 보안에 좋다.(Refresh Token Rotation)

    public TokenResponse issueAccessToken(HttpServletRequest request){
        String accessToken = jwtTokenProvider.resolveAccessToken(request);
        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
        //accessToken이 만료됐고 refreshToken이 맞으면 accessToken을 새로 발급
        if(!jwtTokenProvider.isValidAccessToken(accessToken)){  //클라이언트에서 토큰 재발급 api로의 요청을 확정해주면 이 조건문은 필요 없을 것 같다.
            System.out.println("Access 토큰 만료됨");
            if(jwtTokenProvider.isValidRefreshToken(refreshToken)){     //들어온 Refresh 토큰이 자체적으로 유효한지
                System.out.println("Refresh 토큰은 유효함");
                Claims claimsToken = jwtTokenProvider.getClaimsToken(refreshToken);
                String userId = (String)claimsToken.get("userId");
                Optional<User> user = userRepository.findByUserId(userId);
                String tokenFromDB = authRepository.findByUserId(user.get().getId()).get().getRefreshToken();
                System.out.println("tokenFromDB = " + tokenFromDB);
                if(refreshToken.equals(tokenFromDB)) {   //DB의 원본 refresh토큰과 지금들어온 토큰이 같은지 확인
                    System.out.println("Access 토큰 재발급 완료");
                    accessToken = jwtTokenProvider.createAccessToken(userId);
                }
                else{
                    //DB의 Refresh토큰과 들어온 Refresh토큰이 다르면 중간에 변조된 것임
                    //예외발생
                    System.out.println("Refresh Token Tampered");
                }
            }
            else{
                //입력으로 들어온 Refresh 토큰이 유효하지 않음
            }
        }
        return TokenResponse.builder()
                .ACCESS_TOKEN(accessToken)
                .REFRESH_TOKEN(refreshToken)
                .build();
    }

Refresh 토큰을 사용하여 Access 토큰을 재발급하는 메소드인데 뭔가 내용이 많아보이지만 사실상 간단하다.

 

 

  • Access 토큰이 만료됐다면
  • Refresh 토큰은 유효하다면
  • DB에 저장돼있는 Refresh 토큰 원본과 지금 들어온 Refresh 토큰이 일치한다면

Access 토큰을 재발급 해주는 흐름이다.

 

 

또, 이 글을 작성하면서 알게된 사실인데 Refresh 토큰의 페이로드에는 사용자 정보를 넣지 않는게 좋다고 한다. 생각해 보니 그런 것 같다. Refresh 토큰은 유효기간이 길기에 탈취될 수 있고(그 자체로는 뭘 할순 없지만) 탈취되어 내용을 까보면 치명적이진 않더라도 사용자의 정보가 노출되어 버리기 때문인 것 같다. 수정해야겠다.

 

 

 

 


4. Refresh 토큰에 대한 고찰

로그인이나 회원가입을 하고 Refresh 토큰을 클라이언트에게 반환하는 것을 보안상 문제로 인해 지양해야 한다는 주장이 있다. 카카오는 작년 업데이트를 통해 Javascript를 통한 카카오 로그인 기능에서 response 값에서 Refresh 토큰을 제외하기로 했다.

 

 

https://devtalk.kakao.com/t/javascript-api-sdk-refresh-token/105942

 

[공지] JavaScript 키를 이용한 API/SDK 사용 시 refresh token 응답 필드 제거 안내

카카오 데브톡. 카카오 플랫폼 서비스 관련 질문 및 답변을 올리는 개발자 커뮤니티 사이트입니다.

devtalk.kakao.com

 

 

현재 나의 자그마한 뇌로는 구체적으로 어떻게 위협이 될 수 있는지 잘 모르겠다만 아무래도 민감한 정보를 클라이언트에 노출하는 것이다보니 이런 결정을 한 것 같다.

 

이 뿐만 아니더라도 JWT에 대한 정보를 찾다보면 클라이언트로 발급한 Refresh 토큰을 어디에 저장해야 하는지에 대한 고민을 가진 사람들이 많았다.

 

그 중 어떤 분이 Refresh 토큰을 토큰 자체로 반환하지 말고 DB에 저장된 곳의 인덱스(정수 or 해시값)만 반환한다면, 클라이언트 측에서는 무의미한 인덱스 숫자만 알게 되는 것이기에 보안적으로 조금 더 좋다는 의견을 제시했다.

https://doogle.link/jwt-%ED%98%B9%EC%9D%80-oauth2-%EC%9D%98-refresh-%ED%86%A0%ED%81%B0%EC%9D%84-%EC%96%B4%EB%94%94%EB%8B%A4-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C/

 

JWT 혹은 OAuth2 의 refresh 토큰을 어디다 저장해야 할까? | 두글 블로그

요즘 네이버로그인, 카카오 로그인이나 구글 로그인등등 소셜 미디어(Social media) 사용자 로그인 처리를 하다보니 로그인된 상태가 끊임없이 유지되는 것을 구현해야 되더군요. 그러려면 결국 리

doogle.link

 

 

상당히 멋진 아이디어라고 생각됐기에 나름대로 구현을 시도해보았다. 

결과부터 말하면 실패.

 

 

    //refresh 토큰의 인덱스를 통한 검증
    public TokenIndexResponse issueAccessIndexToken(HttpServletRequest request){
        String accessToken = jwtTokenProvider.resolveAccessToken(request);
        Long refreshTokenIndex = jwtTokenProvider.resolveRefreshIndexToken(request);
        if(jwtTokenProvider.isOnlyExpiredToken(accessToken)) {  //만료만 된 토큰이라면
            Optional<Auth> findAuth = authRepository.findById(refreshTokenIndex);
            String refreshTokenFromDB = findAuth.get().getRefreshToken();
            Claims claimsToken = jwtTokenProvider.getClaimsToken(refreshTokenFromDB);
            String userId = (String)claimsToken.get("userId");
            if (jwtTokenProvider.isValidRefreshToken(refreshTokenFromDB)) {
                System.out.println("Access 토큰 재발급 완료 by 인덱스");
                accessToken = jwtTokenProvider.createAccessToken(userId);
            } else {
                //예외발생
                System.out.println("Refresh Token Tampered");
            }
        }
        return TokenIndexResponse.builder()
                .ACCESS_TOKEN(accessToken)
                .REFRESH_TOKEN_INDEX(refreshTokenIndex)
                .build();
    }

 

 

위에서 Refresh 토큰의 페이로드에는 사용자 정보를 담지 않는게 좋다고 했는데

Refresh 토큰 대신 인덱스를 반환하는 이 방식에서는 Refresh 토큰은 DB상에만 존재하게 되니까 상관없을 것 같다.

저 코드대로 해보면 되긴 된다. 근데 이제 매우 안전하지 않다는게 문제다.

구체적으론, 어떤 사용자가 요청한 것인지 확신을 못한다.

 

 

Attempt 1

  • 요청은 Access 토큰과 Refresh 토큰의 id(인덱스) 값
  • Access 토큰이 Expire만 됐고 다른 정보는 유효하다면, Refresh 토큰의 인덱스를 통해 Refresh 토큰 조회 후 Access 토큰 재발급

But,

  • 다른 사람의 만료된 Access 토큰과 아무 인덱스 숫자를 요청하고 운좋게 해당 인덱스에 누군가의 Refresh 토큰이 들어있었다면 그 사람의 권한을 가진 Access 토큰이 제 3자에게 발급돼 버린다.

 

 

 

 

 

Attempt 2

  • Refresh 토큰을 저장하는 Auth 테이블에 원래 Refresh 토큰 필드만 있었는데 Access 토큰 필드도 추가해 준다.
  • Auth 테이블에는 [id], [Access 토큰], [Refresh 토큰] 이렇게 3개의 필드가 있는 것.
  • 요청받은 Access 토큰(만료된거)과 Refresh토큰의 인덱스번호가 동시에 일치하는 경우에만 Access 토큰 속 사용자가 올바른 사용자라고 판단하여 Access 토큰을 재발급한다.
  • 즉, 입력된 인덱스(예를들어 2)에 해당하는 DB의  Access 토큰이 입력된 Access 토큰과 같은지 확인하고 같다면 Access 토큰 속 사용자가 맞다고 판단했다.

But,

  • Access 토큰의 원본과 비교한다는 점에서 Access 토큰의 조작을 막을 수 있다해도, 다른 사람이 Access 토큰을 탈취하여 무작위 인덱스 번호를 쭉 갈겨버린다면 뚫리기 마련이다.

 

 

 

뭔가 처음엔 될 것 같아서 시도해보면 터무니없는 방법임을 알게 된다. 자괴감온다. ㅠㅠ

그래도 고민을 하면서 JWT에 대한 이해는 깊어졌다.

 

 

 


5. 결론

그래서 Refresh 토큰을 클라이언트로 보내도 되는가? 안된다면 어떻게 해결하는가?

 

개인적으로는 Refresh 토큰 그대로 클라이언트로 전송해도 괜찮을 것 같다. 그리고 그게 제일 무난한 방법인 것 같다.

클라이언트 측에서 HTTPOnly 옵션과 Secure 코딩을 적용한 쿠키에 토큰을 보관하는 방법이 여러 기술 블로그들의 주류였다.

 

 

그래도 더 개선해보면

Refresh Token Rotation 방법이 있다.

Access 토큰의 짧은 수명이 다하고 Refresh 토큰을 통해 재발급할때, Refresh 토큰도 바로 재발급 해버리는 것이다.

 

그렇게 되면 Refresh 토큰의 조작여부를 쉽게 파악할 수 있고 Refresh 토큰 자체도 클라이언트에서 조금 더 안전하다.

(계속 바뀌니까)

 

 

 

 

 

 

참고:

더보기
  1. 공부중 2022.03.17 10:49

    좋은 글 감사합니다. 공부에 많은 도움이 되었습니다.

  2. 순식꾸이 2022.04.18 15:33

    너무 잘 읽고 갑니다

  3. Curious Rabbit 2022.05.31 22:29 신고

    잘 보고 갑니다~

  4. 안주형 2022.07.28 22:41 신고

    1~15까지 정리된 내용 감사합니다.

지난 포스팅에서 JWT가 무엇인지 알아보았다. 그럼 실제로 구현해보자.

https://llshl.tistory.com/26

 

[Web] JWT란?

1. JWT란? Json Web Token의 줄임말이다. 두 개체에서 JSON객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안정성 있게 전달해 주는 인증 방식이다. 2. 세션과의 차이점 세션 보통 로그인을 구현

llshl.tistory.com


1. JWT 테스트 시나리오

  1. 이메일과 비밀번호를 받아 회원 등록
  2. 로그인하면 JWT 토큰 반환
  3. JWT토큰을 Client 헤더에 등록하고, 요청 시 Spring Security에서 제한을 걸어놓은 리소스의 접근을 확인

 

전체 코드는 깃헙에 업로드 해놓았다.

참고로 이 코드는 

여기

블로그에서 참고했다. 설명이 아주 잘 돼 있으니 참고하면 좋다.

 

 

 

주요 코드만 살펴보자.

 

 

 


2. JWT를 사용해 로그인 기능을 구현해보자

 

회원가입 컨트롤러

    // 회원가입
    @PostMapping("/join")
    public Long join(@RequestBody Map<String, String> user) {
        return userRepository.save(User.builder()
                .email(user.get("email"))
                .password(passwordEncoder.encode(user.get("password")))
                .roles(Collections.singletonList("ROLE_ADMIN")) // 최초 가입시 USER 로 설정
                .build()).getId();
    }

그냥 별거없다. 이메일과 비밀번호를 입력받아서 Jpa의 save를 해준다. 참고로 데이터베이스는 H2를 썼다.

이렇게 포스트맨의 바디에 회원가입 정보를 담아서 요청을 보냈다.

 

 

 

 

로그인 컨트롤러

    // 로그인
    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> user) {
        User member = userRepository.findByEmail(user.get("email"))
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
        if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }
        return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
    }

로그인 컨트롤러에서는 입력받은 이메일과 비밀번호가 데이터베이스에 있는 정보와 일치하는지 확인하고

일치한다면 JWT 토큰을 만들어서 반환해준다.

토큰에 사용자의 이메일(getUsername)과 권한 정보를 넣어줄 것이기에 파라미터로 넘겨준다.

회원가입한 정보 그대로 로그인 요청을 보낸다. 그러면 응답으로 JWT토큰이 돌아왔다!

 

 

 

 

JWT 토큰 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String secretKey = "llshlllshlllshlllshl";

    // 토큰 유효시간 30분
    private long tokenValidTime = 30 * 60 * 1000L;
    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

이 클래스의 createToken 메소드를 통해 토큰을 생성한다.

이렇게 생성된 JWT 토큰은 이후 사용자의 요청마다 헤더에 담아져 함께 전달된다.

 

 

이 토큰을 디코딩해보면

우리가 토큰에 넣어주었던 사용자의 정보가 잘 나오는것을 확인 할 수 있다.

이건 https://jwt.io/ 여기서 할 수 있다.

 

 

 

권한이 있어야 접근 가능하도록

토큰이 권한을 가져다 주는지 확인하기 위해 Spring Security의 WebSecurityConfig 클래스를 설정해준다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
        http
                .httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
                .csrf().disable() // csrf 보안 토큰 disable처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
                .and()
                .authorizeRequests() // 요청에 대한 사용권한 체크
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests(); // 권한요청 처리 설정 메서드

        // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
    }

 

configure 메소드에서 user와 admin으로 시작하는 요청에는

각각 USER와 ADMIN의 권한이 있어야만 접근 가능하도록 제한을 걸어두었다.

 

 

 

권한은 어떻게 확인?

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            System.out.println("토큰 유효하다");
        }
        chain.doFilter(request, response);
    }
}

이 필터가 요청을 가로채어 JWT 토큰이 유효한지 판단한다. 토큰 검증 후 유효하다면 다시 요청을 진행시킨다.

 

 

 

확인해보자

    @PostMapping("/user/test")
    public Map userResponseTest() {
        Map<String, String> result = new HashMap<>();
        result.put("result","user ok");
        return result;
    }

    @PostMapping("/admin/test")
    public Map adminResponseTest() {
        Map<String, String> result = new HashMap<>();
        result.put("result","admin ok");
        return result;
    }

접근 제한을 걸어둔 이 두개의 리소스에 요청을 보내어 결과가 잘 받아지는지 확인해보자.

회원가입시 기본적으로 얻는 권한은 USER다.

 

 

 

 

Headers에 우리가 설정해둔 키인 X-AUTH-TOKEN에 대응되게 value로 JWT 토큰을 넣어서 요청을 보내주면 올바른 응답이 돌아옴을 확인할 수 있다. 즉 인증이 되었다는 것.

 

 

 

 

 

당연하게도 토큰을 빼고 요청을 보내면 응답을 받을 수 없다. 

 

 

 

 

또한 USER권한으로는 ADMIN에 접근이 안됨을 확인할 수 있다.

 

 

 

 

참고:

  1. 정지원 2022.06.05 15:02

    혹시 깃헙 주소가 어떻게 되시나요?/ 전체 코드 보고싶습니다!

H2 데이터베이스를 사용할 일이 많음에도 불구하고 초기 설정할때마다 삐걱대는게 지겨워져서 한번 정리한다.


1. H2 데이터베이스란?

H2 데이터베이스는 설치가 필요 없고 용량이 매우 가벼우며 웹용 콘솔을 제공하여 개발용 로컬 DB로 사용하기 좋은 데이터베이스이다. H2의 특징을 정리하면 다음과 같다.

  • 스프링 부트가 지원하는 인메모리 관계형 데이터베이스이다.
  • 인메모리로 띄우면 애플리케이션을 재시작할 때마다 초기화된다.
  • 별도의 설치가 필요 없다.
  • 로컬 환경, 테스트 환경에서 많이 사용된다.

http://h2database.com에 들어가서 다운받으면 된다.

 

 


2. Spring boot에서 H2 사용하기

 

build.gradle 설정

runtimeOnly 'com.h2database:h2'

 

 

application.yml 설정

spring:
  h2:
    console:
      enabled: true
      path: /jwt

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE
    username: sa
    password:

h2.console.enable : true 는 웹 콘솔을 사용하겠다는 의미

h2.console.path : /jwt는 콘솔의 경로를 의미(나는 jwt 관련 프로젝트를 진행하고 있기에 이름을 이렇게 했다)

 

 

이렇게 하면 h2 웹 콘솔에 접속할 때 

http://localhost:8080/jwt

주소로 들어가면 된다. 

 

 

datasource.url은 로컬에서 어디에 h2 데이터베이스의 정보를 저장할 것이냐는 건데, 나의 경우

~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

로 설정하면 바탕화면에 jwt-h2라는 폴더가 생기고 그 안에 jwt라는 이름으로 저장된다.

 

 

 

이제 웹 콘솔창에서 url 전체를 입력하고 connect를 누르면 사용 가능하다.

jdbc:h2:file:~/Desktop/jwt-h2/jwt;AUTO_SERVER=TRUE

 

 

 


3. 스프링 시큐리티를 사용한다면

스프링 시큐리티에 의해서 h2 콘솔 접근이 차단될 수 있다. 나의 경우는

h2 console localhost에서 연결을 거부했습니다.

라고 뜨며 다음과 같은 화면이 나왔다.

 

 

 

 

이럴땐 스프링 시큐리티의 WebSecurityConfig 클래스에서 h2-console에 대한 접근을 허용해줘야 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

	// ... 
	
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
    
    //...
}

configure 메소드에 위와 같이 h2 콘솔에 대한 접근을 허용해 주면 된다. 그럼 문제 해결

 

 

 

 

 

참고:

1편에서 이어지는 글이다. 카카오 디벨로퍼스에서의 설정은 끝났으니 코드로 남은 구현을 마무리하자.


1. REST API 키 복사하기

여기서 두번째 저것을 사용해야한다. 복사하자.

 

 

 

2. Callback Url을 받을 Controller (인증 코드 요청)

@GetMapping("/user/kakao/callback")
public String kakaoLogin(String code) {
  // code는 카카오 서버로부터 받은 인가 코드
  log.info("kakaoLogin");
  memberService.kakaoLogin(code);
  return "redirect:/";
}

이 컨트롤러에서 다음 그림의 첫번째 한쌍의 절차를 수행한 것이다.

 

 

 

3. 토큰 요청

service의 kakaoLogin 메소드

public void kakaoLogin(String authorizedCode) {
        log.info("kakaoLogin Service 호출");
        // 카카오 OAuth2 를 통해 카카오 사용자 정보 조회
        KakaoUserInfo userInfo = kakaoOAuth2.getUserInfo(authorizedCode);
        System.out.println("userInfo.getNickname() = " + userInfo.getNickname());

        //카카오에서 받아온 사용자의 정보
        Long kakaoId = userInfo.getId();
        String nickname = userInfo.getNickname();
        String email = userInfo.getEmail();

        //카카오 로그인을 통해 이미 회원가입한 회원인지 확인하기 위해 카카오ID를 통해 검색
        MemberVo kakaoMember = memberMapper.findByKakaoId(kakaoId)
                .orElse(null);

        //중복된 사용자가 없다면(처음으로 카카오 로그인을 하는 경우 카카오에서 받은 정보를 통한 회원가입 진행)
        if(kakaoMember == null) {
            MemberVo sameEmailMember = memberMapper.findByEmail(email).orElse(null);
            if(sameEmailMember != null){
                //카카오로그인은 처음인데 이미 그냥 회원가입은 돼있는경우
                kakaoMember = sameEmailMember;
                kakaoMember.setKakao_id(kakaoId);    //이미 저장돼있는 회원 정보에 카카오 ID만 추가해서 다시 저장한다.
                memberMapper.updateMember(kakaoMember);     //기존 회원에 카카오 아이디만 추가해준다
            }
            else{
                String userName = nickname;
                String password = kakaoId + ADMIN_TOKEN;
                String encodedPassword = passwordEncoder.encode(password);
                kakaoMember = MemberVo.builder()
                        .kakao_id(kakaoId)
                        .login_id(email)    //카카오 로그인의 경우에는 login id가 없기에 이메일을 넣어줌
                        .nickname(nickname)
                        .name(userName)
                        .email(email)
                        .login_password(encodedPassword)
                        .build();
                memberMapper.save(kakaoMember); //회원가입

                int idByLoginId = memberMapper.findIdByLoginId(email);
                kakaoMember.setMember_id(idByLoginId);
            }
        }

        // 로그인 처리
        // 스프링 시큐리티 통해 인증된 사용자로 등록
        MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
        Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

 

 

kakaoOAuth2 메소드

@Component
public class KakaoOAuth2 {

    public KakaoUserInfo getUserInfo(String authorizedCode) {
        System.out.println("getUserInfo 호출");
        // 인가코드 -> 액세스 토큰
        String accessToken = getAccessToken(authorizedCode);
        // 액세스 토큰 -> 카카오 사용자 정보
        KakaoUserInfo userInfo = getUserInfoByToken(accessToken);

        return userInfo;
    }

    private String getAccessToken(String authorizedCode) {
        System.out.println("getAccessToken 호출");
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpBody 오브젝트 생성
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", "여기에 REST API 키");   
        params.add("redirect_uri", "http://localhost:8080/user/kakao/callback");
        params.add("code", authorizedCode);

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(params, headers);

        // Http 요청하기, Post방식으로, 그리고 response 변수의 응답 받음
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // JSON -> 액세스 토큰 파싱
        String tokenJson = response.getBody();
        JSONObject rjson = new JSONObject(tokenJson);
        String accessToken = rjson.getString("access_token");   //우리가 필요한건 accessToken

        return accessToken;
    }

    //토큰을 통해 사용자 정보 가져오기
    private KakaoUserInfo getUserInfoByToken(String accessToken) {
        // HttpHeader 오브젝트 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HttpHeader와 HttpBody를 하나의 오브젝트에 담기
        RestTemplate rt = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest = new HttpEntity<>(headers);

        // Http 요청하기 - Post방식으로 - 그리고 response 변수의 응답 받음.
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoProfileRequest,
                String.class
        );

        JSONObject body = new JSONObject(response.getBody());
        Long id = body.getLong("id");
        String email = body.getJSONObject("kakao_account").getString("email");
        String nickname = body.getJSONObject("properties").getString("nickname");

        //가져온 사용자 정보를 객체로 만들어서 반환
        return new KakaoUserInfo(id, email, nickname);
    }
}

 

 

 

kakaoLogin 메소드의 마지막 부분인

SecurityContextHolder.getContext().setAuthentication(authentication);

을 통해서 로그인 처리가 된다.

 

*SecurityContextHolder란 Spring Security의 인메모리 세션저장소

 

 

 

4. 로그인 페이지

<button id="login-kakao-btn" onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id=[여기에RESTAPI키]&redirect_uri=http://localhost:8080/user/kakao/callback&response_type=code'">
        카카오로 로그인하기
</button>

 

 

 

5. 현재 사용자의 정보 가져오기

지금까지 OAuth2를 사용하지 않고 로그인을 구현할 때는 

session.setAttribute("loginMemberId",loginDto.getUserID());

이런식으로 세션에 직접 무언가를 set 해줬는데 스프링 시큐리티를 사용할때는

 

 

MemberDetailsImpl memberDetails = new MemberDetailsImpl(kakaoMember);
Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);

이렇게  MemberDetails로 꺼내 사용자의 session 생성하고 이를 SecurityContextHolder에 저장한다.

이것을 가져올때는

 

 

@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal MemberDetailsImpl memberDetails){
    model.addAttribute("username", memberDetails.getUsername());
    System.out.println("member_id: "+memberDetails.getMemberVo().getMember_id());
    return "index";
}

@AuthenticationPrincipal 어노테이션을 통해 SecurityContextHolder에 저장된 현재 사용자 정보를 가져올 수 있다. 위 코드의 출력 결과는 사용자의 member_id가 된다. 이것을 사용하여 여러 쿼리문의 where에 사용할 수 있겠지? 

 

 

사실 스프링시큐리티와 OAuth2에 대한 정보가 많이 부족하기에 혹시라도 지나가시던 고수님께서 보시고 틀린점이나 추가할 점을 발견하신다면 한소리 부탁드립니다..

 

 

 

 

참고:

지난 네이버 아이디로 로그인하기에 이어서 다음 소셜 로그인으로 카카오를 선택했다.

선택했다기보단 지금 모 코딩 교육 프로그램에서 한번 배웠기에 간단하게라도 복기하는 느낌으로 정리하는거다.

 


1. 소셜 로그인?

우리가 사용하는 웹 사이트들마다 전부 회원가입을 하는것은 사용자에게 너무 부담이 된다. 귀찮으니까. 또한 대부분 지키지 않겠지만 웹 사이트마다 다른 아이디와 비밀번호를 사용해야 보안적으로도 좋기에 각각 어떤 아이디였는지 외우기도 힘들다. 웹사이트를 운영하는 측면에서도 개인정보를 지켜야하는 부담이 있다.

따라서 OAuth를 사용한 소셜로그인이 등장했다.

 

 

OAuth란,

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜이다.

대표적으로 구글, 페이스북, 네이버, 카카오에서 서비스를 제공해주고 있다.

 

 

카카오 로그인의 큰 흐름은 다음과 같다.

 

 

 

 


2. 카카오 로그인 설정하기

1. 카카오 디벨로퍼스에 프로젝트 등록하기

https://developers.kakao.com/console/app

 

카카오계정 로그인

여기를 눌러 링크를 확인하세요.

accounts.kakao.com

 

애플리케이션 추가하기를 눌러서 적으라는것을 다 적는다! 등록된 애플리케이션 ID는 정확히 어디에 사용되는 것인지 모르기에 일단은 모자이크 처리를 하여서 캡쳐했다.

 

 

 

2. 사이트 도메인 입력

좌측 메뉴에서 플랫폼에 들어간 후 Web 플랫폼을 등록해준다.

 

 

 

3. Callback Url 설정하기

등록하러 가기를 눌러서 카카오 로그인이 성공하면 redirect 될 url을 설정해준다.

나는 이렇게 설정했고 controller에서 저 url을 처리해 줄 예정이다.

 

 

 

4. 동의항목 설정

카카오로부터 받을 사용자의 정보를 선택해야한다.

닉네임과 이메일정도만 받아보자.

 

 

다음부턴 코드로 구현해야한다. 다음 포스팅에 이어 작성하겠다.

 

 

 

 

참고:

 

스프링을 많이 써온건 아니지만 그래도 쓰긴 쓰고있는 이 시점에서 문뜩 의문이 들었다. 친구가 나에게 만약 "스프링이 뭐야?" 라고 물어온다면 나는 뭐라고 대답 할 것인가? 스프링을 써오면서 이게 뭔지 정확히 모르고 사용해온 것 같아서 스스로 부끄러움을 느꼈다. 그래서 한번 조사해보았다. 스프링 기본 원리를.

 

한 마디로 요약하면?

"IoC와 AOP를 지원하는 경량의 컨테이너 프레임워크"

 

이제 이 문장을 하나씩 뜯어보자

 

컨테이너가 뭘까?

컨테이너는 특정 객체의 생성과 관리를 담당하며 객체 운용에 필요한 다양한 기능을 제공한다. 애플리케이션 운용에 필요한 객체를 생성하고 객체 간의 의존관계를 관리한다는 점에서 스프링도 일종의 컨테이너라고 할 수 있다.

 

컨테이너의 종류

스프링에서는 BeanFactory와 이를 상속한 ApplicationContext 두 가지 유형의 컨테이너를 제공한다. BeanFactory는 스프링 빈을 관리하고 조회하는 역할을 담당한다. BeanFactory를 전부 상속받는 ApplicationContext는 메시지 소스를 활용한 국제화 기능, 환경변수, 애플리케이션 이벤트, 편리한 리소스 조회 와 같은 편리한 부가기능이 있다. BeanFactory를 직접 사용할 일은 거의 없으며 보통  Applicationcontext를 사용한다. 

 

출처: 김영한님의 스프링 핵심 원리

 

자 그럼 IoC는 뭘까?

애플리케이션 개발 시 중요한점 중에 하나는 낮은 결합도와 높은 응집도이다. 스프링의 IoC는 객체 생성을 자바 코드로 직접 처리하는 것이 아니라 컨테이너가 대신 처리하게 한다. 그리고 객체와 객체 사이의 의존관계 역시 컨테이너가 처리한다. 이처럼 프로그램의 제어 흐름을 개발자가 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)라고 한다.

 

의존성 주입

의존 관계란 객체와 객체간의 결합 관계이다. 한 객체에서 다른 객체의 변수나 메소드를 사용하려면 해당 객체의 레퍼런스 정보가 필요하다. DI기능을 사용하지 않고 순수 자바코드처럼 코드에서 new를 통해 객체를 직접 생성해서 사용하면 이는 높은 결합도를 가지게 된다. new를 사용하지 않고 의존성을 주입하는 방법은

 

1. XML을 통한 의존성 주입

2. 속성을 통한 의존성 주입

3. 어노테이션을 통한 의존성 주입

 

이 있는데 이중 3번에 대해서 3가지를 패턴을 소개하겠다.

 

1. 생성자 주입

@Component
public class SampleController {
    private SampleRepository sampleRepository;
 
    @Autowired
    public SampleController(SampleRepository sampleRepository) {
        this.sampleRepository = sampleRepository;
    }
}

 

2. 필드 주입

@Component
public class SampleController {
    @Autowired
    private SampleRepository sampleRepository;
}

 

3. Setter 주입

@Component
public class SampleController {
    private SampleRepository sampleRepository;
 
    @Autowired
    public void setSampleRepository(SampleRepository sampleRepository) {
        this.sampleRepository = sampleRepository;
    }
}

 

 

 

AOP는 뭘까?

직역하면 "관점지향프로그래밍"이다.

개발을 하다보면 코드 곳곳에서 공통적으로 요구되는 기능들이 있다. 예를들어 메소드들의 실행시간 로그라던가, 컨트롤러 호출 시 세션검사라던가 이런 기능을 구현하고 싶을때 모든 메소드마다 코드를 추가해가며 개발하기는 너무 비효율 적이기에 AOP라는 기능이 있는것이다. 

 

즉, AOP는 중복을 제거하기에 아주 효과적인 방법이다.

 

이또한 XML을 사용한 설정과 어노테이션을 사용한 설정이 있는데 나는 주로 후자를 많이 사용했다. (XML 너무 복잡해ㅠㅠ) AOP에 관해서는 나중에 따로 포스팅을 해보겠다.

 

 

오늘은 간단하게 스프링이 무엇인지에 대한 얕은 개념들을 알아보았는데 사실 너무 방대해서 눈감고 더듬는 그런 느낌이다. 언젠가 내가 알던 개념들이 한군대로 파파팍 모이며 스프링의 전체 모양을 이해하는 날이 빨리 오면 좋겠다. (1일 1포스팅만 해도 훨씬 일찍 올듯..)

 

 

참고:

https://asfirstalways.tistory.com/334

https://atoz-develop.tistory.com/entry/Spring-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85DI-Dependency-Injection%EC%9D%98-%EC%84%B8%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

 

Spring 의 시작, 프레임워크의 구성요소와 동작원리

Spring Framework의 구성요소와 동작원리 POJO 스프링의 특징을 살펴보면 POJO라는 단어가 등장한다. POJO란 Plain Old Java Object로 직역하자면 평범한 옛날 자바 객체이다. 말 그대로 자바 객체인 것이다..

asfirstalways.tistory.com

 

+ Recent posts