
JWT (JSON Web Token)는 웹에서 로그인을 하거나 인증이 필요한 경우에 사용되는 "디지털 신분증"이라고 생각하면 쉬워요.
예를 들어, 놀이공원에 갔을 때, 입장 티켓을 받잖아요? 그 티켓을 보여줘야 놀이기구를 탈 수 있어요. 이때 그 티켓이 바로 JWT이에요. 당신이 한 번 티켓을 받으면 그걸 가지고 놀이터 여러 곳에서 사용할 수 있는 거죠!
JWT는 클라이언트와 서버 간에 데이터를 안전하게 주고받기 위한 방식으로, 로그인을 하고 나면 서버가 클라이언트에게 "토큰"을 주고, 이 토큰을 나중에 API 요청 시 인증에 사용해요. 토큰은 로그인 후 일정 기간 동안 유효하고, 클라이언트는 요청마다 토큰을 헤더에 추가해 서버에 보냅니다. 이 방식은 세션을 서버에 저장하지 않아도 되기 때문에 분산 시스템에 유리하죠.
JWT 방식을 사용하는 기본적인 흐름을 살펴볼게요:
이 프로그램은 로그인을 할 때 컴퓨터가 "이 사람이 진짜 로그인한 사람인가?"를 확인하고, 그 사람에게 입장 티켓(JWT)을 줘서 이후에는 그 티켓만 보여주면 자동으로 놀이기구를 탈 수 있게 도와줘요.
위 코드는 Spring Security를 사용한 JWT 인증 설정입니다.
SecurityConfig:JwtTokenProvider:JwtAuthenticationFilter:Login.js:이게 바로 웹과 앱에서 흔히 사용하는 JWT 방식 인증입니다.
SecurityConfig.java (서버 설정 파일)@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "org.project.backend")
public class SecurityConfig {
@Configuration:@EnableWebSecurity:@ComponentScan:org.project.backend 패키지에서 필요한 빈들을 찾아 자동으로 등록합니다. private final MemberDetailsService memberDetailsService;
MemberDetailsService는 사용자의 정보를 로드하는 서비스입니다. 이를 통해 Spring Security가 사용자 정보를 불러와 인증에 활용합니다.사용자의 정보를 로드하는 서비스라는 것은,
사용자가 이전에 제공한 정보를 기억하고,
그 정보를 바탕으로 다음 대화에서 참고하거나 반응할 수 있는 기능을 의미합니다.
이를 통해 사용자가 한 번 제공한 정보를 반복해서 입력할 필요 없이, 대화의 연속성을 유지하고 더 맞춤화된 답변을 제공할 수 있습니다.
예를 들어, 사용자가 자신의 성격 유형, 직업 계획, 혹은 개인적인 관심사를 공유했다면, 그 정보를 기억하고 이후 대화에서 자연스럽게 그 내용을 반영할 수 있습니다. 이러한 서비스는 개인 맞춤형 경험을 제공하기 위해 사용됩니다.
public SecurityConfig(MemberDetailsService memberDetailsService) {
this.memberDetailsService = memberDetailsService;
}
SecurityConfig 클래스의 생성자로, MemberDetailsService를 받아서 클래스 내부에서 사용합니다. @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
SecurityFilterChain을 통해 HTTP 요청을 어떻게 처리할지 정의하는 메소드입니다. 여기에서 로그인, 로그아웃, 권한 검증 등의 보안 정책을 설정합니다. http
.csrf().disable()
CSRF(Cross-Site Request Forgery) 방어 기능을 비활성화합니다. JWT 방식에서는 CSRF 방어가 필요 없기 때문에 꺼두는 것이 일반적입니다. .authorizeRequests()
.antMatchers("/", "/register", "/login", "/api/members/**").permitAll()
.anyRequest().authenticated()
authorizeRequests():antMatchers("/", "/register", "/login", "/api/members/**").permitAll():anyRequest().authenticated(): .formLogin()
.loginPage("/login")
.loginProcessingUrl("/perform_login")
formLogin():loginPage("/login"):loginProcessingUrl("/perform_login"): .successHandler(new CustomAuthenticationSuccessHandler(jwtTokenProvider()))
.failureHandler(new CustomAuthenticationFailureHandler())
successHandler():CustomAuthenticationSuccessHandler가 사용됩니다.failureHandler(): .logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new CustomLogoutSuccessHandler())
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID");
logout():logoutUrl("/logout"):logoutSuccessHandler():invalidateHttpSession(true):deleteCookies("JSESSIONID"): return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
return new DelegatingPasswordEncoder(idForEncode, encoders);
}
passwordEncoder():BCryptPasswordEncoder:bcrypt 알고리즘을 사용해 비밀번호를 암호화합니다. 매우 안전한 암호화 방식으로, 비밀번호를 저장할 때 사용됩니다.DelegatingPasswordEncoder:bcrypt 방식을 사용하고 있지만, @Bean
public JwtTokenProvider jwtTokenProvider() {
return new JwtTokenProvider();
}
JwtTokenProvider는 JWT 토큰을 생성하고 검증하는 기능을 제공하는 클래스입니다. 이 메소드에서 그 객체를 빈으로 등록하여 다른 곳에서 사용할 수 있도록 합니다. @Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider(), memberDetailsService);
}
JwtAuthenticationFilter는 HTTP 요청에서 JWT 토큰을 확인하고, 그 토큰을 사용해 사용자를 인증하는 필터입니다. JwtTokenProvider.java (JWT 토큰 처리)private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final String JWT_SECRET = "your-secret-key";
private final long JWT_EXPIRATION_MS = 604800000L;
JWT_SECRET:JWT_EXPIRATION_MS:public String generateToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, JWT_SECRET)
.compact();
}
generateToken():userDetails.getUsername():setIssuedAt(now):setExpiration(expiryDate):signWith(SignatureAlgorithm.HS512, JWT_SECRET):compact():public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(JWT_SECRET)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token);
return true;
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token", ex);
} catch (Exception ex) {
logger.error("Invalid JWT token", ex);
}
return false;
}
try 블록에서 토큰이 유효한지 확인합니다.false를 반환합니다.JwtAuthenticationFilter.java (JWT 인증 필터)@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwt = getJwtFromRequest(request);
try {
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
String username = jwtTokenProvider.getUsernameFromJWT(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (JwtException ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
• 설명:
• doFilterInternal(): HTTP 요청이 들어올 때마다 호출되는 메소드로, 여기서 JWT 토큰을 확인하고 유효성을 검사합니다.
• getJwtFromRequest(): HTTP 요청 헤더에서 JWT 토큰을 추출합니다.
• validateToken(): 추출된 JWT 토큰의 유효성을 검사합니다.
• loadUserByUsername(): JWT 토큰에서 사용자 이름을 추출한 후, 데이터베이스에서 해당 사용자 정보를 가져옵니다.
• UsernamePasswordAuthenticationToken: 가져온 사용자 정보로 인증 객체를 생성합니다.
• SecurityContextHolder: 스프링 시큐리티의 보안 컨텍스트에 이 인증 정보를 설정하여 이후 요청에서 인증된 사용자로 처리되도록 합니다.
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
설명:
• 이 메소드는 요청 헤더에서 Authorization 값을 가져와 JWT 토큰을 추출합니다. Bearer 로 시작하는 부분을 제외하고 토큰 값만 반환합니다.
이 코드는 JWT를 사용한 인증을 React Native와 Spring Boot 서버에서 처리하는 전체 흐름을 보여줍니다. React Native 클라이언트는 서버로 로그인 요청을 보내고, 서버는 JWT 토큰을 발급해 줍니다. 이후 API 요청마다 클라이언트는 이 토큰을 보내 인증을 받고 서버는 해당 요청을 처리합니다.
이 기능을 사용하고자 하는 예비 개발자들에게 중요한 몇 가지 추가 정보를 알려드릴게요.
JWT 인증 방식은 다양한 프로젝트에서 사용할 수 있지만, 올바르게 설계하고 안전하게 사용하기 위해서는 몇 가지 추가 고려 사항이 필요합니다.
JWT 토큰은 세 부분으로 구성되어 있습니다:
이러한 구조를 잘 이해하고, Payload에 저장하는 정보가 너무 많지 않도록 주의해야 합니다. 민감한 정보는 저장하지 않는 것이 원칙입니다.
JWT 토큰을 클라이언트 측에서 어떻게 저장하고 관리할지 신중히 결정해야 합니다.
HttpOnly 플래그를 설정하면 자바스크립트에서 접근할 수 없으므로 XSS 공격에 대한 방어가 가능합니다. 하지만 CSRF 공격에 취약할 수 있으므로 주의가 필요합니다.추천: 모바일 앱에서는 주로 AsyncStorage나 SecureStorage 같은 안전한 저장소를 이용하는 것이 좋습니다.
JWT 토큰은 만료 시간이 설정되어 있으므로, 시간이 지나면 토큰이 더 이상 유효하지 않게 됩니다. 따라서 만료된 토큰을 갱신하는 방법을 고려해야 합니다.
Tip:
Refresh Token은 상대적으로 안전하게 관리해야 하며, 서버에서 저장하고 관리하는 것이 좋습니다. 이를 통해 만료된 토큰을 재발급할 수 있는 흐름을 만들 수 있습니다.
JWT를 사용할 때는 보안에 대한 여러 가지 고려 사항이 중요합니다.
토큰의 서명(Signing):
토큰을 안전하게 서명하기 위해서는 강력한 비밀 키를 사용해야 합니다. 특히 HS256과 같은 알고리즘을 사용하는 경우, 비밀 키를 안전하게 관리하고 적절히 길이를 설정해야 합니다. 강력한 서명 알고리즘으로 RSA 또는 ECDSA 같은 비대칭 키 서명을 사용하는 것도 좋은 방법입니다.
토큰의 유효성 검증:
서버에서 JWT 토큰을 검증할 때는 서명뿐만 아니라 토큰의 만료 시간, 발급자 정보, 그리고 예상하는 사용자 정보와 일치하는지 확인해야 합니다.
HTTPS 사용:
클라이언트와 서버 간에 주고받는 모든 JWT 토큰은 반드시 HTTPS를 통해 암호화된 채널로 전송해야 합니다. 그렇지 않으면 토큰이 노출될 위험이 있습니다.
JWT 방식에서는 서버 측 세션을 사용하지 않기 때문에, 클라이언트에서 로그아웃 기능을 구현할 때 몇 가지 방법이 있습니다.
React Native 앱이 Spring Boot 서버에 요청을 보낼 때, 특히 다른 도메인에서 요청이 발생하는 경우 CORS(Cross-Origin Resource Sharing) 문제가 발생할 수 있습니다. 서버에서 CORS 정책을 설정해 요청을 허용해야 합니다.
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
};
}
이렇게 CORS 설정을 추가해 특정 도메인(예: http://localhost:3000)에서 오는 요청을 허용할 수 있습니다.
Spring Security와 JWT를 연동하는 과정에서 발생할 수 있는 여러 가지 문제를 디버깅할 수 있는 방법을 알아두면 좋습니다.
application.properties 파일에 다음과 같은 설정을 추가하면 더 많은 디버깅 정보를 확인할 수 있습니다.logging.level.org.springframework.security=DEBUG
JwtAuthenticationFilter에서 토큰을 올바르게 읽어오는지 확인하는 것이 중요합니다.React Native 앱에서 API 호출 시 axios를 사용해 요청을 보낼 때,
매번 토큰을 추가하는 작업을 자동화할 수 있습니다.
axios 인스턴스를 만들어 모든 요청에서 이 인스턴스를 사용하도록 설정할 수 있습니다.const createAxiosInstance = (token) => {
return axios.create({
baseURL: 'http://your-api-url.com',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
};
이렇게 만들어진 인스턴스를 사용하면 이후 모든 API 요청에서 자동으로 토큰이 포함됩니다.
React Native 앱에서 로그인 요청을 보낼 때 실패할 경우 사용자에게 적절한 에러 메시지를 제공해야 합니다.
try {
const response = await axios.post('/login', credentials);
// 로그인 성공
} catch (error) {
if (error.response && error.response.status === 401) {
Alert.alert("Error", "Invalid username or password.");
} else {
console.error("Login Error:", error);
Alert.alert("Error", "An unexpected error occurred.");
}
}
JWT 기반 인증 시스템을 React Native와 Spring Boot에서 연동하는 방식은 널리 사용되는 방법입니다. 토큰을 어떻게 관리하고, 만료 처리나 보안 강화를 어떻게 할지 고민하면서 구현하면 안정적이고 확장성 있는 인증 시스템을 만들 수 있습니다.
계속해서 실습을 통해 이해를 높여가면서, 위의 권장 사항들을 적용해 보세요!
