Spring Security OAuth2 - gae-jang-mo/app GitHub Wiki

Spring Security OAuth2

๊ธฐ๋ณธ์ ์œผ๋กœ OAuth2 ๋ฐฉ์‹์—๋Š” 4๊ฐ€์ง€ ๋ฐฉ์‹์ด ์žˆ์Šต๋‹ˆ๋‹ค.

  • Authorization Code Grant
  • Implicit Grant
  • Resource Owner Password Credentials Grant
  • Client Credentials Grant

Authorization Code Grant Flow

๊ทธ์ค‘ ์ฒซ๋ฒˆ์งธ์ธ Authorization Code Grant ๋ฐฉ์‹์ด ์ œ์ผ ๋งŽ์ด ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

แ„‰แ…ณแ„แ…ณแ„…แ…ตแ†ซแ„‰แ…ฃแ†บ 2019-12-07 แ„‹แ…ฉแ„’แ…ฎ 5 57 08
  • (A) Resource Owner(์‚ฌ์šฉ์ž)๋Š” User-Agent(๋ธŒ๋ผ์šฐ์ €)๋ฅผ ํ†ตํ•ด Client(Application)์—๊ฒŒ domain.com/oauth2/authorization/github ๊ฒฝ๋กœ๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

  • (A) Client๋Š” ์œ„ ๊ฒฝ๋กœ๋กœ ๋“ค์–ด์˜จ ์š”์ฒญ์— ๋Œ€ํ•ด OAuth2 ์ธ์ฆ ๋ฐฉ์‹ ์š”์ฒญ์ž„์„ ํ™•์ธํ•˜๊ณ  Authorization Server(๊ถŒํ•œ ์„œ๋ฒ„, OAuth Provider ์„œ๋ฒ„ : Github OAuth App Server)์—๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ๋กœ๋ฅผ Location ํ•ด๋”์— ๋‹ด์•„ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. ์ด๋•Œ Client๋Š” Location ์ •๋ณด๋กœ authorization-endpoint (https://github.com/login/oauth/authorize)์™€ ์ฟผ๋ฆฌ ์ŠคํŠธ๋ง์œผ๋กœ Client Identifier (client-id), Redirection-URI(domain.com/login/oauth2/code/github) ๋“ฑ(scope, state...)์„ ๋‹ด์•„์ค๋‹ˆ๋‹ค.

  • (A) 302 ๋ฆฌ๋‹ค์ด๋ ‰์…˜ ์‘๋‹ต์„ ๋ฐ›์€ User-Agent๋Š” Location ๊ฒฝ๋กœ์— ์˜ํ•ด Authorization Server์—๊ฒŒ ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์ด ๊ถŒํ•œ ์„œ๋ฒ„๋Š” ์œ„์—์„œ Client๊ฐ€ ๋‹ด์•„๋†“์€ client-id๋ฅผ ํ™•์ธํ•˜์—ฌ ํ•ด๋‹น oauth app์˜ ๊ถŒํ•œ ์Šน์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™์‹œ์ผœ์ค๋‹ˆ๋‹ค. (oauth2 provider์— ๋กœ๊ทธ์ธ ๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค๋ฉด ๋กœ๊ทธ์ธ์„ ๋จผ์ € ํ•˜๋ผ๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™์‹œํ‚ต๋‹ˆ๋‹ค.)

  • (B) User-Agent์— ์Šน์ธํŽ˜์ด์ง€๊ฐ€ ๋„์šฐ๊ณ  Resource Owner๋Š” ๊ถŒํ•œ์„ ๋ถ€์—ฌํ•˜๊ฑฐ๋‚˜ ๊ฑฐ์ ˆํ•ฉ๋‹ˆ๋‹ค. ๊ถŒํ•œ ์Šน์ธ(๋˜๋Š” ๊ฑฐ์ ˆ) ์ •๋ณด๋ฅผ Authorization Server์— ๋ณด๋ƒ…๋‹ˆ๋‹ค.

  • (C) ๋งŒ์•ฝ Resource Owner๊ฐ€ ๊ถŒํ•œ ์Šน์ธํ–ˆ๋‹ค๋ฉด Authorization Server๋Š” token(code)์„ ๋ฐœํ–‰ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  User-Agent๋ฅผ ์ด์ „์— ์ „๋‹ฌ ๋ฐ›์€ Redirect-URI ๊ฒฝ๋กœ(domain.com/login/oauth2/code/github)๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์‹œํ‚ต๋‹ˆ๋‹ค. ์ด๋•Œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๊ฒฝ๋กœ์— ๋ฐœ๊ธ‰ํ•œ ํ† ํฐ๊ณผ ๋”๋ถˆ์–ด ์ด์ „์— Client๊ฐ€ ์ „๋‹ฌํ•œ ์—ฌ๋Ÿฌ ์ƒํƒœ ๊ฐ’์„ ๊ฐ™์ด ๋‹ด์•„์ค๋‹ˆ๋‹ค.

  • (D) User-Agent๋Š” Client๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋  ๊ฒƒ์ด๊ณ  Client๋Š” ๋‹ค์‹œ Authorization Server์—๊ฒŒ Token-URI(https://github.com/login/oauth/access_token)๊ฒฝ๋กœ์— Post ์š”์ฒญ์œผ๋กœ Access Token์„ ๋‹ฌ๋ผ๋Š” ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ด๋•Œ Access Token์„ ๋ฐœ๊ธ‰ํ•˜๊ธฐ์œ„ํ•œ ์ธ์ฆ์„ ์œ„ํ•ด ์ด์ „์— ๋ฐ›์€ token(code)๊ณผ ๋”๋ถˆ์–ด Authorization Server์— ๋ณด๋ƒˆ์—ˆ๋˜ ์ƒํƒœ๊ฐ’(client-id, client-secret, redirect-uri)์„ ๊ฐ™์ด ๋ณด๋ƒ…๋‹ˆ๋‹ค.

  • (E) Authorization Server๋Š” Client๊ฐ€ ๋ณด๋‚ธ ๊ฐ’์„ ๊ฐ€์ง€๊ณ  ํƒ€๋‹นํ•œ์ง€๋ฅผ ํ™•์ธํ•˜๊ณ  ์œ ํšจํ•œ ์ •๋ณด๊ฐ€ ํ™•์ธ๋์„ ๊ฒฝ์šฐ Access Token์„ ๋ฐœํ–‰ํ•˜์—ฌ (์„ ํƒ์ ์œผ๋กœ Refresh Token๋„ ๊ฐ™์ด ๋ฐœํ–‰ํ•œ๋‹ค.) Client๋กœ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

    (C)์—์„œ ๋ฐœํ–‰ํ•œ token๊ณผ (E)์—์„œ ๋ฐœํ–‰ํ•œ access token์€ ๋‹ค๋ฅธ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

  • ์ถ”๊ฐ€๋กœ Client๊ฐ€ Access Token์„ ๋ฐœ๊ธ‰ ๋ฐ›์œผ๋ฉด ํ•ด๋‹น ํ† ํฐ์„ ์ด์šฉํ•˜์—ฌ Resource Server์˜ user-Info-endpoint(https://api.github.com/user/{user-id}) ๊ฒฝ๋กœ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ›์•„์˜ต๋‹ˆ๋‹ค.

Spring Security + OAuth2

OAuth ์„ค์ •

์‚ฌ์‹ค์ƒ Spring Security์—์„œ OAuth2์—์„œ ํ•„์š”ํ•œ ์„ค์ •์„ ๋Œ€๋ถ€๋ถ„ ํ•ด์ฃผ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

    package org.springframework.security.config.oauth2.client;
    
    public enum CommonOAuth2Provider {
    	...
    
    	GITHUB {
    
    		@Override
    		public Builder getBuilder(String registrationId) {
    			ClientRegistration.Builder builder = getBuilder(registrationId,
    					ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
    			builder.scope("read:user");
    			builder.authorizationUri("https://github.com/login/oauth/authorize");
    			builder.tokenUri("https://github.com/login/oauth/access_token");
    			builder.userInfoUri("https://api.github.com/user");
    			builder.userNameAttributeName("id");
    			builder.clientName("GitHub");
    			return builder;
    		}
    	},
    
    	...
    }

์ด๋ฏธ Spring Security๊ฐ€ ์œ„ CommonOAuth2Provider ์ฒ˜๋Ÿผ OAuth ์—ฐ๋™์— ํ•„์š”ํ•œ (github oauth์˜)๊ธฐ๋ณธ ์„ค์ • ์ •๋ณด๋ฅผ ๋‹ค ๋งŒ๋“ค์–ด๋†”์„œ ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์ œ๊ณตํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Authorization Code Grant Flow์—์„œ ์ด์•ผ๊ธฐํ•œ Authorization Server๋กœ ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•œ authorization-endpoint, Access Token์„ ๋ฐœ๊ธ‰๋ฐ›๊ธฐ ์œ„ํ•œ uri, ์ธ๊ฐ€๋ฅผ ๋ฐ›๊ณ  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ›๊ธฐ์œ„ํ•œ userInfo-endpoint ๋“ฑ CommonOAuth2Provider.GITHUB ์—์„œ ์ •์˜๋œ ๊ฐ’์œผ๋กœ ์ž๋™ ์„ค์ •๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ถ”๊ฐ€๋กœ ์ฒ˜์Œ ์‚ฌ์šฉ์ž(Resource Owner)๊ฐ€ oauth ์ธ์ฆ์„ ํ•˜๊ธฐ์œ„ํ•œ ์‹œ๋„์˜ ์‹œ๋ฐœ์ (A)์œผ๋กœ ์ ‘๊ทผํ•˜๋Š” ๊ฒฝ๋กœ domain.com/oauth2/authorization/github ๋˜ํ•œ Spring Security๊ฐ€ ๊ธฐ๋ณธ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” ์„ค์ • ๊ฐ’์ž…๋‹ˆ๋‹ค.

๊ทธ๋ž˜์„œ ์šฐ๋ฆฌ๋Š” ์•„๋ž˜์ฒ˜๋Ÿผ ๋‘๊ฐ€์ง€(client-id / client-secret)๋งŒ ์„ค์ •ํ•ด์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

# application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: <ํด๋ผ์ด์–ธํŠธ ID>
            client-secret: <ํด๋ผ์ด์–ธํŠธ SECRET>

OAuth ์ ์šฉ

    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(final HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .antMatchers("/").permitAll()
                    .anyRequest().authenticated()
                .and()
                    .oauth2Login();            // ๊ธฐ๋ณธ oauth login ์ ์šฉ
        }
    }

.oauth2Login() ์„ ์ ์šฉํ•จ์œผ๋กœ์จ Spring Security ์— ์ƒˆ๋กœ์šด ํ•„ํ„ฐ๊ฐ€ ๋‘๊ฐœ๊ฐ€ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค.

์ด ํ•„ํ„ฐ๊ฐ€ OAuth2 ์ ์šฉ์„ ์œ„ํ•œ ์„ค์ •๊ณผ ํ†ต์‹ ์„ ๋‹ด๋‹นํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

class org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter
class org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter

์œ„ Authorization Code Grant Flow ์—์„œ (A), (B), (C) ๊นŒ์ง€ User-Agent(๋ธŒ๋ผ์šฐ์ €)์™€ Authorization Server๊ฐ„์˜ ํ†ต์‹ ์ด์—ˆ๊ณ  (D), (E)๊ฐ€ Client์™€ Authorization / Resource Server๊ฐ„์˜ ํ†ต์‹ ์ž…๋‹ˆ๋‹ค.

๋ธŒ๋ผ์šฐ์ €๋ฅผ ํ†ตํ•œ ํ†ต์‹ ์€ ๊ทธ๋ ‡๋‹ค ์ณ๋„ ์„œ๋ฒ„๊ฐ„(Client - Authorization Server) ํ†ต์‹ ์€ ์–ด๋–ป๊ฒŒ ์ด๋ค„์งˆ๊นŒ?

    package org.springframework.security.oauth2.client.endpoint;
    
    // Access Token ๋ฐœ๊ธ‰ ๊ณผ์ •
    public class DefaultAuthorizationCodeTokenResponseClient {
    	...
    
    	@Override
    	public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
    		...
    		// post request ์ƒ์„ฑ
    		// public T convert(S s) {
    		//	...
    		//	// ๊ธฐ์กด์— Spring Security๊ฐ€ ์„ค์ •ํ•œ token uri ๊ฐ’์„ ๋ถˆ๋Ÿฌ์™€ uri ๊ฒฝ๋กœ๋กœ ์„ค์ •
    		//	URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri())
    		//		.build()
    		//		.toUri();
    		//	return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
    		// }
    		RequestEntity<?> request = this.requestEntityConverter.convert(authorizationCodeGrantRequest);
    
    		ResponseEntity<OAuth2AccessTokenResponse> response;
    		try {
    			// RestOperation์„ ์ด์šฉํ•œ ํ†ต์‹ ์„ ์ง„ํ–‰
    			response = this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
    		} catch (RestClientException ex) {
    			OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
    					"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + ex.getMessage(), null);
    			throw new OAuth2AuthorizationException(oauth2Error, ex);
    		}
    
    		OAuth2AccessTokenResponse tokenResponse = response.getBody();
    
    		...
    	}
    
    	...
    }

์œ„ ์ฝ”๋“œ์— ๋‚˜์™€์žˆ๋‹ค์‹œํ”ผ this.requestEntityConverter.convert(authorizationCodeGrantRequest); ๋ฅผ ํ†ตํ•ด Http Request๋ฅผ Access Token์„ ๋ฐœ๊ธ‰๋ฐ›๊ธฐ ์œ„ํ•œ ์š”์ฒญ ๊ทœ์•ฝ์— ๋งž๊ฒŒ ์ƒ์„ฑํ•˜๊ณ  this.restOperations.exchange(request, OAuth2AccessTokenResponse.class); ๋กœ ํ†ต์‹ ์„ ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

SecurityConfig Spring Security ์„ค์ •์—์„œ .oauth2Login()๋งŒ์œผ๋กœ ์„ค์ •ํ•ด๋†จ๊ธฐ ๋•Œ๋ฌธ์— Default๋กœ ์„ค์ •๋œ ํด๋ž˜์ŠคDefaultAuthorizationCodeTokenResponseClient์— ์˜ํ•ด oauth ์ธ์ฆ - ์ธ๊ฐ€๊ฐ€ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

    package org.springframework.security.oauth2.client.userinfo;
    
    public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    	...
    	@Override
    	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    		...
    		// convert() ๋กœ request ์š”์ฒญ ์ƒ์„ฑ
    		RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
    
    		ResponseEntity<Map<String, Object>> response;
    
    		try {
    			// Authorization Server์™€ ํ†ต์‹ 
    			response = this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE);
    		} catch (OAuth2AuthorizationException ex) {
    			...
    		}
    
    		Map<String, Object> userAttributes = response.getBody();
    		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);
    	}
    
    	...
    }

