스프링부트 OAuth2 - 3. OAuth 인증 서버 구축

https://spring.io/guides/tutorials/spring-boot-oauth2/ 를 참조하여 작성하였습니다.

개인적으로 서버사이드 프로그래밍을 하면서 가장 어렵기도 하고 귀찮은 부분이 인증하는 부분이라고 생각한다. 로직을 작성해 나간다기 보다는 클라이언트와 서버사이드 간의 통신을 통해 사용자를 인지하고 이를 세션 등으로 관리할 뿐 아니라, 권한 문제까지 연결되는 부분이기 때문이다. 고로 가장 민감한 부분이라고 본다.

스프링 공식 홈페이지의 OAuth2 가이드를 보면 다른 가이드와는 다르게 다소 스크롤의 압박이 느껴진다. 따라서 크게 세 부분으로 나누어 포스팅이 진행될 예정이다.

  1. Facebook으로 로그인
  2. Github로 로그인
  3. OAuth2 인증 서버 구축

인증 서버 구축

이번 포스팅에서는 앞서 만들었던 어플리케이션을 OAuth2 인증 서버로 만든다. 앞서 구현했던 facebookgithub를 통한 인증을 사용하지만, 별도의 자체 액세스 토큰을 만들어서 인증을 수행한다. 이 토큰을 사용하여 백엔드 자원을 보호하고 타 어플리케이션과 SSO를 수행한다.

인증 설정 다듬기

인증 서버를 본격적으로 구축하기 전에 githubfacebook에 관련된 설정을 먼저 다듬어야 할 필요가 있다. ssoFilter()를 수정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
// Application.java
private Filter ssoFilter() {
CompositeFilter filter = new CompositeFilter();
List<Filter> filters = new ArrayList<>();

filters.add(ssoFilter(facebook(), "/login/facebook"));
filters.add(ssoFilter(github(), "/login/github"));

filter.setFilters(filters);

return filter;
}

ssoFilter()overloading한 새로운 메서드를 작성해야 한다.

1
2
3
4
5
6
7
8
9
private Filter ssoFilter(ClientResources client, String path) {
OAuth2ClientAuthenticationProcessingFilter filter = new OAuth2ClientAuthenticationProcessingFilter(path);
OAuth2RestTemplate template = new OAuth2RestTemplate(client.getClient(), oauth2ClientContext);
filter.setRestTemplate(template);
UserInfoTokenServices tokenServices = new UserInfoTokenServices(client.getResource().getUserInfoUri(), client.getClient().getClientId());
tokenServices.setRestTemplate(template);
filter.setTokenServices(tokenServices);
return filter;
}

이전에 ssoFilter()에 작성한 내용과 유사하지만 약간의 공통화 과정을 거친 것이라 보면 되겠다. ClientResources라는 오브젝트는 존재하지 않는다. 따라서 별도의 wrapper 객체를 생성한다. 이는 별도의 @Beaㅜ로 선언된 OAuth2ProtectedResourceDetailsResourceServerProperties를 통합하는 역할이라 보면 되겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ClientResources {
@NestedConfigurationProperty
private AuthorizationCodeResourceDetails client = new AuthorizationCodeResourceDetails();

@NestedConfigurationProperty
private ResourceServerProperties resource = new ResourceServerProperties();

public AuthorizationCodeResourceDetails getClient() {
return client;
}

public ResourceServerProperties getResource() {
return resource;
}
}

wrapper 클래스는 @NestedConfigurationProperty를 사용하여 어노테이션 프로세서가 하나의 값을 표현하지 않고 중첩 형식을 나타내기 때문에 메타데이터에 접근하여 크롤링하도록 지시한다.

위 wrapper 클래스 작성으로 spring 설정을 이전처럼 사용할 수 있을 뿐만 아니라 공통화를 지향한 결과이다. 마지막으로 각각 provider의 설정값을 가져오도록 @Bean을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
@Bean
@ConfigurationProperties("github")
public ClientResources github() {
return new ClientResources();
}

@Bean
@ConfigurationProperties("facebook")
public ClientResources facebook() {
return new ClientResources();
}

인증 서버 가용 상태로 만들기

특별히 어마어마한 타이핑이 필요하지 않고 어노테이션 하나로 끝낼 수 있다.

1
2
3
4
5
6
@SpringBootApplication
@EnableOAuth2Client
@EnableAuthorizationServer // 추가
public class SpringBootOauth2Application extends WebSecurityConfigurerAdapter{
// ...
}

새로운 어노테이션을 추가하게 되면 필요한 엔드포인트와 security를 어플리케이션에 로드할 것이다. 그리고 몇가지 OAuth2 클라이언트에 관한 정보를 설정해야 한다.

1
2
3
4
5
6
7
8
# application.yml
security:
oauth2:
client:
client-id: acme
client-secret: acmesecret
scope: read, write
auto-approve-scopes: '.*'

위 작업은 facebook.client*, github.client*에 대한 작업과 동일한 것이다. auto-approve-scopes는 위 설정처럼 정규표현식으로 작성이 가능하다. 이번 포스팅에서 작성하는 샘플에서는 특별히 제한을 두지 않기 때문에 모든 것을 허용하지만, 실제 프로젝트나 운영 단계에서는 세부적으로 설정할 필요가 있다.

