본문 바로가기
Spring

[Spring] 로그인 구현 2 - 회원 가입 & 자체 로그인 구현

by Bhinney 2022. 12. 20.

메인 프로젝트를 하면서 REST API로 로그인을 구현해보았다.

해당 부분을 기억하기 위해 이 글을 작성하였다.

이 포스팅은 프로젝트 과정에서 흐름을 기억하기 위해 작성한 것으로, 기본 흐름의 틀정도라고 생각하면 좋을 듯 하다.

해당 포스팅의 코드는 아래의 깃헙 주소에서도 확인 가능.

 

GitHub - Bhinney/Study: ✨ 공부하면서 기록하는 공간 ✨

✨ 공부하면서 기록하는 공간 ✨. Contribute to Bhinney/Study development by creating an account on GitHub.

github.com


✨ 들어가기 전에, 메인 프로젝트에서 로그인 구현의 흐름

: 아래의 흐름을 바탕으로 + Redis를 이용하여 구현


왼쪽은 회원 가입이고, 오른쪽은 로그인이다


1️⃣ build.gradle 설정

  • JWT 토큰을 이용하여 로그인을 구현할 예정이므로 jjwt 의존성도 추가하였음.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-validation'

/* lombok > mapstruct */
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'

/* Security */
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

/* DB */
implementation 'com.h2database:h2'

/* jwt */
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly    'io.jsonwebtoken:jjwt-jackson:0.11.5'

/* Redis */
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2️⃣ application.yml 설정

  • 토큰 발급을 위해 secret key 환경변수 설정
  • 데이터 베이스는 인메모리인 H2 사용
spring:
  h2:
    console:
      enabled: true
      path: /h2
  datasource:
    url: jdbc:h2:mem:test
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379
jwt:
  secret-key: ${JWT_SECRET_KEY}

3️⃣ LoginRequestDto 구현

  • 로그인 시 들어올 요청 DTO 구현
  • 이후 토큰 발급에 도움을 줄 메서드 구현

 

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginRequestDto {

   private String email;
   private String password;

   public UsernamePasswordAuthenticationToken toAuthentication() {
      return new UsernamePasswordAuthenticationToken(email, password);
   }
}

4️⃣ JwtProvider 클래스 구현

  • Access Token과 Refresh Token 발급을 위한 로직 작성
  • 토큰 복호화 및 유효성 검증 로직 확인
@Slf4j
@Component
public class JwtProvider{
   private final Key key;
   private final CustomAuthorityUtils authorityUtils;
   private final MemberRepository memberRepository;

   private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 6;
   private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;
   private static final String AUTHORITIES_KEY = "role";
   private static final String BEARER_TYPE = "Bearer ";

   /* 시크릿 키 */
   public JwtProvider(@Value("${jwt.secret-key}") String secretKey, CustomAuthorityUtils authorityUtils,
      MemberRepository memberRepository) {
      this.authorityUtils = authorityUtils;
      this.memberRepository = memberRepository;
      byte[] keyBytes = Decoders.BASE64.decode(secretKey);
      this.key = Keys.hmacShaKeyFor(keyBytes);
   }

   public TokenDto generatedTokenDto(String username) {

      /* 🐥 권한 가져오기 */
      Member member = memberRepository.findByEmail(username)
         .orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다."));
      String authorities = member.getRole();

      long now = (new Date()).getTime();
      Date accessTokenExpiration = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
      Date refreshTokenExpiration = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

      /* 🐹 Access Token 생성 */
      String accessToken = createAccessToken(username, authorities, accessTokenExpiration);

      /* 🦊 Refresh Token 생성 */
      String refreshToken = createRefreshToken(username, refreshTokenExpiration);

      return TokenDto.builder()
         .grantType(BEARER_TYPE)
         .accessToken(accessToken)
         .accessTokenExpiresIn(accessTokenExpiration.getTime())
         .refreshToken(refreshToken)
         .refreshTokenExpiresIn(refreshTokenExpiration.getTime())
         .build();
   }

   /* 🐹 Access Token 생성 */
   public String createAccessToken(String username, String role, Date expiration) {
      Map<String, Object> claims = new HashMap<>();
      claims.put("username", username);
      claims.put("role", role);

      String accessToken =
         Jwts.builder()
            .setSubject(username)
            .setClaims(claims)
            .signWith(key, SignatureAlgorithm.HS256)
            .setExpiration(expiration)
            .compact();

      return accessToken;
   }

