1. 기술면접

[Spring Secuirty] JWT 인증 구조 분석 + MSA 적용 (CIEL 프로젝트)

lhk9311 2026. 5. 13. 05:36

[Spring Secuirty] JWT 인증 구조 분석 + MSA 적용 (CIEL 프로젝트)

목차

1. JWT란
2. 전체 흐름
3. MSA에서 JWT가 필요한 이유

 

1. JWT란

먼저 토큰이 무엇인가...
 
HTTP는 Stateless 프로토콜이라 요청-응답이 끝나면 서버는 클라이언트를 기억하지 못함.(로그인 후 다른 페이지로 이동하면 서버 입장에서 누군지 알 수 없음). 이를 해결하기 위해 로그인 성공시 서버가 클라이언트에게 토큰을 발급하고, 이후 요청마다 토큰을 HTTP헤더에 실어 서버로 보냄. 세션이나 JWT나 HTTP의 stateless 한계를 보완하기 위해 매 요청마다 신원을 증명하는 방식이라는 점은 같지만, 사용자 정보를 어디에 저장하느냐가 다름.

  • 세션 : 서버가 상태를 저장 -> stateful
  • JWT : 클라이언트(토큰)가 정보를 들고다님, 서버는 검증만 -> stateless

 ■ 둘다 하기와 같은 구조

(인증이 필요한) API 요청할 때마다 클라이언트 → (토큰 + 요청) → 서버

■ 세션 방식

① 로그인 성공
② 서버 메모리에 사용자 정보 저장
③ 그 메모리 주소(JSESSIONID)를 쿠키로 브라우저에 줌
④ 브라우저가 요청할 때마다 쿠키에 JSESSIONID 자동으로 담아서 서버로 보냄
⑤ 서버가 JSESSIONID로 메모리 조회 → "아 이 사람이구나"

 
참고 :
https://lhk9311.tistory.com/5

 

[JSP/Servlet] 로그인 인증 구조 정리 : Session, Remember-Me

[JSP/Servlet] 로그인 인증 구조 정리 : Session, Remember-Me목차1. Session 로그인 방식 선택 이유2. Remember-Me 방식 추가 리팩토링3. Session vs Remember-Me 비교4. Session 방식의 한계와 확장 방향5. 부록 : [JavaScript]

lhk9311.tistory.com

 
■ JWT 방식

① 로그인 성공
② 사용자 정보를 토큰 안에 담아서 클라이언트한테 줌 (서버엔 저장 안 함)
③ 브라우저 sessionStorage에 토큰 저장
④ 브라우저가 요청할 때마다 Authorization 헤더에 토큰 담아서 서버로 보냄
⑤ 서버가 토큰 서명 검증 → "아 이 사람이구나"

■ 세션 vs JWT 비교

  세션 JWT
인증 정보 저장 위치 서버 클라이언트
서버 부하 높음 (매 요청 DB 조회) 낮음 (서명 검증만)
수평 확장 어려움 (세션 공유 문제) 쉬움 (토큰만 검증하면 됨)
토큰 강제 만료 쉬움 어려움

 
※ 수평 확장 = 서버를 여러 대로 늘리는 것. 세션 방식은 서버마다 세션 저장소가 달라서 공유 문제가 생김. JWT는 토큰 자체에 정보가 있으니까 어느 서버에서든 검증 가능함.
 
+ 세션은 서버 메모리에 있으니 session.invalidate() 한 줄 이면 끝 (토큰 강제 만료)

+ JWT는 토큰이 클라이언트한테 있어서 서버에서 지울 게 없음. 예를 들어 Access Token 만료시간을 30분으로 설정하면 탈취됐다는 걸 알아도 서버에서 즉시 무효화할 방법이 없음. 30분이 지나야 자동 만료됨. ---> CIEL 프로젝트에서는 이 문제를 보완하기 위해 Refresh Token을 DB에 저장하고 revokedYn 컬럼으로 관리해 재발급을 차단하는 방식으로 설계함.

 

※ 실제 Token이 탈취되면 어떻게 대응하는가?

Access Token의 경우 서버에서 즉시 막을 방법이 없기 때문에, Access Token 만료시간을 아예 짧게 설정해두는 방식이 있음. (예 : 5분)

 