인증 서버 구축을 마무리하기 위해서 UI에 관련된 security 설정이 필요하다. 샘플 어플리케이션이기 때문에 많은 설정이 필요하진 않지만, oauth와 관련된 endpoint 등의 필요한 부분에 설정해 줄 필요가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**") // ①
.authorizeRequests()
.antMatchers("/", "/login**", "/webjars/**", "/error**").permitAll() // ②
.anyRequest().authenticated() // ③
.and().exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/")) // ④
.and().logout().logoutSuccessUrl("/").permitAll()
.and().csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and().addFilterBefore(ssoFilter(), BasicAuthenticationFilter.class);
}
  • ① : 기본적으로 모든 요청은 보호된다.
  • ② : 로그인 엔드포인트는 제외된다.
  • ③ : 그 외의 모든 요청은 인증이 필요하다.
  • ④ : 인증되지 않은 사용자는 /로 redirect 된다.

Access Token 얻기

이제 우리가 구축한 인증 서버를 통해 Access Token을 얻을 수 있다. 가장 쉬운 방법은 “acme” 클라이언트를 통해서이다. curl 명령어를 사용하여 토큰을 얻어보자

1
2
3
4
$ curl acme:acmesecret@localhost:8080/oauth/token -d grant_type=client_credentials
# % Total % Received % Xferd Average Speed Time Time Time Current
# Dload Upload Total Spent Left Speed
# 100 146 0 117 100 29 7312 1812 --:--:-- --:--:-- --:--:-- 9125{"access_token":"c42ba0f2-543e-4275-9eb2-efb1f48fa680","token_type":"bearer","expires_in":43186,"scope":"read write"}

단순히 토큰을 얻는 것만으로는 뭔가 완벽한 어플리케이션을 위해서는 부족해 보인다. 특정 유저에게 생성하도록 만들어야 한다. 스프링 어플리케이션을 구동했을 때 아마 Using generated security password: ... 처럼 자동 생성되는 기본 암호를 볼 수 있을 것이다. 이를 이용하여 다시 토큰을 얻어보자.

1
2
3
4
$ curl acme:acmesecret@localhost:8080/oauth/token -d grant_type=password -d username=user -d password=...
# % Total % Received % Xferd Average Speed Time Time Time Current
# Dload Upload Total Spent Left Speed
# 100 251 0 172 100 79 1577 724 --:--:-- --:--:-- --:--:-- 2302{"access_token":"629d6260-5eba-43e7-9072-7608b6b46254","token_type":"bearer","refresh_token":"bd7e65ce-6663-40b1-8307-04787221197f","expires_in":43199,"scope":"read write"}

명령어의 “…” 부분에는 앞서 말한 자동생성되는 암호를 기입해주어서 curl명령을 날리면 역시 토큰을 받을 수 있다. 현재 테스트한 방법 외에 일반적인 소셜 로그인에서는 “인증 코드”를 받아야 한다. 즉, 이를 통해 redirect, cookie 등을 핸들링하거나 외부 provider로부터 UI를 렌더링할 수 있어야 한다.

클라이언트 어플리케이션 생성하기

우리가 구축한 인증 서버에 필요한 client를 생성해보자. ClientApplication.java를 생성할 것이다. 단, 기존 *Application.java와 같은 패키지(또는 서브 패키지)에 위치해서는 안된다. 스프링은 기존의 Application을 구동하면서 ClientApplication을 하나의 설정으로 구동시킬 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ClientApplication.java
// src/test/java/ 에 위치해있다.
@EnableAutoConfiguration
@Configuration
@EnableOAuth2Sso
@RestController
public class ClientApplication {
@RequestMapping("/")
public String home(Principal user) {
return "Hello, " + user.getName();
}

public static void main(String[] args) {
new SpringApplicationBuilder(ClientApplication.class)
.properties("spring.config.name=client").run(args);
}
}

단순하게 사용자의 이름을 출력하는 페이지로 구성이 되어있다. spring.config.name=client라는 설정파일을 불러와서 실행될 것이다. client.yml파일을 생성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# client.yml
server:
port: 9999
servlet:
context-path: /client
security:
oauth2:
client:
client-id: acme
client-secret: acmesecret
access-token-uri: http://localhost:8080/oauth/token
user-authorization-uri: http://localhost:8080/oauth/authorize
resource:
user-info-uri: http://localhost:8080/me
spring:
main:
allow-bean-definition-overriding: true

메인이 되는 어플리케이션 설정과 비슷하지만, facebook이나 github 대신 “acme” 클라이언트로 통하도록 설정이 되어있다. 어플리케이션은 9999 포트에서 띄워져서 기존 포트와의 충돌을 방지한다. server.context-path의 값을 별도로 세팅하였다. 따라서 http://localhost:9999/client 를 통해 확인이 가능하다. 어플리케이션을 시작하면 아래 그림처럼 두개의 포트가 띄워질 것이다.(사실 이 부분을 늦게 확인하는 바람에 어떻게 9999포트가 열린 상태인지 확인을 못했다.)

Client쪽 port가 올라온 모습

사용자 정보 엔드포인트 보호하기

Single sign on을 위한 새로운 인증 서버를 사용하기 위해 facebook과 github를 사용하는데, 사용자가 인증할 때 생성된 쿠키로 보호된다. 로컬에 부여 된 액세스 토큰과 함께 보안을 유지하기 위해 기존의 엔드포인트를 재사용하고 새 경로로 alias를 지정할 수 있다.

1
2
3
4
5
6
@RequestMapping({ "/user", "/me" })
public Map<String, String> user(Principal principal) {
Map<String, String> map = new LinkedHashMap<>();
map.put("name", principal.getName());
return map;
}

기존 메서드에서 PrincipalMap으로 바꾼 이유는 브라우저에서 민감한 정보를 노출시키지 않기 위해 숨기는 것이다. 추가적으로 필요한 부분이 있다면 Map에 추가적으로 put해줌으로써 브라우저에 노출시키는 것이 가능하다.

“/me” 경로는 어플리케이션이 리소스 서버로 선언됨으로서 access token으로 보호된다. 다음과 같이 새로운 설정 클래스를 만들어보자

