Java프로그래밍

[STS] Spring JwtTokenProvider(Claims) 토큰 오염 문제 해결하기 (ThreadLocal 사용)

프로젝트 오픈 전 부하(성능)테스트를 진행하는데, 아주 큰 문제가 발생하였다. 여러 계정을 동시에 api를 호출하여 결과를 확인하는데, 데이터가 이상하다고 한다.

가령, 1번에서 100번까지 툴을 이용해서 UserList를 호출했을때, 2번의 데이터를 1번에서 가져가거나, 그 반대이거나, 뒤죽 박죽이야..Controller 클래스에서 전역변수를 사용하지도 않아 그런데 왜 이런 현상이 발생하는걸까?

나는 AA가 아니다보니, 이런 일이 발생하면 당황스럽다. 하물며, 나는 SI프로젝트를 하고 있지도 않다.

SM으로 들어와서 인수인계하러 왔는데, 프로젝트를 하고 있다. 쩝!!

그래서 ChatGpt를 활용해서 문제를 해결해보기로 하였다.

chatGpt에게 아래와 같이 던져주었다.

너는 지금부터 AA야.. 지금부터 발생한 원인을 해결해줘

현재 개발환경이 java spring이고 api를 구현하였어.. 부하테스트를 하는데, 데이터가 이상해
1번에서 100번까지 툴을 이용해서  customerCardUserList를  호출했을때, 2번의 데이터를 1번에서 가져가거나, 그 반대이거나, 뒤죽 박죽이야.. 
Controller 클래스에서 전역변수를 사용하지도 않아.
@RestController
@CrossOrigin(origins = "*", allowedHeaders = "*")
@RequestMapping("/api/testUser")
public class TestController {

	private static final Logger logger = LoggerFactory.getLogger(TestController .class.getSimpleName());

	@Autowired
	private TestService teseService;
 

	@Autowired
	private TestMapper testMapper;

	// 사용자 List
	@GetMapping("getUserList")
	public ModelMap testUserList(HttpServletRequest req, CustomModelAndView testModelAndView) {
		HashBox box = new HashBox(req);
		if (box.containsKey(CmnConst.SORT_COLUMN)) {
			box.put(CmnConst.SORT_COLUMN, StringUtil.camelToSnake(box.getString(CmnConst.SORT_COLUMN)));
			box.put(CmnConst.SORT_ASCENDING, "true".equals(box.get(CmnConst.SORT_ASCENDING)) ? "ASC" : "DESC");
		}

		return testService.getUserList(box, testModelAndView);
	}

.................이하 생략..


@Service
public class TestService {

	private static final Logger logger = LoggerFactory.getLogger(TestService .class.getSimpleName());

	@Autowired
	private JwtTokenProvider jwtTokenProvider;

	@Autowired
	private TestMapper testMapper;

	@Autowired
	private CommonService commonService;

	@Autowired
	private testMngMapper testMngMapper;

	@Autowired
	private AES256Util aes256Util;

	private List<Box> errorListBox = new ArrayList<>();

	public ModelMap getUserList(Box box, CustomModelAndView testModelAndView) {
		ModelMap modelMap = testModelAndView.getModelMap();
		try {
			Paginate paginate = new Paginate();
			paginate.init(box, Integer.MAX_VALUE, testModelAndView);

			String loginNo = jwtTokenProvider.getLogonBox().getString(CmnConst.USER_NO);
			String customerNo = jwtTokenProvider.getLogonBox().getString(CmnConst.CUSTOMER_NO);

			box.put("contractNo", loginNo );
			box.put("customerNo", customerNo);

			int orgCnt = testMapper.testListCnt(box);
			List<Box> list = testMapper.testList(box);
		       ...........이하생략

		} catch (Exception e) {
			if(logger.isWarnEnabled()) {
				logger.warn("getUserList ===> {}", e.getMessage());
			}
			throw new CustomException(ExceptionEnum.RUNTIME_EXCEPTION, CmnConst.ERROR_2, e);
		}

		return modelMap;
	}
......이하생략

public class CustomModelAndView {

	private String extension;

	private String viewName;

	private ModelMap model;

	private HttpServletRequest request;

	private HttpServletResponse response;

