0_ch4n
기계쟁이\n개발자
0_ch4n
0chn.xxx@gmail.com @0ch._.n
전체 방문자
오늘
어제

공지사항

  • All (282)
    • 🖥 CS (21)
      • 네트워크 (12)
      • 운영체제 (3)
      • 자료구조 (2)
      • Web (4)
    • 🧠 Algorithm (185)
      • [C] BOJ (93)
      • [JAVA] Programmers (91)
    • 📚 Study (69)
      • HTML&CSS (19)
      • MySQL (11)
      • JAVA (22)
      • Servlet&JSP (8)
      • Thymeleaf (2)
      • Spring (5)
      • JPA (2)
    • 📖 Book (1)
    • 📃 Certification (6)
      • 정보처리기사 (6)

인기 글

최근 글

최근 댓글

태그

  • til
  • 카카오
  • kakao
  • 코딩테스트
  • CSS
  • 코테
  • 프로그래머스
  • 자바
  • java
  • Programmers

블로그 메뉴

  • 홈
  • 태그
  • 방명록

티스토리

hELLO · Designed By 정상우.
0_ch4n

기계쟁이\n개발자

[Spring] Spring Security + JWT
📚 Study/Spring

[Spring] Spring Security + JWT

2022. 8. 1. 21:20
반응형

공부용으로 작성한 글입니다. 틀린 부분은 댓글로 지적해주시면 감사드리겠습니다.

 

✔️ 인증(Authentication)과 인가(Authorization)

인증(Authentication)은 특정 리소스에 액세스하려는 사람의 신원을 확인하는 방법입니다. 사용자를 인증하는 일반적인 방법은 사용자에게 사용자 이름과 암호를 입력하도록 요구하는 것입니다.

 

인가(Authorization)는 인증을 마친 유저에게 권한(Authority)를 부여하여 대상 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정입니다.

 

인가는 반드시 인증 과정 이후 수행되어야 하며 권한은 Role 형태로 부여하는 것이 일반적입니다.

 

 

✔️ DelegatingFilterProxy

스프링 시큐리티는 주로 서블릿 필터와 이들로 구성된 필터체인을 사용하고 있습니다.

서블릿 필터는 WAS의 Servlet Container에서 생성되고 실행되기 때문에 스프링에서 정의한 필터 빈을 주입해서 사용할 수 없습니다.

서블릿 필터에게 요청 받은 DelegatingFilterProxy는 springSecurityFilterChain 에게 요청을 위임합니다.

 

✔️ FilterChainProxy

springSecurityFilterChain의 이름으로 생성되는 필터 빈입니다.

DelegatingFilterProxy에게 요청을 위임 받아 실제로 인증/인가를 처리하고 스프링 시큐리티의 필터들을 관리하고 제어합니다.

 

📌 Spring Security Filter 목록

더보기
  • ForceEagerSessionCreationFilter
  • ChannelProcessingFilter
  • WebAsyncManagerIntegrationFilter
  • SecurityContextPersistenceFilter
  • HeaderWriterFilter
  • CorsFilter
  • CsrfFilter
  • LogoutFilter
  • OAuth2AuthorizationRequestRedirectFilter
  • Saml2WebSsoAuthenticationRequestFilter
  • X509AuthenticationFilter
  • AbstractPreAuthenticatedProcessingFilter
  • CasAuthenticationFilter
  • OAuth2LoginAuthenticationFilter
  • Saml2WebSsoAuthenticationFilter
  • UsernamePasswordAuthenticationFilter
  • OpenIDAuthenticationFilter
  • DefaultLoginPageGeneratingFilter
  • DefaultLogoutPageGeneratingFilter
  • ConcurrentSessionFilter
  • DigestAuthenticationFilter
  • BearerTokenAuthenticationFilter
  • BasicAuthenticationFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • JaasApiIntegrationFilter
  • RememberMeAuthenticationFilter
  • AnonymousAuthenticationFilter
  • OAuth2AuthorizationCodeGrantFilter
  • SessionManagementFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor
  • SwitchUserFilter

 

✔️ ExceptionTranslationFilter