1
2
3
4
5
6
7
8
9
10
// Application.java
@Configuration
@EnableResourceServer
protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.antMatcher("/me")
.authorizeRequests().anyRequest().authenticated();
}
}

@Order 어노테이션을 추가한다. SecurityPropertiesACCESS_OVERRIDE_ORDERdeprecated 상태이므로 넘어간다. 어플리케이션이 security 수행 시, 엔드포인트에 대한 우선순위가 있지만 여기서는 정하지 않겠다.

테스트

http://localhost:9999/client/ 로 접속하게 되면 redirect가 되면서 localhost:8080으로 이동할 것이다. facebook과 github 로그인 중 하나를 선택하면 인증이 시작되고 완료되면 다시 http://localhost:9999/client/ 로 이동될 것이다. 그리고 인증된 사용자의 이름이 출력되면서 테스트를 마무리할 수 있다.

Client Application 테스트 결과

마무리

길고 긴 OAuth2인증부터 서버 구축까지 마무리를 해보았다. Spring Security는 이러한 기능도 하고 있지만 더 많은 기능을 담고 있는 강력한 모듈이기 때문에 추가적인 학습이 필요하다. 사실 이 포스팅 뒤에 에러 처리 등의 자잘한 과정이 남아있지만 이번 포스팅에서는 다루지 않겠다. 아마 여기까지 따라오기만 해도 꽤나 지칠 수 있기 때문이다. 이정도면 OAuth2의 기능을 살펴보았다고 해도 좋다. 참고 URL은 포스팅 처음에 링크를 걸어두었으니 가서 참고하면 좋을 것이다.(영어긴 하지만..)

소스코드

https://github.com/hwiVeloper/SpringBootStudy/tree/495193fb5ca875be9b7833765ce6379971bc0ba0/spring-boot-oauth2

스프링부트 OAuth2 - 2. Github 인증

https://spring.io/guides/tutorials/spring-boot-oauth2/ 를 참조하여 작성하였습니다.

스프링 공식 홈페이지의 OAuth2 가이드를 보면 다른 가이드와는 다르게 다소 스크롤의 압박이 느껴진다. 따라서 크게 세 부분으로 나누어 포스팅이 진행될 예정이다.

  1. Facebook으로 로그인
  2. Github로 로그인
  3. OAuth2 인증 서버 구축

Github로 로그인

이번 포스팅에서는 지난 포스팅에서 다루었던Facebook으로 로그인에 이어서 Github계정으로 OAuth2 인증을 하는 과정에 관한 글이다. 방식은 비슷해서 금방 구현이 가능하다.

우선 index.html 파일에 Github로 로그인 할 수 있는 링크를 추가하자.

1
2
3
4
5
6
7
8
9
<h1>로그인</h1>
<div class="container unauthenticated">
<div>
Facebook : <a href="/login/facebook">클릭</a>
</div>
<div>
Github : <a href="/login/github">클릭</a>
</div>
</div>

원래는 provider(facebook, github, google 등)마다 엔드포인트에 대한 처리가 달라져야겠지만 이번 시리즈의 포스팅에서는 따로 처리하지 않아도 인증 처리와 관련된 응답에서 name이라는 필드를 공통적으로 가지고 있기 때문에 특별히 변경할 사항은 없다.

Github 인증 필터 추가

Application.java에서 /login/github에 대한 필터 추가가 필요하다 ssoFilter()에 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private Filter ssoFilter() {

CompositeFilter filter = new CompositeFilter();
List<Filter> filters = new ArrayList<>();

OAuth2ClientAuthenticationProcessingFilter facebookFilter = new OAuth2ClientAuthenticationProcessingFilter("/login/facebook");
OAuth2RestTemplate facebookTemplate = new OAuth2RestTemplate(facebook(), oauth2ClientContext);
facebookFilter.setRestTemplate(facebookTemplate);
UserInfoTokenServices tokenServices = new UserInfoTokenServices(facebookResource().getUserInfoUri(), facebook().getClientId());
tokenServices.setRestTemplate(facebookTemplate);
facebookFilter.setTokenServices(tokenServices);
filters.add(facebookFilter);

OAuth2ClientAuthenticationProcessingFilter githubFilter = new OAuth2ClientAuthenticationProcessingFilter("/login/github");
OAuth2RestTemplate githubTemplate = new OAuth2RestTemplate(github(), oauth2ClientContext);
githubFilter.setRestTemplate(githubTemplate);
tokenServices = new UserInfoTokenServices(githubResource(), getUserInfoUri(), github().getClientId());
githubFilter.setTokenServices(tokenServices);
filters.add(githubFilter);

filter.setFilters(filters);
return filter;
}

// ... 중략 ...

@Bean
@ConfigurationProperties("github.client")
public OAuth2ProtectedResourceDetails github() {
return new AuthorizationCodeResourceDetails();
}

@Bean
@ConfigurationProperties("github.resource")
public ResourceServerProperties githubResource() {
return new ResourceServerProperties();
}

이전에 등록한 Facebook 필터로 인해 CompositeFilter를 구성하여 List로 추가하는 방식을 사용하였다. github(), githubResource()를 추가하여 나머지 내용도 보충하였다.

또한 application.yml에 github 관련 설정을 추가한다. 선행되어야 하는 OAuth2 앱을 작성해야 하는데 github OAuth2 앱을 만드는 방법은 여기를 참조하자.

1
2
3
4
5
6
7
8
9
github:
client:
clientId: CLIENT-ID
clientSecret: CLIENT-SECRET
accessTokenUri: https://github.com/login/oauth/access_token
userAuthorizationUri: https://github.com/login/oauth/authorize
clientAuthenticationScheme: form
resource:
userInfoUri: https://api.github.com/user