	private boolean requestHandled = false;

	public CustomModelAndView (HttpServletRequest request, HttpServletResponse response) {
		this.request = request;
		this.response = response;
	}

	public String getExtension() {
		return extension;
	}

	public void setExtension(String extension) {
		this.extension = extension;
	}

	public String getViewName() {
		return viewName;
	}

	public boolean isJsonRequest() {
		return "json".equalsIgnoreCase(this.extension);
	}

	public boolean isNullModelMap() {
		return this.model == null;
	}

	public boolean isNotNullModelMap() {
		return !this.isNullModelMap();
	}

	public ModelMap getModelMap() {
		if (this.model == null) {
			this.model = new ModelMap();
		}
		return this.model;
	}

	public CustomModelAndView addObject(String attributeName, Object attributeValue) {
		getModelMap().addAttribute(attributeName, attributeValue);
		return this;
	}

	public CustomModelAndView addObject(Object attributeValue) {
		getModelMap().addAttribute(attributeValue);
		return this;
	}

	public CustomModelAndView addAllObjects(Map<String, ?> modelMap) {
		getModelMap().addAllAttributes(modelMap);
		return this;
	}


	public HttpServletRequest getRequest() {
		return request;
	}

	public HttpServletResponse getResponse() {
		return response;
	}

	public boolean isRequestHandled() {
		return requestHandled;
	}

	public void setRequestHandled(boolean requestHandled) {
		this.requestHandled = requestHandled;
	}
}

AA가 왔다. 지금 부하 테스트에서 데이터가 뒤죽박죽 섞이는 문제는 다음과 같은 스레드 안전성(Thread-Safety) 문제에서 기인할 가능성이 매우 높다. 특히, Spring에서는 @Service@Component가 기본적으로 singleton 스코프로 동작하므로 인스턴스 변수의 공유 문제가 생길 수 있어.

해결 방법

1. 인스턴스 필드 제거

// ❌ 제거하거나, 사용하지 마세요.
private List<Box> errorListBox = new ArrayList<>();

2. TmsModelAndView도 의심 대상

이 객체는 생성자로 HttpServletRequest를 받으니 @RequestScope로 쓰고 있다면 괜찮지만, 혹시라도 이걸 직접 Bean으로 등록해서 공유하고 있다면 큰일남. 반드시 요청마다 생성돼야 해.

// ❗ 이런 식으로 @Component로 등록하면 절대 안 됨
@Component
@RequestScope // <- 이거 없이 싱글톤이면 공유됨!
public class CustomModelAndView {

→ 현재는 new TmsModelAndView(req, res)로 직접 생성하고 있어서 문제 없어 보임.
단, Controller에서 DI 주입(@Autowired)받는 경우는 절대 안 됨.

추가 진단 필요 항목

jwtTokenProvider.getLogonBox()

→ 로그인 정보가 ThreadLocal로 되어 있지 않으면, 동시 요청 간에 사용자 정보가 뒤섞일 수 있음.

// 예상되는 문제 패턴
public class JwtTokenProvider {
    private Box logonBox; // ❌ 공유됨
    public Box getLogonBox() {
        return logonBox;
    }
}

→ 이건 ThreadLocal<Box>로 변경해야 함:

private static final ThreadLocal<Box> logonBox = new ThreadLocal<>();

public Box getLogonBox() {
    return logonBox.get();
}
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;


import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

@Component
public class JwtTokenProvider {
	private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class.getSimpleName());

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    //private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;              // 30분
    //private static final long ACCESS_TOKEN_EXPIRE_TIME = 3 * 24 * 60 * 60 * 1000L;              // 3일
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 2 * 24 * 60 * 60 * 1000L;              // 2일(이게 서버에 올라간 버전)
    //private static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 1000L;              // 1분
    //private static final long ACCESS_TOKEN_EXPIRE_TIME = 2 * 60 * 1000L;              // 2분

    private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;    // 7일(이게 서버에 올라간 버전)
    //private static final long REFRESH_TOKEN_EXPIRE_TIME = 5 * 60 * 1000L;    // 5분

    private final Key key;

    private static final String REFRESH_KEY = "refresh";