   /* 🦊 Refresh Token 생성 */
   public String createRefreshToken(String username, Date expiration) {
      return Jwts.builder()
         .setSubject(username)
         .signWith(key, SignatureAlgorithm.HS256)
         .setExpiration(expiration)
         .compact();
   }


   public Authentication getAuthentication(String accessToken) {

      /* 토큰 복호화 */
      Map<String, Object> claims = parseClaims(accessToken);

      /* 만약 복호화 한 토큰 안에 권한이 없으면 예외 던지기 */
      if (claims.get(AUTHORITIES_KEY) == null) {
         throw new RuntimeException("권한이 존재하지 않습니다.");
      }

      /* 🐥 권한이 있다면, 권한 가져오기 */
      List<GrantedAuthority> authorities = authorityUtils.createAuthorities((String)claims.get(AUTHORITIES_KEY));

      /* UserDetails 객체를 만들어 Authentication 리턴 */
      UserDetails principal = new User((String)claims.get("username"), "", authorities);


      return new UsernamePasswordAuthenticationToken(principal, null, authorities);
   }

   /* 토큰 존재 여부 판별 */
   public boolean validate(String token) {
      return this.getTokenClaims(token) != null;
   }

   public Claims getTokenClaims(String token) {
      try {
         return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();
      } catch (SecurityException exception) {
         log.info("유효하지 않은 JWT 토큰 서명입니다.");
      } catch (MalformedJwtException exception) {
         log.info("유효하지 않은 JWT 토큰입니다.");
      } catch (ExpiredJwtException exception) {
         log.info("만료된 JWT 토큰입니다.");
      } catch (UnsupportedJwtException exception) {
         log.info("지원되지 않는 JWT 토큰입니다.");
      } catch (IllegalArgumentException exception) {
         log.info("무언가 잘못되었습니다.");
      }
      return null;
   }

   /* 토큰 복호화 */
   public Claims parseClaims(String accessToken) {
      try {
         return Jwts.parserBuilder()
            .setSigningKey(key).build()
            .parseClaimsJws(accessToken).getBody();
      } catch (ExpiredJwtException exception) {
         log.info("이미 만료된 토큰입니다.");

         return exception.getClaims();
      }
   }

   public long getAccessTokenTime() {
      return ACCESS_TOKEN_EXPIRE_TIME;
   }
   public long getRefreshTokenTime() {
      return REFRESH_TOKEN_EXPIRE_TIME;
   }
}

5️⃣ RedisConfig 클래스 구현

  • RefreshToken을 Redis에 저장하기 위해 RedisConfig 생성
  • RedisTemplate 방식을 이용하여 구현
@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {
   private final RedisProperties redisProperties;

   @Bean
   public RedisConnectionFactory redisConnectionFactory() {
      return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
   }

   @Bean
   public RedisTemplate<String, Object> redisTemplate() {
      RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      redisTemplate.setConnectionFactory(redisConnectionFactory());
      redisTemplate.setKeySerializer(new StringRedisSerializer()); /* redis 에서 데이터를 볼 수 있도록 */
      redisTemplate.setValueSerializer(new StringRedisSerializer()); /* redis 에서 데이터를 볼 수 있도록 */
      return redisTemplate;
   }
}

6️⃣ MemberController 클래스 구현

  • 회원 가입 로직을 같이 구현한 이유?
  • 메인 프로젝트 때에 회원 가입을 하면 자동 로그인 하도록 하였기 때문
@Slf4j
@RestController
@RequiredArgsConstructor
@Transactional
public class MemberController {
   private final MemberService memberService;
   private final MemberMapper mapper;