결과 확인

결과를 확인해보자. 깃허브 로그인 링크를 클릭하면 다음과 같은 화면을 볼 수 있고, 승인을 하면 다시 index.html로 돌아와서 사용자정보가 확인되는 것을 볼 수 있다.

Github OAuth2 확인

소스코드

https://github.com/hwiVeloper/SpringBootStudy/tree/18f34d30f05db7f0fa998b6a6cae3b0019c05331/spring-boot-oauth2

스프링부트 OAuth2 - 1. Facebook 인증

https://spring.io/guides/tutorials/spring-boot-oauth2/ 를 참조하여 작성하였습니다.

개인적으로 서버사이드 프로그래밍을 하면서 가장 어렵기도 하고 귀찮은 부분이 인증하는 부분이라고 생각한다. 로직을 작성해 나간다기 보다는 클라이언트와 서버사이드 간의 통신을 통해 사용자를 인지하고 이를 세션 등으로 관리할 뿐 아니라, 권한 문제까지 연결되는 부분이기 때문이다. 고로 가장 민감한 부분이라고 본다.

스프링 공식 홈페이지의 OAuth2 가이드를 보면 다른 가이드와는 다르게 다소 스크롤의 압박이 느껴진다. 따라서 크게 세 부분으로 나누어 포스팅이 진행될 예정이다.

  1. Facebook으로 로그인
  2. Github로 로그인
  3. OAuth2 인증 서버 구축

Facebook으로 로그인

프로젝트 생성

Spring Starter Project를 생성한다. Dependency는 Web을 선택하고 프로젝트 생성을 완료한다.

프로젝트 생성

페이지 추가

index.html파일을 src/main/resources/static에 추가하고 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>OAuth2 Sample Application</title>
<meta name="description" content="OAuth2 Sample Application"/>
<meta name="viewport" content="width=device-width"/>
<base href="/"/>
<link rel="stylesheet" type="text/css" href="/webjars/bootstrap/css/bootstrap.min.css"/>
<script type="text/javascript" src="/webjars/jquery/jquery.min.js"></script>
<script type="text/javascript" src="/webjars/bootstrap/js/bootstrap.min.js"></script>
</head>
<body>
<h1>OAuth2 Sample Application</h1>
<div class="container"></div>
</body>
</html>

<head> 안쪽에 webjars가 보이는데 클라이언트 사이드에서 사용하는 javascript 또는 css 라이브러리 등을 jar 형태로 import시킬 수 있는 기능이라고 보면 되겠다. pom.xml에 해당 내용을 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- webjars -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>

