Back To The Basic

SSO, oAuth, OIDC, JWT의 의해- 최신 웹 인증 아키텍처 완벽 가이드

Cloud Applicaiton Architect 2026. 2. 20. 18:24
반응형

요약

이 문서는 현대 웹 애플리케이션의 인증 아키텍처를 체계적으로 설명합니다. 전통적인 세션/쿠키 방식부터 JWT 토큰 기반 인증, OAuth 2.0과 OIDC 프로토콜, SSO와 페더레이션까지 전체 인증 생태계를 다룹니다.

전통적인 세션 방식은 서버 메모리에 사용자 정보를 저장하고 JSESSIONID라는 키값으로 브라우저와 연결하는 Stateful 방식입니다. 이는 단일 서버 환경에서는 효과적이지만, 마이크로서비스 아키텍처에서는 세션 공유 문제로 확장성에 한계가 있습니다. 반면 JWT 토큰 방식은 모든 필요한 정보를 토큰 자체에 담아 전달하는 Stateless 방식으로, 서버가 세션을 기억할 필요가 없어 분산 환경에 적합합니다.

OAuth 2.0은 권한 부여(Authorization)를 위한 프로토콜이며, OIDC(OpenID Connect)는 OAuth 2.0 위에 인증(Authentication) 레이어를 추가한 확장 프로토콜입니다. OIDC는 ID Token으로 사용자 신원을 확인하고, OAuth 2.0의 Access Token으로 API 접근 권한을 관리합니다. 이러한 표준 프로토콜 덕분에 SSO(Single Sign-On)와 페더레이션이 가능하며, 서로 다른 인증 서비스(AWS Cognito, Okta, Azure AD 등)들이 상호 운용할 수 있습니다.

마이크로서비스 환경에서는 OIDC 기반 SSO 솔루션과 JWT 토큰을 조합하여 각 서비스가 독립적으로 인증을 처리하는 확장 가능한 아키텍처를 구축할 수 있습니다. 본 문서는 이론적 설명과 함께 실무에서 바로 활용할 수 있는 Java 코드 예제를 제공하여, 개발자가 즉시 적용 가능한 인증 시스템을 구현할 수 있도록 돕습니다.

1. 기본 개념: 세션과 쿠키

세션 (Session)

  • 저장 위치: 서버 메모리
  • 내용: 사용자의 전역 변수 및 상태 정보
  • 보안: 서버에만 존재하여 상대적으로 안전
  • 용량: 서버 메모리가 허용하는 만큼
  • 예시: 로그인 정보, 장바구니, 사용자 권한 등

쿠키 (Cookie)

  • 저장 위치: 브라우저 (클라이언트 측)
  • 내용: 세션을 찾기 위한 키값 또는 덜 민감한 데이터
  • 보안: 사용자가 볼 수 있고 조작 가능
  • 용량: 약 4KB로 제한적
  • 예시: 언어 설정, 테마 선택, "로그인 유지" 체크박스 등

JSESSIONID

역할: 서버의 세션 데이터와 브라우저를 연결하는 유니크한 키값

동작 방식:

  1. 사용자 최초 접속 → 서버가 세션 생성 및 JSESSIONID 발급
  2. JSESSIONID를 쿠키로 브라우저에 전달
  3. 이후 모든 요청마다 브라우저가 자동으로 JSESSIONID를 전송
  4. 서버는 JSESSIONID로 해당 세션 데이터를 찾아서 사용

세션/쿠키 동작 흐름

전통적인 세션 방식의 한계

  • Stateful: 서버가 세션 정보를 기억해야 함
  • 확장성 문제: 서버가 여러 대일 경우 세션 공유 필요
  • 마이크로서비스 부적합: 각 서비스가 세션을 공유하기 어려움

2. 현대적 접근: JWT 토큰