+ Refresh Token 관련해서 현재 CIEL 프로젝트에서는 revoked_yn = 'Y'으로 전환하는 것 까지만 구현되어있고 탈취를 서버가 감지하고 강제 로그아웃하는 기능은 Refresh Token Rotation 적용해야 가능함. ---> JwtAuthService_imple.java 의 refresh() 메서드에 해당 내용 있음. 즉, 현재 관리자가 직접 DB 들어가서 UPDATE tbl_refreshtoken SET revoked_yn = 'Y' WHERE principal_no = 탈취당한회원번호; 실행해야 하는 구조임.... 실무에서는 Rotation으로 자동화 필요함.


■ JWT 구조 (Header.Payload.Signature)

eyJhbGciOiJIUzI1NiJ9                                                           ← Header (Base64 인코딩)
.eyJzdWIiOiJ1c2VyMSIsInJvbGVzIjoiUk9MRV9VU0VSIn0  ← Payload (Base64 인코딩) .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c     ← Signature

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

{
  "alg": "HS256"
}

 
- Payload — 실제 담긴 정보 (CIEL 기준) ----> 이게 Access token 임

{
  "sub": "user1",             ← 로그인 아이디
  "principalType": "MEMBER",  ← 회원/관리자 구분
  "principalNo": 1,           ← 회원 번호
  "name": "이한경",            ← 이름
  "roles": "ROLE_USER",       ← 권한
  "iat": 1234567890,          ← 발급 시간
  "exp": 1234569690           ← 만료 시간
}

 
※ Payload는 Base64로 인코딩만 되어있고 암호화는 아님. 누구나 디코딩해서 볼 수 있음. 그래서 비밀번호 같은 민감한 정보는 넣으면 안 됨.

 
※ 실제로 jwt.io에서 확인해보면  ... F12 -> Application -> Session Storage에서 accessToken 복사 -> jwt.io에 붙여넣으면 하기와 같이 Payload가 디코딩도어 나옴. sub(로그인 아이디), principalType, principalNo, name, roles 등 토큰에 담긴 정보를 누구나 확인할 수 있음.
 
+ jwt.io 는 JWT 토큰을 디코딩해서 볼 수 있는 사이트 : https://jwt.io

 

JSON Web Tokens - jwt.io

JSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).

www.jwt.io

 

 
- Signature — 위변조 방지용 서명

HMACSHA256(
  base64(Header) + "." + base64(Payload),
  서버의 비밀키
)

 
서버만 비밀키를 알고 있어서 클라이언트가 Payload를 변조하면 Signature가 맞지 않아 검증 실패함.
 
실제 코드에서는 JwtTokenProvider.java 에서 확인할 수 있음.

// JwtTokenProvider.java

// 비밀키는 application.yml에서 환경변수로 관리 (노출 방지)
@Value("${jwt.secret}")
private String secretKey;

private SecretKey getSigningKey() {
    return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}

// 토큰 생성 시 비밀키로 서명
String accessToken = Jwts.builder()
        .subject(loginId)
        .claim("principalType", principalType)
        .claim("principalNo", principalNo)
        .claim("name", name)
        .claim("roles", authorities)
        .issuedAt(new Date(now))           // iat
        .expiration(accessTokenExpiresIn)  // exp
        .signWith(getSigningKey())         // ← 여기서 Signature 생성
        .compact();

// 토큰 검증 시 동일한 비밀키로 검증
public boolean validateToken(String token) {
    Jwts.parser()
        .verifyWith(getSigningKey())       // ← Signature 불일치 시 예외 발생
        .build()
        .parseSignedClaims(token);
}

 
※ 비밀키는 application.yml에서 환경변수로 관리함


■ JwtTokenProvider.java 구조 뜯어보기

JwtTokenProvider.java 가 JWT의 핵심 설정 파일임! 여기 access token + refresh token 관련 셋팅 다 되어있음

 

메서드 목록 정리

메서드 역할
generateToken() Access Token + Refresh Token 생성
getAuthentication() 토큰 -> Authentication 객체로 변환
validateToken() 서명 검증 + 만료 여부 확인
parseClaims() 토큰에서 Payload 꺼냄 (만료된 토큰도 읽음)
resolveToken() "Bearer eyJ..." -> "eyJ..." 순수 토큰 추출
getTokenInfo() loginId, principalType, principalNo 3개만 꺼

 

