JWT 기반 SSO(Single Sign-On) 구현 회고

2025. 11. 17. 23:37·🗄️ Backend/Spring

회사에서 여러 솔루션을 운영하고 있었는데,

각 시스템마다 별도로 로그인해야 하는 불편함을 해소하기 위해서

그리고 개발팀 입장에서도 각 시스템마다 인증 로직을 중복 관리하는 게 비효율적이었다.

이에 여러 솔루션을 한번의 로그인으로 진행해야하는 작업이 내게 주어졌고 ,

JWT는 이전에 토이 프로젝트를 진행하면서 한번 아주 그냥 맛보기 처럼 해본 기억이 있긴 한데,,

정확히 어떤 방식인지 잘 모르기도 했었고 생소한 기술이었다.

아무튼 그래서 이런 저런 이유로 JWT 기반 SSO(Single Sign-On) 시스템을 구축하게 되었다.

처음에는 "JWT로 SSO? 그냥 토큰 발급하고 검증하면 되는 거 아냐?"라고 생각했는데

막상 구현하려니 생각보다 고려할 게 정말 많았다.

이 글에서는 내가 직접 겪으면서 배운 것들,

그리고 최종적으로 어떻게 안정적인 SSO 시스템을 만들 수 있었는지를 공유하려고 한다.


세션 vs JWT, 뭘 써야 하나?

기존에는 세션 기반 인증을 사용하고 있었다. 각 서버마다 독립적인 세션을 관리하고 있었는데,

이걸 SSO로 통합하려니 몇 가지 선택지가 있었다:

  1. 공유 세션 저장소 (Redis): 모든 서버가 하나의 Redis를 바라보게
  2. 중앙 인증 서버 (CAS 같은): 매번 인증 서버에 확인
  3. JWT: 토큰 자체에 정보를 담아서 분산 처리

결국 JWT + DB 저장소를 선택했다.

이유인 즉슨,

  • Redis 인프라를 새로 구축할 여력이 없었다. (고객사 개발 환경이나 솔루션 환경이 적합하지 않다..?)
  • 매번 인증 서버에 요청하는 건 부하가 걱정됨.
  • JWT는 각 서버에서 독립적으로 검증 가능하고,
  • DB에 메타데이터를 저장하면 즉시 폐기도 가능하니

JWT의 장점(분산 검증)과 세션의 장점(즉시 폐기)을 둘 다 가져가는 방식이라 생각이 되었다. 

"JWT를 Access Token 형태로 발급하되,
서버 측 DB에 JTI·Fingerprint·만료정보·폐기상태를 저장하여
토큰 도난 방지(IP/UA 바인딩), 즉시 폐기, 재사용 방지 등이
가능한 Stateful JWT 구조로 SSO를 구축했다."

사실 참 구현은 어찌저찌 했는데 , 누군가한테 내가 생각하는 방식을 설명하는 것이 참 어려웠다.


왜 Refresh Token은 사용하지 않았는가?

좀 찾아보니 Access Token과 Refresh Token? 이 있었고 , 보통 두개를 전부 활용하는것이 정배? 인데,

구글이나 네이버 같은 엔터프라이즈 OAuth 구조에서는
Access Token + Refresh Token 조합이 거의 정석이다.

나는 이것에 대해 오히려 불필요하지 않은가 라고 느꼈다.. 굳이 이렇게까지?

"애초에 세션이 30분 만료이기도 하고 , 세션은 세션대로 연장 시키고 ,

access token expired가 되면 그냥 로그인을 다시 시키면 되는게 아닌가?

굳이 refresh token까지 만들고 세션은 또 세션대로 연장시키고 , 테이블은 많아지고,

엔터프라이즈한 엄청난걸 만들지 않는데 그런 공수가 필요할까?"

이 문제로 동기와 많이 설왕설래도 있었고 참 이래저래 많은 일이 있었는데 ,

결국 우리 환경·우리 시스템에 맞는 현실적 선택을 하는 것이 더 중요하다고 생각했다.

여기에 쓰긴 너무 긴 얘기니 언젠가 말할 기회가 생기면 좋겠다.

그래서 최종적으로 다음과 같은 구조를 만들었다