사용자가 인증이 필요한 자원이나 특정한 인가가 필요한 자원에 접근하려 할 때 인증/인가에 실패하게 되면 처리해준다.

 

📌 AutenticationException (인증 예외 처리)

인증에 실패 했을 시 인증 예외 처리를 호출하여 2가지 일을 진행합니다.

 

1. AuthenticationEntryPoint 을 호출해 로그인 페이지로 Redirect, Error Code 전달 등

 

2. HttpSessionRequestCache에 SavedRequest를 통해 요청 정보 저장, RequestCache를 통해 캐시 매커니즘 제공

 

📌 AccessDeniedException (인가 예외 처리)

AccessDeniedHandler를 호출해 그 안에서 후속처리를 하며 보통 /denied 페이지를 Redirect 합니다.

 

 

✔️ Authentication (인증)

Spring Security 인증의 핵심인 SecurityContextHolder는 인증된 사람의 세부 정보를 저장하는 곳입니다.

SecurityContextHolder엔 다른 스레드와의 경쟁을 피하기 위해 비어 있는 SecurityContext를 만듭니다.

다음으로 Authentication 객체를 생성해 SecurityContext에 담습니다.

 

AuthenticationManager는 AuthenticationFilter가 인증을 수행하는 방법을 정의하는 API입니다.

ProviderManager는 그의 일반적인 구현체로 여러 검증 방법을 가진 AuthenticationProvider들을 통해 검증합니다.

1. 사용자가 Http Request로 username, password를 가진 상태로 요청합니다.

 

2. Authentication Filter가 username, password로 Authentication(UsernamePasswordAuthenticationToken)을 만듭니다.

 

3. Token을 검증하기 위해 Authentication Manager에 보내줍니다.

 

4. AuthenticationProvider -> UserDetailsService -> UserDetails를 거쳐 DB에 존재하는지 확인합니다.

 

5. DB에 존재한다면 요청 받은 password를 암호화 시킨 후 DB의 password와 비교합니다.

 

6. 비교에 성공하면 Authentication Manager가 Authentication를 만들어 SecurityContext에 저장해 세션을 유지합니다.

 

✔️ Authorization (인가)

인가에는 세션-쿠키, 토큰(JWT), 다른 채널을 통한 인증(OAuth) 이 3가지 방법을 주로 사용합니다.

이 중 JWT는 세션과 달리 서버가 아닌 클라이언트에 저장되기 때문에 서버의 부담을 덜 수 있습니다.

또한 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함되어 있습니다.

 

JWT를 사용하면 RESTful과 같은 무상태(stateless)인 환경에서 사용자 데이터를 주고 받을 수 있게 됩니다.

세션을 사용하게 되면 쿠키 등을 통해 식별하고 서버에 세션을 저장했지만 JWT는 HTTP 헤더만으로도 데이터를 주고 받을 수 있습니다.

 

📌 JWT의 구조

JWT 토큰은 간단하게 사용자의 정보를 JSON 객체로 안전하게 전송하기 위한 방법입니다.

 

 

  • Header

Header는 일반적으로 서명 알고리즘(HMAC SHA256, RSA)과 토큰 유형(JWT) 두 부분으로 구성됩니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Header는 Base64Url로 인코딩되어 JWT의 첫 번째 부분을 형성합니다.

 

  • Payload

Payload는 클레임을 포함합니다. 클레임은 엔티티 및 추가 데이터에 대한 설명입니다.

일반적으로 토큰을 오래 보관해선 안되며 민감한 정보를 토큰에 포함시키면 안됩니다.

{
  //등록된 클레임
  "iss": "발급자",
  "exp": "만료시간",
  "sub": "제목",
  "aud": "대상",
  
  //공개 클레임
  "name": "John Doe", //기타
  "admin": true,
  
  //비공개 클레임
  "molu": "molu?"
}

등록된 클레임은 미리 정의된 클레임 집합으로 iss, exp, sub, aud 등이 있습니다.

공개 클레임은 웹 토큰 스토리지에 정의되거나 충돌 방지 네임스페이스를 포함하는 URI로 정의된 클레임입니다.

비공개 클레임은 등록된 클레임이나 공개 클레임이 아닌 당사자 간에 생성된 맞춤 클레임입니다.