※ 재발급 흐름에서 "만료된 Access Token이어도 거기서 principalNo 꺼내서 DB 조회"해야 함. 그래서 일반 validateToken() 이랑 분리해 둠.

 

■ RefreshTokenDAO.java 구조 뜯어보기

메서드 역할
insertRefreshToken() 최초 로그인 시 저장
selectRefreshTokenByPrincipal() 회원번호로 조회
selectRefreshTokenByTokenValue() 토큰 문자열로 조회
updateRefreshToken() 재로그인 시 갱신
revokeRefreshToken() 강제 무효화(revoked_yn = "Y")
deleteRefreshToken() 로그아웃 시 삭제

 

+ Access Token / Refresh Token 나누는 이유
Acess Token 1개만으로 관리하는 경우 만료시간이 너무 짧으면, 로그인을 계속적으로 해야해서 불편하고 너무 길면 탈취될 가능성이 있어서 두 개로 나누어서 관리함.

  Access Token Refresh Token
역할 API 요청 시 인증(매 요청마다) Access Token 재발급용
만료 시간 짧게 (CIEL: 30분) 길게 (CIEL: 14일)
저장 위치 클라이언트 sessionStorage 클라이언트 + 서버 DB
사용 빈도 매 요청마다 Access Token 만료 시에만

 
Access Token이 만료되면 Refresh Token으로 새 Access Token을 재발급받는 구조임. Refresh Token은 서버 DB에도 저장해두기 때문에 서버에서 강제로 무효화할 수 있음.

 

흐름으로 보면 : 

Access Token 만료 (30분)
→ Refresh Token으로 재발급 요청
→ 새 Access Token 발급
→ 이때 Refresh Token도 같이 새로 발급 (만료시간 14일 리셋)

 

만약 Refresh token 갱신을 안하게끔 설계하면 :

10일째 사용 중
→ Access Token 만료돼서 재발급 요청
→ 새 Access Token은 나왔는데
→ Refresh Token은 4일 남음
→ 4일 후에 결국 로그인 다시 해야 함

 

결국 정리하면, refresh Token을 재발급 하는 이유는 로그인 만료시간을 계속 연장하기 위해서임.

 

■ refresh token 테이블은 하기와 같이 설계하였음. (dto + db)

컬럼 의미
principal_type MEMBER / ADMIN / GUEST 구분
principal_no 회원번호
token_value Refresh Token 문자열 원문
expires_at 14일 후 만료 시각
revoked_yn 강제 무효화 여부 (Y/N)
created_at 최초 로그인 시각
updated_at 마지막 재로그인/갱신 시각

 

사용자가 재로그인 시 새 행이 생기는게 아니라 updateRefreshToken으로 기존 행의 token_value, expires_at, updated_at만 수정됨. (RefreshTokenMapper.xml에서 1)최초 로그인시 토큰 저장  2)토큰 조회 3)토큰 갱신 4)강제 무효화 5) 로그아웃 시 삭제 등으로 설계함) 로그인 시 db 테이블이 하기와 같이 insert 됨.

 

흐름으로 보면

첫 로그인 → insertRefreshToken (created_at = 지금)
재로그인 → updateRefreshToken (updated_at = 지금, token_value 새것으로 교체)
로그아웃 → deleteRefreshToken (행 자체 삭제)
강제 무효화 → revokeRefreshToken (revoked_yn = 'Y' 로 업데이트)

 

  • 로그아웃 :  삭제
  • 탈취 등 의심 강제 차단 : revoked_yn = 'Y'로 남겨둠 

2. 전체 흐름

[로그인]
사용자 → POST /api/auth/member/login (아이디/비밀번호)
              ↓
        DaoAuthenticationProvider (인증)
              ↓
        JwtTokenProvider.generateToken()
              ↓
        Access Token (30분) + Refresh Token (14일) 발급
        Refresh Token → DB 저장
              ↓
        클라이언트 sessionStorage에 저장