┌──────────────────┐
│  인증 허브 서버   │  ← JWT 발급만 담당
│  (Auth Hub)      │     - 로그인 처리
└────────┬─────────┘     - 토큰 생성
         │                - DB 저장
         │ JWT Token
         ├────────────┬────────────┐
         ↓            ↓            ↓
    ┌────────┐  ┌────────┐  ┌────────┐
    │ 서비스A │  │ 서비스B │  │ 서비스C │  ← 검증만
    └────────┘  └────────┘  └────────┘     - 서명 확인
                                            - DB 조회
         ↓            ↓            ↓        - 세션 생성
    ┌──────────────────────────────┐
    │      공유 DB (MariaDB)        │
    │     SSO_JWT_TOKEN 테이블      │
    └──────────────────────────────┘

핵심 원칙은

  • 발급은 오직 인증 허브만: 다른 서비스는 절대 토큰을 만들지 않는다
  • 검증은 모든 서비스: 각자 독립적으로 검증하고 세션 생성
  • 상태는 DB에: 즉시 폐기가 필요하면 DB 플래그만 바꾸면 됨

 구현 과정

JWT 이해하기

JWT는 세 부분으로 나뉜다:

eyJhbGci...  .  eyJzdWIi...  .  SflKxwRJ...
  ↑ Header      ↑ Payload      ↑ Signature

Header 어떤 알고리즘으로 서명했는지

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

Payload 실제 데이터

{
  "jti": "550e8400-e29b-41d4-a716-446655440000",  // 토큰 ID
  "sub": "user123",                                // 사용자 ID
  "iat": 1699200000,                              // 발급 시간
  "exp": 1699203600,                              // 만료 시간
  "fingerprint": "a1b2c3d4e5f6..."               // 보안 지문
}

Signature 위변조 방지

HMACSHA256(
  base64(header) + "." + base64(payload),
  secret_key
)

이 서명 부분이 핵심이다. 서명이 있어야 누군가 payload를 마음대로 바꿀 수 없다.

토큰 발급 구현

인증 허브 서버에서만 토큰을 발급하도록 했다. 처음에는 간단하게 생각했는데, 막상 코드를 짜보니 신경 쓸 게 많았다.

public JwtTokenIssueResult issueToken(User user, HttpServletRequest request) {
    // 1. 고유 토큰 ID 생성 (UUID)
    String tokenId = UUID.randomUUID().toString();
    
    // 2. 시간 정보 설정
    LocalDateTime now = LocalDateTime.now();
    LocalDateTime expiresAt = now.plusMinutes(60);  // 60분 후 만료
    
    // 3. 핵심: Fingerprint 생성
    String clientIp = getClientIp(request);
    String userAgent = request.getHeader("User-Agent");
    String fingerprint = DigestUtils.sha256Hex(clientIp + "|" + userAgent);
    
    // 4. Payload 구성
    JwtTokenPayload payload = JwtTokenPayload.builder()
        .tokenId(tokenId)
        .userId(user.getUserId())
        .issuedAt(now)
        .expiresAt(expiresAt)
        .fingerprint(fingerprint)  // ← 이게 핵심!
        .build();
    
    // 5. JJWT 라이브러리로 서명된 토큰 생성
    String token = Jwts.builder()
        .setId(tokenId)
        .setSubject(user.getUserId())
        .setIssuedAt(toDate(now))
        .setExpiration(toDate(expiresAt))
        .claim("fingerprint", fingerprint)
        .signWith(secretKey, SignatureAlgorithm.HS256)
        .compact();
    
    // 6. DB에 메타데이터 저장
    tokenMapper.insertToken(TokenEntity.builder()
        .tokenId(tokenId)
        .userId(user.getUserId())
        .tokenHash(DigestUtils.sha256Hex(token))  // 토큰 전체 해시
        .issuedAt(now)
        .expiresAt(expiresAt)
        .clientIp(clientIp)
        .clientAgent(userAgent)
        .revokedYn("N")
        .build());
    
    return new JwtTokenIssueResult(token, expiresAt);
}
  1. Fingerprint가 생명이다: 단순히 토큰만 발급하면 누가 가져가도 쓸 수 있다. IP와 User-Agent를 조합해서 해시를 만들고, 이걸 토큰에 박아넣었다. 나중에 검증할 때 이게 다르면 탈취로 판단한다.
  2. UUID를 jti로 쓴 이유: JWT의 jti (JWT ID) 클레임에 UUID를 넣었다. 이게 DB와 연결되는 기본키 역할을 한다. 나중에 "이 토큰 무효화해!"라고 할 때 이 ID로 찾는다.
  3. 토큰 전체를 해시로 저장: 토큰 자체를 DB에 저장하면 유출 위험이 있어서, SHA-256 해시만 저장했다. 검증할 때 들어온 토큰을 해시해서 DB와 비교한다.