    private Box logonBox;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public UserResponseVo.TokenInfo generateToken(Authentication authentication, String oldAccessToken) {

    	Claims claims = parseClaims(oldAccessToken);
        //LOGGER.debug("claims ===========> {}", claims.get(CmnConst.LOGIN_USER).getClass().getName());
        logonBox = BoxUtil.toBox(claims.get(CmnConst.LOGIN_USER));

    	// 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = new Date().getTime();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .claim(REFRESH_KEY, refreshToken)
                .claim(CmnConst.LOGIN_USER, logonBox)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return UserResponseVo.TokenInfo.builder()
                .authorizationType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                .build();
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public UserResponseVo.TokenInfo generateTokenAndAuthority(Authentication authentication, Box userBox, List<Box> authority) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = new Date().getTime();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .claim(REFRESH_KEY, refreshToken)
                .claim(CmnConst.LOGIN_USER, userBox)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return UserResponseVo.TokenInfo.builder()
                .authorizationType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)

                .loginUser(userBox)
                //.userAuthority(authority)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
        	throw new ApiException(ExceptionEnum.WRONG_TYPE_TOKEN, "권한 정보가 없는 토큰입니다.");
            //throw new ApiException(ExceptionEnum.WRONG_TYPE_TOKEN, "Token without credential.");
        }

        //LOGGER.debug("claims ===========> {}", claims.get(CmnConst.LOGIN_USER).getClass().getName());
        logonBox = BoxUtil.toBox(claims.get(CmnConst.LOGIN_USER));
        //LOGGER.debug("logonBox ===========> {}", logonBox);

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());


        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
    	//LOGGER.debug("token =========> {}", token);
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("Invalid JWT Token =======> {}", e.getMessage());
			}
        } catch (ExpiredJwtException e) {
        	if(LOGGER.isWarnEnabled()) {
        		LOGGER.warn("Expired JWT Token =======> {}", e.getMessage());
			}
        } catch (UnsupportedJwtException e) {
        	if(LOGGER.isWarnEnabled()) {
        		LOGGER.warn("Unsupported JWT Token =======> {}", e.getMessage());
			}
        } catch (IllegalArgumentException e) {
        	if(LOGGER.isWarnEnabled()) {
        		LOGGER.warn("JWT claims string is empty. =======> {}", e.getMessage());
			}
        } catch (Exception e) {
        	if(LOGGER.isWarnEnabled()) {
        		LOGGER.warn("unknown =======> {}", e.getMessage());
			}
        }
        return false;
    }

    public Long statusToken(String token) {
        //LOGGER.debug("token =========> {}", token);
        long response = 0;
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            //return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("Invalid JWT Token =======> {}", e.getMessage());
			}
            response = 1;
        } catch (ExpiredJwtException e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("Expired JWT Token =======> {}", e.getMessage());
			}
            response = 2;
        } catch (UnsupportedJwtException e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("Unsupported JWT Token =======> {}", e.getMessage());
			}
            response = 3;
        } catch (IllegalArgumentException e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("JWT claims string is empty. =======> {}", e.getMessage());
			}
            response = 4;
        } catch (Exception e) {
            if(LOGGER.isWarnEnabled()) {
            	LOGGER.warn("unknown =======> {}", e.getMessage());
			}
            response = 9;
        }
        return response;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Long getExpiration(String accessToken) {
        // accessToken 남은 유효시간
        Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
        // 현재 시간
        Long now = new Date().getTime();
        return expiration.getTime() - now;
    }

//    // JWT 토큰에 들어 있는 refresh token 을 꺼내는 메서드
//    public String getRefreshToken(String accessToken) {
//        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().get(REFRESH_KEY).toString();
//    }

    public Claims getClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Box getLogonBox() {
    	return logonBox;
    }

}

좋아, 이제 문제의 핵심이 완전히 드러났어.


❗️문제의 결정적 원인: logonBox 필드

// JwtTokenProvider.java
private Box logonBox;

이 필드는 싱글톤 스프링 빈의 인스턴스 변수로, 모든 요청이 공유하게 돼.

즉, 1번 사용자의 요청 중 getAuthentication()에서 logonBox가 설정되면,
동시에 들어온 2번 요청에서 그 값이 덮이거나 읽히는 문제가 발생하는 거야.