JWT (JSON Web Token)

  • 개념: 사용자 정보를 담은 자체 포함형 토큰
  • 저장 위치: 브라우저 (로컬스토리지, 쿠키, 또는 메모리)
  • 전송 방식: HTTP Authorization 헤더 (Authorization: Bearer <token>)
  • 특징: Stateless (서버가 세션을 기억하지 않음)

JWT 구조

xxxxx.yyyyy.zzzzz
  • Header: 토큰 타입, 암호화 알고리즘
  • Payload: 실제 데이터 (클레임)
  • Signature: 위변조 방지 서명

JWT 토큰 동작 흐름

세션 vs JWT 비교

세션 방식 (은행 번호표)

  • 서버가 사용자 정보를 메모리에 저장
  • 브라우저는 단순 키값(JSESSIONID)만 보유
  • 매 요청마다 서버가 세션 조회 필요
  • 서버 메모리 사용, 확장성 제한

JWT 방식 (신분증)

  • 모든 필요한 정보를 토큰 자체에 포함
  • 서버는 토큰 검증만 수행 (메모리 조회 불필요)
  • Stateless 아키텍처
  • 마이크로서비스 환경에 적합

3. 인증 프로토콜: OAuth 2.0과 OIDC

OAuth 2.0 (인가 프로토콜)

  • 목적: 권한 부여 (Authorization)
  • 질문: "이 앱이 내 데이터에 접근해도 되나요?"
  • 발급: Access Token (권한 증명서)
  • 예시: "페이스북 앱이 내 사진에 접근 허용"

OIDC (OpenID Connect - 인증 프로토콜)

  • 목적: 사용자 인증 (Authentication)
  • 질문: "당신이 누구인가요?"
  • 발급: ID Token (사용자 정보) + Access Token (권한)
  • 관계: OAuth 2.0 위에 인증 레이어를 추가한 확장 프로토콜

역사적 발전

OAuth 2.0 (2012년 - 권한 부여)
    ↓
    + 인증 레이어 추가
    ↓
OIDC (2014년 - 인증 + 권한 부여)

OAuth 2.0 vs OIDC 비교

OAuth 2.0 (권한 부여 프로토콜)

  • 질문: "이 앱이 내 구글 드라이브에 접근해도 되나요?"
  • 발급: Access Token
  • 내용: 권한 정보 (scope)
  • 결과: 앱이 데이터에 접근 가능 (하지만 사용자가 누구인지는 모름)

↓ 인증 레이어 추가

OIDC (OAuth 2.0 + 인증 기능)

  • 질문: "구글 계정으로 로그인하세요"
  • 발급: ID Token + Access Token
  • 내용: 사용자 정보 + 권한 정보
  • 결과: 사용자가 누구인지 알고, 권한도 있음

JWT 토큰 구성

OIDC 표준 클레임 (인증 관련)

  • iss: 발급자 (Issuer)
  • sub: 사용자 고유 ID (Subject)
  • aud: 클라이언트 ID (Audience)
  • iat: 발급 시간 (Issued At)
  • exp: 만료 시간 (Expiration)
  • auth_time: 인증 시간
  • nonce: 재생 공격 방지

OAuth 2.0 클레임 (인가 관련)

  • scope: 권한 범위 (예: "read:account write:transfer")
  • aud: 대상 API
  • exp: 만료 시간

사용자 정의 클레임 (선택)

  • email: 이메일
  • name: 이름
  • department: 부서
  • team: 팀
  • position: 직급
  • role: 권한

실제 JWT 토큰 예시

{
  "iss": "https://auth-server.com",
  "sub": "user-123",
  "aud": "api-gateway",
  "iat": 1708416000,
  "exp": 1708419600,
  "auth_time": 1708416000,
  "nonce": "random-nonce-value",
  "email": "euideok@example.com",
  "name": "의덕",
  "department": "Engineering",
  "team": "Platform",
  "position": "Senior Engineer",
  "role": "admin",
  "scope": "read:account write:transfer read:loan"
}