IANA JSON 웹 토큰 레지스트리

 

Payload 또한 Base64Url로 인코딩되어 JSON의 두 번째 부분을 형성합니다.

 

  • Signature

Signature를 생성하려면 인코딩 된 Header, 인코딩 된 Payload, 암호, Header에 저장된 알고리즘을 가져와 서명해야 합니다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

메시지가 도중에 변경되지 않았는지 확인하는데 사용되며 개인 키로 서명된 토큰의 경우 보낸 사람이 누구인지 확인할 수 있습니다.

 

 

📌 JWT 작동 원리

일반적으로 JWT는 아래와 같은 순서로 진행됩니다.

 

1. 클라이언트 사용자가 아이디, 패스워드를 통해 웹 서비스 인증

2. 서버에서 서명된 JWT를 생성하여 클라이언트에 응답으로 돌려주기

3. 클라이언트가 서버에 데이터를 추가적으로 요구할 때 JWT를 HTTP Header에 첨부

4. 서버에서 클라이언트로부터 온 JWT를 검증

 

✔️ Spring Security + JWT 사용해보기

  • 라이브러리 추가 (build.gradle)
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

//JWT
implementation 'io.jsonwebtoken:jjwt:0.9.1'

 

  • SecurityConfig 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtFilter jwtFilter;
    private final AuthenticationException authenticationException;
    private final AccessDeniedException accessDeniedException;

    //SecurityFilterChain 설정
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors() //CORS 정책 사용
            .and()
            .csrf().disable() //CSRF 보안 토큰 비활성화
            .formLogin().disable() //formLogin 비활성화
            .httpBasic().disable() //httpBasic 비활성화
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //쿠키, 세션 사용 안함
            .and()
            .exceptionHandling() //예외 처리
            .authenticationEntryPoint(authenticationException) //인증 예외
            .accessDeniedHandler(accessDeniedException) //인가 예외
            .and()
            //인증된 요청들
            //TODO 인증, 인가에 대한 URL과 권한 정해야함
            .authorizeRequests((authz) -> authz
                    .antMatchers("/user/join").permitAll() //해당 경로 요청 허용
                    .antMatchers("/user/login").permitAll()
                    .anyRequest().hasAuthority("ROLE_ADMIN")); //그 외 경로 ADMIN 권한 필요

        http
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); //필터 추가

        return http.build();
    }

    //AuthenticationManager 설정
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    //PasswordEncoder 설정
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new Sha512Encoder();
    }

    //CORS 정책 설정
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        //전부 허용
        configuration.setAllowCredentials(true); //json 형식의 서버 응답을 자바스크립트에서 처리할 수 있게 할지 설정
        configuration.addAllowedOrigin("*"); //허용할 도메인 목록
        configuration.addAllowedHeader("*"); //허용할 헤더 목록
        configuration.addAllowedMethod("*"); //허용할 메서드 목록
        source.registerCorsConfiguration("/**", configuration); //지정된 url에 이 정책 적용

        return source;
    }
}

WebSecurityConfigurerAdapter가 deprecated 되었으므로 SecurityFilterChain을 Bean으로 등록하여 설정해줍니다.

 

csrf.disable() : formLogin.disable(), httpBasic.disable() : API를 사용하기 때문에 다 비활성화 시켜줍니다.

SessionCreationPolicy.STATELESS : 세션도 비활성화시켜줍니다.

addFilterBefore : JwtFilter를 SecurityChainFilter보다 먼저 실행되도록 등록합니다.

exceptionHandling : 인증/인가에 대한 예외처리를 등록합니다.

authorizeRequests : 요청에 따른 인가 설정을 합니다. permitAll()은 모두 허용, hasAuthority()를 통해 권한 지정

 

CorsConfigurationSource를 Bean으로 등록하고 설정해서 CORS 요청을 전부 허용해줍니다.

AuthenticationManager와 PasswordEncoder를 설정해줍니다.

 

  • AuthenticationException
@Component
public class AuthenticationException implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, org.springframework.security.core.AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write("인증되지 않았습니다.");
    }
}

 

  • AccessDeniedException