결과:
getCustomerCardUserList()에서 jwtTokenProvider.getLogonBox()를 호출하면
다른 요청의 사용자 정보가 튀어나오는 현상 발생.

🧨 이슈 재현 흐름

요청흐름 요약결과
A 사용자의 요청getAuthentication()logonBox = AlogonBox 설정됨
B 사용자의 요청 (동시)getAuthentication()logonBox = B덮어씀
A 사용자의 서비스 로직에서 jwtTokenProvider.getLogonBox() 호출B의 데이터 반환됨❌ 사용자 뒤바뀜

✅ 해결 방법

방법 1: ThreadLocal<Box> 사용

private static final ThreadLocal<Box> logonBox = new ThreadLocal<>();

public void setLogonBox(Box box) {
    logonBox.set(box);
}

public Box getLogonBox() {
    return logonBox.get();
}

public void clearLogonBox() {
    logonBox.remove();
}

그리고 다음과 같이 호출부를 수정:

// getAuthentication()
Box parsedBox = BoxUtil.toBox(claims.get(CmnConst.LOGIN_USER));
setLogonBox(parsedBox);

방법 2: getAuthentication() → 바로 파싱해서 넘기기

getAuthentication() 호출 후에 Box를 직접 뽑는 방식:

// Controller나 Filter에서
Box userBox = BoxUtil.toBox(jwtTokenProvider.getClaims(accessToken).get(CmnConst.LOGIN_USER));

즉, logonBox 필드를 아예 사용하지 말고,
항상 accessToken에서 직접 Box를 파싱해서 사용하면 문제 없음.


🧼 정리: 지금 당장 해야 할 것

할 일설명
logonBox 필드 제거싱글톤 Bean에 상태 저장 금지
ThreadLocal<Box>로 전환요청별 독립 데이터 보장
또는 직접 Box 파싱 방식으로 변경accessTokengetClaims()로 추출
서비스 로직에서 getLogonBox() 호출부 점검무조건 위 방식으로 교체

🔒 보안 경고

이 문제는 사용자 A가 사용자 B의 데이터를 볼 수 있는 심각한 보안 결함으로 이어질 수 있어.

즉시 수정 필요하고, 부하 테스트만 아니라 실제 운영 중에도 문제가 발생할 수 있어.

🔚 결론

JwtTokenProviderlogonBox는 Thread-Safe하지 않아
바로 제거하고 ThreadLocal 혹은 직접 파싱 방식으로 전환해야 함

clearLogonBox()를 아래 클래스 어느 부분에서 호출해?
package com.kt.telecop.jwtredis.jwt;