4. SSO (Single Sign-On)

SSO 개념

  • 목적: 한 번 로그인으로 여러 서비스 접근
  • 범위: 여러 애플리케이션/서비스
  • 구현: OIDC, SAML 등의 프로토콜 사용

SSO와 OIDC/JWT의 관계

  • OIDC: SSO를 구현하는 기술적 수단
  • JWT: SSO에서 사용하는 토큰 형식
  • SSO: 비즈니스 요구사항 (한 번 로그인)

SSO 동작 방식

5. 페더레이션 (Federation)

페더레이션 개념

  • 정의: 서로 다른 인증 서비스 간 상호 운용
  • 가능 이유: OIDC 표준 프로토콜 사용
  • 장점: 기존 계정으로 다양한 서비스 접근 가능

페더레이션 아키텍처

페더레이션의 장점

1. 사용자 편의성

  • 기존 계정으로 로그인 (Google, 카카오 등)
  • 새로운 계정 생성 불필요

2. 보안 강화

  • 비밀번호 관리 부담 감소
  • 신뢰할 수 있는 IdP의 보안 기능 활용

3. 개발 효율성

  • 인증 로직 직접 구현 불필요
  • 표준 프로토콜로 쉽게 연동

4. 확장성

  • 새로운 IdP 추가 용이
  • 멀티 클라우드 환경 지원

SSO 솔루션들

모두 OIDC 표준을 지원하여 상호 페더레이션 가능:

  • AWS Cognito
  • Okta
  • Azure AD (Microsoft Entra ID)
  • Keycloak
  • Auth0
  • Google Identity Platform

OIDC 표준 덕분에 가능한 것들

서로 다른 시스템 간 신뢰

  • Okta에서 발급한 토큰을 AWS Cognito가 검증
  • AWS Cognito에서 발급한 토큰을 Azure AD가 검증
  • 모두 OIDC 표준을 따르기 때문에 가능

토큰 교환 (Token Exchange)

Google JWT → AWS Cognito → 새로운 JWT 발급
→ 내부 마이크로서비스에서 사용

6. Java 구현 예제

의존성 (Maven)

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

인증 컨트롤러

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
​
@RestController
@RequestMapping("/auth")
public class OIDCAuthController {
    
    // JWT 서명을 위한 비밀키 (실제로는 환경변수나 AWS Secrets Manager에서 관리)
    private static final String SECRET_KEY = "your-secret-key-here";
    private static final long EXPIRATION_TIME = 3600000; // 1시간
    
    /**
     * 사용자 로그인 및 JWT 토큰 발급
     */
    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        
        // 1. 사용자 검증 (DB 조회)
        User user = validateUser(request.getUsername(), request.getPassword());
        
        if (user == null) {
            throw new UnauthorizedException("Invalid credentials");
        }
        
        // 2. OIDC 표준에 따른 JWT 토큰 생성
        String idToken = generateIdToken(user);
        String accessToken = generateAccessToken(user);
        String refreshToken = generateRefreshToken(user);
        
        // 3. OIDC 표준 응답 형식으로 반환
        return TokenResponse.builder()
                .idToken(idToken)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .tokenType("Bearer")
                .expiresIn(EXPIRATION_TIME / 1000)
                .build();
    }
/**
 * 사용자 검증 (실제로는 DB 조회 및 비밀번호 해시 비교)
 */
private User validateUser(String username, String password) {
    // 실제 구현: DB에서 사용자 조회 및 비밀번호 검증
    // 예시를 위한 간단한 검증
    if ("euideok".equals(username) && "[PASSWORD]".equals(password)) {
        return User.builder()
                .userId("user-123")
                .username("euideok")
                .email("euideok@example.com")
                .department("Engineering")
                .team("Platform")
                .position("Senior Engineer")
                .role("admin")
                .build();
    }
    return null;
}
​
/**
 * OIDC ID Token 생성 (사용자 인증 정보)
 */