   /* 회원 가입 */
   @PostMapping("/members/signup")
   public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) throws Exception {
      Member createMember = memberService.createMember(mapper.memberPostDtoToMember(requestBody));


      /* 로그인 시도를 위한 LoginRequestDto 생성 */
      LoginRequestDto loginRequestDto = new LoginRequestDto(createMember.getEmail(), requestBody.getPassword());

      /* 자동 로그인 */
      return loginMember(loginRequestDto);
   }

   /* 로그인 */
   @PostMapping("/members/login")
   public ResponseEntity loginMember(@RequestBody LoginRequestDto requestBody) {

      /* 로그인 정보로 토큰 생성 */
      TokenDto tokenDto = memberService.tokenLogin(requestBody);

      /* 엑세스 토큰 헤더에 담아주기 */
      HttpHeaders httpHeaders = setHeader(tokenDto.getAccessToken());

      Member member = memberService.findVerifiedMemberByEmail(requestBody.getEmail());

      /* 역할에 따라 응답 바디가 다르므로, 나누어 주었다.*/
      if(member.getRole().equals("SELLER")) {
         return new ResponseEntity<>(mapper.memberToSellerResponseDto(member), httpHeaders, HttpStatus.OK);
      } else if (member.getRole().equals("CLIENT")) {
         return new ResponseEntity<>(mapper.memberToClientResponseDto(member), httpHeaders, HttpStatus.OK);
      }

      /* 로그인이 실패할 경우, 문제가 존재하는 것 */
      throw new RuntimeException("로그인에 실패하였습니다.");
   }

   /* 로그아웃 */
   @GetMapping("/members/logout")
   public ResponseEntity logoutMember(HttpServletRequest request) {
      memberService.logoutMember(request);

      return ResponseEntity.ok("로그아웃 되었습니다.");
   }

   /* 로그인 헤더 설정 */
   private HttpHeaders setHeader(String token) {
      HttpHeaders httpHeaders = new HttpHeaders();
      httpHeaders.set("Authorization", token);

      return httpHeaders;
   }
}

7️⃣ MemberService 클래스 구현

  • 회원 가입 및 로그인 로직 구현
  • 로그인 시, Redis에 리프레시 토큰 저장
  • 로그아웃 시, Redis에 리프레시 토큰 삭제
  • 그 외의 검사가 필요한 메서드들 구현
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class MemberService {
   private final MemberRepository memberRepository;
   private final PasswordEncoder passwordEncoder;
   private final CustomAuthorityUtils authorityUtils;
   private final AuthenticationManagerBuilder authenticationManagerBuilder;
   private final JwtProvider jwtProvider;
   private final RedisTemplate<String, Object> redisTemplate;

   /* 회원 가입 */
   @Transactional
   public Member createMember(Member member) {

      verifyEmailExist(member.getEmail());
      correctRole(member.getRole());
      List<String> roles = authorityUtils.createRoles(member.getRole());
      if (member.getRole().equalsIgnoreCase("client")) {
         member.setClient(new Client());
      }
      if (member.getRole().equalsIgnoreCase("seller")) {
         member.setSeller(new Seller());
      }

      /* 비밀번호 암호화 + 대문자로 저장 */
      String encryptedPassword = passwordEncoder.encode(member.getPassword());

      member.setCreateMember(
         encryptedPassword,
         member.getRole().toUpperCase(),
         roles,
         ProviderType.LOCAL
      );

      return memberRepository.save(member);
   }

   /* 로그인 */
   public TokenDto tokenLogin(LoginRequestDto loginRequestDto) {

      /* 존재하는 회원인지 확인 */
      Member member = findVerifiedMemberByEmail(loginRequestDto.getEmail());

      /* 비밀번호가 일치하는지 확인 */
      verifyPassword(member, loginRequestDto.getPassword());

      /* 프로바이더 확인 */
      checkLocalMember(member);

      /* 로그인 기반으로 "Authentication 토큰" 생성 */
      UsernamePasswordAuthenticationToken authenticationToken = loginRequestDto.toAuthentication();

      /* AuthenticationToken 으로 인증 정보 가져오기 */
      Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

      TokenDto tokenDto = jwtProvider.generatedTokenDto(authentication.getName());

      /* Refresh Token 저장 */
      redisTemplate.opsForValue()
         .set("RefreshToken:" + authentication.getName(), tokenDto.getRefreshToken(),
            tokenDto.getRefreshTokenExpiresIn() - new Date().getTime(), TimeUnit.MICROSECONDS);

      return tokenDto;
   }

   /* 로그아웃 */
   public void logoutMember(HttpServletRequest request) {
      String accessToken = request.getHeader("Authorization").replace("Bearer ", "");

      /* Access Token 검증 */
      if (!jwtProvider.validate(accessToken)) {
         log.error("유효하지 않은 access token");
         throw new RuntimeException("유효하지 않은 access token");
      }

      /* 인증 정보 가져오기 */
      Authentication authentication = jwtProvider.getAuthentication(accessToken);

      /* 현재 유저가 맞는지 확인 */
      String email = SecurityUtil.getCurrentEmail();
      if (!authentication.getName().equals(email)) {
         throw new RuntimeException("로그인한 사용자가 일치하지 않습니다.");
      }

      /* Redis 에서 RefreshToken 삭제 */
      redisTemplate.delete("RefreshToken:" + authentication.getName());
   }

   /* 존재하는 이메일인지 확인 */
   public void verifyEmailExist(String email) {
      Optional<Member> optionalMember = memberRepository.findByEmail(email);
      if (optionalMember.isPresent()) {
         throw new RuntimeException("존재하는 이메일입니다.");
      }
   }

   /* 이메일로 존재하는 회원인지 확인 */
   public Member findVerifiedMemberByEmail(String email) {
      Member findMember = memberRepository.findByEmail(email)
         .orElseThrow(() -> new RuntimeException("회원을 찾을 수 없습니다."));

      return findMember;
   }

   /* 존재하는 회원인지 확인 */
   private Member findVerifiedMember(long memberId) {
      Optional<Member> optionalMember = memberRepository.findById(memberId);
      Member findMember = optionalMember.orElseThrow(
         () -> new RuntimeException("회원을 찾을 수 없습니다."));

      return findMember;
   }

   /* 역할 확인 */
   private void correctRole(String target) {
      if (! target.equalsIgnoreCase("CLIENT") && ! target.equalsIgnoreCase("SELLER")) {
         throw new RuntimeException("역할이 잘못 입력되었습니다.");
      }
   }

   /* 회원가입 시 비밀번호 확인 */
   public void correctPassword(String password, String passwordCheck) {
      if (!password.equals(passwordCheck)) {
         throw new RuntimeException("비밀번호가 일치하지 않습니다.");
      }
   }

   /* 로그인 시 비밀번호 확인 */
   private void verifyPassword(Member member, String password){

      if (!passwordEncoder.matches(password, member.getPassword())) {
         throw new RuntimeException("올바르지 않은 비밀번호 입니다.");
      }
   }

   /* 로컬 회원인지 확인 */
   private void checkLocalMember(Member member) {
      if (member.getProviderType() != ProviderType.LOCAL) {
         throw new RuntimeException("소셜 회원입니다.");
      }
   }
}