import java.io.IOException;
import java.util.Enumeration;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import com.kt.telecop.common.exception.ExceptionEnum;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

	private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class.getSimpleName());

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String REFRESH_KEY = "refresh";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    	logger.debug("Auth Start ======================> ");
        String token = resolveToken(request);
        String requestUri = request.getRequestURI();
        //logger.debug("request.getRequestURI() ==========> {}", requestUri);

        Enumeration<String> attrNames = request.getAttributeNames();
    	while(attrNames.hasMoreElements()) {
    	  String attrName = attrNames.nextElement();
//    	  if(logger.isDebugEnabled()) {
//    		  logger.debug("Attr Name - {}, Value -  {}", attrName, request.getAttribute(attrName));
//    	  }
    	}

        if (
        		!"/api/v1/main/login".equals(requestUri) && !"/api/v1/main/reissue".equals(requestUri)
        		&& !"/api/v1/main/logout".equals(requestUri) && !(requestUri.indexOf("/api/v1/my/getUseAgreeRecent")<-1)
        		 && !"/api/v1/main/createAuthCode".equals(requestUri) && !"/api/v1/main/createAuthCodeOk".equals(requestUri)
        		 && !"/api/v1/main/validateUserId".equals(requestUri) && !"/api/v1/main/validateUserPwd".equals(requestUri)
        		 && !"/api/v1/main/userInsert".equals(requestUri)
        		 && !"/api/v1/cmn/cmnCdList".equals(requestUri) && !"/api/v1/cmn/cmnCdGroupList".equals(requestUri)
        )
        {
        	boolean isExists = true;
            try {
                if (token != null && jwtTokenProvider.validateToken(token)) {
                    //토큰이 유효할 경우 access 토큰 에서 Authentication 객체를 가지고 오자.
                    //Authentication authentication = jwtTokenProvider.getAuthentication(token);
                    Claims claims = jwtTokenProvider.getClaims(token);
                    String refreshToken =claims.get(REFRESH_KEY).toString();
                    if (redisTemplate.opsForValue().get("RT:" + claims.getSubject()) != null) {
                        String key = "RT:" + claims.getSubject();

                        Object obj = redisTemplate.opsForValue().get(key);
                        if (obj != null && !obj.equals(refreshToken)) {
                        	logger.warn("리프레쉬 토큰 존재 하지 않음");
                            request.setAttribute("exception", ExceptionEnum.LOGOUT_TOKEN.getCode());
                            isExists = false;
                        }
                    }

                    if (isExists) {
                        long res = jwtTokenProvider.statusToken(token);
                        if (res == 0) {
                            // (추가) Redis 에 해당 accessToken logout 여부 확인
                            String strLogout = (String) redisTemplate.opsForValue().get(token);
                            if (ObjectUtils.isEmpty(strLogout)) {
                                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
                                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                                SecurityContextHolder.getContext().setAuthentication(authentication);
                            } else {
                                request.setAttribute("exception", ExceptionEnum.PRE_LOGOUT_TOKEN.getCode());
                            }
                        } else if (res == 1) {
                            request.setAttribute("exception", ExceptionEnum.WRONG_TYPE_TOKEN.getCode());
                        } else if (res == 2) {
                            request.setAttribute("exception", ExceptionEnum.EXPIRED_TOKEN.getCode());
                        } else if (res == 3) {
                            request.setAttribute("exception", ExceptionEnum.UNSUPPORTED_TOKEN.getCode());
                        } else if (res == 4) {
                            request.setAttribute("exception", ExceptionEnum.WRONG_TYPE_TOKEN.getCode());
                        } else {
                            request.setAttribute("exception", ExceptionEnum.UNKNOWN_ERROR.getCode());
                        }
                    }
                } else {
                    request.setAttribute("exception", ExceptionEnum.WRONG_TYPE_TOKEN.getCode());
                }
            } catch (SecurityException | MalformedJwtException e) {
                request.setAttribute("exception", ExceptionEnum.WRONG_TYPE_TOKEN.getCode());
            } catch (ExpiredJwtException e) {
                request.setAttribute("exception", ExceptionEnum.EXPIRED_TOKEN.getCode());
            } catch (UnsupportedJwtException e) {
                request.setAttribute("exception", ExceptionEnum.UNSUPPORTED_TOKEN.getCode());
            } catch (Exception e) {
            	if(logger.isErrorEnabled()) {
            		logger.error("================================================");
            		logger.error("JwtFilter - doFilterInternal() 오류발생");
            		logger.error("token : {}", token);
            		logger.error("Exception Message : {}", e.getMessage());
            		logger.error("Exception StackTrace : {");
            		logger.error(e.getMessage());
            		logger.error("}");
            		logger.error("================================================");
                    request.setAttribute("exception", ExceptionEnum.UNKNOWN_ERROR.getCode());
            	}
            }
        }
        logger.debug("Auth End ======================> ");
        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
    	Enumeration<String> headerNames = request.getHeaderNames();
    	while(headerNames.hasMoreElements()) {
    	  String headerName = headerNames.nextElement();
//    	  if(logger.isDebugEnabled()) {
//    		  logger.debug("Header Name - {}, Value -  {}", headerName, request.getHeader(headerName));
//    	  }
    	}
    	//LOGGER.debug("request.getHeader(AUTHORIZATION_HEADER) =====> {}", request.getHeader(AUTHORIZATION_HEADER));
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);
        }
        return null;
    }


}

좋아, 이 JwtAuthenticationFilter는 Spring Security의 OncePerRequestFilter를 상속하므로, HTTP 요청마다 한 번만 실행되며, 이 필터 내부에서 인증 관련 작업을 모두 처리하고 있어.