private String generateIdToken(User user) {
    Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
    
    return JWT.create()
            // OIDC 표준 클레임
            .withIssuer("https://your-auth-server.com")     // iss: 발급자
            .withSubject(user.getUserId())                  // sub: 사용자 고유 ID
            .withAudience("your-client-id")                 // aud: 클라이언트 ID
            .withIssuedAt(new Date())                       // iat: 발급 시간
            .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // exp: 만료 시간
            
            // 추가 사용자 정보 클레임
            .withClaim("email", user.getEmail())
            .withClaim("name", user.getUsername())
            .withClaim("department", user.getDepartment())
            .withClaim("team", user.getTeam())
            .withClaim("position", user.getPosition())
            .withClaim("role", user.getRole())
            
            // OIDC 필수 클레임
            .withClaim("auth_time", new Date())             // 인증 시간
            .withClaim("nonce", "random-nonce-value")       // 재생 공격 방지
            
            .sign(algorithm);
}
​
/**
 * Access Token 생성 (API 접근 권한)
 */
private String generateAccessToken(User user) {
    Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
    
    return JWT.create()
            .withIssuer("https://your-auth-server.com")
            .withSubject(user.getUserId())
            .withAudience("your-api-gateway")
            .withIssuedAt(new Date())
            .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
            
            // OAuth 2.0 권한 정보
            .withClaim("scope", "openid profile email read:data write:data")
            .withClaim("role", user.getRole())
            
            .sign(algorithm);
}
​
    
    /**
     * Refresh Token 생성 (토큰 갱신용)
     */
    private String generateRefreshToken(User user) {
        Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
        
        return JWT.create()
                .withIssuer("https://your-auth-server.com")
                .withSubject(user.getUserId())
                .withIssuedAt(new Date())
                .withExpiresAt(new Date(System.currentTimeMillis() + 604800000)) // 7일
                .sign(algorithm);
    }
    
    /**
     * JWT 토큰 검증 (API Gateway나 마이크로서비스에서 사용)
     */
    @GetMapping("/verify")
    public User verifyToken(@RequestHeader("Authorization") String authHeader) {
        
        // Bearer 토큰 추출
        String token = authHeader.replace("Bearer ", "");
        
        try {
            Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
            
            // 토큰 검증 및 디코딩
            var decodedJWT = JWT.require(algorithm)
                    .withIssuer("https://your-auth-server.com")
                    .build()
                    .verify(token);
            
            // 토큰에서 사용자 정보 추출
            return User.builder()
                    .userId(decodedJWT.getSubject())
                    .email(decodedJWT.getClaim("email").asString())
                    .username(decodedJWT.getClaim("name").asString())
                    .department(decodedJWT.getClaim("department").asString())
                    .team(decodedJWT.getClaim("team").asString())
                    .position(decodedJWT.getClaim("position").asString())
                    .role(decodedJWT.getClaim("role").asString())
                    .build();
                    
        } catch (Exception e) {
            throw new UnauthorizedException("Invalid token: " + e.getMessage());
        }
    }
    
    /**
     * 토큰 갱신 (Refresh Token으로 새로운 Access Token 발급)
     */
    @PostMapping("/refresh")
    public TokenResponse refreshToken(@RequestBody RefreshRequest request) {
        try {
            // Refresh Token 검증
            Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
            var decodedJWT = JWT.require(algorithm)
                    .withIssuer("https://your-auth-server.com")
                    .build()
                    .verify(request.getRefreshToken());
            
            // 사용자 정보 조회
            String userId = decodedJWT.getSubject();
            User user = userService.findById(userId);
            
            // 새로운 Access Token 발급
            String newAccessToken = generateAccessToken(user);
            
            return TokenResponse.builder()
                    .accessToken(newAccessToken)
                    .tokenType("Bearer")
                    .expiresIn(EXPIRATION_TIME / 1000)
                    .build();
                    
        } catch (Exception e) {
            throw new UnauthorizedException("Invalid refresh token");
        }
    }
}

