Backend
home

2025-6-26 (목)

생성일
2025/06/25 23:26
태그
JPA
Spring Security
소셜 로그인
Prometheus
Grafana

1. 테스트

JwtToken 테스트

Postman을 통해 JWT 토큰을 활용한 게시글 등록 테스트 진행
먼저 로그인을 통해 JWT 토큰을 발급 받는다.
발급받은 JWT 토큰을 활용하여 게시글 등록을 한다.
게시글 수정
게시글 상세 조회
게시글 삭제

2. 소셜 로그인 (Google, Kakao)

Config 수정

SecurityConfig
package org.example.backendproject.security.config; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.example.backendproject.oauth2.OAuth2LoginSuccessHandler; import org.example.backendproject.oauth2.OAuth2LogoutSuccessHandler; import org.example.backendproject.oauth2.OAuth2UserService; import org.example.backendproject.oauth2.RedisOAuth2AuthorizationRequestRepository; import org.example.backendproject.security.jwt.JwtTokenFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration // 설정 클래스 등록 @EnableWebSecurity // 스프링 시큐리티 활성화 @RequiredArgsConstructor // 생성자 자동 생성 public class SecurityConfig { private final JwtTokenFilter jwtTokenFilter; private final OAuth2LogoutSuccessHandler oAuth2LogoutSuccessHandler; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2UserService oAuth2UserService; private final RedisTemplate<String, Object> redisTemplate; @Bean public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() { return new RedisOAuth2AuthorizationRequestRepository(redisTemplate); } // 스프링 시큐리티에서 어떤 순서로 어떤 보안 규칙의 필터를 거칠지를 정의하는 클래스 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // CSRF 보호 기능 비활성화 .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests((auth) -> auth .requestMatchers("/", "/index.html", "/*.html", "/favicon.ico", "/css/**", "/fetchWithAuth.js", "/js/**", "/images/**", "/.well-known/**" ).permitAll() // 정적 리소스 누구나 접근 .requestMatchers("/boards/**", "/api/comments/**").authenticated() //인증필요 .requestMatchers( "/api/auth/**", // 로그인/회원가입/로그아웃 등 인증 없이 사용 // "/api/comments/**", // 댓글 읽기 등 인증 없이 사용 "/oauth2/**", // 소셜 로그인 엔드포인트는 누구나 접근 "/login/**", // 스프링 시큐리티 내부 로그인 관련 엔드포인트 "/ws-gpt", "/ws-chat", // 웹소켓 핸드셰이크 "/actuator/prometheus" //프로메테우스 ).permitAll() // 웹소켓 핸드셰이크는 모두 허용! .requestMatchers( "/api/user/**", "/api/rooms/**" ).authenticated() //인증이 필요한 경로 ) // 인증 실패시 예외 처리 .exceptionHandling(e -> e // 인증 안 된 사용자가 접근하려고 할 때 .authenticationEntryPoint((request, response, authException) -> { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); }) // 인증은 되었지만 권한이 없을 때 .authenticationEntryPoint((request, response, authException) -> { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); }) ) // 스프링 시큐리티에서 세션관리정책을 설정하는 부분 // 기본적으로 스프링 시큐리티는 세션을 생성함 // 하지만 JWT 기반 인증은 세션상태를 저장하지 않는 무상태(stateless) 방식 // 인증 정보를 세션에 저장하지 않고, 매 요청마다 토큰으로 인증 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) .oauth2Login(oauth2->oauth2 .loginPage("/index.html") .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService)) .successHandler(oAuth2LoginSuccessHandler) .authorizationEndpoint(authorization -> authorization .authorizationRequestRepository(authorizationRequestRepository())) ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessHandler(oAuth2LogoutSuccessHandler) .permitAll() ) .build(); // 위 명시한 설정들을 적용 } // 회원가입시 비밀번호를 암호화해주는 메서드 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Java
복사
RedisConfig
package org.example.backendproject.stompwebsocket.redis; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; //@Profile("!test") @RequiredArgsConstructor @Configuration public class RedisConfig { @Value("${spring.data.redis.host}") private String host; @Value("${spring.data.redis.port}") private int port; private final RedisSubscriber redisSubscriber; // .... 중략 ... // 추가 @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); return template; } // ... 중략 ... }
Java
복사

소셜 로그인 코드 작성