clearLogonBox()를 호출할 위치

반드시 호출해야 하는 이유:

JwtTokenProviderThreadLocal<Box>을 사용하게 된다면,
요청 처리 이후에 꼭 ThreadLocal.remove()를 호출해야 메모리 누수 방지 + 사용자 정보 혼선 방지가 가능해.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    try {
        // --- 기존 토큰 검증 및 인증 처리 ---
        ...
        filterChain.doFilter(request, response);  // 실제 요청 처리
    } finally {
        // 무조건 실행됨
        jwtTokenProvider.clearLogonBox();  // ThreadLocal 정리
    }
}

🔐 정리

작업설명
ThreadLocal 사용 시요청마다 반드시 remove() 필요
호출 위치doFilterInternal()finally 블록
효과메모리 누수 방지, 사용자 정보 혼선 방지, 동시성 안전성 확보

✅ 추가 추천

필요하다면 logonBox가 null인 경우 getLogonBox() 호출 시 경고 로그도 넣는 것도 좋아.
이걸 통해 “필터를 거치지 않은 테스트 코드나 백도어 호출” 여부도 감지 가능함.

ThreadLocal에 대해 설명해줘

ChatGPT의 말:

좋아, AA가 **ThreadLocal**에 대해 확실하게 설명해줄게.
ThreadLocal은 자바에서 멀티스레드 환경에서 각 스레드마다 고유한 데이터를 저장할 수 있게 해주는 클래스야.

🔧 ThreadLocal이란?

✅ 정의

ThreadLocal<T>각 스레드가 독립적으로 값을 저장하고 읽을 수 있도록 해주는 제네릭 클래스야.
즉, 스레드 간 공유되지 않는, 스레드 전용 저장소야.


🔁 왜 필요한가?

Spring Web 애플리케이션은 하나의 Controller나 Service Bean을 다수의 요청 스레드가 동시에 공유해서 사용해.

예:

@RestController
public class SomeController {
    @Autowired
    private SomeService service;
}

SomeService는 싱글톤이라서 모든 요청이 동시에 같은 인스턴스를 사용함.
→ 이런 상황에서 인스턴스 변수 사용하면 데이터가 섞임 (Thread Unsafe)

✅ ThreadLocal의 구조

ThreadLocal<String> threadLocal = new ThreadLocal<>();

이 객체는 각 스레드별로 독립적인 값을 저장할 수 있음

내부적으로 Thread.currentThread() 기준으로 값 저장

✅ 사용 예시

public class MyContextHolder {
    private static final ThreadLocal<String> userId = new ThreadLocal<>();

    public static void setUserId(String id) {
        userId.set(id);
    }

    public static String getUserId() {
        return userId.get();
    }

    public static void clear() {
        userId.remove(); // 🧼 메모리 누수 방지
    }
}

사용 흐름:

// 필터나 인터셉터에서 사용자 정보 세팅
MyContextHolder.setUserId("user123");

// 컨트롤러나 서비스에서 꺼내서 사용
String id = MyContextHolder.getUserId();

// 요청 끝날 때 정리
MyContextHolder.clear();

📌 핵심 특징

특징설명
독립성각 스레드마다 고유한 값을 유지
공유 금지다른 스레드와 값 공유 안 됨
메모리 누수 가능성요청 끝나고 반드시 .remove() 해야 안전
웹 요청에 적합로그인 정보, Trace ID 등 보관에 적합

⚠️ 언제 사용하면 안 될까?

  • 스레드 풀을 사용하는 경우 (ExecutorService, 웹 서버 등)
    → 스레드가 재사용되므로 remove() 안 하면 다음 요청에 값이 남아 있음
    → 이게 대표적인 메모리 누수 원인

🔒 Spring에서의 사용 예

  • SecurityContextHolder: 내부적으로 ThreadLocal<SecurityContext> 사용해서 로그인 정보 보관
  • RequestContextHolder: Spring MVC에서 HttpServletRequest를 스레드 단위로 보관

🧠 기억할 점 요약