DTO 클래스들

import lombok.Builder;
import lombok.Data;
​
@Data
class LoginRequest {
    private String username;
    private String password;
}
​
@Data
class RefreshRequest {
    private String refreshToken;
}
​
@Data
@Builder
class TokenResponse {
    private String idToken;      // OIDC ID Token (사용자 정보)
    private String accessToken;  // Access Token (API 접근)
    private String refreshToken; // Refresh Token (토큰 갱신)
    private String tokenType;    // "Bearer"
    private long expiresIn;      // 만료 시간 (초)
}
​
@Data
@Builder
class User {
    private String userId;
    private String username;
    private String email;
    private String department;
    private String team;
    private String position;
    private String role;
}
​
class UnauthorizedException extends RuntimeException {
    public UnauthorizedException(String message) {
        super(message);
    }
}

API Gateway 필터 (토큰 검증)

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
​
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
​
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private static final String SECRET_KEY = "your-secret-key-here";
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) 
            throws ServletException, IOException {
        
        // 1. Authorization 헤더에서 토큰 추출
        String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Missing or invalid Authorization header");
            return;
        }
        
        String token = authHeader.substring(7);
    try {
        // 2. JWT 토큰 검증
        Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY);
        var decodedJWT = JWT.require(algorithm)
                .withIssuer("https://your-auth-server.com")
                .build()
                .verify(token);
        
        // 3. 사용자 정보를 request attribute에 저장
        request.setAttribute("userId", decodedJWT.getSubject());
        request.setAttribute("email", decodedJWT.getClaim("email").asString());
        request.setAttribute("role", decodedJWT.getClaim("role").asString());
        
        // 4. 다음 필터로 진행
        filterChain.doFilter(request, response);
        
    } catch (Exception e) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Invalid token: " + e.getMessage());
    }
}
​
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    // 로그인 및 토큰 갱신 엔드포인트는 필터 제외
    String path = request.getRequestURI();
    return path.equals("/auth/login") || path.equals("/auth/refresh");
}

}