[이후 API 요청]
사용자 → GET /reservation/... (Authorization: Bearer {accessToken})
              ↓
        JwtAuthenticationFilter
              ↓
        토큰 유효성 검증
              ↓
        SecurityContext에 인증 정보 저장
              ↓
        Controller 진입

[Access Token 만료 시]
사용자 → POST /api/auth/member/refresh (accessToken + refreshToken)
              ↓
        refreshToken 유효성 검증
        DB 저장값과 비교
        revokedYn 체크
              ↓
        새 Access Token + Refresh Token 재발급

[로그아웃]
사용자 → POST /api/auth/member/logout
              ↓
        DB에서 Refresh Token 삭제
        SecurityContext 초기화
        세션 무효화

 
+ 코드 하나씩 뜯어보면...
① 컨트롤러 (MemberAuthApiController.java) --> 회원 인증 관련 API 3개 담당( 로그인 / 토큰 재발급 / 로그아웃)
 
* 로그인 (loginMember)

@PostMapping("/api/auth/member/login")
public JwtToken loginMember(@RequestBody MemberLoginRequestDTO loginDto,
                            HttpServletRequest request,
                            HttpServletResponse response) {
    return jwtAuthService.loginMember(loginDto, request, response);
}

 

  • @RequestBody → JSON으로 아이디/비밀번호 받음
  • 반환값이 JwtToken → Access Token + Refresh Token을 JSON으로 클라이언트에 줌
  • 실제 인증 로직은 jwtAuthService.loginMember() 에서 처리

 
* 토큰 재발급 (refreshMember)

@PostMapping("/api/auth/member/refresh")
public JwtToken refreshMember(@RequestBody TokenRequestDTO tokenRequestDto)

 

  • Access Token 만료됐을 때 Refresh Token으로 새 토큰 발급
  • TokenRequestDTO 안에 Access Token + Refresh Token 둘 다 담아서 보냄

 
* 로그아웃 (logoutMember)

// 1. JWT principal 인 경우
if (principal instanceof JwtPrincipalDTO jwtPrincipal)

// 2. 일반 Spring Security CustomUserDetails 인 경우
else if (principal instanceof CustomUserDetails userDetails)

// 3. 소셜 로그인인 경우
else if (principal instanceof OAuth2MemberPrincipal oauth2Principal)

 

  • 일반 로그인 → JWT principal
  • 소셜 로그인(카카오/네이버) → OAuth2 principal
  • 로그아웃할 때 어떤 방식으로 로그인했든 Refresh Token을 DB에서 삭제해야 하니까 memberNo를 꺼내야 함

 
----> 로그아웃 할 때 서버에서 DB의 Refresh Token을 삭제해야함. 하지만 로그인 방식에 따라 principal 방식이 다름.

// 일반 로그인한 사람이 로그아웃하면
JwtPrincipalDTO principal
→ principal.getPrincipalNo() 로 memberNo 꺼낼 수 있음

// 카카오로 로그인한 사람이 로그아웃하면
OAuth2MemberPrincipal principal
→ principal.getMemberDto().getMemberNo() 로 꺼내야 함

 
즉, 어떤 방식으로 로그인을 했든, memberNo만 꺼낼 수 있으면 -> refreshTokenDAO.deleteRefreshToken("MEMBER", memberNo) -> refresh Token 삭제 완료


 
②  서비스 (JwtAuthService_imple.java) → 실제 인증 + 토큰 발급

@Override
public JwtToken loginMember(MemberLoginRequestDTO loginDto, ...) {

    // 1. 아이디/비밀번호 검증
    Authentication authentication = memberAuthProvider.authenticate(
        new UsernamePasswordAuthenticationToken(loginDto.getMemberid(), loginDto.getPasswd())
    );

    // 2. DB에서 회원 정보 조회
    MemberDTO member = memberDAO.findByMemberid(loginDto.getMemberid());

    // 3. 토큰 발급
    JwtToken jwtToken = jwtTokenProvider.generateToken(
        authentication, "MEMBER", Long.valueOf(member.getMemberNo()),
        member.getName(), null, null
    );

    // 4. Refresh Token DB 저장
    upsertRefreshToken("MEMBER", Long.valueOf(member.getMemberNo()),
                       member.getMemberid(), jwtToken.getRefreshToken());

    // 5. 세션에도 저장 (하이브리드 구조 - 뒤에서 설명)
    saveAuthenticationToSession(authentication, request);
    saveMemberSessionDto(member, request);

    return jwtToken;
}

 
※ 흐름 정리