Access Token์„ ๋ฐ›์•„์˜ค๋Š” ๋ฐฉ์‹๊ณผ ๋น„์Šทํ•˜๊ฒŒ DefaultOAuth2UserService์—์„œ user-info-endpoint ๊ฒฝ๋กœ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ›์•„์˜ต๋‹ˆ๋‹ค.

    package org.springframework.security.oauth2.client.userinfo;
    
    public class OAuth2UserRequestEntityConverter {
    	...
    
    	@Override
    	public RequestEntity<?> convert(OAuth2UserRequest userRequest) {
    		ClientRegistration clientRegistration = userRequest.getClientRegistration();
    
    		...
    
    		HttpHeaders headers = new HttpHeaders();
    		headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
    
    		// ๊ธฐ์กด์— Spring Security๊ฐ€ ์„ค์ •ํ•œ user-info-endpoint์˜ ๊ฐ’์„ ๋ถˆ๋Ÿฌ์™€ uri ๊ฒฝ๋กœ ์„ค์ •
    		URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri())
    				.build()
    				.toUri();
    
    		...
    
    		return request;
    	}
    	...
    }

Client - Authorization Server ํ†ต์‹  ๋‹ด๋‹น ํด๋ž˜์Šค
์œ„์—์„œ ์„ค๋ช…ํ•œ Access Token ๋ฐœ๊ธ‰ ๋กœ์ง, ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ๋กœ์ง์„ ํ˜ธ์ถœํ•˜๋Š” ํด๋ž˜์Šค๋‹ค.

    package org.springframework.security.oauth2.client.authentication;
    
    public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
    	...
    	
    	@Override
    	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    		
    		...
                OAuth2AccessTokenResponse accessTokenResponse;
		try {
			OAuth2AuthorizationExchangeValidator.validate(
					authorizationCodeAuthentication.getAuthorizationExchange());
                        
                        // access token ๋ฐœ๊ธ‰
			accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
					new OAuth2AuthorizationCodeGrantRequest(
							authorizationCodeAuthentication.getClientRegistration(),
							authorizationCodeAuthentication.getAuthorizationExchange()));

		} catch (OAuth2AuthorizationException ex) {
			OAuth2Error oauth2Error = ex.getError();
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}

                // ์œ„์—์„œ ๋ฐ›์•„์˜จ accessToken ์ถ”์ถœ
    		OAuth2AccessToken accessToken = accessTokenResponse.getAccessToken();
    		Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
    
    		// ์ธ๊ฐ€๋œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ด with accessToken
    		OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
    				authorizationCodeAuthentication.getClientRegistration(), accessToken, additionalParameters));
    
    		...
    
    		// ์œ„์—์„œ ๊ฐ€์ ธ์˜จ ์ •๋ณด ์ €์žฅ
    		OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
    			authorizationCodeAuthentication.getClientRegistration(),
    			authorizationCodeAuthentication.getAuthorizationExchange(),
    			oauth2User,
    			mappedAuthorities,
    			accessToken,
    			accessTokenResponse.getRefreshToken());
    		authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
    
    		return authenticationResult;
    	}
    
    	...
    }