토큰 검증

이제 각 서비스에서 토큰을 검증해야 했다. 

public JwtTokenPayload validateToken(String token, HttpServletRequest request) {
    
    // 1단계: JWT 파싱 및 서명 검증
    Claims claims;
    try {
        claims = Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .setAllowedClockSkewSeconds(60)  // 시계 오차 허용
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new JwtValidationException("토큰이 만료되었습니다");
    } catch (SignatureException e) {
        throw new JwtValidationException("서명이 유효하지 않습니다");
    }
    
    String tokenId = claims.getId();
    
    // 2단계: DB에서 토큰 정보 조회
    TokenEntity entity = tokenMapper.selectByTokenId(tokenId);
    if (entity == null) {
        throw new JwtValidationException("등록되지 않은 토큰입니다");
    }
    
    // 3단계: 폐기 여부 확인
    if ("Y".equals(entity.getRevokedYn())) {
        throw new JwtValidationException("폐기된 토큰입니다");
    }
    
    // 4단계: 만료 확인 (이중 체크)
    if (entity.getExpiresAt().isBefore(LocalDateTime.now())) {
        throw new JwtValidationException("만료된 토큰입니다");
    }
    
    // 5단계: 핵심! Fingerprint 검증
    String currentIp = getClientIp(request);
    String currentAgent = request.getHeader("User-Agent");
    String currentFingerprint = DigestUtils.sha256Hex(currentIp + "|" + currentAgent);
    
    String tokenFingerprint = claims.get("fingerprint", String.class);
    
    if (!currentFingerprint.equals(tokenFingerprint)) {
        // 토큰 탈취 의심! 즉시 폐기
        revokeToken(tokenId, "Fingerprint 불일치 - 토큰 탈취 의심");
        log.warn("⚠️ 토큰 탈취 감지! TokenID: {}, 발급IP: {}, 사용IP: {}", 
                 tokenId, entity.getClientIp(), currentIp);
        throw new JwtValidationException("토큰 바인딩 검증 실패");
    }
    
    // 6단계: 사용 이력 업데이트
    tokenMapper.updateLastUsed(tokenId, currentIp, currentAgent, LocalDateTime.now());
    
    // 모든 검증 통과!
    return JwtTokenPayload.fromClaims(claims);
}

DB 테이블 설계

토큰 메타데이터를 저장할 테이블이 필요했다. 처음엔 간단하게 만들었다가, 운영하면서 컬럼이 계속 추가되었다.

