[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로 전환이 필요함.