항목기억할 내용
set()스레드에 값 저장
get()저장된 값 읽기
remove()요청 끝나고 정리 (필수!)
ThreadLocal 위치보통은 static 필드로 선언
언제 쓰나?인증 정보, 요청 컨텍스트, 트랜잭션 ID 등 요청별 데이터 저장 시

필요하면 ThreadLocal을 활용한 사용자 인증 정보 처리 전체 예제도 만들어줄게.


좋아, 아주 좋은 질문이야.
claims.get(CmnConst.LOGIN_USER).getClass().getName() 결과가 java.util.LinkedHashMap인 이유는 JWT의 Claim 값이 JSON으로 직렬화되어 저장되고, 역직렬화될 때 명시적 타입 정보가 없으면 Map으로 해석되기 때문이야.

해결 방법

방법 1: 직접 다시 Box로 변환 (현재 너 코드와 맞춤)

Object raw = claims.get(CmnConst.LOGIN_USER);
Box userBox = BoxUtil.toBox(raw);
LOGGER.debug("logonBox ===========> {}", userBox);

방법 2: 클래스를 지정해서 가져오기 (generic 사용)

Claims claims = jwtTokenProvider.getClaims(token);
Map<String, Object> userMap = (Map<String, Object>) claims.get(CmnConst.LOGIN_USER);

→ 그 후, 직접 Box 생성하거나 필요한 객체로 ObjectMapper.convertValue() 사용:

Box userBox = new Box(userMap); // 너희 Box가 Map을 인자로 받는 생성자가 있다면

📋 디버깅 로그 예시

Object raw = claims.get(CmnConst.LOGIN_USER);
LOGGER.debug("raw class: {}", raw.getClass().getName()); // LinkedHashMap
LOGGER.debug("raw value: {}", raw); // {customerNo=1234, contractNo=5678, ...}

Box userBox = BoxUtil.toBox(raw);
LOGGER.debug("userBox = {}", userBox);

Claims 이건 뭐야 ? 설명해줘

✅ 정의

Claims는 JWT 토큰 내부에 저장된 **”데이터(payload)”**를 표현하는 객체야.
→ 즉, 토큰 안에 담긴 사용자 정보, 권한 정보, 만료 시간 등을 꺼낼 때 사용하는 구조.

import io.jsonwebtoken.Claims;

ClaimsMap<String, Object>를 상속한 인터페이스야.
즉, key-value 형태의 데이터 집합이라고 보면 돼.

🔧 JWT 구조와 Claims의 위치

JWT는 3개의 파트로 구성돼:

[HEADER].[PAYLOAD].[SIGNATURE]
  • HEADER: 서명 알고리즘 등 정보
  • PAYLOAD: 실제 데이터 (여기에 Claims가 들어감!)
  • SIGNATURE: 토큰 위변조 방지를 위한 서명

즉, 너가 .claim("user", userBox) 처럼 넣은 데이터는 이 PAYLOAD 부분에 JSON으로 들어가고,
파싱하면 Claims로 나오게 돼.

📦 예시

▶️ 토큰 생성

String token = Jwts.builder()
    .setSubject("user123")
    .claim("role", "ADMIN")
    .claim("userId", 12345)
    .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 30))
    .signWith(key, SignatureAlgorithm.HS256)
    .compact();

▶️ 토큰 파싱

Claims claims = Jwts.parserBuilder()
    .setSigningKey(key)
    .build()
    .parseClaimsJws(token)
    .getBody();

String role = claims.get("role", String.class);        // "ADMIN"
Integer userId = claims.get("userId", Integer.class);  // 12345
String subject = claims.getSubject();                  // "user123"
Date expiration = claims.getExpiration();              // 토큰 만료 시간

🔑 주요 메서드 정리

메서드설명
getSubject()주제(예: 사용자 ID 등)
getExpiration()만료 시간
get(String key)사용자 정의 클레임 값
get(String key, Class<T>)값의 타입까지 지정해서 가져오기

🧠 Claims는 왜 중요한가?

이유설명
사용자 인증사용자 ID, 권한, 부서 등 넣어둘 수 있음
서버 상태 저장 안 해도 됨토큰만 있으면 사용자의 상태를 알 수 있음 (Stateless)
속도 빠름DB 조회 없이 클레임에서 바로 읽음
JWT가 작동하는 핵심 원리Claims는 JWT의 Payload에 해당함