① 아이디/비밀번호 검증 (DaoAuthenticationProvider)
② 회원 정보 DB 조회
③ Access Token + Refresh Token 생성
④ Refresh Token DB 저장 (upsert - 없으면 insert, 있으면 update)
⑤ 세션에도 저장
⑥ 토큰 반환

- upsert = insert + update 합성어. 기존 Refresh Token이 있으면 update, 없으면 insert함. 재로그인 시 토큰이 계속 쌓이지 않도록 하기 위함.


③ 토큰 생성  (JwtTokenProvider.java)

public JwtToken generateToken(Authentication authentication, ...) {

    String accessToken = Jwts.builder()
        .subject(loginId)                        // 로그인 아이디
        .claim("principalType", principalType)   // MEMBER / ADMIN 구분
        .claim("principalNo", principalNo)       // 회원 번호
        .claim("name", name)                     // 이름
        .claim("roles", authorities)             // 권한
        .issuedAt(new Date(now))                 // 발급 시간
        .expiration(accessTokenExpiresIn)        // 만료 시간 (30분)
        .signWith(getSigningKey())               // 비밀키로 서명
        .compact();

    String refreshToken = Jwts.builder()
        .subject(loginId)
        .claim("principalType", principalType)
        .claim("principalNo", principalNo)
        .expiration(refreshTokenExpiresIn)       // 만료 시간 (14일)
        .signWith(getSigningKey())
        .compact();

    return JwtToken.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
}

 
※ Refresh Token에는 최소한의 정보만 담음 (loginId, principalType, principalNo). Access Token처럼 name, roles 등은 넣지
않음. Refresh Token은 재발급용으로만 쓰이기 때문에 불필요한 정보는 담지 않는 게 맞음.


④ 클라이언트에서 토큰 저장 - loginform.html (Thymeleaf)

// loginform.html (Thymeleaf)

$.ajax({
    url: "/api/auth/member/login",
    type: "POST",
    contentType: "application/json",
    data: JSON.stringify({ memberid: id, passwd: pw }),
    success: function(json) {
        if (json.accessToken) {
            // sessionStorage에 토큰 저장
            sessionStorage.setItem("JWT", JSON.stringify(json));
            location.href = contextPath + "index";
        }
    }
});

 
- sessionStorage를 선택한 이유:

  • 탭을 닫으면 자동으로 삭제됨 → 공용 PC에서 로그아웃 안 해도 안전
  • localStorage보다 노출 범위가 좁음

※ sessionStorage도 JS로 접근 가능하기 때문에 XSS 공격에는 취약함. 완전한 보안을 위해서는 HttpOnly 쿠키를 쓰는 게 이상적이지만, 이 프로젝트에서는 sessionStorage로 구현함.
 
(~~~ 여기까지 로그인~~~)


⑤ 로그인 후 API 요청 흐름 (JwtAuthenticationFilter)
로그인 완료 후 사용자가 예약 페이지 같은 인증이 필요한 페이지에 접근하면, sessionStorage에서 토큰을 꺼내 Authorization 헤더에 담아 서버로 요청함.

// 클라이언트에서 API 요청 시
const jwt = JSON.parse(sessionStorage.getItem("JWT"));

$.ajax({
    url: "/api/reservation/...",
    headers: { "Authorization": "Bearer " + jwt.accessToken },
    ...
});

 
서버에서는 모든 요청이 들어올 때마다 JwtAuthenticationFilter가 먼저 실행됨.

// JwtAuthenticationFilter.java

@Override
protected void doFilterInternal(HttpServletRequest request, ...) {

    // 1. Authorization 헤더에서 토큰 꺼냄
    // "Bearer eyJhbGci..." → "eyJhbGci..."
    String bearerToken = request.getHeader("Authorization");
    String accessToken = jwtTokenProvider.resolveToken(bearerToken);

    // 2. 토큰 유효성 검증 (서명 확인 + 만료 여부)
    if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {

        // 3. 토큰에서 사용자 정보 꺼내서 Authentication 객체 생성
        Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);

        // 4. SecurityContext에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    // 5. 다음 필터로 넘김
    filterChain.doFilter(request, response);
}

 
흐름을 정리하자면 ...

