be_better

[Springboot] 서비스와 인증과 권한 부여 - 스프링 시큐리티와 JWT 적용 본문

Springboot/이론

[Springboot] 서비스와 인증과 권한 부여 - 스프링 시큐리티와 JWT 적용

NiceTry 2023. 12. 7. 16:52

목차

    스프링 시큐리티와 JWT 적용

    SpringSecurity 의존성

    프로젝트 생성후 의존성을 추가
    // gradle
    dependencies {
      // spring security
      implementation 'org.springframework.boot:spring-boot-starter-security'
      // jwt 토큰
      implementation 'io.jsonwebtoken:jjwt:0.9.1'
    }
    
    // maven
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
      <version>0.9.1</version>
    </dependency>

     

     

    UserDetails와 UserDetailsService 구현

    data/entity/User.java
    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    @Entity
    public class User implements UserDetails {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private long id;
    
        @Column(nullable = false, unique = true)
        private String uid;
    
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Column(nullable = false)
        private String password;
    
        @Column(nullable = false)
        private String name;
    
        @ElementCollection(fetch = FetchType.EAGER)
        @Builder.Default // 필드값이 초기값을 갖고 있을 때 사용
        private List<String> roles = new ArrayList<>();
    
        /*
         계정이 가지고 있는 권한 목록 리턴
         */
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return roles
                    .stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
        }
    
        /*
        계정의 이름을 리턴
        일반적으로 아이디(이메일)를 리턴
         */
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Override
        public String getUsername() {
            return this.uid;
        }
    
        /*
        계정이 만료되었는지를 리턴
        true는 만료되지 않았음을 의미
         */
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        /*
        계정이 잠겨있는지 리턴
        true는 잠겨있지 않음을 의미
         */
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        /*
        비밀번호가 만료되었는지 리턴
         */
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        /*
        계정이 활성화되어 있는지 리턴
         */
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        @Override
        public boolean isEnabled() {
            return true;
        }
    }

     

    UserDetails는

    UserDetailsService 를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할

     

    • getAuthorities(): 계정이 가지고 있는 권한 목록 리턴
    • getPassword(): 계정의 비밀번호 리턴
    • getUsername(): 계정의 이름 리턴, 일반적으로 아이디 리턴
    • isAccountNonExpired(): 계정이 만료되었는지 리턴. true -> 만료 x
    • isAccountNonLocked(): 계정이 잠겨있는지 리턴. true -> unLock
    • isCredentialNonExpired(): 비밀번호가 만료되었는지 리턴. true -> 만료 x
    • isEnabled(): 계정이 활성화 되어 있는지 리턴. true -> 활성화 상태

     

    UserRepository 구현 (data/repository/UserRepository.java)
    @Repository
    public interface UserRepository extends JpaRepository<User, Long> {
        
        User getByUid(String uid);
        
    }

     

    UserDetailsServiceImpl 구현 (service/impl/UserDetailsService.java)
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        private final UserRepository userRepository;
    
        /*
        Username(uid) 로 User 조회
         */
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            log.info("[loadUserByUsername] loadUserByUsername 수행, username : {}", username);
            return userRepository.getByUid(username);
        }
    
    }

     

    UserDetailsService
    public interface UserDetailsService {
    
       UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
    
    }

     

    UserDetails

    • 스프링 시큐리티에서 제공하는 개념.
    • UserDetails의 username은 각 사용자를 구분하는 ID(email, id, 등..)
    • UserDetails의 구현체로 User 엔티티를 생성했기 때문에
      UserDetailsServiceImpl에서는User 객체를 리턴하게끔 구현한 것

    JwtTokenProvider 구현

    이제 UserDetails에서 JWT 토큰 생성에 필요한 정보를 얻을 수 있게 되었다.

     

    JWT 토큰을 생성하는 JwtTokenProvider 생성 (config/security/JwtTokenProvider.java)
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class JwtTokenProvider {
    
        private final UserDetailsService userDetailsService;
    
        @Value("${springboot.jwt.secret}")
        private String secretKey = "secretKey";
        private final long tokenValidMillisecond = 1000L * 60 * 60;
    
        @PostConstruct
        protected void init() {
            log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
            secretKey = Base64
                    .getEncoder()
                    .encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
    
            log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
        }
    
        public String createToken(String userUid, List<String> roles) {
            log.info("[createToken] 토큰 생성 시작");
            Claims claims = Jwts.claims()
                    .setSubject(userUid);
            claims.put("roles", roles);  // 일종의 map
    
            Date now = new Date(System.currentTimeMillis());
    
            String token = Jwts.builder()       // 토큰 생성
                    .setClaims(claims)
                    //  시작 시간 : 현재 시간기준으로 만들어짐
                    .setIssuedAt(now)
                    // 끝나는 시간 : 지금 시간 + 유지할 시간(입력받아옴)
                    .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
    
            log.info("[createToken] 토큰 생성 완료");
            return token;
        }
    
        /*
        토큰 인증 정보 조회
         */
        public Authentication getAuthentication(String token) {
            log.info("[getAuthentication] 토큰 인증 정보 조회 시작");
            UserDetails userDetails =
                    userDetailsService.loadUserByUsername(this.getUsername(token));
            log.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails Username : {}", userDetails.getUsername());
            return new UsernamePasswordAuthenticationToken(
                    userDetails, "", userDetails.getAuthorities()
            );
        }
    
        /*
        token 으로 uid 추출
         */
        private String getUsername(String token) {
            log.info("[getUsername] 토큰 기반 회원 email 추출");
            String info = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token) // Jws<Claims>
                    .getBody() // Claims
                    .getSubject(); // uid 값
            log.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
            return info;
        }
    
        /*
        헤더로부터 token 추출
         */
        public String resolveToken(HttpServletRequest request) {
            log.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
            return request.getHeader("X-AUTH-TOKEN");
        }
    
        /*
        토큰 유효성 체크
         */
        public boolean validateToken(String token) {
            log.info("[validateToken] 토큰 유효 체크 시작");
            try {
                Jws<Claims> claims =
                        Jwts.parser()
                                .setSigningKey(secretKey)
                                .parseClaimsJws(token);
    
                return !claims.getBody().getExpiration().before(new Date());
            } catch (Exception e) {
                log.info("[validateToken] 토큰 유효 체크 예외 발생");
                return false;
            }
        }
    
    }

     

    application.yml 파일에 @Value 값 정의
    springboot:
      jwt:
        secret: "asdfasdfqwer"

    yml 파일에서 값을 가져올 수 없다면 직접 지정해준 'secretKey' 값을 사용한다.

     

    init()

    • @PostConstruct - 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 지칭
    • secretKey를 Base64 형식으로 인코딩

    createToken()

    • JWT 토큰의 payload 에 값을 넣기 위해 Claims 사용.
    • User의 uid 값을 setSubject() 를 통해 sub 속성에 추가.
    • claims.put("key", "value") 로 role 값을 별개로 추가.
    • Jwts.builder()를 사용해 토큰 생성

    getAuthentication()

    • 필터에서 인증 성공 -> SecurityContextHolder에 저장할 Authentication 생성
    • UsernamePasswordAuthenticationToken 사용해 Authentication 구현

     

    UsernamePasswordAuthenticationToken 구조

    Authentication, CredentialContainer 인터페이스를 구현하는

    AbstractAuthenticationToken를 상속하는 구조

     

    getUsername()

    Jwts.parser()를 통해 secretKey 값을 확인 후 sub 값 추출

     

    resolveToken()

    • 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴
    • 클라이언트가 헤더를 통해 JWT 토큰 값을 전달해야 추출 가능
    • 헤더의 이름은 임의로 변경 가능

     

    validateToken()

    클레임의 유효기간 체크 후 boolean 타입의 값 리턴

     

    JwtAuthenticationFilter 구현

    JwtAuthenticationFilter

    - JWT 토큰을 인증하고 SecurityContextHolder에 추가하는 필터 설정 클래스

     

    JwtAuthenticationFilter (config/security/JwtAuthenticationFilter.java)
    @Slf4j
    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
            this.jwtTokenProvider = jwtTokenProvider;
        }
    
        @Override
        protected void doFilterInternal(
                HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {
            
            String token = jwtTokenProvider.resolveToken(request);
            log.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
    
            log.info("[doFilterInternal] token 값 유효성 체크 시작");
            if (token != null && jwtTokenProvider.validateToken(token)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("[doFilterInternal] token 값 유효성 체크 완료");
            }
    
            filterChain.doFilter(request, response);
            
        }
    
    }

     

    doFilterInternal()

    • 마지막 줄 doFilter()
      • 서블릿 실행 메서드.
      • doFilter() 앞에 작성한 코드는 서블릿이 실행되기 전에 실행
      • 뒤에 작성된 코드는 서블릿이 실행된 후 실행
    • 토큰이 유효하다면 Authentication 객체를 생성해 SecurityContextHolder에 추가

     

    SeuciryConfiguration 구현

    스프링 시큐리티 관련 설정.

    WebSecurityConfigureAdapter를 상속받는 Configuration 클래스 구현 (대표적인 방법)

     

    SecurityConfiguration 클래스 (config/security/SecurityConfiguration.java)
    @Slf4j
    @Configuration
    public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        @Autowired
        public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
            this.jwtTokenProvider = jwtTokenProvider;
        }
    
        @Override
        protected void configure(HttpSecurity httpSecurity) throws Exception {
            log.info("[configure] Security configure start");
            httpSecurity.httpBasic().disable()
    
                    .csrf().disable()
    
                    .sessionManagement()
                    .sessionCreationPolicy(
                            SessionCreationPolicy.STATELESS
                    )
    
                    .and()
                    .authorizeRequests()
                    .antMatchers("/sign-api/sign-up", "/sign-api/sign-in",
                            "/sign-api/exception").permitAll()
                    .antMatchers(HttpMethod.GET, "/product/**").permitAll()
    
                    // 그 외
                    .antMatchers("**exception**").permitAll()
    
                    .anyRequest().hasRole("ADMIN")
    
                    .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                    .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
    
                    .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                            UsernamePasswordAuthenticationFilter.class)
            ;
        }
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring()
                    .antMatchers(
                            "/v2/api-docs",
                            "/swagger-resources/**",
                            "/swagger-ui.html",
                            "/webjars/**",
                            "/swagger/**",
                            "/sign-api/exception"
                    );
        }
    }

     

    HttpSecurity configure() 

    대표적인 기능

    • 리소스 접근 권한 설정
    • 인증 실패 시 발생하는 예외 처리
    • 인증 로직 커스터마이징
    • csrf, cors 등의 스프링 시큐리티 설정

     

    세부 로직

    • httpBasic().disable()
      • 기본값인 UI 사용 설정 비활성화
    • csrf().disable()
      • CSRF는 Cross-Site Request Forgery의 줄임말 - '사이트 간 요청 위조'를 의미
      • REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화
      • csrf() 메서드는 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰 검증
      • 브라우저 사용 환경이 아니라면 비활성화해도 크게 문제가 되지 않음
    사이트 간 요청 위조
    
    웹 애플리케이션의 취약점 중 하나로서
    사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써
    특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 하는 공격 방법
    • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      • REST API 기반 애플리케이션의 동작 방식 설정
      • Session 대신 JWT 토큰으로 인증 처리 하기 때문에 STATELESS 로 설정
    • authorizeRequest()
      • 애플리케이션에 들어오는 요청에 대한 사용 권한 체크
      • antMatchers() 메서드는 antPattern을 통해 권한 설정
      • permitAll(): 모두에게 허용
      • '/product'로 시작하는 GET 요청 모두 허용
      • 'exception' 단어가 들어간 경로는 모두 허용
      • 기타 요청은 인증된 권한을 가진 사용자에게 허용
    • exceptionHandling().accessDeniedHandler()
      • 권한 확인 과정에서 예외 발생할 경우 예외 전달
    • exceptionHandling().authenticationEntryPoint()
      • 인증 과정에서 예외 발생할 경우 예외 전달
    • addFilterBefore()
      • 커스텀 필터를 어느 필터 앞에 추가할 것인지 설정할 것인지 결정
      • UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter를 추가한다는 의미

    WebSecurity configure()

    WebSecurity는 HttpSecurity 앞단에 적용됨.

     

    인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용됨.

     

    커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

    CustomAccessDeniedHandler 클래스 (config/secutiry/CustomAccessDeniedHandler.java)
    @Slf4j
    @Component
    public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request,
                           HttpServletResponse response,
                           AccessDeniedException accessDeniedException)
                throws IOException, ServletException {
    
            log.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
            response.sendRedirect("/sign-api/exception");
    
        }
    
    }
    • AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외.
    • 이 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용됨.

     

    CustomAuthenticationEntryPoint 클래스 (config/secutiry/CustomAuthenticationEntryPoint.java)
    @Slf4j
    @Component
    public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            log.info("[commence] 인증 실패로 response.sendError 발생");
    
            EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
            entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
    
            response.setStatus(401);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
        }
        
    }

     

    EntryPointErrorResponse 클래스 (data/dto/EntryPointErrorResponse.java)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString
    public class EntryPointErrorResponse {
    
        private String msg;
    
    }

     

    회원가입과 로그인 구현

    SignUpResultDto 클래스 (data/dto/SignUpResultDto.java)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString
    public class SignUpResultDto {
        
        private boolean success;
        
        private int code;
        
        private String ms;
        
    }

     

    SignInResultDto 클래스 (data/dto/SignInResultDto.java)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString
    public class SignInResultDto extends SignUpResultDto{
    
        private String token;
    
        @Builder
        public SignInResultDto(boolean success, int code, String msg, String token) {
            super(success, code, msg);
            this.token = token;
        }
    
    }

     

    SignService 인터페이스 (service/SignService.java)
    public interface SignService {
    
        SignUpResultDto signUp(String id, String password, String name, String role);
    
        SignInResultDto signIn(String id, String password) throws RuntimeException;
    
    }

     

    SignServiceImpl 클래스 (service/impl/SignServiceImpl.java)
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class SignServiceImpl implements SignService {
    
        public UserRepository userRepository;
        public JwtTokenProvider jwtTokenProvider;
        public PasswordEncoder passwordEncoder;
    
        @Autowired
        public SignServiceImpl(UserRepository userRepository,
                               JwtTokenProvider jwtTokenProvider,
                               PasswordEncoder passwordEncoder) {
            this.userRepository = userRepository;
            this.jwtTokenProvider = jwtTokenProvider;
            this.passwordEncoder = passwordEncoder;
        }
    
        @Override
        public SignUpResultDto signUp(String id, String password, String name, String role) {
            log.info("[getSignUpResult] 회원 가입 정보 전달");
            User user;
            if (role.equalsIgnoreCase("admin")) {
                user = User.builder()
                        .uid(id)
                        .name(name)
                        .password(passwordEncoder.encode(password))
                        .roles(Collections.singletonList("ROLE_ADMIN"))
                        .build();
            } else {
                user = User.builder()
                        .uid(id)
                        .name(name)
                        .password(passwordEncoder.encode(password))
                        .roles(Collections.singletonList("ROLE_USER"))
                        .build();
            }
    
            User savedUser = userRepository.save(user);
            SignUpResultDto signUpResultDto = new SignUpResultDto();
            log.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
    
            if (!savedUser.getName().isEmpty()) {
                log.info("[getSignUpResult] 정상 처리 완료");
                setSuccessResult(signUpResultDto);
            } else {
                log.info("[getSignUpResult] 실패 처리 완료");
                setFailResult(signUpResultDto);
            }
    
            return signUpResultDto;
        }
    
        @Override
        public SignInResultDto signIn(String id, String password) {
            log.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
            User user = userRepository.getByUid(id);
            log.info("[getSignInResult] id : {}", id);
    
            log.info("[getSignInResult] 패스워드 비교");
            if (!passwordEncoder.matches(password, user.getPassword())) {
                throw new RuntimeException();
            }
            log.info("[getSignInResult] 패스워드 일치");
    
            log.info("[getSignInResult] SignInResultDto 객체 생성");
            SignInResultDto signInResultDto =
                    SignInResultDto.builder()
                            .token(jwtTokenProvider.createToken(
                                            String.valueOf(user.getUid()),
                                            user.getRoles()
                                    )
                            )
                            .build();
            
            log.info("[getSignInResult] SignInResultDto 객체에 값 주입");
            setSuccessResult(signInResultDto);
            
            return signInResultDto;
        }
        
        private void setSuccessResult(SignUpResultDto result) {
            result.setSuccess(true);
            result.setCode(CommonResponse.SUCCESS.getCode());
            result.setMsg(CommonResponse.SUCCESS.getMsg());
        }
        
        private void setFailResult(SignUpResultDto result) {
            result.setSuccess(false);
            result.setCode(CommonResponse.FAIL.getCode());
            result.setMsg(CommonResponse.FAIL.getMsg());
        }
    }

     

    PasswordEncoder 활용을 위해 Bean으로 등록해야 함.

    PasswordEncoderConfiguration 클래스 (config/PasswordEncoderConfiguration.java)
    @Configuration
    public class PasswordEncoderConfiguration {
        
        @Bean
        public PasswordEncoder passwordEncoder() {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
        
    }

     

    SignServiceImpl 내부 로직 동작 과정

    1. id를 기반으로 UserRepository에서 User 엔티티를 가져옴.
    2. PasswordEncoder를 사용해 db에 저장되어있는 password 와 입력받은 password 일치 여부 확인
    3. password가 일치해서 인증 통과 -> JwtTokenProvider를 통해 id와 role 값으로 토큰 생성 후 전달
    CommonResponse 클래스 (common/CommonResponse.java)
    public enum CommonResponse {
        
        SUCCESS(0, "Success"), FAIL(-1, "Fail");
        
        int code;
        String msg;
        
        CommonResponse(int code, String msg) {
            this.code = code;
            this.msg = msg;
        }
        
        public int getCode() {
            return code;
        }
        
        public String getMsg() {
            return msg;
        }
        
    }

     

    SignController 전체 코드 (controller/SignController.java)
    @Slf4j
    @RestController
    @RequestMapping("/sign-api")
    public class SignController {
    
        private final SignService signService;
    
        @Autowired
        public SignController(SignService signService) {
            this.signService = signService;
        }
    
        @PostMapping(value = "/sign-in")
        public SignInResultDto signIn(
                @ApiParam(value = "ID", required = true) @RequestParam String id,
                @ApiParam(value = "Password", required = true) @RequestParam String password)
                throws RuntimeException {
            
            log.info("[signIn] 로그인 시도. id : {}, pw : ****", id);
            SignInResultDto signInResultDto = signService.signIn(id, password);
            
            if (signInResultDto.getCode() == 0) {
                log.info("[signIn] 정상적으로 로그인되었습니다. id : {}, token : {}", id, signInResultDto.getToken());
            }
            
            return signInResultDto;
        }
        
        @PostMapping(value = "/sign-up")
        public SignUpResultDto signUp(
                @ApiParam(value = "ID", required = true) @RequestParam String id,
                @ApiParam(value = "비밀번호", required = true) @RequestParam String password,
                @ApiParam(value = "이름", required = true) @RequestParam String name,
                @ApiParam(value = "권한", required = true) @RequestParam String role) {
            log.info("[signUp] 회원가입을 수행합니다. id : {}, password: ****, name : {}, role : {}", id, name, role);
            SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);
            
            log.info("[signUp] 회원가입을 완료했습니다. id : {]", id);
            return signUpResultDto;
        }
        
        @PostMapping(value = "exception")
        public void exceptionTest() throws RuntimeException {
            throw new RuntimeException("접근이 금지되었습니다.");
        }
        
        @ExceptionHandler(value = RuntimeException.class)
        public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
            HttpHeaders responseHeaders = new HttpHeaders();
            // responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
            HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
            
            log.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());
            
            Map<String, String> map = new HashMap<>();
            map.put("error type", httpStatus.getReasonPhrase());
            map.put("code", "400");
            map.put("message", "에러 발생");
            
            return new ResponseEntity<>(map, responseHeaders, httpStatus);
        }
    }