스프링 부트로 OAuth2 구현(페이스북, 구글, 카카오, 네이버)
- Spring/Spring Boot
- 2019. 4. 30. 22:32
* 아래의 모든 코드는 깃 저장소에 올려놨습니다. 참고 부탁드려요 *
OAuth란?
OAuth(Open Authorization)는 토큰 기반의 인증 및 권한을 위한 표준 프로토콜입니다. OAuth와 같은 인증 프로토콜을 통해 유저의 정보를 페이스북, 구글, 카카오 등의 서비스에서 제공받을 수 있고 이 정보를 기반으로 어플리케이션 사용자에게 로그인이나 다른 여러 기능들을 손쉽게 제공할 수 있습니다. 자세한 내용은 여기를 참조하시면 좋을 것 같습니다.
스프링 부트로 OAuth2를 통한 로그인 기능
스프링 부트로 OAuth2를 통한 로그인 기능을 제공할 수 있습니다. 여기서는 페이스북, 구글, 카카오에서 제공하는 정보를 통해 사용자가 웹페이지에 손쉽게 로그인하는 기능을 구현할 것입니다. 스프링부트로 해당 기능을 구현하기 전에 먼저 위 3가지 서비스의 개발자 사이트에 들어가서 OAuth 인증을 위한 설정을 해야합니다.
페이스북
1. 페이스북 개발자 페이지에 가서 아래와 같이 새 앱을 추가합니다.
2. 아래와 같이 새 앱에 대한 정보를 입력합니다.
3. 왼쪽 사이드바의 설정 > 기본설정에 보면 앱 ID와 앱 시크릿 코드가 있습니다. OAuth2 인증을 위해 필요한 정보들이며 나중에 스프링 부트의 설정 정보에 저장하여 OAuth2 인증을 하는 데 쓰일 것입니다.
4. 왼쪽 사이드바의 제품에서 Facebook 로그인을 추가합니다. 그 다음 설정에서 다음과 같이 옵션들을 설정합니다. 어플리케이션 개발 시 페이스북에서는 localhost 리디렉션에 대해 개발모드시 자동으로 허용되므로 리디렉션 URI정보에 따로 적지 않아도 됩니다.
구글
1. 구글 디펠로퍼 콘솔에 접속해서 새 프로젝트를 생성합니다.
2. 프로젝트를 생성할 때 정보를 작성합니다.
3. 왼쪽 사이드바의 사용자 인증 정보에서 사용자 인증 정보 만들기 > OAuth2 클라이언트 ID 를 클릭합니다.
4. OAuth 클라이언트 ID 만들기 창에서 동의 화면 구성을 클릭합니다.
5. OAuth 동의화면에서 애플리케이션 필요한 정보를 입력한 후 저장합니다.
6. 애플리케이션 유형 중 웹 애플리케이션을 선택한 다음 아래와 같이 입력한 후 저장합니다.
7. 아래와 같이 OAuth 정보가 설정됬다는 창이 뜨면서 구글 OAuth 로그인 인증을 할 수 있는 준비가 완료됬습니다.
카카오
1. 카카오 개발자 페이지에 가서 내 애플리케이션을 클릭한 다음 앱 만들기를 클릭합니다.
2. 앱 만들기 창이 나오면 해당 정보를 입력한 후 앱 만들기를 클릭합니다.
3. 앱을 만든 후 다음과 같이 Key 정보가 주어집니다. 여기서 쓰일 키 정보는 REST API 키입니다.
4. 왼쪽 사이드바의 개요를 클릭하면 다음과 같은 화면이 나타납니다. 사용자 관리에서 사용자 관리를 활성화 한 다음 비즈 앱 정보의 설정을 클릭합니다.
5. 아래와 같이 웹 플랫폼을 추가한 후 사이트 도메인을 아래와 같이 localhost로 저장합니다.
6. 왼쪽 사이드 바에서 설정 > 일반을 클릭한 후 플랫폼에서 위에서 설정했던 웹 플랫폼을 클릭한 뒤 다음과 같이 정보를 저장합니다(8080 포트까지 정확하게 적어야 합니다).
7. 왼쪽 사이드 바에서 설정 > 고급을 클릭한 후 SecretID를 설정합니다.
8. 아래와 같이 Secret 코드가 생성되는 것을 볼 수 있습니다.
네이버
1. https://developers.naver.com/apps 로 가서 내 애플리케이션을 등록합니다. 또한 애플리케이션 사용자의 어떤 정보를 불러올 것인지 설정을 합니다.
2. 환경 추가에서 PC 웹을 선택한 다음 서비스 URL과 Callback URL을 다음과 같이 설정합니다. 등록하기를 누르며은 다음 화면으로 이동합니다.
3. 여기서 Client ID와 Client Secret에 대한 정보를 기억해 둡시다. 스프링 부트 어플리케이션의 설정에 넣어야하는 값들입니다.
프로젝트 구조
\---src
+---main
| +---java
| | \---com
| | \---example
| | \---oauth2
| | | Oauth2Application.java
| | |
| | +---security
| | | CustomOAuth2Provider.java
| | | OAuth2Controller.java
| | | SecurityConfig.java
| | | SocialType.java
| | |
| | \---service
| | CustomOAuth2UserService.java
| |
| \---resources
| | application.yml
| |
| +---static
| \---templates
| hello.html
| home.html
| login.html
|
의존성
plugins {
id 'java'
id 'org.springframework.boot' version '2.2.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
}
group 'saelobi'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
application.yml
# 아래 client-id와 client-secret은 예시로 넣은 더미데이터입니다. 참고만 부탁드려요.
spring:
h2:
console:
enabled: true
path: /console
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
google:
client-id: 958180173202-sii4epowero1eouvvjrlrnt27sikub08k.apps.googleusercontent.com
client-secret: k0asdfag801WdcJF8k9ST_8SG9
facebook:
client-id: 11612612521462
client-secret: 6578235217c5c365a98c51b02d0308ac
custom:
oauth2:
kakao:
client-id: 88a081241234cfacbedef7018ac451316f3
client-secret: jpXULoEzrasfawegsqYXlRLyyOHn2i60q
naver:
client-id: B5yvH3Zaweyawerawwe6repj4z0L
client-secret: sArXFasdfawef
소스 코드
Oauth2Application ( main 진입점)
package com.example.oauth2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Oauth2Application {
public static void main(String[] args) {
SpringApplication.run(Oauth2Application.class, args);
}
}
OAuth2Controller
package com.example.oauth2.security;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class OAuth2Controller {
@GetMapping({"", "/"})
public String getAuthorizationMessage() {
return "home";
}
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping({"/loginSuccess", "/hello"})
public String loginSuccess() {
return "hello";
}
@GetMapping("/loginFailure")
public String loginFailure() {
return "loginFailure";
}
}
SecurityConfig
package com.example.oauth2.security;
import com.example.oauth2.service.CustomOAuth2UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static com.example.oauth2.security.SocialType.*;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.antMatchers("/", "/oauth2/**", "/login/**", "/css/**",
"/images/**", "/js/**", "/console/**", "/favicon.ico/**")
.permitAll()
.antMatchers("/facebook").hasAuthority(FACEBOOK.getRoleType())
.antMatchers("/google").hasAuthority(GOOGLE.getRoleType())
.antMatchers("/kakao").hasAuthority(KAKAO.getRoleType())
.antMatchers("/naver").hasAuthority(NAVER.getRoleType())
.anyRequest().authenticated()
.and()
.oauth2Login()
.userInfoEndpoint().userService(new CustomOAuth2UserService()) // 네이버 USER INFO의 응답을 처리하기 위한 설정
.and()
.defaultSuccessUrl("/loginSuccess")
.failureUrl("/loginFailure")
.and()
.exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository(
OAuth2ClientProperties oAuth2ClientProperties,
@Value("${custom.oauth2.kakao.client-id}") String kakaoClientId,
@Value("${custom.oauth2.kakao.client-secret}") String kakaoClientSecret,
@Value("${custom.oauth2.naver.client-id}") String naverClientId,
@Value("${custom.oauth2.naver.client-secret}") String naverClientSecret) {
List<ClientRegistration> registrations = oAuth2ClientProperties
.getRegistration().keySet().stream()
.map(client -> getRegistration(oAuth2ClientProperties, client))
.filter(Objects::nonNull)
.collect(Collectors.toList());
registrations.add(CustomOAuth2Provider.KAKAO.getBuilder("kakao")
.clientId(kakaoClientId)
.clientSecret(kakaoClientSecret)
.jwkSetUri("temp")
.build());
registrations.add(CustomOAuth2Provider.NAVER.getBuilder("naver")
.clientId(naverClientId)
.clientSecret(naverClientSecret)
.jwkSetUri("temp")
.build());
return new InMemoryClientRegistrationRepository(registrations);
}
private ClientRegistration getRegistration(OAuth2ClientProperties clientProperties, String client) {
if("google".equals(client)) {
OAuth2ClientProperties.Registration registration = clientProperties.getRegistration().get("google");
return CommonOAuth2Provider.GOOGLE.getBuilder(client)
.clientId(registration.getClientId())
.clientSecret(registration.getClientSecret())
.scope("email", "profile")
.build();
}
if("facebook".equals(client)) {
OAuth2ClientProperties.Registration registration = clientProperties.getRegistration().get("facebook");
return CommonOAuth2Provider.FACEBOOK.getBuilder(client)
.clientId(registration.getClientId())
.clientSecret(registration.getClientSecret())
.userInfoUri("https://graph.facebook.com/me?fields=id,name,email,link")
.scope("email")
.build();
}
return null;
}
}
- OAuth2 인증 설정의 가장 핵심되는 부분입니다. antMatchers 메서드를 이용하여 매칭되는 url를 정의하였습니다. /facebook, /google URL에서는 권한이 있을 때 url에 접근할 수 있도록 설정했습니다.
- login 성공 시 loginSuccess로 실패시, loginFailure로 redirect되게끔 설정하였습니다.
- 만약 권한이 없을 때 나온 error는 /login으로 가게끔 설정하였습니다. authenticationEntryPoint에서 스프링에서 제공하는 form login 템플릿이 아닌 /login 메서드에서 제공하는 템플릿으로 화면에 나타나게끔 지정했습니다.
- clientRegistrationRepository 메서드를 통하여 facebook, kakao, google의 인증 정보들이 메모리상에 상주하게끔 설정했습니다.
- CustomUserOAuth2UserService 클래스를 만들어서 설정에 추가했습니다. NAVER에서는 HTTP response body에 유저 정보에 대한 것을 response 속성 안에 id, user_name 등의 유저 정보를 넣기 때문에 id값을 불러오려면 별도의 처리를 해야되기 때문입니다.
CustomOAuth2UserService
package com.example.oauth2.service;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequestEntityConverter;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";
private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";
private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE =
new ParameterizedTypeReference<Map<String, Object>>() {};
private Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();
private RestOperations restOperations;
public CustomOAuth2UserService() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
this.restOperations = restTemplate;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(
MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " +
userRequest.getClientRegistration().getRegistrationId(),
null
);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(
MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " +
userRequest.getClientRegistration().getRegistrationId(),
null
);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response;
try {
response = this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE);
} catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
StringBuilder errorDetails = new StringBuilder();
errorDetails.append("Error details: [");
errorDetails.append("UserInfo Uri: ").append(
userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
if (oauth2Error.getDescription() != null) {
errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
}
errorDetails.append("]");
oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
} catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the UserInfo Resource: " + ex.getMessage(), null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}
Map<String, Object> userAttributes = getUserAttributes(response);
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
// 네이버는 HTTP response body에 response 안에 id 값을 포함한 유저정보를 넣어주므로 유저정보를 빼내기 위한 작업을 함
private Map<String, Object> getUserAttributes(ResponseEntity<Map<String, Object>> response) {
Map<String, Object> userAttributes = response.getBody();
if(userAttributes.containsKey("response")) {
LinkedHashMap responseData = (LinkedHashMap)userAttributes.get("response");
userAttributes.putAll(responseData);
userAttributes.remove("response");
}
return userAttributes;
}
}
- NAVER의 OAuth2 인증을 통해서 불러온 유저 정보를 처리하기 위한 Custom 클래스입니다. 별도의 설정을 하지 않으면 DefaultOAuth2UserService를 통해 유저 정보를 처리하지만 별도의 처리 로직을 둬야할 경우 이렇게 상속을 통하여 Custom 클래스를 작성해야합니다.
- DefaultOAuth2UserService와 달라진 점은 단지 getUserAttributes 메서드를 통해서 별도의 데이터처리를 하는 것 뿐 모든 것은 DefaultOAuth2UserService의 loadUser 메서드와 일치합니다.
CustomOAuth2Provider
스프링 부트에서는 google 및 facebook에 대한 OAuth2정보를 기본적으로 제공합니다. 하지만 Kakao와 NAVER 는 스프링 부트에서 기본적인 정보를 제공하지 않으므로 위와 같이 따로 해당 정보를 제공하는 클래스를 작성하여야 합니다.
package com.example.oauth2.security;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
public enum CustomOAuth2Provider {
KAKAO {
@Override
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.POST, DEFAULT_LOGIN_REDIRECT_URL);
builder.scope("profile");
builder.authorizationUri("https://kauth.kakao.com/oauth/authorize");
builder.tokenUri("https://kauth.kakao.com/oauth/token");
builder.userInfoUri("https://kapi.kakao.com/v2/user/me");
builder.userNameAttributeName("id");
builder.clientName("Kakao");
return builder;
}
},
NAVER {
@Override
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.POST, DEFAULT_LOGIN_REDIRECT_URL);
builder.scope("profile");
builder.authorizationUri("https://nid.naver.com/oauth2.0/authorize");
builder.tokenUri("https://nid.naver.com/oauth2.0/token");
builder.userInfoUri("https://openapi.naver.com/v1/nid/me");
builder.userNameAttributeName("id");
builder.clientName("Naver");
return builder;
}
};
private static final String DEFAULT_LOGIN_REDIRECT_URL = "{baseUrl}/login/oauth2/code/{registrationId}";
protected final ClientRegistration.Builder getBuilder(
String registrationId, ClientAuthenticationMethod method, String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUriTemplate(redirectUri);
return builder;
}
public abstract ClientRegistration.Builder getBuilder(String registrationId);
}
SocialType
package com.example.oauth2.security;
public enum SocialType {
FACEBOOK("facebook"),
GOOGLE("google"),
KAKAO("kakao"),
NAVER("naver");
private final String ROLE_PREFIX = "ROLE_";
private String name;
SocialType(String name) { this.name = name; }
public String getRoleType() { return ROLE_PREFIX + name.toUpperCase(); }
public String getValue() { return name; }
public boolean isEquals(String authority) { return this.getRoleType().equals(authority);}
}
hello.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>인증을 무사히 완료하였습니다.</h1>
</body>
</html>
home.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>홈페이지입니다.</h1><br>
<a th:href="@{/login}">로그인 하기</a>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h2>로그인</h2><br/><br/>
<a href="javascript:;" class="btn_social" data-social="facebook">페이스북 로그인</a><br/>
<a href="javascript:;" class="btn_social" data-social="google">구글 로그인</a><br/>
<a href="javascript:;" class="btn_social" data-social="kakao">카카오톡 로그인</a><br/>
<a href="javascript:;" class="btn_social" data-social="naver">네이버 로그인</a><br/>
<script>
let socials = document.getElementsByClassName("btn_social");
for(let social of socials) {
social.addEventListener('click', function(){
let socialType = this.getAttribute('data-social');
location.href="/oauth2/authorization/" + socialType;
})
}
</script>
</body>
</html>
- 스프링 부트에서 제공하는 OAuth2 클라이언트에서는 기본적으로 OAuth2 권한 요청 url이 /oauth2/authorization/{id}로 지정되어 있기 때문에 위와 같이 자바스크립트로 설정한 모습입니다.
loginFailure.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>인증을 실패하였습니다.</h1>
<a th:href="@{/login}">다시 로그인하기</a>
</body>
</html>
테스트
'Spring > Spring Boot' 카테고리의 다른 글
[스프링 부트/ Spring Boot] 스프링 게시판 만들기 - 부트로 쉽게 구현한 Spring 게시판 (23) | 2019.06.03 |
---|---|
[Spring Boot #32] 스프링 부트 Actuator, JConsole, VisualVM, 스프링 Admin (0) | 2019.01.23 |
[Spring Boot #31] 스프링 부트 RestTemplate, WebClient (0) | 2019.01.23 |
[Spring Boot #30] 스프링 부트 시큐리티 커스터마이징 (1) | 2019.01.22 |
[Spring Boot #29] 스프링 부트 시큐리티 (0) | 2019.01.12 |
이 글을 공유하기