Authorization: Bearer {token} 헤더로 요청 들어옴
    ↓
"Bearer " 제거 → 순수 토큰 문자열 추출
    ↓
서명 검증 + 만료 여부 확인
    ↓
토큰에서 사용자 정보 꺼내서 Authentication 객체 생성
    ↓
SecurityContextHolder에 저장
    ↓
Controller에서 @AuthenticationPrincipal로 꺼내 쓸 수 있음

 
※ SecurityContext = Spring Security가 현재 로그인한 사용자 정보를 보관하는 저장소. 여기에 Authentication이 저장되어 있어야 인증된 사용자로 인식함. JwtAuthenticationFilter가 매 요청마다 토큰을 검증하고 SecurityContext를 채워주는 역할을 함.
 
※ 이 필터는 UsernamePasswordAuthenticationFilter 앞에 위치함. Spring Security 기본 필터가 "인증 안 됨" 처리하기 전에 JWT를 먼저 검증해서 SecurityContext를 채워줘야 하기 때문임. 순서가 바뀌면 토큰이 있어도 인증 안 된 사용자로 처리됨.

// SecurityConfig.java
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                 UsernamePasswordAuthenticationFilter.class)

 
즉, API 요청 → JwtAuthenticationFilter → 토큰 검증 → 통과 → Controller 
 
(~~~~ 30분 지나면 ~~~)→  토큰 만료


⑥ Access Token 만료 시 재발급 흐름 (MemberAuthApiController.java )
서버에서는 validateToken() 이 false를 반환하면 SecurityContext에 인증 정보를 저장하지 않고 그냥 다음 필터로 넘김. 결과적으로 인증이 필요한 API에서 401 응답이 내려옴.
재발급 API는 구현되어 있음.

@PostMapping("/api/auth/member/refresh")
public JwtToken refreshMember(@RequestBody TokenRequestDTO tokenRequestDto) {
    return jwtAuthService.refresh(tokenRequestDto);
}

 
재발급 서버 로직은 하기와 같음 (JwtAuthService_imple.java):

① Refresh Token 서명/만료 검증
② Access Token ↔ Refresh Token 사용자 정보 일치 확인
③ DB에 저장된 토큰 존재 여부 확인
④ revokedYn = 'N' 여부 확인 (무효화 여부) -> 'Y'면 탈취 의심으로 차단된 토큰이므로 재발급 거부
⑤ DB 저장값 ↔ 요청값 일치 확인
→ 검증 통과 시 새 Access Token + Refresh Token 발급

 
※ 이번 프로젝트에서는 클라이언트 측 자동 재발급은 구현하지 않았음. 개선 방향으로는 401 응답을 감지하면 자동으로 재발급 API를 호출하고 새 토큰으로 원래 요청을 재시도하는 로직 추가가 필요함.
 
⑦ 로그아웃 흐름 (JwtAuthService_imple.java)

@Override
public void logout(String principalType, Long principalNo, ...) {

    // 1. DB에서 Refresh Token 삭제
    refreshTokenDAO.deleteRefreshToken(principalType, principalNo);

    // 2. SecurityContext 초기화
    SecurityContextHolder.clearContext();

    // 3. 세션 무효화
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
}

 
- 흐름 정리

① DB에서 Refresh Token 삭제
   → 이후 재발급 요청이 와도 DB에 토큰이 없으니 차단됨
② SecurityContext 초기화
   → 현재 요청에서 인증 정보 제거
③ 세션 무효화
   → 하이브리드 구조라 세션도 같이 정리

 
※ Access Token은 클라이언트 sessionStorage에 있어서 서버에서 직접 삭제할 수 없음. 하지만 탭을 닫으면 sessionStorage가 자동으로 삭제되고, 만료 시간(30분)이 지나면 자동 만료됨. 로그아웃 시 Refresh Token을 DB에서 삭제하는 것만으로도 재발급을 막을 수 있기 때문에 보안상 큰 문제는 없음.
 