AuthController - 일반 로그인 로직 수정 + 로그아웃 기능 추가
package org.example.backendproject.Auth.controller; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.HashMap; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.backendproject.Auth.dto.LoginRequestDTO; import org.example.backendproject.Auth.dto.LoginResponseDTO; import org.example.backendproject.Auth.dto.SignUpRequestDTO; import org.example.backendproject.Auth.service.AuthService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RestController @RequestMapping("/api/auth") @Slf4j public class AuthController { private final AuthService authService; .... 중략 .... /** 로그인 **/ // @PostMapping("/loginSecurity") // public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginRequestDTO loginRequestDTO) { // LoginResponseDTO loginResponseDTO = authService.login(loginRequestDTO); // return ResponseEntity.ok(loginResponseDTO); // } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequestDTO loginRequestDTO) { LoginResponseDTO loginResponseDTO = authService.login(loginRequestDTO); return ResponseEntity.ok(loginResponseDTO); } .... 중략 .... @PostMapping("/logout") public ResponseEntity<?> logout(HttpServletResponse response) { // accessToken 쿠키 삭제 Cookie accessTokenCookie = new Cookie("accessToken", null); accessTokenCookie.setHttpOnly(true); accessTokenCookie.setPath("/"); accessTokenCookie.setMaxAge(0); // 즉시 만료! // refreshToken 쿠키 삭제 Cookie refreshTokenCookie = new Cookie("refreshToken", null); refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setPath("/"); refreshTokenCookie.setMaxAge(0); // 응답에 쿠키 삭제 포함 response.addCookie(accessTokenCookie); response.addCookie(refreshTokenCookie); // (추가) 서버 세션도 있다면 만료 // request.getSession().invalidate(); return ResponseEntity.ok().body("로그아웃 완료 (쿠키 삭제됨)"); } }
Java
복사
AuthRepository - Auth 엔티티에 토큰 저장
package org.example.backendproject.Auth.repository; import java.util.Optional; import org.example.backendproject.Auth.entity.Auth; import org.example.backendproject.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface AuthRepository extends JpaRepository<Auth, Long> { Optional<Auth> findByRefreshToken(String refreshToken); boolean existsByUser(User user); // Auth 엔티티에 토큰 저장 (User와 1:1 매핑) Optional<Auth> findByUser(User user); }
Java
복사
OAuth2LoginSuccessHandler
package org.example.backendproject.oauth2; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Map; @Slf4j @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { // 로그인 동작을 커스텀으로 구현하고 싶을 때 사용하는 인터페이스 // OAuth2 로그인 성공시 호출되는 메서드 @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { DefaultOAuth2User oAuth2User = (DefaultOAuth2User) authentication.getPrincipal(); Map<String, Object> attributes = oAuth2User.getAttributes(); String accessToken = (String) attributes.get("accessToken"); String refreshToken = (String) attributes.get("refreshToken"); String name = (String) attributes.get("name"); System.out.println("[OAuth2_LOG]" + "소셯 로그인 시도한 이름 = "+name); // 사용자 ID를 안전하게 꺼내기 (null 체크 및 타입 캐스팅) Long id = null; Object idObj = attributes.get("id"); if (idObj != null) { // Long 타입이 아닐 수도 있으니 안전하게 변환 id = Long.valueOf(idObj.toString()); } //토큰 전달방식 // 또는, 보안을 강화하려면 아래처럼 HttpOnly 쿠키로 전달해도 됨 Cookie accessTokenCookie = new Cookie("accessToken", accessToken); accessTokenCookie.setHttpOnly(true); accessTokenCookie.setPath("/"); // accessTokenCookie.setMaxAge(60 * 3); // 3분짜리 임시쿠키 response.addCookie(accessTokenCookie); Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setPath("/"); // refreshTokenCookie.setMaxAge(60 * 60 * 24); // 1일짜리 response.addCookie(refreshTokenCookie); response.sendRedirect("/main.html?" + "&id=" + id); // response.sendRedirect("http://localhost:3000/main" + (id != null ? "?id=" + id : ""));//리액트 } }
Java
복사
OAuth2LogoutSuccessHandler
package org.example.backendproject.oauth2; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Map; @Component public class OAuth2LogoutSuccessHandler implements LogoutSuccessHandler { // 카카오 REST API 키 (환경변수나 properties에서 가져오세요) private final String kakaoClientId = "kakaoClientId"; // 카카오 개발자 사이트에 있는 ClientID 추가 private final String kakaoLogoutRedirectUri = "http://localhost:8080/login.html"; // 앱 환경에 맞게 변경 @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 기본 리디렉션 URL → 일반 로그아웃 시 index.html로 이동 String redirectUrl = "/index.html"; if (authentication != null && authentication.getPrincipal() instanceof DefaultOAuth2User auth2User){ Map<String,Object> attributes = auth2User.getAttributes(); Object email = attributes.get("email"); if (email != null && email.toString().endsWith("@gmail.com")){ System.out.println("구글 로그아웃입니다."); redirectUrl = "https://accounts.google.com/Logout"; } // 카카오 로그인 사용자인 경우 (attributes에 'id' 키가 있음) else if (attributes.containsKey("id")){ System.out.println("카카오 로그아웃입니다."); redirectUrl = "https://kauth.kakao.com/oauth/logout?client_id=" + kakaoClientId + "&logout_redirect_uri=" + kakaoLogoutRedirectUri; } } // 최종적으로 redirectUrl로 리디렉트 response.sendRedirect(redirectUrl); } }
Java
복사
OAuth2UserService
package org.example.backendproject.oauth2; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.backendproject.Auth.entity.Auth; import org.example.backendproject.Auth.repository.AuthRepository; import org.example.backendproject.security.core.CustomUserDetails; import org.example.backendproject.security.core.Role; import org.example.backendproject.security.jwt.JwtTokenProvider; import org.example.backendproject.user.entity.User; import org.example.backendproject.user.entity.UserProfile; import org.example.backendproject.user.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Optional; @Service @Slf4j @RequiredArgsConstructor public class OAuth2UserService extends DefaultOAuth2UserService { // DefaultOAuth2UserService - spring security에서 기본적으로 제공하는 OAuth2 인증 정보를 처리하는 클래스 private final AuthRepository authRepository; // JWT 저장용 (access, refresh) private final UserRepository userRepository; // 회원 정보 DB 접근 private final JwtTokenProvider jwtTokenProvider; // JWT 발급기 @Value("${jwt.accessTokenExpirationTime}") private Long jwtAccessTokenExpirationTime; @Value("${jwt.refreshTokenExpirationTime}") private Long jwtRefreshTokenExpirationTime; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { /** 소셜 로그인 성공 시 사용자 정보를 구글이나 카카오에서 받아서 처리하는 메소드 **/ // 기본 OAuth2User 정보 가져오기 (email, name 등) OAuth2User oAuth2User = super.loadUser(userRequest); // 어떤 소셜 로그인 제공자인지 (google, kakao 등) String provider = userRequest.getClientRegistration().getRegistrationId(); // 소셜 사용자 정보 추출 Map<String, Object> attributes = oAuth2User.getAttributes(); String userid, username, email; // provider 별로 파싱 방식이 다름 if ("google".equals(provider)) { email = (String) attributes.get("email"); // 구글의 고유 키 userid = email; // 사용자 아이디로 email 사용 username = (String) attributes.get("name"); } else if ("kakao".equals(provider)) { userid = attributes.get("id").toString()+"@kakao"; // 고유 id + 구분자 Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); email = (String) kakaoAccount.get("email"); Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile"); username = (String) profile.get("nickname"); } else { // 기타 provider 처리 안함 email = null; username = null; userid = null; } System.out.println(provider + " 로그인 확인 userid = " + userid); System.out.println(provider + " 로그인 확인 email = " + email); System.out.println(provider + " 로그인 확인 username = " + username); // 회원 정보가 DB에 존재하는지 확인 User user = userRepository.findByUserid(userid) .orElseGet(() -> { // 회원이 없다면 자동 회원가입 처리 User newUser = new User(); newUser.setUserid(userid); newUser.setPassword(""); // 소셜 로그인은 비밀번호 없음 newUser.setRole(Role.ROLE_USER); // 기본 권한 설정 // 프로필 엔티티 생성 및 양방향 관계 설정 UserProfile profile = new UserProfile(); profile.setUsername(username != null ? username : "소셜유저"); profile.setEmail(email != null ? email : ""); profile.setPhone(""); profile.setAddress(""); profile.setUser(newUser); // 주인 쪽 설정 newUser.setUserProfile(profile); // 양방향 설정 return userRepository.save(newUser); // 회원가입 저장 }); // 시큐리티에서 사용할 인증 객체 생성 CustomUserDetails customUserDetails = new CustomUserDetails(user); Authentication authentication = new UsernamePasswordAuthenticationToken( customUserDetails, user.getPassword(), Collections.singleton(new SimpleGrantedAuthority(user.getRole().name()))); // JWT 액세스 & 리프레시 토큰 발급 String accessToken = jwtTokenProvider.generateToken(authentication,jwtAccessTokenExpirationTime); String refreshToken = jwtTokenProvider.generateToken(authentication,jwtRefreshTokenExpirationTime); log.info("accessToken = " + accessToken); log.info("refreshToken = " + refreshToken); // JWT는 SuccessHandler에서 쿠키/쿼리로 전달 → 여기선 속성에만 담아 둠 Map<String, Object> customAttributes = new HashMap<>(attributes); customAttributes.put("accessToken", accessToken); customAttributes.put("refreshToken", refreshToken); customAttributes.put("id", user.getId()); // ← PK(id) 추가 // Auth 엔티티에 토큰 저장 (User와 1:1 매핑) Optional<Auth> optionalAuth = authRepository.findByUser(user); if (optionalAuth.isPresent()) { Auth auth = optionalAuth.get(); auth.updateAccessToken(accessToken); auth.updateRefreshToken(refreshToken); authRepository.save(auth); // 반드시 저장! user.setAuth(auth); // (option) 연관관계 유지 } else { // Auth auth = new Auth(user, refreshToken, accessToken, "Bearer"); Auth auth = new Auth(user, refreshToken, accessToken, "Bearer"); authRepository.save(auth); user.setAuth(auth); } // 최종적으로 Spring Security에 전달할 OAuth2User 반환 return new DefaultOAuth2User( Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())), // 권한 customAttributes, // 속성 정보 (JWT 포함) "id" // PK로 사용할 식별자 (프론트에서도 사용할 수 있음) ); } }
Java
복사
RedisOAuth2AuthorizationRequestRepository
package org.example.backendproject.oauth2; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import java.time.Duration; @RequiredArgsConstructor public class RedisOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> { // Redis에 저장할 때 사용할 key prefix (고유 식별자 역할) private static final String PREFIX = "oauth2_auth_request:"; // RedisTemplate은 Spring에서 제공하는 Redis 클라이언트 private final RedisTemplate<String, Object> redisTemplate; // public RedisOAuth2AuthorizationRequestRepository(RedisTemplate<String, Object> redisTemplate) { // this.redisTemplate = redisTemplate; // } @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { // 요청 파라미터에서 state 값을 가져옴 (OAuth2의 CSRF 방지 토큰 역할) String state = request.getParameter("state"); if (state == null) return null; // Redis에서 해당 state 값을 가진 AuthorizationRequest 조회 return (OAuth2AuthorizationRequest) redisTemplate.opsForValue().get(PREFIX + state); } @Override public void saveAuthorizationRequest( OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { // null일 경우 저장하지 않음 if (authorizationRequest == null) return; // 고유 식별자인 state 값을 키로 사용 String state = authorizationRequest.getState(); // Redis에 10분동안 저장(10분 뒤 삭제됨) redisTemplate.opsForValue().set( PREFIX + state, authorizationRequest, Duration.ofMinutes(10)); } /** * [3] 인가 요청 삭제하기 * 인증 과정이 끝나면 Redis에서 해당 요청 정보를 제거함 */ @Override public OAuth2AuthorizationRequest removeAuthorizationRequest( HttpServletRequest request, HttpServletResponse response) { String state = request.getParameter("state"); if (state == null) return null; String key = PREFIX + state; // Redis에서 해당 요청을 가져오고 OAuth2AuthorizationRequest authRequest = (OAuth2AuthorizationRequest) redisTemplate.opsForValue().get(key); // 가져온 후 삭제 (한 번 사용 후 만료되므로) redisTemplate.delete(key); return authRequest; } }
Java
복사
== OAuth2 소셜 로그인에서의 Redis 역할 ==
소셜 로그인을하면 세션이 발생하는데 세션은 하나의 서버에서만 유효한거라 서버 분산 환경에서는 세션이 공유가 되지 않아서 로그인 유지가 안 됨. 그러므로 레디스에 정보를 저장해서 서버들이랑 공유함.
JWtTokenFilter
package org.example.backendproject.security.jwt; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.backendproject.security.core.CustomUserDetailsService; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @Component @RequiredArgsConstructor @Slf4j public class JwtTokenFilter extends OncePerRequestFilter { // JwtTokenFilter 모든 HTTP 요청을 가로채서 JWT 토큰을 검사하는 필터 역할 // OncePerRequestFilter는 한 요청당 딱 한 번만 실행되는 필터 역할 private final JwtTokenProvider jwtTokenProvider; private final CustomUserDetailsService customUserDetailsService; // HTTP 매 요청마다 호출 @Override protected void doFilterInternal(HttpServletRequest request, // http 요청 HttpServletResponse response, // http 응답 FilterChain filterChain ) throws ServletException, IOException { String accessToken = extractTokenFromRequest(request); // 이 부분 수정 if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) { UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(accessToken); // 토큰에서 사용자를 꺼내서 담은 사용자 인증 객체 authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // http 요청으로부터 부가 정보(ip, 세션 등)를 추출해서 사용자 인증 객체에 넣어줌 SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 토큰에서 사용자 인증정보를 조회해서 인증정보를 현재 스레드에 인증된 사용자로 등록 String url = request.getRequestURI().toString(); log.info("현재 들어온 HTTP 요청 = " + url); String method = request.getMethod(); // GET, POST, PUT ... log.info("HTTP 메소드 + method = " + method); } /** * CharacterEncodingFilter: 문자 인코딩 처리 * CorsFilter: CORS 정책 처리 * CsrfFilter: CSRF 보안 처리 * JWTTokenFilter: JWT 토큰 처리(핵심) * SecurityContextFilter: 인증/인가 정보 저장 * ExceptionFilter: 예외 처리 */ filterChain.doFilter(request, response); // JwtTokenFilter를 거치고 다음 필터로 넘어감 (이동...이동...이동) } // HTTP 요청 헤더에서 토큰을 추출하는 메서드 // public String getTokenFromRequest(HttpServletRequest request) { // // String token = null; // // String bearerToken = request.getHeader("Authorization"); // if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { // token = bearerToken.substring(7); // } // // return token; // } ... 중략 ... // 쿠키에서 accessToken을 추출하는 메서드 (추가!) private String extractTokenFromCookie(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals("accessToken")) { return cookie.getValue(); } } } return null; } }
Java
복사

application.properties 에 google, kakao 관련 정보 추가

#google spring.security.oauth2.client.registration.google.client-id= spring.security.oauth2.client.registration.google.client-secret= #spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.google.scope=profile, email spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code #kakao spring.security.oauth2.client.registration.kakao.client-id= spring.security.oauth2.client.registration.kakao.client-secret= spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post #spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me spring.security.oauth2.client.provider.kakao.user-name-attribute=id
Java
복사

구글 로그인 테스트

구글 로그인 클릭
로그인 확인
로그아웃 후 메인페이지 복귀

카카오톡 로그인 테스트

로그인 하기
전체 동의하기 클릭 후 “동의하고 계속하기” 클릭
정상 동작 확인

3. 토큰을 제대로 불러오지 못하는 에러

JwtTokenFilter 쪽에 Cookie 쪽 토큰을 바라보는 메서드 추가 후 doFilterInternal 메서드에 적용

private String extractTokenFromRequest(HttpServletRequest request) { // 1. 쿠키에서 accessToken 확인 String cookieToken = extractTokenFromCookie(request); if (StringUtils.hasText(cookieToken)) { return cookieToken; } // 2. Authorization 헤더에서 토큰 확인 String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); // "Bearer " 제거 } return null; } // HTTP 매 요청마다 호출 @Override protected void doFilterInternal(HttpServletRequest request, // http 요청 HttpServletResponse response, // http 응답 FilterChain filterChain ) throws ServletException, IOException { String accessToken = extractTokenFromRequest(request); // 요청 헤더에서 토큰 추출 if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) { UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(accessToken); // 토큰에서 사용자를 꺼내서 담은 사용자 인증 객체 authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // http 요청으로부터 부가 정보(ip, 세션 등)를 추출해서 사용자 인증 객체에 넣어줌 SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 토큰에서 사용자 인증정보를 조회해서 인증정보를 현재 스레드에 인증된 사용자로 등록 String url = request.getRequestURI().toString(); log.info("현재 들어온 HTTP 요청 = " + url); String method = request.getMethod(); // GET, POST, PUT ... log.info("HTTP 메소드 + method = " + method); } /** * CharacterEncodingFilter: 문자 인코딩 처리 * CorsFilter: CORS 정책 처리 * CsrfFilter: CSRF 보안 처리 * JWTTokenFilter: JWT 토큰 처리(핵심) * SecurityContextFilter: 인증/인가 정보 저장 * ExceptionFilter: 예외 처리 */ filterChain.doFilter(request, response); // JwtTokenFilter를 거치고 다음 필터로 넘어감 (이동...이동...이동) }
Java
복사

4. 모니터링 실행

build.gradle 설정

// Spring Boot Actuator 매트릭/모니터링 implementation 'org.springframework.boot:spring-boot-starter-actuator' // 프로메테우스 implementation 'io.micrometer:micrometer-registry-prometheus'
Shell
복사

application-properties 설정

# actuator and metric and prometheus # prometheus 전용 앤드포인트 생성 management.prometheus.metrics.export.enabled=true management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always # never -> 절대 상세 정보 제공 안함 (항상 status만 응답) # when-authorized -> 인증된 사용자/로컬 요청에만 상세 정보 제공 (기본값, 권장) # always -> 항상 상세 정보 포함 (외부/내부 관계없이 상세 정보 노출, 개발·테스트용)
Shell
복사

Docker-compose yml 파일로 Grafana, Prometheus 세팅

services: prometheus: image: prom/prometheus:latest container_name: prometheus restart: unless-stopped ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml # [설정파일] - ./volumes/prometheus:/prometheus # [데이터 볼륨] # depends_on: # - backend1 # - backend2 # - backend3 networks: - prod_server grafana: image: grafana/grafana:latest container_name: grafana restart: unless-stopped ports: - "3000:3000" depends_on: - prometheus volumes: - ./volumes/grafana:/var/lib/grafana # [데이터 볼륨] networks: - prod_server # # elasticsearch: # image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 # container_name: elasticsearch # environment: # - node.name=es01 # - discovery.type=single-node # - xpack.security.enabled=false # ulimits: # memlock: # soft: -1 # hard: -1 # ports: # - "9200:9200" # - "9300:9300" # volumes: # - ./volumes/esdata:/usr/share/elasticsearch/data # networks: # - prod_server ## ## ## kibana: ## image: docker.elastic.co/kibana/kibana:8.12.0 ## container_name: kibana ## environment: ## - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 ## - SERVER_SSL_ENABLED=false ## ports: ## - "5601:5601" ## volumes: ## - ./volumes/kibana-data:/usr/share/kibana/data ## depends_on: ## - elasticsearch ## networks: ## - prod_server ## ## logstash: ## image: docker.elastic.co/logstash/logstash:8.12.0 ## container_name: logstash ## ports: ## - "5044:5044" # For beats (optional) ## - "5000:5000" # TCP input ## volumes: ## - ./logstash/logstash.conf:/usr/share/logstash/pipeline/logstash.conf #logstash 설정파일 ## - ./logstash/logstash.yml:/usr/share/logstash/config/logstash.yml:ro # <-- 이 줄 추가! ## - ../../logs:/logs #로그 볼륨 ## depends_on: ## - elasticsearch ## networks: ## - prod_server networks: prod_server: external: true #도커 자체 볼륨을 사용할떄 선언해야 함 #volumes: # volumes:
Shell
복사
docker-compose 실행

Docker Container 확인

admin/admin으로 로그인 후 비밀번호 설정 → 1234
localhost:9090 - 프로메테우스 접속
SpringBoot 서버 실행한 이후에 Status > Target Health 메뉴에서 다음 화면 확인
SpringBoot 켜져있지 않으면 모두 Down 처리 되어있음
Data sources에서 다음과 같이 설정
대시보드 만들기 > Import a dashboard
만들어진 Dashboard 확인
Import dashboard에서 grafana.com/dashboards 클릭
spring 입력 > JVM 클릭 > Download JSON
다운받은 json 파일을 Import 하기
Import 후 다음과 같이 설정
대시보드 내용 확인

5. 오늘 푸시한 커밋리스트

날짜
커밋 메시지
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26
2025-06-26