​
### 마이크로서비스에서 토큰 사용
​
```java
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
​
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
    
    /**
     * 계좌 조회 API
     * JWT 토큰에서 추출한 사용자 정보 사용
     */
    @GetMapping("/{accountId}")
    public AccountResponse getAccount(@PathVariable String accountId,
                                      HttpServletRequest request) {
        
        // 필터에서 설정한 사용자 정보 가져오기
        String userId = (String) request.getAttribute("userId");
        String role = (String) request.getAttribute("role");
        
        // 권한 체크
        if (!"admin".equals(role) && !isAccountOwner(userId, accountId)) {
            throw new ForbiddenException("Access denied");
        }
        
        // 계좌 정보 조회 및 반환
        return accountService.getAccount(accountId);
    }
    
    private boolean isAccountOwner(String userId, String accountId) {
        // 실제 구현: DB에서 계좌 소유자 확인
        return accountService.isOwner(userId, accountId);
    }
}
​
class ForbiddenException extends RuntimeException {
    public ForbiddenException(String message) {
        super(message);
    }
}

7. 핵심 정리

개념 관계도

HTTP (전송 프로토콜)
    ↓
OAuth 2.0 (권한 부여 프로토콜)
    ↓
OIDC (OAuth 2.0 + 인증 레이어)
    ↓
JWT (토큰 형식)
    ↓
SSO (한 번 로그인으로 여러 서비스 접근)
    ↓
페더레이션 (서로 다른 IdP 간 상호 운용)

역할 분담

  • HTTP/HTTPS: 통신 수단 (도로)
  • OAuth 2.0: 권한 부여 프로토콜 (무엇을 할 수 있나?)
  • OIDC: 인증 프로토콜 (당신은 누구인가?)
  • JWT: 토큰 형식 (정보를 담는 신분증)
  • SSO: 인증 방식 (한 번 로그인)
  • 페더레이션: 서로 다른 시스템 간 신뢰

마이크로서비스 환경 권장 사항

  • 인증: OIDC 기반 SSO 솔루션 사용 (AWS Cognito, Okta 등)
  • 토큰: JWT 형식 사용
  • 전송: HTTP Authorization 헤더
  • 검증: API Gateway + 각 마이크로서비스 독립 검증
  • 페더레이션: 다양한 IdP 연동 지원
  • 보안: HTTPS 필수, 짧은 만료 시간 + Refresh Token 조합

8. 보안 모범 사례

JWT 보안

  • HTTPS 필수: 토큰 탈취 방지
  • 짧은 만료 시간: Access Token 15분, Refresh Token 2주
  • HttpOnly 쿠키: XSS 공격 방지
  • 민감 정보 제외: Payload는 Base64 인코딩이라 디코딩 가능
  • 서명 검증: 위변조 방지
  • 비밀키 관리: 환경변수나 AWS Secrets Manager 사용

토큰 관리

  • Access Token: 짧은 만료 시간, API 접근용
  • Refresh Token: 긴 만료 시간, 토큰 갱신용
  • ID Token: 사용자 정보, 인증 확인용

Zero Trust 접근

  • API Gateway: 1차 JWT 검증
  • 마이크로서비스: 2차 권한 검증 (선택적)
  • 서비스 간 통신: mTLS 또는 서비스 메시 사용

토큰 갱신 플로우

보안 고려사항

  • Refresh Token Rotation: 사용 후 새로운 Refresh Token 발급
  • 사용자 상태 확인: 계정 비활성화, 권한 변경 등 확인
  • 재사용 감지: Refresh Token 재사용 시 모든 토큰 무효화
  • 토큰 블랙리스트: 로그아웃 시 Redis 등에 토큰 저장하여 무효화

실무 체크리스트

  1. HTTPS 적용 (TLS 1.2 이상)
  2. 비밀키를 환경변수나 Secrets Manager에서 관리
  3. Access Token 만료 시간 15분 이하
  4. Refresh Token 만료 시간 2주 이하
  5. HttpOnly, Secure, SameSite 쿠키 속성 설정
  6. CORS 정책 적절히 설정
  7. Rate Limiting 적용 (무차별 대입 공격 방지)
  8. 로그인 실패 횟수 제한
  9. 토큰 갱신 시 사용자 상태 확인
  10. 민감한 정보는 JWT Payload에 포함하지 않음

마무리

이 문서는 웹 인증 아키텍처의 전체 스펙트럼을 다루었습니다. 전통적인 세션/쿠키 방식부터 현대적인 JWT 토큰 기반 인증, OAuth 2.0과 OIDC 프로토콜, SSO와 페더레이션까지 체계적으로 정리했습니다.

핵심 요점

  • 세션은 Stateful, JWT는 Stateless
  • OAuth 2.0은 권한 부여, OIDC는 인증
  • JWT는 토큰 형식, OIDC/OAuth는 프로토콜
  • SSO는 한 번 로그인, 페더레이션은 IdP 간 상호 운용
  • 마이크로서비스 환경에서는 JWT + OIDC 조합이 최적

실무 적용

제공된 Java 코드 예제를 참고하여 인증 시스템을 구축할 수 있습니다. AWS Cognito, Okta, Keycloak 등의 관리형 솔루션을 사용하면 더 빠르게 구현할 수 있습니다.

반응형

'Back To The Basic' 카테고리의 다른 글

IP Class, Subnet mask 그리고 CIDR 이란?(사이더 란?)  (2) 2021.11.27
Hub, Switch, Router 비교  (0) 2021.11.25
Unicast, Broadcast, Mulitcast, Anycast  (0) 2021.11.24
ARP Request  (0) 2021.11.23
TCP vs UDP  (0) 2021.11.08