CREATE TABLE SSO_JWT_TOKEN (
    -- 기본 정보
    TOKEN_ID            VARCHAR(64)   NOT NULL COMMENT 'JWT의 jti 클레임',
    USER_ID             VARCHAR(50)   NOT NULL COMMENT '사용자 ID',
    TOKEN_HASH          CHAR(64)      NOT NULL COMMENT '토큰 SHA-256 해시',
    
    -- 시간 관련
    ISSUED_AT           DATETIME      NOT NULL COMMENT '발급 시간',
    EXPIRES_AT          DATETIME      NOT NULL COMMENT '만료 시간',
    LAST_USED_AT        DATETIME               COMMENT '마지막 사용 시간',
    
    -- 보안 추적 (이게 중요!)
    CLIENT_IP           VARCHAR(64)           COMMENT '발급 당시 IP',
    CLIENT_AGENT        VARCHAR(255)          COMMENT '발급 당시 User-Agent',
    LAST_USED_IP        VARCHAR(64)           COMMENT '최근 사용 IP',
    LAST_USED_AGENT     VARCHAR(255)          COMMENT '최근 사용 Agent',
    
    -- 폐기 관리
    REVOKED_YN          CHAR(1)       NOT NULL DEFAULT 'N',
    REVOKED_AT          DATETIME               COMMENT '폐기 시간',
    REVOCATION_REASON   VARCHAR(200)           COMMENT '폐기 사유',
    
    CREATED_AT          DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    PRIMARY KEY (TOKEN_ID),
    UNIQUE KEY UK_TOKEN_HASH (TOKEN_HASH),
    KEY IDX_USER_ID (USER_ID),
    KEY IDX_EXPIRES_AT (EXPIRES_AT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

 

  • TOKEN_HASH에 UNIQUE 제약: 같은 토큰이 중복 저장되는 걸 막기 위해
  • CLIENT_IP와 LAST_USED_IP 분리: 발급 장소와 사용 장소를 추적해야 이상 징후를 발견할 수 있다
  • REVOCATION_REASON: 나중에 감사할 때 "왜 폐기됐지?" 알 수 있어야 한다

인터셉터로 자동 로그인

매번 컨트롤러에서 검증하는 건 비효율적이어서, Spring Interceptor로 만들었다.

@Component
public class JwtAuthInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) throws Exception {
        
        // 1. 세션이 이미 있으면 통과
        HttpSession session = request.getSession(false);
        if (session != null && session.getAttribute("user") != null) {
            return true;
        }
        
        // 2. JWT 토큰 찾기 (우선순위별로)
        String token = extractToken(request);
        
        if (token == null) {
            // 토큰 없음 → 로그인 페이지로
            redirectToLogin(request, response);
            return false;
        }
        
        try {
            // 3. 토큰 검증
            JwtTokenPayload payload = tokenValidator.validateToken(token, request);
            
            // 4. DB에서 사용자 정보 조회
            User user = userService.getUserById(payload.getUserId());
            
            // 5. 세션 자동 생성! (핵심)
            HttpSession newSession = request.getSession(true);
            newSession.setAttribute("user", user);
            newSession.setAttribute("loginTime", LocalDateTime.now());
            
            // 6. 쿠키 갱신 (보안 설정 포함)
            Cookie cookie = new Cookie("ssoJwtToken", token);
            cookie.setHttpOnly(true);   // JavaScript 접근 차단
            cookie.setSecure(true);     // HTTPS만 전송
            cookie.setPath("/");
            cookie.setMaxAge(3600);     // 1시간
            response.addCookie(cookie);
            
            return true;
            
        } catch (JwtValidationException e) {
            // 검증 실패 → 로그인 페이지로
            log.warn("JWT 검증 실패: {}", e.getMessage());
            redirectToLogin(request, response);
            return false;
        }
    }
    
    private String extractToken(HttpServletRequest request) {
        // 우선순위 1: Authorization 헤더
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        
        // 우선순위 2: 커스텀 헤더
        String header = request.getHeader("X-SSO-Token");
        if (header != null) {
            return header;
        }
        
        // 우선순위 3: URL 파라미터 (콜백용)
        String param = request.getParameter("jwtToken");
        if (param != null) {
            return param;
        }
        
        // 우선순위 4: 쿠키
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie c : cookies) {
                if ("ssoJwtToken".equals(c.getName())) {
                    return c.getValue();
                }
            }
        }
        
        return null;
    }
}
  • 세션 만료되어도 JWT만 살아있으면 자동으로 재로그인된다
  • 사용자는 토큰이 있는지 없는지 신경 쓸 필요가 없다
  • 각 컨트롤러는 세션만 확인하면 됨 → 기존 코드 변경 최소화

HttpOnly, Secure 쿠키

Cookie cookie = new Cookie("ssoJwtToken", token);
cookie.setHttpOnly(true);   // ← JavaScript로 접근 불가
cookie.setSecure(true);     // ← HTTPS에서만 전송
cookie.setSameSite("Lax");  // ← CSRF 방어

 

리다이렉트 화이트리스트

private static final Set ALLOWED_DOMAINS = Set.of(
    "https://service-a.example.com",
    "https://service-b.example.com"
);

public void validateReturnUrl(String returnUrl) {
    boolean isAllowed = ALLOWED_DOMAINS.stream()
        .anyMatch(returnUrl::startsWith);
    
    if (!isAllowed) {
        throw new SecurityException("허용되지 않은 리다이렉트: " + returnUrl);
    }
}

* 트러블슈팅 및 확장 기능

로그아웃했는데 여전히 접근됨

증상:로그아웃 → 세션 삭제이나, 뒤로가기 하면 또 접근됨

원인: 쿠키에 JWT가 남아있어서 인터셉터가 자동으로 세션 재생성