bootstrap과 jquery를 추가하였다. webjars-locator-core라는 녀석은 앞서 추가한 webjars들의 path를 지정해주는 역할이라고 보면 좋을 것 같다. 앞서 index.html에서 /webjars/**에 위치한 js 파일과 css파일을 불러오기 위한 dependency이다.

Security

어플리케이션을 안전하게 만들기 위해서는(다소 어색한 말이지만 guide에 있는 말을 빌려왔다.) Spring Security와 관련된 dependency를 적용해야 한다. pom.xml에 추가해보자.

1
2
3
4
5
6
7
8
9
10
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>

Facebook으로 연결하기 위하여 Application.java@EnableOAuth2Sso 어노테이션을 추가한다.

1
2
3
4
5
6
7
@SpringBootApplication
@EnableOAuth2Sso // <-- 이부분을 추가한다.
public class SpringBootOauth2Application {
public static void main(String[] args) {
SpringApplication.run(SpringBootOauth2Application.class, args);
}
}

application.yml 또는 application.properties를 열어서 다음과 같이 페이스북 관련 정보를 기입한다. 정보에 대한 내용은 페이스북 개발자 페이지에서 앱 생성 후 oauth2 관련 세팅을 완료한 후에 다음 스텝으로 넘어가자.

1
2
3
4
5
6
7
8
9
10
11
12
security:
oauth2:
client:
client-id: CLIENT-ID
client-secret: CLIENT-SECRET
accessTokenUri: https://graph.facebook.com/oauth/access_token
userAuthorizationUri: https://www.facebook.com/dialog/oauth
tokenName: oauth_token
authenticationScheme: query
clientAuthenticationScheme: form
resource:
userInfoUri: https://graph.facebook.com/me

서버를 실행하고 http://localhost:8080으로 접속해보자. 올바르게 설정이 완료되었다면 곧바로 페이스북 앱에 대해 동의하는 화면이 나올 것이다.

서버 실행

앱에서 정보 수신에 대해 동의버튼을 누르면 원래 우리가 호출하려 했던 index.html과 같은 화면이 등장할 것이다. 이 때 (크롬 기준) 개발자도구를 열고 Network 탭을 확인하면 아래와같이 Cookie에 JSESSIONID가 생성된 것을 확인할 수 있다.

로그인 후 확인

Welcome Page 추가하기

Facebook 로그인 링크와 로그인 되었을 때 사용자 정보를 출력하는 페이지를 작성해보자. 다음과 같이 index.html을 작성한다.

1
2
3
4
5
6
<div class="container unauthenticated">
Facebook : <a href="/login">클릭</a>
</div>
<div class="container authenticated" style="display:none">
로그인 되었습니다 : <span id="user"></span>
</div>

authenticatedunauthenticated 클래스를 통해 인증 여부를 확인하고 그에 따라 display를 해주는 부분이다. 이에 대한 내용을 jQuery를 사용하여 작성하자

1
2
3
4
5
6
7
<script type="text/javascript">
$.get("/user", function(data) {
$("#user").html(data.userAuthentication.details.name);
$(".unauthenticated").hide()
$(".authenticated").show()
});
</script>

서버사이드 변경

위 javascript 소스코드처럼 /user 에 대한 부분을 작성해야 한다. UserController.java를 작성하자.

1
2
3
4
5
6
7
@RestController
public class UserController {
@RequestMapping("/user")
public Principal user(Principal principal) {
return principal;
}
}

본래 Principal 전체를 return 하는 것은 권장되는 방법이 아니지만 샘플 프로젝트의 빠른 작업을 위해 이와 같이 사용한다. 추후 브라우저에서 보이고싶지 않은 부분에 대해서는 숨기는 처리를 한다.

잘 작동하지만 사용자가 링크를 클릭 할 수가 없다. 로그인 링크를 표시하려면 WebSecurityConfigurer를 추가하여 설정을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Application.java
@SpringBootApplication
@EnableOAuth2Sso
public class SpringBootOauth2Application extends WebSecurityConfigurerAdapter{
public static void main(String[] args) {
SpringApplication.run(SpringBootOauth2Application.class, args);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests()
.antMatchers("/", "/login**", "/webjars/**", "/error**")
.permitAll()
.anyRequest()
.authenticated();
}
}

WebSecurityConfigurerAdapter를 상속받고 configure()를 작성한다. 그리고 인증을 처리하는 로그인 엔드포인트와 다른 요청들에 대한 인증이 필요하도록 설정한다.

이제 서버를 재시작하고 접속하면 로그인 링크가 등장하고 로그인을 실행하면 로그인된 사용자의 정보가 등장할 것이다.

로그아웃 버튼 추가하기

index.htmlauthenticated클래스 안쪽에 로그아웃 버튼을 추가해보자. 또한 로그아웃 버튼에 대한 이벤트 함수를 작성한다.

1
2
3
4
5
6
<div class="container authenticated" style="display:none">
로그인 되었습니다 : <span id="user"></span>
<div>
<button onClick="logout()" class="btn btn-primary">로그아웃</button>
</div>
</div>
1
2
3
4
5
6
7
8
var logout = function() {
$.post("/logout", function() {
$("#user").html('');
$(".unauthenticated").show();
$(".authenticated").hide();
});
return true;
}

서버사이드 역시 /logout에 대한 엔드포인트가 필요하다. 조금 전 작성했던 configure()에 로그아웃에 대한 엔드포인트를 작성하자.

/logoutPOST메소드로 요청할 필요가 있다. 또한 CSRF(Cross Site Request Forgery)로부터 보호해야 한다. 현재 세션에 이로부터 보호하는 토큰값이 있기 때문에 클라이언트 단에서 이에 대한 값을 가지고 있어야 한다.

요즘 성행하는 자바스크립트 프레임워크에는 위와 같은 문제에 대한 방도가 내장이 되어있어서 사용해주면 된다. 자세한 내용은 좀 더 봐야 할 것 같다. 아직 이부분에 대해서는 심화학습이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
// Application.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests()
.antMatchers("/", "/login**", "/webjars/**", "/error**")
.permitAll()
.anyRequest()
.authenticated()
.and().logout().logoutSuccessUrl("/").permitAll()
.and().csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}

CSRF 토큰을 클라이언트 단에 추가하기

이번 샘플에서는 자바스크립트 프레임워크 등을 사용하지 않기 때문에 pom.xml에 라이브러리를 추가하는 것으로 대체한다. 라이브러리를 추가하고 html에 적용해보자.

1
2
3
4
5
<dependency>
<groupId>org.webjars</groupId>
<artifactId>js-cookie</artifactId>
<version>2.1.0</version>
</dependency>
1
<script type="text/javascript" src="/webjars/js-cookie/js.cookie.js"></script>
1
2
3
4
5
6
7
8
9
10
$.ajaxSetup({
beforeSend : function(xhr, settings) {
if (settings.type == 'POST' || settings.type == 'PUT'
|| settings.type == 'DELETE') {
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
xhr.setRequestHeader("X-XSRF-TOKEN", Cookies.get('XSRF-TOKEN'));
}
}
}
});

어플리케이션을 재시작하고 결과를 확인해본다.

OAuth2 클라이언트에 대한 설정

@EnableOAuth2Sso 어노테이션에는 OAuth2 클라이언트와 인증 두 가지 기능이 있다. 클라이언트 기능은 인증 서버(이번 경우는 Facebook)에서 제공하는 OAuth2 리소스와 통신하는데 사용한다. 인증 기능은 해당 어플리케이션을 타 Spring Security와 연동시키는 것이다. 클라이언트 기능은 Spring Security OAuth2에 의해 제공되는 기능이고, @EnableOAuth2Client에 의해 enable/disable이 가능하다. 이번에는 @EnableOAuth2Sso 어노테이션을 사용하지 않고 @EnableOAuth2Client 어노테이션을 사용해보자.

우선 OAuth2ClientContext를 주입시키고 인증 filter를 security 설정에 추가할 수 있게 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootApplication
@EnableOAuth2Client // 변경
@RestController
public class SocialApplication extends WebSecurityConfigurerAdapter {

@Autowired
OAuth2ClientContext oauth2ClientContext; // 의존성 주입

@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests()
.antMatchers("/", "/login**", "/webjars/**", "/error**")
.permitAll()
.anyRequest()
.authenticated()
.and().logout().logoutSuccessUrl("/").permitAll()
.and().csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
.and().addFilterBefore(ssoFilter(), BasicAuthenticationFilter.class);
}

OAuth2ClientContext를 사용하는 ssoFilter()를 작성한다.

1
2
3
4
5
6
7
8
9
private Filter ssoFilter() {
OAuth2ClientAuthenticationProcessingFilter facebookFilter = new OAuth2ClientAuthenticationProcessingFilter("/login/facebook");
OAuth2RestTemplate facebookTemplate = new OAuth2RestTemplate(facebook(), oauth2ClientContext);
facebookFilter.setRestTemplate(facebookTemplate);
UserInfoTokenServices tokenServices = new UserInfoTokenServices(facebookResource().getUserInfoUri(), facebook().getClientId());
tokenServices.setRestTemplate(facebookTemplate);
facebookFilter.setTokenServices(tokenServices);
return facebookFilter;
}

위 필터에서는 facebook과 관련된 application 속성을 가지고 client 정보를 등록한다.

1
2
3
4
5
@Bean
@ConfigurationProperties("facebook.client") // application 속성
public AuthorizationCodeResourceDetails facebook() {
return new AuthorizationCodeResourceDetails();
}

그리고 facebook 사용자 정보를 가져오는 엔드포인트를 등록하여 인증처리를 한다. (facebookResource() 작성)

1
2
3
4
5
@Bean
@ConfigurationProperties("facebook.resource")
public ResourceServerProperties facebookResource() {
return new ResourceServerProperties();
}

위 수정사항들을 추가하면서 application.yml(application.properties)의 속성을 바꿔줄 필요가 있다.

1
2
3
4
5
6
7
8
9
10
11
facebook:
client:
client-id: CLIENT-ID
client-secret: CLIENT-SECRET
accessTokenUri: https://graph.facebook.com/oauth/access_token
userAuthorizationUri: https://www.facebook.com/dialog/oauth
tokenName: oauth_token
authenticationScheme: query
clientAuthenticationScheme: form
resource:
userInfoUri: https://graph.facebook.com/me

그리고 index.html파일을 수정해준다. 링크의 url을 변경한다.

1
2
3
4
5
6
<h1>로그인</h1>
<div class="container unauthenticated">
<div>
Facebook : <a href="/login/facebook">클릭</a> <!-- url 변경 -->
</div>
</div>

마지막으로 어플리케이션에서 Facebook으로 redirect하는 것을 지원하는 Filter를 작성하고 Bean으로 등록한다. 필터는 이미 @EnableOAuth2Client를 통해 등록된 것이기 때문에 정상적인 필터 작동을 위해 연결시켜줄 필요가 있다.

1
2
3
4
5
6
7
@Bean
public FilterRegistrationBean<OAuth2ClientContextFilter> oauth2ClientFilterRegistration(OAuth2ClientContextFilter filter) {
FilterRegistrationBean<OAuth2ClientContextFilter> registration = new FilterRegistrationBean<OAuth2ClientContextFilter>();
registration.setFilter(filter);
registration.setOrder(-100);
return registration;
}

이제 마지막으로 로그인 / 로그아웃을 테스트해본다. 잘되는 모습을 볼 수 있다.

막바지의 어노테이션 변경 부분부터 다소 어려운 부분인 것 같다. 평소 못보았던 클래스도 많이 보이고 OAuth2와 관련된 필터 설정이 생각보다 손이 많이 가는 것을 보았다. 포스팅 가장 처음에 말했듯이 가장 어려우면서도 귀찮은 부분인 인증 과정에 대해 책을 정독하듯이 따라오게 되었는데, 앞으로 이 부분(특히 Filter를 적용하는 부분)에 대해 더 심도있게 알아보고 싶다. 그래도 한 번 해놓으면 또 써먹을 날이 오겠지..

다음 포스팅에서는 이와 유사하게 Github 인증을 진행할 것이다.

소스코드

https://github.com/hwiVeloper/SpringBootStudy/tree/2c714d3ba9c511e790e087b9362842017dcfc9f3/spring-boot-oauth2

스프링부트 스케쥴러

스프링 부트에서는 스케쥴러가 사용이 가능하다. 따로 Maven이나 Gradle에서 dependency를 추가하지 않고 기본 요소로 탑재가 되어있다. 간단하게 어노테이션과 Task를 정의하는 것만으로도 사용할 수 있는데, 이에 대해서 알아보자.

스케쥴러 ON

Application.java

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling // 이 부분 추가
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}

스프링 스타터 프로젝트 생성 시 기본으로 생성되는 어플리케이션 소스코드이다. @EnableScheduling 어노테이션 추가를 통해 스케쥴러를 동작시키겠다는 정의를 내리는 부분이다.

스케쥴(task) 정의

이제 스케쥴을 정의해보자. 따로 패키지 위치는 상관없는 것으로 보인다.

ScheduleTasks.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class ScheduledTasks {

private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);

private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");

/* 스케쥴 task 정의 */
@Scheduled(fixedRate = 5000)
public void reportCurrentTime() {
log.info("The time is now {}", dateFormat.format(new Date()));
}
}