⑧ 하이브리드 구조 (JWT + Session)
처음 설계 시 세션 방식으로 팀 협의를 마치고 각자 기능 개발을 진행함. 개발이 어느 정도 진행된 시점에 JWT 방식으로 전환하자는 팀 협의가 이루어졌음. 문제는 이미 Thymeleaf의 sec:authorize 태그를 세션 기반으로 사용하는 코드가 전체에 퍼져있는 상태였음.

<!-- 이미 전체 화면에 퍼져있는 코드 -->
<div sec:authorize="hasRole('ROLE_USER')">
    예약 내역 보기
</div>

 
이 태그는 세션의 SecurityContext를 참조하기 때문에, JWT만 쓰면 화면 렌더링 시 권한 체크가 동작하지 않음.
전체 코드를 다 바꾸기에는 일정상 무리가 있어서, JWT 발급 시 세션에도 인증 정보를 같이 저장하는 하이브리드 구조로 절충함.

REST API 요청 → JWT로 인증 (JwtAuthenticationFilter)
화면 렌더링   → 세션으로 인증 (sec:authorize)

 
※ 이상적으로는 JWT만 쓰는 Stateless 구조가 맞지만, 현실적인 일정과 기존 코드 구조를 고려한 절충안임. 개선 방향으로는 Thymeleaf sec:authorize를 JWT 기반으로 동작하도록 전환하는 것이 있음. 완전히 JWT로 가려면 React로 전환해야 함.
 
+ 현재 CIEL 은 Thymeleaf로 화면 구성함. Thymeleaf는 하기와 같이 동작함

지금 (Thymeleaf)
브라우저가 페이지 요청
    ↓
서버가 HTML 만들어서 응답
→ 이때 sec:authorize 태그 때문에 세션이 필요함

 
SPA(React, Vue) 는 반대임 하기와 같이 동작함

SPA (React)
브라우저가 처음에 JS 파일만 받음
    ↓
이후 데이터만 API로 받아서 브라우저에서 화면 그림
→ 서버는 API만 제공하면 됨
→ sec:authorize 같은 태그 없음
→ 세션 필요 없음
→ JWT만으로 완전한 Stateless 가능

 
즉,
Thymeleaf → 서버가 화면을 만듦 → 세션 필요
React     → 브라우저가 화면을 만듦 → 세션 불필요 → JWT만으로 충분


3. MSA에서 JWT가 필요한 이유

■ 세션 방식의 한계
CIEL 프로젝트는 처음에 세션 방식으로 개발을 시작했음. 그런데 서버를 여러 대로 나누면 하기와 같은 문제 발생함.

세션 방식으로 서버 2대 운영하면?

final_hotelmain (8001) 에서 로그인
→ 세션이 8001 서버 메모리에만 저장됨
→ final_notice (8002) 에 요청
→ 8002에는 세션이 없음 → 인증 실패!

 
즉, 세션은 로그인한 서버에서만 유효함. 서버를 여러 대로 나누는 순간 인증이 깨짐. 이를 해결하려면 Redis 같은 공용 세션 저장소를 따로 두는 방식이 있으나 추가 인프라가 필요하고 구조가 복잡해짐.
 
■ JWT로 해결
JWT는 토큰 자체에 인증 정보가 담겨있고, 서버는 비밀키로 서명만 검증하면 됨.

JWT 방식으로 서버 2대 운영하면?

final_hotelmain (8001) 에서 로그인 → JWT 발급
→ 비밀키: e37ea07e222779c4066309ce...

final_notice (8002) 에 요청
→ Authorization: Bearer {token} 헤더에 담아서 전송
→ 같은 비밀키: e37ea07e222779c4066309ce...
→ 서명 검증 통과 → 인증 성공!

 
핵심은 비밀키가 동일하면 어느서버에서든 검증 가능하다는 것.
 
- 실제 코드로 확인
CIEL 프로젝트(final_hotelmain, final_notice)는 실제로 MSA 구조로 나뉘어져 있음.

final_hotelmain (포트 8001) → 메인 서버 (로그인, 예약, 객실 등)
final_notice   (포트 8002) → 공지사항 서버
Eureka Server  (52.xx.xxx.xx:8761) → 서비스 등록/발견

 
+ final_hotelmain application.yml