해결:

// 로그아웃 시 토큰 폐기 + 쿠키 삭제
revokeToken(tokenId, "사용자 로그아웃");

Cookie cookie = new Cookie("ssoJwtToken", "");
cookie.setMaxAge(0);  // 즉시 삭제
response.addCookie(cookie);

 

만료된 토큰 자동 정리

DB에 토큰이 계속 쌓이면 안 되니까, 배치 작업 추가:

@Scheduled(cron = "0 0 2 * * ?")  // 매일 새벽 2시
public void cleanupExpiredTokens() {
    LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
    int deleted = tokenMapper.deleteExpiredTokens(cutoff);
    log.info("만료 토큰 {}개 삭제 완료", deleted);
}

만료된 지 7일 지난 건 삭제. 감사 로그는 따로 아카이빙 해줘야 할듯

의심스러운 접근 모니터링

// Fingerprint 불일치 발생 시
if (!fingerprintMatches) {
    alertService.sendSecurityAlert(
        "토큰 탈취 의심",
        "TokenID: " + tokenId,
        "발급IP: " + entity.getClientIp(),
        "사용IP: " + currentIp
    );
}

Slack/Discord으로 실시간 알림 보내는 방식도 구현 가능해보임.

-- 가장 많이 사용된 시간대
SELECT HOUR(LAST_USED_AT) as hour, COUNT(*) as cnt
FROM SSO_JWT_TOKEN
WHERE DATE(LAST_USED_AT) = CURDATE()
GROUP BY hour;

-- 평균 토큰 수명
SELECT AVG(TIMESTAMPDIFF(MINUTE, ISSUED_AT, LAST_USED_AT)) as avg_minutes
FROM SSO_JWT_TOKEN
WHERE REVOKED_YN = 'N';

 

 


Stateless vs Stateful JWT

JWT의 가장 큰 장점은 "Stateless"라는 점인데, 우리는 DB에 상태를 저장하는 "Stateful" 방식을 선택했다. 이것이 JWT의 본질을 훼손하는 것 아닌가?

완전한 Stateless JWT는 즉시 폐기가 불가능하다. 보안 사고 발생 시 대응이 어렵다.

우리는 JWT의 분산 검증 장점과 세션의 제어 가능성을 모두 가져가기로 했다.

트레이드오프를 인정하고 현실적인 선택을 한 것이다.


토큰 만료 시간?

처음에는 30분으로 설정했다가, 리뷰 후 1시간이 적당하다고 생각했다.

고려 사항:

  • 너무 짧으면: 사용자 경험 저하 (자주 재로그인)
  • 너무 길면: 보안 위험 증가 (탈취 시 피해 기간)

->  Access Token = 60분 + 세션은 세션 따로, 자동 연장

  • 활동 중인 사용자는 세션이 계속 유지됨
  • 비활성 사용자는 자연스럽게 만료
  • 필요시 재로그인하면 새 토큰 발급 

보안은 레이어드 디펜스(Layered Defense)

단일 보안 메커니즘은 완벽하지 않다. 우리는 여러 겹의 방어선을 구축했다:

  1. 서명 검증 (위조 방지)
  2. 만료 시간 (시간 제한)
  3. Token Binding (탈취 방지)
  4. DB 상태 관리 (즉시 폐기)
  5. 쿠키 보안 옵션 (XSS/CSRF 방어)
  6. 화이트리스트 (Open Redirect 방지)

하나가 뚫려도 다른 방어선이 있다.


로깅과 모니터링은 선택이 아닌 필수

개발할 때는 기능 구현에만 집중하기 쉽지만, 운영 단계에서는 로깅이 생명이다.

  • 모든 발급/검증/폐기 이벤트 로깅
  • 실패 케이스 상세 기록
  • 이상 패턴 실시간 알림
  • 정기적인 사용 패턴 분석

이것들이 있어야 문제 발생 시 빠르게 대응할 수 있다.

Server Health check에 대해서도 그렇고 이런 단순 로깅 체크에 대해서도,,

좀 더 파악이 빠르게 되는 방법이 없을까? 늘 고민하고 고민하는데.. 

Jenkins같은 CI/CD 기술과 쉘 스크립트를 잘 활용해서 Trigger 처럼 작동시킨다면.. 음음음.. 생각에만 그치는 게 아쉽다.

 