@Scheduled 어노테이션으로 해당 메서드가 스케쥴러에 의해 동작되는 것임을 알려주는 부분이다. 이 어노테이션은 현재 fixedRate의 값이 지정되어 있고 이는 5초에 한번씩 동작하게끔 한다. 이외에도 특히 쓸만하다고 생각되는 것은 cron이다. 리눅스 환경에서의 crontab와 같은 부분이다.

단, crontab에서는 다섯자리 표현식이 가능했던 반면, Spring boot scheduler에서는 6자리 표현식만 허용된다.

1
@Scheduled(cron="*/5 * * * * *")

cron 속성은 총 6자리로 이루어지고 일반적은 cron을 설정하듯이 작성해준다. 작성 후 서버를 재기동 하면 그 순간부터 스케쥴러가 돌기 시작하면서 사전에 정의된 메서드를 실행한다.

@Scheduled 어노테이션 속성은 cron 외에도 다음과 같은 것들이 있다. 더 다양한 속성이 있지만 두 가지만 살펴보도록 하자.

| 속성 | type | 설명 |
|———— |—— |——————————————————– |
| fixedDelay | int | invoke 완료 후 지정된 시간(milliseconds) 이후에 재실행 |
| fixedRate | int | 지정된 시간(milliseconds) 간격으로 실행 |