8️⃣ MemberPrincipalService 클래스와 MemberPrincipal 클래스 구현

  • 현재 로그인 한 유저의 정보를 저장
  • 해당 클래스가 없으면 MemberService에서 인증 정보를 가져오지 못함
@Service
@RequiredArgsConstructor
public class MemberPrincipalService implements UserDetailsService {
   private final MemberRepository memberRepository;

   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      Member member = memberRepository.findByEmail(username)
         .orElseThrow(() -> new RuntimeException("회원이 존재하지 않습니다."));

      return MemberPrincipal.create(member);
   }
}

 

@Getter
@Setter
@AllArgsConstructor
@RequiredArgsConstructor
public class MemberPrincipal implements UserDetails, OAuth2User, OidcUser {

   /* username == email */
   private final String username;
   private final String password;
   private final ProviderType providerType;
   private final String role;
   private final Collection<GrantedAuthority> authorities;
   private Map<String, Object> attributes;

   public static MemberPrincipal create(Member member) {
      return new MemberPrincipal(
         member.getEmail(),
         member.getPassword(),
         member.getProviderType(),
         member.getRole(),
         Collections.singletonList(new SimpleGrantedAuthority(member.getRoles().toString()))
      );
   }

   public static MemberPrincipal create(Member member, Map<String, Object> attributes) {
      MemberPrincipal memberPrincipal = create(member);
      memberPrincipal.setAttributes(attributes);

      return memberPrincipal;
   }
   @Override
   public String getName() {
      return username;
   }

   @Override
   public Map<String, Object> getAttributes() {
      return attributes;
   }

   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
      return authorities;
   }

   @Override
   public String getPassword() {
      return password;
   }

   @Override
   public String getUsername() {
      return username;
   }

   @Override
   public boolean isAccountNonExpired() {
      return true;
   }

   @Override
   public boolean isAccountNonLocked() {
      return true;
   }

   @Override
   public boolean isCredentialsNonExpired() {
      return true;
   }

   @Override
   public boolean isEnabled() {
      return true;
   }

   @Override
   public Map<String, Object> getClaims() {
      return null;
   }

   @Override
   public OidcUserInfo getUserInfo() {
      return null;
   }

   @Override
   public OidcIdToken getIdToken() {
      return null;
   }
}