server:
  port: 8001

jwt:
  secret: ${JWT_SECRET}  # 환경변수로 관리

eureka:
  instance:
    instance-id: ${spring.application.name}:${server.port}
  client:
    service-url:
      defaultZone: http://52.78.215.25:8761/eureka/

 
+ final_notice application.yml

server:
  port: 8002

jwt:
  secret: ${JWT_SECRET}  # 환경변수로 관리  ← 동일한 비밀키!

eureka:
  instance:
    instance-id: ${spring.application.name}:${server.port}
  client:
    service-url:
      defaultZone: http://52.78.215.25:8761/eureka/

 
두 서버의 jwt.secret 값이 완전히 동일함. 이것 때문에 final_hotelmain에서 발급한 토큰을 final_notice에서 검증할 수 있음.
 
+ final_notice의 JwtTokenProvider

// final_notice - JwtTokenProvider.java
// generateToken() 이 없음! 검증만 함.

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;  // ← final_hotelmain과 동일한 비밀키

    // 토큰 검증만 담당 (발급 X)
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())  // ← 같은 비밀키로 검증
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // 토큰에서 인증 정보 꺼내기
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);
        // ... principal 구성
    }
}

 
final_hotelmain의 JwtTokenProvider에는 generateToken() 이 있지만, final_notice의 JwtTokenProvider에는 generateToken() 이 없음.

final_hotelmain → generateToken() O / validateToken() O (발급 + 검증)
final_notice →       generateToken() X / validateToken() O (검증만)

 
+ final_notice의 SecurityConfig

// final_notice - SecurityConfig.java

.formLogin(form -> form.disable())  // ← 로그인 폼 완전 비활성화

.authorizeHttpRequests(auth -> auth
    // 공지사항 목록/상세 → 누구나 접근 가능
    .requestMatchers("/notice/list", "/notice/detail/**").permitAll()

    // 공지사항 작성/수정/삭제 → 관리자만
    .requestMatchers("/notice/write", "/notice/edit/**", "/notice/delete")
        .hasAnyRole("ADMIN_HQ", "ADMIN_BRANCH")

    .anyRequest().authenticated()
)

// JWT 필터만 등록 (로그인 필터 없음)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                 UsernamePasswordAuthenticationFilter.class)

 
formLogin().disable() 로 로그인 자체가 막혀있음. 공지사항 서버는 로그인 기능이 없고 JWT 검증만 함.
 


■ 전체 흐름 정리

① 사용자가 final_hotelmain (8001) 에서 로그인
② JWT 발급 → 클라이언트 sessionStorage 저장
③ 공지사항 작성 버튼 클릭
④ final_notice (8002) 로 요청
   Authorization: Bearer {token} 헤더에 담아서 전송
⑤ JwtAuthenticationFilter가 토큰 검증
   → 같은 비밀키로 서명 검증 → 통과
⑥ ADMIN_HQ 또는 ADMIN_BRANCH 권한 확인
⑦ 공지사항 작성 완료

 
세션 방식이었다면 ⑤번에서 "세션 없음 → 인증 실패" 로 끝났을 것임. JWT 덕분에 서버가 분리되어 있어도 동일한 비밀키만 공유하면 인증이 가능함.
 

 
----- 마치며
이번 포스팅은 CIEL 프로젝트에서 JWT 인증 구조를 코드 레벨로 뜯어보면서 정리한 내용임.
 
처음에 세션 방식으로 개발을 시작했다가 JWT로 전환한 경험이 있어서, 둘의 차이를 실제로 체감할 수 있었음. 그리고 MSA 구조(final_hotelmain + final_notice)에서 JWT 비밀키를 공유하는 방식으로 서버 간 인증을 구현한 것을 직접 확인하면서 JWT가 왜 MSA에서 유리한지 이해할 수 있었음.
 
아쉬운 점은 Thymeleaf 기반 구조 때문에 완전한 Stateless를 구현하지 못하고 하이브리드로 절충한 부분임. 완전한 Stateless 구조를 위해서는 React 같은 SPA로 전환이 필요함.