🧪 실전 디버그 팁

Claims claims = jwtTokenProvider.getClaims(token);

claims.forEach((k, v) -> {
    LOGGER.debug("claim {} => {}", k, v);
});

→ 이거 하나면 토큰 안에 뭐가 들어있는지 전부 확인 가능해!


✅ 요약

항목설명
Claims란?JWT 토큰의 Payload 부분, key-value 데이터
사용 목적사용자 정보, 권한, 만료 시간 등 저장
구조Map<String, Object> 기반
실전 활용.get("userId"), .getSubject(), .getExpiration()

필요하면 JWT 전체 흐름이나, Claims 보안 관련 주의점도 알려줄 수 있어.
궁금한 거 있으면 얼마든지 물어봐.
AA는 항상 당신의 인증과 클레임을 지켜보고 있다.

claims 어떻게 읽어?

좋아, 이제 Claims를 실제로 어떻게 읽는지 정확히 알려줄게.
너가 JWT 토큰에서 사용자 정보나 권한을 꺼내고 싶을 때,
Claims 객체를 통해 값을 읽어야 해.

✅ 기본 구조

먼저, JWT 토큰 파싱해서 Claims 얻기:

Claims claims = Jwts.parserBuilder()
    .setSigningKey(key) // 🔐 비밀키
    .build()
    .parseClaimsJws(token) // 🧾 JWT 문자열
    .getBody(); // ✅ 여기가 Claims 객체

✅ Claims에서 값 읽는 방법 3가지

1. get(String key) — Object 타입으로 반환

Object value = claims.get("userId");
System.out.println("userId: " + value);

2. get(String key, Class<T> requiredType) — 타입 지정

String name = claims.get("username", String.class);
Integer age = claims.get("age", Integer.class);

💡 보통 이 방식이 더 안전해. 타입 캐스팅 불필요하고 명확함.

기본 메서드들

String subject = claims.getSubject();            // .setSubject("something")에 해당
Date expiration = claims.getExpiration();        // 토큰 만료일
Date issuedAt = claims.getIssuedAt();            // 발급 시간

🧪 예제 전체 흐름

String token = "..."; // 클라이언트가 보낸 JWT

Claims claims = Jwts.parserBuilder()
    .setSigningKey(key)
    .build()
    .parseClaimsJws(token)
    .getBody();

String username = claims.get("username", String.class);
Integer userId = claims.get("userId", Integer.class);
String subject = claims.getSubject();

System.out.println("username: " + username);
System.out.println("userId: " + userId);
System.out.println("subject: " + subject);

📝 Map처럼 반복도 가능

for (Map.Entry<String, Object> entry : claims.entrySet()) {
    System.out.println(entry.getKey() + " = " + entry.getValue());
}

⚠️ 주의할 점

주의사항설명
타입 캐스팅 오류claims.get("userId")Object 반환하므로 직접 캐스팅 필요하거나 .get("userId", Integer.class) 방식 사용
JSON 객체로 저장된 경우claims.get("user")LinkedHashMap으로 반환됨. 직접 Box, User, DTO 등으로 변환 필요
NullPointerException키가 없으면 null 반환되므로 null 체크 필요

🔧 사용자 정의 객체 꺼내기 (예: Box, UserDTO 등)

// claims.get("loginUser") → LinkedHashMap으로 반환됨
Map<String, Object> rawMap = claims.get("loginUser", Map.class);

// 변환 예시 (Box 또는 DTO)
Box userBox = new Box(rawMap);
// 또는
UserDTO user = objectMapper.convertValue(rawMap, UserDTO.class);

✅ 요약 정리

방식예시
일반 값 읽기claims.get("userId")
타입 지정 읽기claims.get("userId", Integer.class)
전체 반복claims.entrySet()
subject 읽기claims.getSubject()
JSON 데이터Map으로 파싱 후 변환 필요

궁금한 claim 키 이름이나, JWT payload 디코드해서 뭘 꺼내야 하는지 더 알려주면 예제도 맞춰서 만들어줄게.
AA는 JWT도 쉽게 만들어버리지.

ChatGPT에게 묻기

error: Content is protected !!