향후 개선 계획

1. Redis 캐싱 도입

현재는 매 검증마다 DB를 조회하지만, Redis를 도입하면:

  • 토큰 메타데이터 캐싱
  • 폐기된 토큰 블랙리스트 캐싱
  • 성능 향상

단, Redis 장애 시 Fallback 전략도 함께 고려해야 한다.

일단 Redis를 배울 기회가 생기길.. ㅠㅠㅠ


2. Rate Limiting

동일 IP나 사용자로부터 과도한 검증 시도가 발생하면 제한하는 기능

if (failedAttempts > threshold) {
    blockTemporarily(identifier, duration);
    alertService.sendBruteForceAlert(...);
}

3. 토큰 갱신 정책

현재는 만료되면 재로그인하지만, UX 개선을 위해:

  • 만료 임박 시 자동 갱신 (Silent Refresh)
  • 또는 Refresh Token 도입 검토

단, 복잡도와 편의성 사이의 균형을 찾아야 한다.


4. 감사 로그 아카이빙

장기 보관이 필요한 감사 로그를 위해:

  • 삭제된 토큰 정보를 별도 아카이빙 테이블로 이동
  • 규정 준수를 위한 로그 보관 정책 수립

 

JWT 기반 SSO를 구축하면서 많은 것을 배웠다.

막상 프로덕션 레벨의 시스템을 만들려니 고려할 것이 정말 많았다..

  • 보안은 어디까지 해야 하는가?
  • 성능과 보안의 균형점은?
  • 표준을 따를 것인가, 우리 환경에 맞출 것인가?
  • 복잡도를 감수하고 Refresh Token을 도입할 것인가?

한도 끝도 없이 보안을 생각하면 너무나 많은 경우의 수가 있고,

그렇다고 모든걸 충족할 수 없다고 생각한다.  

가장 중요한 것은 왜 이렇게 만들었는가를 명확히 하는 것이다.

모든 설계 결정에는 이유가 있어야 하고, 그 이유를 설명할 수 있어야 한다.

'🗄️ Backend > Spring' 카테고리의 다른 글

[트러블슈팅] Spring Tx 이중 Ajax 업로드 트랜잭션 처리 로직 해결  (0) 2025.07.10
Spring과 Spring Boot의 차이점  (1) 2025.02.08
Spring Boot 아키텍처 : Entity, DTO, Repository, Service, Controller 흐름과 활용  (1) 2025.01.28
SpringBoot 비동기(Async) 처리  (6) 2025.01.17
Spring Boot 기초: 웹 애플리케이션 설정  (0) 2024.11.10
'🗄️ Backend/Spring' 카테고리의 다른 글
  • [트러블슈팅] Spring Tx 이중 Ajax 업로드 트랜잭션 처리 로직 해결
  • Spring과 Spring Boot의 차이점
  • Spring Boot 아키텍처 : Entity, DTO, Repository, Service, Controller 흐름과 활용
  • SpringBoot 비동기(Async) 처리
hjwjo
hjwjo
백엔드 및 풀스택 개발에 관심 있는 초보 개발자의 개발 블로그입니다.
  • hjwjo
    Jeongwoo's Devlog
    hjwjo
  • 전체
    오늘
    어제
    • Devlog N
      • 🗄️ Backend N
        • Java
        • Spring N
        • JPA
        • SQL
        • JSP
        • AWS
        • GCP
        • Linux
        • GitHub
        • ML
        • Security
      • 🖥️ Frontend
        • React
        • CSS
      • 🏅 Project
        • Hackathon
        • Team Project
      • 📊 Algorithm
        • BOJ
      • 📜 Certs
        • ADsP
        • SQLD
        • 정보처리기사
      • 📖
        • JavaScript
      • 일상
        • 면접후기
  • 블로그 메뉴

    • 홈
    • Devlog
    • 태그
    • 방명록
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    java기초
    백준
    정처기
    스프링
    jsp
    java
    백엔드
    http
    정보처리기사
    스프링부트
    쿼리
    AWS
    DML
    GCP
    ADsP
    springboot
    자바
    SQL
    데이터베이스
    Spring
  • 최근 댓글

  • 최근 글

hjwjo
JWT 기반 SSO(Single Sign-On) 구현 회고
상단으로

티스토리툴바