정리

스프링의 스케쥴러는 일반적인 리눅스의 crontab사용보다도 더 간단해서 마음에 쏙 들었다. 하지만 너무 많은 스케쥴 task, 또는 과중한 스케쥴러는 어플리케이션에 지장을 줄 수도 있으니 적절하게 쓰는 것이 중요하다고 생각된다.

참조

https://spring.io/guides/gs/scheduling-tasks/

스프링부트에서 STOMP 웹소켓 사용하기

spring guide를 보던 중 STOMP 프로토콜을 사용하여 WebSocket을 구현하는 재미있는 샘플이 있어서 사용해보았다. WebSocket은 TCP레이어 위에 존재하여 중간에 메세지를 전달할 수 있는 채널의 개념이다. 공식 가이드에서는 STOMP라는 프로토콜을 사용하여 간단한 요청 및 응답을 하는 기본적인 구성으로 되어있다. 이에 더하여 정말 간단한 채팅 어플리케이션을 구현하는 것까지가 이번 글의 목표이다.

소스코드는 http://spring.io/guides/gs/messaging-stomp-websocket에서 참조했다.

STOMP

프로젝트 생성 전에 STOMP(https://stomp.github.io/)가 무엇인지 간단히 알아보자. Simple Text Oriented Messaging Protocol의 약자로 단순 텍스트 기반 메세징 프로토콜이다.

1
2
3
4
5
6
COMMAND
key(header):value
key(header):value
...

BODY^@

위와 같은 형식으로 되어있다. COMMAND에는 보통 SEND, SUBSCRIBE와 같은 명령을 사용할 수 있다. 추가적인 header와 body 본문 내용을 통해 통신을 하는 방식이라고 보면 되겠다.

STOMP 통신 포맷 예시

위 예시 이미지는 소켓 통신 커넥션을 생성하는 부분이므로 COMMAND와 header만 존재하고 body 본문 내용은 존재하지 않다.

더 자세한 내용은 https://zetawiki.com/wiki/STOMP 등에서 참조하도록 하고 본격적인 프로젝트 생성부터 들어가보자.

프로젝트 생성

https://start.spring.io/ 에서 프로젝트를 생성하거나 eclipse, sts 등 사용하고 있는 IDE에서 spring boot project를 생성한다.

STS에서 프로젝트 생성-1
STS에서 프로젝트 생성-2

빌드 툴

GradleMaven을 사용할텐데 두 가지 뭐로 하든 무방하다.

Gradle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
buildscript {
ext {
springBootVersion = '2.1.1.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}

apply plugin: 'java'
apply plugin: 'eclipse-wtp'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
baseName = 'spring-boot-chatting'
version = '0.1.0'
}

sourceCompatibility = '1.8'

repositories {
mavenCentral()
}

dependencies {
compile("org.springframework.boot:spring-boot-starter-websocket")
compile("org.webjars:webjars-locator-core")
compile("org.webjars:sockjs-client:1.0.2")
compile("org.webjars:stomp-websocket:2.3.3")
compile("org.webjars:bootstrap:3.3.7")
compile("org.webjars:jquery:3.1.0")

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

task stage {
dependsOn build
}
Maven

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.springframework</groupId>
<artifactId>gs-messaging-stomp-websocket</artifactId>
<version>0.1.0</version>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.5.RELEASE</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.3</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>3.3.7</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.1.0</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<properties>
<java.version>1.8</java.version>
</properties>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

메세지 Class 작성

STOMP메세지 포맷에 맞게 getter를 가진 domain class를 생성해보자. STOMP 메세지에서 body는 JSON 오브젝트로 구성이 되어있다. 스프링 부트에서 Jackson JSON 라이브러리를 사용하고 있기 때문에 domain class를 파라미터로 사용하거나 return시켜준다면 json으로 변환이 된 상태로 통신할 것이다.

1
2
3
{
"name": "hwiVeloper"
}

HelloMessage.java를 생성하고 내용을 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package dev.hwi.domain;

public class HelloMessage {
private String name;

public HelloMessage() {

}

public HelloMessage(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

그리고 Greeting.java를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package dev.hwi.domain;

public class Greeting {
private String content;

public Greeting() {

}

public Greeting(String content) {
this.content = content;
}

public String getContent() {
return content;
}
}

컨트롤러

STOMP 메세지는 스프링에서는 컨트롤러에서 핸들링된다. 아래 GreetingConroller.java를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package dev.hwi.controller;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;

import dev.hwi.domain.Chat;
import dev.hwi.domain.Greeting;
import dev.hwi.domain.HelloMessage;

@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(100); // delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}

단순한 컨트롤러이지만 생소한 annotation 두 개가 보인다. @MessageMapping은 클라이언트에서 /hello쪽으로 메세지를 전달하면 greeting메서드가 실행된다. 그리고 이 메서드는 다시 @SendTo 어노테이션에 정의된 쪽으로 결과를 return시킨다. 예제에서는 Thread.sleep(1000);를 작성했지만 생략해도 무방하다. @SendTo로 return시킨 후에 클라이언트에서는 전달받은 값을 렌더링하여 브라우저에 표시해 줄 수 있는 상태가 된다.

STOMP 관련 설정

설정이 필요하므로 WebSocketConfig.java파일을 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package dev.hwi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket").withSockJS();
}
}

@Configuration, @EnableWebSocketMessageBroker를 통해 WebSocket과 관련된 설정을 작동시킨다. WebSocketConfig 클래스는 WebSocketMessageBrokerConfigurerimplements하고 두 개의 메서드를 오버라이딩한다.

메세지 브로커(Message Broker)는 송신자에게 수신자의 이전 메세지 프로토콜로 변환해주는 모듈 중에 하나이다. 요청이 오면 그에 해당하는 통신 채널로 전송해주고 응답 역시 왔던 길을 그대로 다시 가서 안정적으로 메세지를 응답해주기 위한 부분입니다.

  • configureMessageBroker()
    • enableSimpleBroker는 클라이언트로 메세지를 응답해줄 때 prefix를 정의한다.
    • setApplicationDestinationPrefixes는 클라이언트에서 메세지 송신 시 붙여줄 prefix를 정의한다.
  • registerStompEndpoints()
    • 최초 소켓 연결을 하는 경우, endpoint가 되는 url이다. 추후 javascript에서 SockJS 생성자를 통해 연결될 것이다.

html, js 작성

/src/main/resources/static/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/main.css" rel="stylesheet">
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script src="/app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
<div class="row">
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<label for="connect">WebSocket connection:</label>
<button id="connect" class="btn btn-default" type="submit">Connect</button>
<button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<label for="name">What is your name?</label>
<input type="text" id="name" class="form-control" placeholder="Your name here...">
</div>
<button id="send" class="btn btn-default" type="submit">Send</button>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12">
<table id="conversation" class="table table-striped">
<thead>
<tr>
<th>Greetings</th>
</tr>
</thead>
<tbody id="greetings">
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

/src/main/resources/static/app.js

부가 내용은 주석으로 설명

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var stompClient = null;

function setConnected(connected) {
$("#connect").prop("disabled", connected);
$("#disconnect").prop("disabled", !connected);
if (connected) {
$("#conversation").show();
}
else {
$("#conversation").hide();
}
$("#greetings").html("");
}

function connect() {
var socket = new SockJS('/websocket');
stompClient = Stomp.over(socket);
// SockJS와 stomp client를 통해 연결을 시도.
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', function (greeting) {
showGreeting(JSON.parse(greeting.body).content);
});
});
}