9️⃣ SecurityConfig 설정

  • PasswordEncoder를 Bean으로 등록해야 함
  • JWT로 로그인을 구현하기 때문에 폼 로그인은 사용하지 않음
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {
   private final JwtProvider jwtProvider;

   @Bean
   public PasswordEncoder passwordEncoder() {
      return PasswordEncoderFactories.createDelegatingPasswordEncoder();
   }

   @Bean
   public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      http
         .headers().frameOptions().sameOrigin() /* h2 사용을 위해 */
         .and()
         .csrf().disable()
         .cors()
         .and()
         .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
         .and()
         .formLogin().disable()
         .httpBasic().disable()
         .apply(new JwtSecurityConfig(jwtProvider))
         .and()
         .authorizeHttpRequests(authorize -> authorize

            /* 메인 페이지는 모두 접근이 가능해야한다. */
            .antMatchers(HttpMethod.GET, "/").permitAll() /* 메인 페이지 */

            /* 회원 관련 접근 제한 */
            .antMatchers(HttpMethod.POST, "/members/signup").permitAll() /* 자체 회원가입 */
            .antMatchers(HttpMethod.POST, "/login").permitAll() /* 자체 로그인 */
            .antMatchers(HttpMethod.GET, "/login/**").permitAll() /* 소셜 로그인을 위해 */
            .antMatchers(HttpMethod.POST, "/login/**").permitAll() /* 소셜 로그인을 위해 */

			.anyRequest().authenticated()
         )
         .exceptionHandling()
         .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
         .accessDeniedHandler(new MemberAccessDeniedHandler());

      return http.build();
   }
}

🔟 그 외에 필요한 핸들러와 필터 구현

  • SecurityFilter  : OncePerRequestFilter를 상속받아 구현
  • MemberAccessDeniedHandler : 접근 할 수 없는 권한을 가진 경우, 접근 권한 제한 핸들러
  • MemberAuthenticationEntryPointHandler : 유효하지 않은 인증이거나, 인증 정보가 부족할 때
  • JwtSecurityConfig : SecurityFilter를 추가
  • WebMvcConfig : CORS 설정을 위한 config
@RequiredArgsConstructor
public class SecurityFilter extends OncePerRequestFilter {
   private final JwtProvider jwtProvider;
   private static final String BEARER_TYPE = "Bearer ";

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

      /* Header 에서 토큰 꺼내기 ->BEARER_TYPE 이 지워진 채 리턴*/
      String token = resolvedToken(request);

      /* 토큰이 공백 제외 1글자 이상이고, 유효성 검사를 통과하면 조건문 실행 */
      if (StringUtils.hasText(token) && jwtProvider.validate(token)) {
         Authentication authentication = jwtProvider.getAuthentication(token);
         SecurityContextHolder.getContext().setAuthentication(authentication);
      }

      /* 필터 체인 전달 요청 */
      filterChain.doFilter(request, response);
   }

   private String resolvedToken(HttpServletRequest request) {
      String bearerToken = request.getHeader("Authorization");

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

      return null;
   }
}

 

 

/* 접근에 필요한 권한 없이 접근 -> 403 forbidden 에러 */
@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
   @Override
   public void handle(HttpServletRequest request, HttpServletResponse response,
      AccessDeniedException accessDeniedException) throws IOException, ServletException {
      log.warn("403 Forbidden error happened: {}", accessDeniedException.getMessage());
      log.error("접근 권한 없음 에러 : {}", accessDeniedException.getMessage());
   }
}

 

/* 유효하지 않은 인증이거나 인증 정보가 부족 ->  401 Unauthorized 에러 */
@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
   @Override
   public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {
      log.warn("401 Unauthorized error happened: {}", authException.getMessage());
      log.error("유효하지 않은 인증 혹은 인증 정보 없음 에러 : {}", authException.getMessage());
   }
}

 

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
   private final JwtProvider jwtProvider;

   @Override
   public void configure(HttpSecurity http) {
      SecurityFilter customFilter = new SecurityFilter(jwtProvider);
      http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
   }
}

 

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

   @Override
   public void addCorsMappings(CorsRegistry registry) {
      registry.addMapping("/**")
         .allowedMethods("POST", "PUT", "GET", "DELETE", "OPTIONS", "PATCH") /* 요청 가능한 메서드 */
         .allowedHeaders("*") /* 헤더 허용 */
         .exposedHeaders("Authorization", "Refresh") /* 헤더를 통하여 토큰을 전달해야 하기 때문, 추가 헤더 허용 */
         .allowedOriginPatterns("*")
         .allowCredentials(true); /* 쿠키 요청 허용 */
   }
}

댓글