우선, 이 페이지가 세션 기반 인증 방식이 아닌 이유를 먼저 설명해야 할 것 같다.
왜 JWT를 사용했나?
모바일 앱의 등장이 주 원인이라고 할 수 있다. 모바일 앱을 추후에 만든다고 해도 통합된 인증 체계로부터 얻는 이점이 분명히 있다고 생각했다.
모바일 앱을 사용할 땐 주로 토큰 기반 인증 방식을 사용한다. 그 이유는 다음과 같다.
- 장기 세션 유지의 어려움: 모바일 디바이스는 종종 네트워크 연결 상태가 변할 수 있고, 앱이 백그라운드에서 종료되거나 장치가 재부팅될 수 있다. 세션은 이런 환경에서는 연결이 끊기기 쉬워 토큰 방식이 더 안정적인 경우가 많다.
- 스케일러빌리티: 세션 정보를 서버에서 관리해야 할 경우, 사용자가 많아질수록 서버의 부담이 커질 수 있다. 반면 토큰은 클라이언트 측에서 관리되기 때문에 서버는 인증을 확인만 하면 되므로 부하가 줄어든다.
- 다양한 플랫폼 지원: 모바일 앱뿐만 아니라 웹사이트, 다른 종류의 클라이언트에서도 동일한 방식으로 인증 시스템을 구현할 수 있다. 이는 통합된 인증 체계를 유지하기에 유리하다.
이러한 이유들이 내가 인증 방식을 JWT로 구현하고 싶어지게 했다.
우선, 개발 환경은 다음과 같다.
- Spring Boot 3.2.2
- Java 21
- Spring Security 3.2.2
- io.jsonwebtoken:jjwt 0.12.3
- MySQL 8
로그인 흐름
유저가 /login 으로 username, password를 같이 보내면 스프링 시큐리티 내부적으로는 UsernamePasswordAuthenticationFilter라는 필터를 통해 해당 유저가 현재 Database에 존재하는 유저인지 찾고 그 유저의 로그인 정보(ID, PW)가 일치하는지 확인해서 맞다면 JWT토큰을 만들어서 유저에게 반환한다. (그림에서는 JWT 토큰 반환은 생략)
인증과 인가의 차이가 뭔가요?
- 인증(Authentication): 인증은 사용자가 누구인지 확인하는 과정. 사용자가 시스템에 로그인할 때 아이디와 비밀번호를 제공하는 것이 대표적인 예. 스프링 시큐리티에서는 AuthenticationManager가 이 역할을 담당하고, 사용자의 신원을 확인한 후 'Authentication' 객체에 이 정보를 저장한다.
- 인가(Authorization): 인가는 인증된 사용자가 특정 자원에 접근하거나 특정 작업을 수행할 수 있는 권한을 가지고 있는지 확인하는 과정. 예를 들어 어떤 사용자가 특정 페이지에 접근하거나 데이터를 수정할 권한이 있는지 검사하는 것
Unauthorization과 Forbidden의 차이는요?
- Unauthorization(401): 인증이 실패했을 때 반환. 아이디나 비밀번호가 잘못됐거나 아예 제공되지 않았을 때. 즉, 사용자가 누구인지 시스템이 식별하지 못했을 때 발생
- Forbidden(403): 사용자 인증은 성공적으로 마쳤지만, 요청한 자원에 대한 접근 권한이 없을 때 반환. 예를 들어, 사용자가 로그인은 했지만 관리자 페이지에 접근하려 할 때 해당 페이지에 대한 접근 권한이 없는 경우 발생.
정리하자면, "Unauthorization은 당신이 누구인지 모르겠으니 로그인이 필요합니다"라는 의미이고, Forbidden은 "당신이 누구인지 알겠지만, 이 작업을 수행할 권한이 없습니다"라는 의미이다.
의존성
build.gradle
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
//MySQL
implementation 'mysql:mysql-connector-java:8.0.33'
엔티티 설계 및 구현과 레포지토리
내가 만드는 서비스에 접속할 수 있는 유저는 오로지 어드민뿐이다. 그 중에서도 선택받은 어드민 유저만 접속하게 하기 위해 Administrator라는 엔티티를 만들었다.
Administrator (src/main/java/path/your/package/entity/Administrator.java)
import jakarta.persistence.*;
import kr.co.tbell.mm.entity.BaseEntity;
import lombok.*;
@Getter
@Entity
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Administrator extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Enumerated(value = EnumType.STRING)
private Role role;
@Column(nullable = false)
private boolean isExpired;
@Column(nullable = false)
private boolean isLocked;
@Column(nullable = false)
private boolean isCredentialsExpired;
@Builder.Default
@Column(nullable = false)
private boolean isEnabled = true;
public boolean isAccountNonExpired() {
return !isExpired;
}
public boolean isAccountNonLocked() {
return !isLocked;
}
public boolean isCredentialsNonExpired() {
return !isCredentialsExpired;
}
public boolean isEnabled() {
return isEnabled;
}
}
Role Enum 클래스를 보자. 이 Enum 클래스에선 Role이 추가될수도 아닐수도 있겠지만 스프링 시큐리티에서 롤 관련 인가 정책을 잘 만들어 두었기 때문에 사용해보기로 했다.
Role (src/main/java/path/your/package/entity/Role.java)
import lombok.Getter;
import java.util.Arrays;
@Getter
public enum Role {
ROLE_ADMIN("ROLE_ADMIN");
private final String description;
Role(String description) {
this.description = description;
}
public static Role getRole(String description) {
return Arrays.stream(Role.values())
.filter(role -> role.description.equals(description))
.findFirst()
.orElse(null);
}
}
AdministratorRepository (src/main/java/path/your/package/repository/AdministratorRepository.java)
레포지토리는 간단하다. 추후에 구현해야 할 UserDetailsService 인터페이스를 위해 findByUsername()을 인터페이스에 추가했다.
import kr.co.tbell.mm.entity.administrator.Administrator;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AdministratorRepository extends JpaRepository<Administrator, Long> {
Optional<Administrator> findByUsername(String username);
}
회원가입
회원가입을 해야 로그인을 할 수 있으니까 회원가입 컨트롤러 - 서비스 레벨을 구현한다.
회원가입 컨트롤러
AdministratorController (src/main/java/path/your/package/controller/AdministratorController.java)
import jakarta.validation.Valid;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import kr.co.tbell.mm.dto.administrator.ReqCreateAdministrator;
import kr.co.tbell.mm.dto.administrator.ResCreateAdministrator;
import kr.co.tbell.mm.dto.common.Response;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.service.administrator.AdministratorService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import javax.management.InstanceAlreadyExistsException;
@Slf4j
@RestController
@RequestMapping("/api/v1/admin")
@RequiredArgsConstructor
public class AdministratorController {
private final AdministratorService administratorService;
@PostMapping("/signup")
public ResponseEntity<Response<ResCreateAdministrator>> signup(
@RequestBody @Valid ReqCreateAdministrator reqCreateAdministrator) {
ResCreateAdministrator administrator;
try {
administrator = administratorService.createAdministrator(reqCreateAdministrator);
} catch (InstanceAlreadyExistsException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new Response<>(false, e.getMessage(), null));
}
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new Response<>(true, null, administrator));
}
}
위 컨트롤러에서 사용하는 DTO정보는 다음과 같다.
ReqCreateAdministrator (src/main/java/path/your/package/dto/ReqCreateAdministrator.java)
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ReqCreateAdministrator {
@NotNull(message = "'username' must be required.")
private String username;
@NotNull(message = "'password' must be required.")
private String password;
}
ResCreateAdministrator (src/main/java/path/your/package/dto/ResCreateAdministrator.java)
import kr.co.tbell.mm.entity.administrator.Role;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ResCreateAdministrator {
private Long id;
private String username;
private Role role;
}
회원가입 서비스
AdministratorService (src/main/java/path/your/package/service/AdministratorService.java)
import kr.co.tbell.mm.dto.administrator.ReqCreateAdministrator;
import kr.co.tbell.mm.dto.administrator.ResCreateAdministrator;
import javax.management.InstanceAlreadyExistsException;
public interface AdministratorService {
ResCreateAdministrator createAdministrator(ReqCreateAdministrator reqCreateAdministrator)
throws InstanceAlreadyExistsException;
}
AdministratorServiceImpl (src/main/java/path/your/package/service/AdministratorServiceImpl.java)
import kr.co.tbell.mm.dto.administrator.*;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.repository.administrator.AdministratorRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import javax.management.InstanceAlreadyExistsException;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AdministratorServiceImpl implements AdministratorService, UserDetailsService {
private final AdministratorRepository administratorRepository;
private final PasswordEncoder passwordEncoder;
@Override
public ResCreateAdministrator createAdministrator(ReqCreateAdministrator reqCreateAdministrator)
throws InstanceAlreadyExistsException {
Optional<Administrator> adminOptional =
administratorRepository.findByUsername(reqCreateAdministrator.getUsername());
if (adminOptional.isPresent()) {
throw new InstanceAlreadyExistsException("Admin already exists with username: "
+ reqCreateAdministrator.getUsername());
}
Administrator admin = Administrator
.builder()
.username(reqCreateAdministrator.getUsername())
.password(passwordEncoder.encode(reqCreateAdministrator.getPassword()))
.role(Role.ROLE_ADMIN)
.build();
Administrator savedAdmin = administratorRepository.save(admin);
return new ResCreateAdministrator(savedAdmin.getId(), savedAdmin.getUsername(), savedAdmin.getRole());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Administrator> byUsername = administratorRepository.findByUsername(username);
if (byUsername.isEmpty()) {
throw new UsernameNotFoundException(username);
}
return new CustomAdministratorDetails(byUsername.get());
}
}
이 AdministratorServiceImpl에서는 두개의 인터페이스를 구현한다. AdministratorSerivce, UserDetailsService.
이 UserDetailsService는 스프링 시큐리티에서 회원 인증을 위해 구현해야 하는 인터페이스다. 이 인터페이스를 구현해서 데이터베이스로부터 특정 'Username'을 가진 유저가 있는지 찾아, 있다면 UserDetails 라는 인터페이스 타입의 클래스를 반환한다. UserDetails도 마찬가지로 직접 구현해야한다.
CustomAdministratorDetails (src/main/java/path/your/package/dto/CustomAdministratorDetails.java)
이 클래스가 UserDetails를 구현하는 클래스이다.
import kr.co.tbell.mm.entity.administrator.Administrator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@RequiredArgsConstructor
public class CustomAdministratorDetails implements UserDetails {
private final Administrator administrator;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return administrator.getRole().getDescription();
}
});
return collection;
}
@Override
public String getPassword() {
return administrator.getPassword();
}
@Override
public String getUsername() {
return administrator.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return administrator.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return administrator.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return administrator.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return administrator.isEnabled();
}
}
SecurityConfiguration
SecurityConfiguration (src/main/java/path/your/package/config/SecurityConfiguration.java)
import jakarta.servlet.http.HttpServletRequest;
import kr.co.tbell.mm.entity.administrator.Role;
import kr.co.tbell.mm.jwt.JwtFilter;
import kr.co.tbell.mm.jwt.JwtManager;
import kr.co.tbell.mm.jwt.LoginFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.Collections;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtManager jwtManager;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// JWT 방식을 사용하기 때문에 CSRF 공격에 대한 위험성 X
http.csrf(AbstractHttpConfigurer::disable);
// JWT 방식을 사용하기 때문에 FormLogin, Basic 방식을 Disable
http.formLogin(AbstractHttpConfigurer::disable);
http.httpBasic(AbstractHttpConfigurer::disable);
// 한개의 아이디에 대해 최대 중복 로그인 개수 maximumSessions
// maxSessionPreventsLogin 다중 로그인 개수를 초과했을 때 처리방법. true: 새로운 로그인 차단, false: 기존 세션 하나 삭제
// JWT 방식을 사용하기 때문에 SessionCreationPolicy STATELESS
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// AntPathRequestMatcher 객체를 사용한 이유는 개인적으로 가시성이 좀 더 좋고 어떤 Http Method인지 바로 알 수 있어서 사용
// 굳이 안 사용해도 된다.
http.authorizeHttpRequests(request ->
request.requestMatchers(
new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
new AntPathRequestMatcher("/login", "POST")
).permitAll()
.anyRequest().hasRole("ADMIN"));
// LoginFilter 앞에 JwtFilter 등록
http.addFilterBefore(new JwtFilter(jwtManager), LoginFilter.class);
// addFilterAt은 정확히 그 필터(UsernamePasswordAuthenticationFilter)를 내가 만든 LoginFilter로 대체하겠다는 메서드.
// addFilterBefore, addFilterAfter 이 것들은 말 그대로 그 전 또는 그 후에 붙이겠다는 의미
http.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), jwtManager),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
위 코드에서 URL Path에 따라 인증이 필요한지 또는 특정 Role을 가진 유저인지 확인하는 다음 코드를 보자.
http.authorizeHttpRequests(request ->
request.requestMatchers(
new AntPathRequestMatcher("/api/v1/admin/signup", "POST"),
new AntPathRequestMatcher("/login", "POST")
).permitAll()
.anyRequest().hasRole("ADMIN"));
이 코드는 "/api/v1/admin/signup", "/login"으로의 요청은 인증이 필요없이 모두 허가한다는 의미이고, 그 외(anyRequest())는 모두 ADMIN Role이 있어야 접근 가능하다는 설정이다.
LoginFilter (src/main/java/path/your/package/jwt/LoginFilter.java)
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtManager jwtManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
log.info("[attemptAuthentication]: Username : {}, Password: {}", username, password);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authentication) {
log.info("[successfulAuthentication]: Authentication Success");
CustomAdministratorDetails administratorDetails = (CustomAdministratorDetails) authentication.getPrincipal();
String username = administratorDetails.getUsername();
String role = authentication
.getAuthorities()
.iterator()
.next()
.getAuthority();
String token = jwtManager.createJwt(username, role);
response.addHeader("Authorization", "Bearer " + token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed) throws IOException {
log.info("[unsuccessfulAuthentication]: Authentication Failed");
String errorMessage = String.format("error: Failed login with this username: %s", obtainUsername(request));
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(errorMessage);
}
}
JwtFilter (src/main/java/path/your/package/jwt/JwtFilter.java)
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.tbell.mm.dto.administrator.CustomAdministratorDetails;
import kr.co.tbell.mm.entity.administrator.Administrator;
import kr.co.tbell.mm.entity.administrator.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtManager jwtManager;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.error("[doFilterInternal]: JWT token does not exist.");
filterChain.doFilter(request, response);
return;
}
String token = authorization.split(" ")[1];
if (jwtManager.isExpired(token)) {
log.error("[doFilterInternal]: JWT token expired.");
filterChain.doFilter(request, response);
return;
}
String username = jwtManager.getUsername(token);
String roleStringValue = jwtManager.getRole(token);
// 현재 로그인 한 사용자 정보를 기반으로 Administrator 객체 생성
Administrator administrator = Administrator
.builder()
.username(username)
.role(Role.getRole(roleStringValue))
.build();
// UserDetails 회원 정보 객체에 현재 로그인 한 사용자 담기
CustomAdministratorDetails administratorDetails = new CustomAdministratorDetails(administrator);
// 스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(
administratorDetails,
null,
administratorDetails.getAuthorities());
// 스프링 시큐리티 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
JwtManager (src/main/java/path/your/package/jwt/JwtManager.java)
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtManager {
private final SecretKey secretKey;
public JwtManager(@Value("${jwt.secret}") String secret) {
secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
Jwts.SIG.HS256.key().build().getAlgorithm());
}
private static final long EXPIRED_MS = 1800000L; // 30분
public String getUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
public String createJwt(String username, String role) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + EXPIRED_MS))
.signWith(secretKey)
.compact();
}
}
테스트
Authorization을 헤더에 넣지 않고 요청하면 특정 Path가 아니고선 전부 403 Forbidden.
로그인
/login으로 username, password를 입력 후 요청하면 200 OK가 떨어지고 Response Headers에 JWT Token이 발급된다.
이 Token을 이용해서 다시 위에서 인가 거부된 요청을 날려보면 다음과 같이 정상 응답한다.
'Spring, Apache, Java' 카테고리의 다른 글
스프링 빈은 무상태로 설계해야 한다 ❗️ (0) | 2024.05.24 |
---|---|
SOLID - 좋은 객체 지향 설계의 5가지 원칙 (2) | 2024.05.22 |
Spring Application Architecture (2) | 2023.12.01 |
[Spring/JPA] 컨트롤러에서 Entity를 반환할 때 생기는 문제점 (0) | 2023.11.13 |
[Spring Boot] 서버 재시작을 하지 않고 static 파일 변경 적용하기 (0) | 2023.10.30 |