function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}

function sendName() {
// /app/hello로 JSON 파라미터를 메세지 body로 전송.
stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}

function showGreeting(message) {
$("#greetings").append("<tr><td>" + message + "</td></tr>");
}

$(function () {
$("form").on('submit', function (e) {
e.preventDefault();
});
$( "#connect" ).click(function() { connect(); });
$( "#disconnect" ).click(function() { disconnect(); });
$( "#send" ).click(function() { sendName(); });
});

이후 작성한 프로젝트를 Server Start 한 후 확인한다.

실행 후 개발자도구로 확인하는 모습

여기까지가 공식가이드에서 보여주는 샘플이다. 진짜 채팅처럼(그래도 디자인은 자신이 없다..) ui를 약간만 추가해주자.

간단한 채팅 어플리케이션 만들어보기

/app/chat으로 송신하여 /topic/chat으로 수신을 받아서 여러 명이 접속하는 경우까지 가정하여 작성해보도록 한다. 송신하는 데이터 포맷은 다음과 같다.

1
2
3
4
{
"name": "hwiVeloper",
"message": "안녕하세요."
}

이를 위해 Greeting.java와 유사하게 메세지를 받고 전달해줄 domain class가 필요하다.

Chat.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package dev.hwi.domain;

public class Chat {
private String name;
private String message;

public Chat() {

}

public Chat(String name, String message) {
this.name = name;
this.message = message;
}

public String getName() {
return name;
}

public String getMessage() {
return message;
}
}

GreetingController.java에는 메서드를 하나 추가해준다.

1
2
3
4
5
@MessageMapping("/chat")
@SendTo("/topic/chat")
public Chat chat(Chat chat) throws Exception {
return new Chat(chat.getName(), chat.getMessage());
}

동일하게 @MessageMapping@SendTo 어노테이션을 통해 매핑해주고 return시켜줄 내용을 작성해준다.

index.html

좀전 예제에서 이름을 입력했던 input 태그 밑에 다음과 같은 내용을 추가한다.

1
2
3
4
5
<div class="form-group">
<label for="message">Input Message</label>
<input type="text" id="chatMessage" class="form-control" placeholder="message.." />
</div>
<button id="chatSend" class="btn btn-default" type="button">Chat Send</button>

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ... 앞에 생략 ...

function connect() {
var socket = new SockJS('/websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
// ...

/* 이 부분 추가 */
stompClient.subscribe('/topic/chat', function (chat) {
showChat(JSON.parse(chat.body));
});
});
}

// ... 중략 ...

/* Chat과 관련된 메서드 추가 */
function sendChat() {
stompClient.send("/app/chat", {}, JSON.stringify({'name': $("#name").val(), 'message': $("#chatMessage").val()}));
}
function showChat(chat) {
$("#greetings").append("<tr><td>" + chat.name + " : " + chat.message + "</td></tr>");
}

$(function () {
// ...

$( "#chatSend" ).click(function(){ sendChat(); }); // 추가
});

테스트

이제 브라우저 세션을 여러 개 띄운 후 채팅하듯이 테스트를 해본다.

4개의 브라우저에서 테스트하는 모습

정리

STOMP기반의 메세징 서비스에 대해 알아보고 이를 Spring Boot에서 어떻게 사용하는지를 알아보았다.

소스코드

https://github.com/hwiVeloper/SpringBootStudy/tree/master/spring-boot-websocket

참고

http://spring.io/guides/gs/messaging-stomp-websocket
https://zetawiki.com/wiki/STOMP
https://stomp.github.io/
http://www.egovframe.go.kr/wiki/doku.php?id=egovframework:rte3.5:ptl:stomp