@Component
public class AccessDeniedException implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, org.springframework.security.access.AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write("권한이 없습니다.");
    }
}

 

  • Sha512Encoder
public class Sha512Encoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence rawPassword) {
        return Sha512DigestUtils.shaHex((String) rawPassword);
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if(encode(rawPassword).equals(encodedPassword)) {
            return true;
        }
        return false;
    }
}

PasswordEncoder 중 SHA512를 사용할 수 있는걸 못 찾겟어서 Sha512DigestUtils를 이용해 직접 구현했습니다.

rawPassword와 encodedPassword를 비교하는데 사용하거나 회원가입 시 비밀번호를 암호화해서 DB에 저장하기 위해 사용합니다.

 

  • JwtFilter
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtProvider.resolveToken(request);

        if(StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

요청마다 필터를 거치게끔 하기 위해 OncePerRequestFilter 를 상속 받아 구현합니다.

HTTP 요청 헤더에서 토큰이 있고 유효하다면 해당 토큰으로 UserDetailsService를 이용해 UserDetails를 찾고

UserDetails의 정보를 토대로 UsernamePasswordAuthenticationToken을 만들어낸 후

얻은 Authentication을 SecurityContextHolder에 저장합니다.

(UsernamePasswordAuthenticationToken은 Authentication을 구현체하는 AbstractAuthenticationToken의 구현체입니다.)

 

  • JwtProvider
@Component
@RequiredArgsConstructor
public class JwtProvider {
    private final CustomUserDetailService userDetailService;

    @Value("${jwt.secret-key}")
    private String securityKey;
    @Value("${jwt.token-valid-time}")
    private long tokenValidTime;

    public String resolveToken(HttpServletRequest req) {
        String token = req.getHeader(HttpHeaders.AUTHORIZATION);

        if(StringUtils.hasText(token)) {
            return token;
        }

        return "";
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(securityKey).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("Expired JWT");
        } catch (UnsupportedJwtException e) {
            throw new RuntimeException("Unsupported JWT");
        } catch (MalformedJwtException e) {
            throw new RuntimeException("Malformed JWT");
        } catch (SignatureException e) {
            throw new RuntimeException("Signature Exception");
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("IllegalArgument Exception");
        }
    }

    public String getUsername(String token) {
        return Jwts.parser().setSigningKey(securityKey).parseClaimsJws(token).getBody().getSubject();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailService.loadUserByUsername(getUsername(token));
        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
    }

    public String createToken(UserDetails user) {
        Map<String, Object> header = new HashMap<>();
        header.put("typ", "JWT");
        header.put("alg", "HS512");

        Claims claims = Jwts.claims().setSubject(user.getUsername());
        claims.put("role", user.getAuthorities().stream().findFirst());

        Date now = new Date();
        Date validity = new Date(now.getTime() + tokenValidTime);

        return Jwts.builder()
                .setHeader(header)
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS512, securityKey)
                .compact();
    }
}

검증에 필요한 대부분의 로직이 모여있는 클래스입니다.

resolveToken은 HTTP 요청 헤더에서 토큰을 뽑아냅니다.

validateToken은 비밀키를 통해 토큰이 유효한지 확인합니다.

getUsername은 토큰에서 username을 파싱해냅니다.

getAuthentication은 토큰으로 UsernamePasswordAuthenticationToken을 만들어냅니다.

createToken은 JWT의 Header, Payload, Signature를 설정해서 토큰을 만들어냅니다.

 

  • CustomUserDetails
public class CustomUserDetails implements UserDetails {

    private User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        String role = user.getRole().getRoleName();
        authorities.add(() -> role);
        return authorities;
    }

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

    @Override
    public String getUsername() {
        return 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;
    }
}

Spring Security에서 인증 요소(principal)로 사용되는 객체로 UserDetails를 상속 받아 만들고 User를 가져야 합니다.

getAuthorities() : user의 role에 권한 정보를 담고 반환합니다.

getUsername() : user의 username으로 사용할 프로퍼티 반환

getPassword() : user의 password로 사용할 프로퍼티 반환

isAccountNotExpired() : 계정이 만료되지 않았는지를 리턴 (true는 만료되지 않음)