์œ„์—์„œ ์ €์žฅ๋œOAuth2LoginAuthenticationToken authenticationResult ๋Š” ์ถ”ํ›„ ์—ฌ๋Ÿฌ ๊ถŒํ•œ ๊ฐ’๊ณผ ํ•จ๊ป˜ OAuth2AuthenticationToken ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•˜๊ณ  SecurityContextHolder์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

SecurityContextHolder์— ๋“ฑ๋ก๋œ ์—ฌ๋Ÿฌ ์ปจํ…์ŠคํŠธ๋“ค์€ oauth ์ธ์ฆ - ์ธ๊ฐ€ ํ๋ฆ„์— ๋งž๋Š” ์ƒ๋ช…์ฃผ๊ธฐ๋กœ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

    package com.gaejangmo.apiserver;
    
    @ResController
    public class Controller {
    
      @GetMapping("/access_token")
      public String index(OAuth2AuthenticationToken authenticationToken) {

    	// SecurityContextHolder์— ์ €์žฅ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด ์‚ฌ์šฉ
        log.info("authenticationToken {}", authenticationToken);
    
    	return null;
      }
    }

๊ทธ๋ž˜์„œ ์œ„์™€ ๊ฐ™์ด ์šฐ๋ฆฌ๋Š” ์ปจํŠธ๋กค๋Ÿฌ์—์„œ oauth ์ธ๊ฐ€๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” OAuth2AuthenticationToken ๊ฐ์ฒด๋ฅผ ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

reference

โš ๏ธ **GitHub.com Fallback** โš ๏ธ