isAccountNonLocked() : 계정이 잠겨 있는지를 리턴 (true는 잠겨있지 않음)

isCredentialNonExpired() : 계정의 패스워드가 만료되었는지를 리턴 (true는 만료되지 않음)

isEnabled() : 계정이 사용 가능한지 리턴 (true는 사용 가능)

 

  • CustomUserDetailsService
@Component
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        return new CustomUserDetails(user);
    }
}

UserDetailsService를 상속받으며 loadUserByUsername을 통해서

AuthenticationManager가 authentication()을 통해 인증을 진행할 때 DB에서 인증 대상 객체를 찾아 UserDetails로 반환합니다.

 

  • UserController
@RestController
@RequestMapping("/user/*")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody User user) {
        String token = userService.login(user);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("token", token);
        return ResponseEntity.ok()
                .headers(httpHeaders)
                .body("login success!");
    }

    @PostMapping("/join")
    public ResponseEntity<String> join(@RequestBody User user) {
        String token = userService.join(user);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("token", token);
        return ResponseEntity.ok()
                .headers(httpHeaders)
                .body("join success!");
    }

    @GetMapping("/{id}")
    public User find(@PathVariable Long id) {
        return userService.findById(id);
    }
}

 

  • UserService
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final JwtProvider jwtProvider;
    private final AuthenticationManager authenticationManager;
    private final PasswordEncoder passwordEncoder;

    public String login(User user) {
        User findMember = findByEmail(user.getEmail()).orElseThrow(() -> new IllegalArgumentException("가입되지 않은 이메일입니다."));

        if(!passwordEncoder.matches(user.getPassword(), findMember.getPassword())) {
            log.info(passwordEncoder.encode(user.getPassword()));
            log.info(Sha512DigestUtils.shaHex(user.getPassword()));
            log.info(findMember.getPassword());

            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword());
        authenticationManager.authenticate(authenticationToken);
        UserDetails principal = new CustomUserDetails(findMember);

        return jwtProvider.createToken(principal);
    }

    public String join(User user) {
        Optional<User> findUser = userRepository.findByEmail(user.getEmail());

        if(!findUser.isEmpty()) {
            throw new RuntimeException("이미 가입된 유저입니다.");
        }

        User joinUser = User.builder()
                .email(user.getEmail())
                .password(Sha512DigestUtils.shaHex(user.getPassword()))
                .role(user.getRole())
                .build();

        userRepository.save(joinUser);

        UserDetails principal = new CustomUserDetails(joinUser);

        return jwtProvider.createToken(principal);
    }

    public Optional<User> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    public User findById(Long id) {
        return userRepository.findById(id);
    }
}

 

  • UserRepository
@Repository
@Transactional
@RequiredArgsConstructor
public class UserRepository {
    private final EntityManager em;

    public Optional<User> findByEmail(String email) {
        List result = em.createQuery("select u from User u where u.email = :email")
                .setParameter("email", email)
                .getResultList();

        if(result.size() == 0) {
            return Optional.empty();
        }

        Optional<User> user = Optional.of((User) result.get(0));

        return user;
    }

    public void save(User user) {
        em.persist(user);
    }

    public User findById(Long id) {
        return em.find(User.class, id);
    }
}

 

✔️ 테스트

  • JWT 없이 요청

  • JWT와 함께 요청 (ROLE_USER)

  • JWT와 함께 요청 (ROLE_ADMIN)

 

📄 Reference

https://www.okta.com/kr/identity-101/authentication-vs-authorization/

https://webfirewood.tistory.com/m/115?category=694472 

https://docs.spring.io/spring-security/reference/servlet/architecture.html

https://jwt.io/introduction

반응형
저작자표시 (새창열림)

'📚 Study > Spring' 카테고리의 다른 글

[Spring] Validation  (0) 2022.08.10
[Spring] Entity, DTO, DAO, VO?  (0) 2022.08.09
[Spring] JUnit을 통한 TDD  (0) 2022.08.05
[Spring] Swagger? Spring REST Docs?  (0) 2022.07.21
    0_ch4n
    0_ch4n
    while(true) { study(); }

    티스토리툴바