문제 발생 상황

npx expo start --web


***


TypeError: fetch failed
    at node:internal/deps/undici/undici:13502:13
    at processTicksAndRejections (node:internal/process/task_queues:105:5)

Metro Bundler가 시작되지만, 네트워크 요청을 처리하지 못하고 종료됨

 

문제 원인 분석

1️⃣ Expo가 내부적으로 HTTPS 요청을 보낼 때 SSL 인증 문제 발생

  • Expo가 네트워크 요청을 보내서 필요한 데이터를 가져와야 하는데 실패
  • 특히 Windows 환경에서는 TLS 인증 관련 오류가 발생할 가능성 있음

2️⃣ 네트워크 문제는 아님

  • 브라우저에서 https://exp.host/ 접속 시 정상적으로 열림
  • 인터넷 연결 문제 없음

3️⃣ 방화벽/프록시 영향 가능성

  • VPN 사용 여부 확인 (사용 중지)
  • Windows 방화벽이 Expo 요청을 차단할 가능성 있음

해결 과정

.env 파일 생성 및 NODE_TLS_REJECT_UNAUTHORIZED=0 설정

  1. 프로젝트 루트(fullStackProject1-front/)에 .env 파일 생성
  2. .env 파일에 다음 내용 추가
  3. Expo 실행

 

해결 방법 정리

  1. .env 파일 생성 후 NODE_TLS_REJECT_UNAUTHORIZED=0 설정 (최고 효과적)
  2. Expo CLI 최신 버전 업데이트 (npm install -g expo-cli)
  3. Expo 캐시 삭제 후 재시작 (npx expo start --clear)

'웹개발 > 프론트엔드' 카테고리의 다른 글

JAVASCRIPT Promise  (0) 2022.08.09
REACT componentDidMount  (0) 2022.08.09
REACT 프로젝트 생성  (0) 2022.08.03
프론트엔드란  (0) 2022.08.03

제목: OAuth2 + JWT 코드에서 /api/admin/** 경로에 접근하지 못했던 이유와 해결 과정


문제 정의

Spring Boot 애플리케이션에서 OAuth2 인증과 JWT 기반의 권한 관리를 설정하면서, ROLE_ADMIN 권한을 가진 사용자가 /api/admin/** 경로에 접근할 수 없다는 문제가 발생했습니다. 이번 포스팅에서는 이 문제의 원인을 찾고, 해결하는 과정을 공유하고자 합니다.


문제 발생: /api/admin/** 경로에 접근하지 못하는 이유

OAuth2 인증 후 사용자가 ROLE_ADMIN 권한을 가지고 있음에도 불구하고, /api/admin/** 경로에 접근할 때 403 Forbidden 응답이 반환되었습니다. 다음은 이 문제를 해결하기 위한 디버깅 과정과 해결 방법에 대한 기록입니다.

1. 문제 증상

로그를 통해 문제를 확인한 결과, 사용자가 ROLE_ADMIN 권한을 가지고 있음에도 불구하고, Spring Security에서 해당 권한을 인식하지 못하고 있었습니다.

 

2024-06-23 19:55:27 [http-nio-8080-exec-10] DEBUG o.s.security.web.FilterChainProxy - Securing POST /api/admin/announcements
2024-06-23 19:55:27 [http-nio-8080-exec-9] DEBUG o.s.s.w.a.AnonymousAuthenticationFilter - Set SecurityContextHolder to anonymous SecurityContext
2024-06-23 19:55:27 [http-nio-8080-exec-9] DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to http://localhost:8080/login

로그에서 AnonymousAuthenticationFilter가 활성화되었고, SecurityContext가 익명으로 설정된 것을 확인할 수 있었습니다.

2. 문제 원인 분석

다양한 점검을 통해 다음과 같은 문제 원인들을 발견했습니다:

  • SecurityContext가 올바르게 설정되지 않음: 인증된 사용자의 정보가 SecurityContext에 제대로 설정되지 않았기 때문에, 사용자가 익명으로 처리되었습니다.
  • OAuth2 인증 후 JWT 사용: OAuth2 인증 후, 프론트엔드와의 통신은 커스텀 JWT 토큰을 사용하고 있었습니다. 이로 인해 OAuth2 인증 정보가 실제 사용되지 않았습니다.
  • JWT 필터 설정의 불완전: JWT를 기반으로 한 권한 부여 로직이 SecurityContext에 제대로 반영되지 않았습니다.

문제 해결: /api/admin/** 경로 접근 문제

문제를 해결하기 위해 다음과 같은 단계들을 거쳤습니다:

1. SecurityContext 설정 확인

먼저, OAuth2 로그인 후 SecurityContext에 사용자 인증 정보가 올바르게 설정되고 유지되는지 확인했습니다. 이를 위해 AuthSuccessHandler에서 JWT 토큰을 생성하고, 이를 기반으로 SecurityContext를 설정하도록 수정했습니다.

 

//oauth2 로그인 성공 핸들러
@Component
public class AuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final UserService userService;

    public AuthSuccessHandler(UserService userService) {
        this.userService = userService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        if (authentication instanceof OAuth2AuthenticationToken) {
            User user = userService.findUserByOAuthToken((OAuth2AuthenticationToken) authentication);

            // 로그인 성공시 사용자 정보를 기반으로 JWT 토큰 생성
            String token = userService.generateTokenForUser(user);

        }
    }
}

2. JWT 필터 설정 및 권한 부여

JWT 토큰을 사용하여 프론트엔드와의 통신을 처리하면서, Spring Security가 이를 인식하고 적절한 권한을 부여하도록 JwtTokenFilter를 수정했습니다.

 

@Component
public class JwtTokenFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserService userService;

    public JwtTokenFilter(JwtService jwtService, UserService userService) {
        this.jwtService = jwtService;
        this.userService = userService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        SecurityContext context = SecurityContextHolder.getContext();
		//스프링 시큐리티 콘텍스트에서 인증정보를 가지고 있지 않으면 인증정보 추가
        if (context.getAuthentication() == null) {
            String token = request.getHeader("Authorization");
            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7); // "Bearer " 접두어 제거

                try {
                    // 토큰 유효성 검사 및 클레임 추출
                    Claims claims = jwtService.validateTokenAndExtractClaims(token);

                    // 클레임에서 사용자 ID 추출
                    String userId = claims.getSubject();
                    
                    // 사용자 정보 조회
                    User tokenUser = userService.findUserById(userId);

                    // 사용자 엔티티에서 권한 정보 추출
                    List<GrantedAuthority> authorities = tokenUser.getRoles().stream()
                            .map(User.Role::name)
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());

                    // 새로운 Authentication 객체 생성
                    Authentication authentication = new UsernamePasswordAuthenticationToken(
                            userId, null, authorities);

                    // SecurityContext에 Authentication 객체 저장
                    context.setAuthentication(authentication);
                } catch (Exception e) {
                    // 토큰 검증 실패 처리
                    SecurityContextHolder.clearContext();
                    logger.error("JWT 토큰 검증 실패: {}", e.getMessage());
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

3. Spring Security 설정 조정

JWT 필터가 작동하도록 Spring Security 설정을 수정했습니다. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가하여, JWT가 유효한 경우 적절한 권한을 부여하도록 했습니다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(corsCustomizer()) // CORS 설정 적용
        .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용
            .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") // '/api/admin' 경로는 ROLE_ADMIN 권한을 가진 사용자만 접근 허용
            .anyRequest().permitAll() // 그 외 모든 요청은 로그인 여부에 관계없이 허용
        )
        .oauth2Login(oauth2 -> oauth2
            .successHandler(authSuccessHandler) // 로그인 성공 핸들러
            .failureUrl("/loginFailure") // 로그인 실패 시 이동할 URL
            .userInfoEndpoint(userInfo ->
                userInfo.userService(customOAuth2UserService) // 사용자 정보를 로드하는 서비스
            )
        )
        //JwtTokenFilter가 UsernamePasswordAuthenticationFilter보다 먼저실행돼야함. (addFilterBefore)
        .addFilterBefore(new JwtTokenFilter(jwtService, userService), UsernamePasswordAuthenticationFilter.class);
    
    return http.build();
}

 

결론

이번 포스팅에서는 /api/admin/** 경로에 접근하지 못했던 이유를 분석하고, 해결하는 과정을 다루었습니다. OAuth2 인증 후 JWT 토큰을 사용하여 프론트엔드와 소통하고, Spring Security에서 권한을 부여하는 방법을 통해 문제를 해결할 수 있었습니다.

  1. SecurityContext 설정 확인: OAuth2 로그인 후 SecurityContext에 인증 정보가 제대로 설정되고 유지되는지 확인했습니다.
  2. JWT 필터 설정: JWT 토큰을 검증하고 SecurityContext에 권한을 부여하는 필터를 구성했습니다.
  3. Spring Security 설정 조정: Spring Security의 필터 체인에서 JWT 필터가 적절하게 작동하도록 설정을 조정했습니다.

이러한 해결 과정을 통해 애플리케이션의 인증 및 권한 부여를 보다 견고하게 구축할 수 있었습니다. 여러분의 프로젝트에서도 비슷한 문제가 발생했을 때, 이 글이 도움이 되기를 바랍니다.

pring Boot 애플리케이션에서 API 전용으로 설정했음에도 불구하고, /favicon.ico 요청으로 인해 Circular view path [favicon] 오류가 발생하는 경우가 있습니다. 이 오류는 Spring Boot가 favicon.ico 요청을 처리하려고 시도할 때 적절한 핸들러를 찾지 못해 발생합니다.

이 포스팅에서는 이 문제를 해결하기 위해 사용한 두 가지 접근 방법에 대해 설명하겠습니다.

문제 설명

Spring Boot 애플리케이션에서 /favicon.ico에 대한 GET 요청이 들어오면, 이를 처리할 핸들러가 없을 때 Circular view path 오류가 발생할 수 있습니다. 이 오류는 기본적으로 뷰 리졸버가 요청을 처리하려고 시도하지만, 이를 반복해서 호출하게 되어 발생합니다.

jakarta.servlet.ServletException: Circular view path [favicon]: would dispatch back to the current handler URL [/favicon] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)
 

이 문제를 해결하기 위해서는 favicon.ico 요청을 적절히 처리하거나 무시하도록 설정해야 합니다.

해결 방법

문제를 해결하기 위해 다음 두 가지 방법을 사용했습니다:

  1. 정적 리소스 핸들러 설정
  2. /favicon.ico 요청을 처리하는 Controller 추가

1. 정적 리소스 핸들러 설정

Spring Boot에서는 WebMvcConfigurer를 사용하여 정적 리소스를 제공할 수 있는 경로를 설정할 수 있습니다. 정적 리소스 핸들러를 설정함으로써, /static/** 경로의 리소스 요청이 classpath:/static/ 디렉토리에서 제공되도록 할 수 있습니다.

아래는 정적 리소스 핸들러를 설정한 코드입니다:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/");
    }
}
 
 

이 설정을 통해 애플리케이션은 /static/ 경로의 정적 리소스를 classpath:/static/에서 제공하게 됩니다. 그러나 /favicon.ico 요청은 여전히 처리되지 않기 때문에, 다음 단계에서 이를 처리하는 Controller를 추가해야 합니다.

2. /favicon.ico 요청을 처리하는 Controller 추가

/favicon.ico 요청에 대해 204 No Content 응답을 반환하도록 설정하여, Spring Boot가 이 요청을 적절히 처리하도록 할 수 있습니다. 이를 위해 간단한 Controller를 추가합니다:

 
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/");
    }
}

 

이 Controller는 /favicon.ico 요청이 들어올 때 빈 응답을 반환하며, HTTP 상태 코드 204 (No Content)를 설정합니다. 이를 통해 순환 참조 오류 없이 안전하게 요청을 무시할 수 있습니다.

최종 결과

위의 두 가지 설정을 적용한 후, /favicon.ico 요청으로 인해 발생하던 Circular view path 오류는 더 이상 발생하지 않게 되었습니다. 이 과정에서 우리는 /favicon.ico 요청을 무시하거나 적절히 처리하는 방법을 배웠으며, Spring Boot 애플리케이션에서 API와 정적 리소스 경로를 명확히 구분할 수 있게 되었습니다.

요약

Spring Boot에서 /favicon.ico 요청으로 인해 발생하는 오류를 해결하기 위해 다음 두 가지 방법을 적용했습니다:

  1. 정적 리소스 핸들러 설정: WebMvcConfigurer를 사용하여 정적 리소스를 특정 경로에서 제공하도록 설정했습니다.
  2. /favicon.ico 요청을 처리하는 Controller 추가: /favicon.ico 요청이 들어왔을 때 204 No Content 응답을 반환하는 Controller를 추가했습니다.

이 해결 방법은 Spring Boot 애플리케이션에서 API 전용 설정을 유지하면서 불필요한 오류를 방지하는 데 매우 유용합니다. 앞으로 비슷한 문제가 발생할 때 이와 같은 방법을 적용해 보세요.

문제 상황

Docker Compose를 사용하여 Spring Boot 애플리케이션과 MySQL 데이터베이스를 함께 배포하려 했습니다. docker-compose up 명령을 실행하자, Spring Boot 애플리케이션이 데이터베이스 연결에 실패하며 시작하지 못하는 문제가 발생했습니다. 로그를 확인한 결과, Spring Boot 애플리케이션이 MySQL 데이터베이스가 준비되지 않은 상태에서 기동을 시도하고 있었던 것을 알게 되었습니다. 이는 데이터베이스가 준비되기 전에 애플리케이션이 시작되면서 연결을 시도해 실패한 것이 원인이었습니다.

cohttp://m.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.

...

org.springframework.jdbc.CannotGetJdbcConnectionException: Could not get JDBC Connection; nested exception is org.apache.commons.dbcp2.SQLNestedException: Cannot create PoolableConnectionFactory (Communications link failure)

...

java.net.ConnectException: Connection refused (Connection refused)
at java.base/sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:779)

 

해결 방법

이 문제를 해결하기 위해 Docker Compose의 depends_on 및 healthcheck 기능을 사용하여 데이터베이스가 완전히 준비될 때까지 Spring Boot 애플리케이션이 시작되지 않도록 설정했습니다. healthcheck를 통해 데이터베이스의 상태를 모니터링하고, 준비 상태를 확인한 후에만 Spring Boot 애플리케이션이 기동되도록 했습니다.

 

version: '3.8'

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: mypassword
      MYSQL_DATABASE: mydatabase
      MYSQL_USER: myuser
      MYSQL_PASSWORD: mypassword
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - my-network
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -p${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5

  springboot-app:
    build:
      context: ./springboot
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/mydatabase
      - SPRING_DATASOURCE_USERNAME=myuser
      - SPRING_DATASOURCE_PASSWORD=mypassword
    depends_on:
      db:
        condition: service_healthy
    networks:
      - my-network

volumes:
  db_data:
    driver: local

networks:
  my-network:
    driver: bridge
docker-compose up --build
명령을 사용했을때 발생하는 
failed to solve: failed to read dockerfile: open /var/lib/docker/tmp/buildkit-mount277479487/Dockerfile: no such file or directory  
오류는,
도커컴포즈 파일에 명시한 경로에서 도커파일을 찾지 못했을때 발생합니다.
먼저 도커파일이 해당 경로에 있는지 확인하고, 대소문자등 틀린 부분이 없나 확인해줍니다.
그래도 문제가 해결되지 않을경우,
혹시 도커파일을 새 텍스트파일 만들기 등으로 만들었다가 .txt를 지워버린 형태로 생성하지는 않았나 생각해봅니다.
해당 파일을 지우고, IDE에서 새 파일 만들기로 도커파일을 다시 만든 후에 컴포즈 up 명령을 실행해봅니다.
 

1. 파일 확장자 문제

.txt로 만들었다가 확장자를 지우는게 안되는 이유:

Windows 탐색기에서 파일을 생성할 때 파일 확장자가 숨겨진 경우가 많습니다. 이로 인해, Dockerfile을 생성할 때 .txt 확장자가 자동으로 추가될 수 있습니다. 사용자가 Dockerfile이라고 이름을 변경해도 실제 파일 이름은 Dockerfile.txt로 남아 있을 수 있습니다.

이 경우, 파일 탐색기에서는 Dockerfile로 보이지만, 내부적으로는 여전히 텍스트 파일로 인식되기 때문에 도커가 이를 Dockerfile로 인식하지 못합니다.

 

2. 파일 형식 문제

텍스트 편집기에서 파일을 저장할 때, 파일의 형식이 중요합니다. Windows의 메모장이나 다른 텍스트 편집기는 기본적으로 파일을 UTF-16 형식으로 저장할 수 있으며, 도커 빌드는 UTF-8 형식을 기대할 때 문제가 발생할 수 있습니다.

다음은 이 문제와 관련된 몇 가지 사항입니다:

  • 텍스트 인코딩: Dockerfile을 UTF-8 형식으로 저장해야 합니다. 많은 IDE는 파일을 기본적으로 UTF-8 형식으로 저장하지만, Windows 메모장 등은 그렇지 않을 수 있습니다.
  • 파일 끝에 숨겨진 문서 헤더: 메모장 등 일부 편집기는 파일 시작 부분에 숨겨진 BOM (Byte Order Mark)을 추가할 수 있습니다. 이것이 도커가 파일을 읽는 데 문제를 일으킬 수 있습니다.

먼저 개념 정리를 하고 들어가겠습니다.

OAuth2란 무엇인가

OAuth2는 사용자 인증 및 권한 부여를 위한 업계 표준 프로토콜입니다. 이를 통해 애플리케이션은 서드 파티 서비스의 기능을 안전하게 사용할 수 있습니다.

 

OAuth2 의존성의 핵심 역할

OAuth2 의존성은 애플리케이션에 인증 및 권한 부여 기능을 쉽게 통합할 수 있도록 지원합니다. 이를 통해 개발자는 복잡한 보안 요구사항을 쉽게 구현할 수 있습니다.

 

스프링 시큐리티의 역할

스프링 시큐리티는 애플리케이션의 보안을 위한 포괄적인 프레임워크를 제공합니다. 이는 인증 및 권한 부여, 보안 헤더 관리, CSRF 보호 등을 관리합니다.

 

YML 파일의 OAuth2 설정

YML 파일에서 OAuth2 설정을 통해 클라이언트 ID, 시크릿, 리다이렉션 URI 등과 같은 필수 인증 정보를 구성할 수 있습니다. 이를 통해 OAuth2 인증 흐름이 애플리케이션에 통합됩니다.

 

인증 성공 후 JWT 토큰 발급 이유

인증 성공 후 JWT 토큰을 발급하는 이유는 서버와 클라이언트 간의 안전한 통신을 보장하기 위함입니다. JWT는 사용자 인증 정보를 포함하며, 이를 통해 후속 요청의 인증을 간소화합니다.

 

이제부터 순서대로 따라 오시면 됩니다.

1. 메이븐 혹은 그래들로 의존성 주입.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

 

2. 원하는 소셜 로그인 개발자 홈페이지에서 앱 등록 후,

클라이언트ID, 클라이언트Secret 발급

원하는 리디렉트URI 설정하고 오기.

 

이 포스트에서는 카카오를 예로 구현하겠습니다.

카카오 개발자 사이트 주소(https://developers.kakao.com/)

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

카카오ID로 로그인 후 ->

 

내 애플리케이션 ->

 

애플리케이션 추가하기 ->

 

왼쪽 요약정보에서 REST API키 메모 후 아래 플랫폼 설정하기 ->

 

애플리케이션에 맞는 플랫폼 선택 후 (모바일이 아니면 Web을 선택하세요) ->

 

내 애플리케이션 주소 입력 (개발환경이면 http://localhost:8080 등) ->

 

다시 왼쪽 메뉴에서 카카오 로그인 클릭 ->

 

활성화 및 리디렉트Uri 설정 ->

(내도메인/login/oauth2/code/kakao)

(이 주소로 접속하면 카카오 로그인 페이지가 나오게 됩니다)

(이 주소는 잠시 후 yml파일에서도 동일하게 작성합니다)

 

왼쪽 메뉴에서 보안 클릭 ->

 

Secret키 발급 후 메모 (활성화는 사용안함 선택하세요)

3. application.yml(혹은 properties) 작성

security:
    oauth2:
      client:
        registration:
          google:
            clientId: 
            clientSecret:
            scope:
              - profile
            redirectUri: http://localhost:8080/login/oauth2/code/google
          kakao:
            clientId: 카카오개발자에서 메모한 RESTApi 키
            clientSecret: 카카오개발자에서 생성한 Secret 키
            redirectUri: "http://localhost:8080/login/oauth2/code/kakao" (개발자에서 설정한것)
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Kakao
          naver:
            client-id: 
            client-secret: 
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
        provider: (구글은 필요없고 카카오 네이버는 작성해야함)
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

 

 

진행중인 웹커뮤니티 프로젝트의 기능 구현이 어느정도 완료되었습니다.

이제 일련의 서비스 단위별로 예외처리를 진행해보려고 합니다.

이 포스팅에서는 댓글서비스에서 진행한 예외처리에 대해서 다루고자 합니다.

먼저 컨트롤러를 살펴봅니다.

 @PostMapping
    public ResponseEntity<?> addComment(@RequestBody CommentRequest commentRequest) {
        try {
            CommentResponse savedComment = 
						commentService.addComment(commentRequest);
            return ResponseEntity.status(HttpStatus.CREATED).body(savedComment);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }

 

Post요청을 처리하는 메서드입니다.

@RequestBody어노테이션이 HTTP요청의 body를 자바 객체로 자동 변환합니다.

현재는 단순히, 서비스레이어의 메서드를 호출하고 성공하면 201코드와 저장된 댓글을 json으로 리턴하고, 실패하면 400코드와 오류 메세지를 반환합니다.

저는 이 프로세스의 시작지점인 입력값부터 검증하려고 합니다.

사실 저는 이러한 데이터 검증을 그저 그동안 하던 대로, 엔티티 클래스에 적용해놓았었습니다.

 

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userIp;

    @NotNull(message = "닉네임이 입력되지 않았습니다...")
    @Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
    private String nickname;

    @NotNull(message = "내용이 입력되지 않았습니다...")
    @Size(max = 5000, message = "댓글은 최대 5000자까지 입력 가능합니다.")
    @Column(length = 5000) // 데이터베이스 컬럼 길이도 설정
    private String text;

    @NotNull(message = "비밀번호가 입력되지 않았습니다...")
		@Size(min = 4, max = 15, message = "비밀번호는 4~15 사이로만 입력 가능합니다. ")
    private String password;

...
}

 

하지만 다음과 같은 의견을 보고 DTO로 옮기게 되었습니다.

“유효성 검사는 가능한 한 데이터의 진입점(예: 컨트롤러)에서 수행되어야 합니다. 이는 잘못된 데이터가 시스템 깊숙이 전파되는 것을 방지하고, 잘못된 데이터로 인한 오류를 조기에 발견할 수 있도록 합니다. DTO에서의 유효성 검사는 입력 데이터의 구조와 형식을 검증하는 반면, 도메인 모델에서는 보다 복잡한 비즈니스 규칙과 무결성을 검증합니다.”

 

public record CommentRequest(

    @NotNull(message = "닉네임이 입력되지 않았습니다...")
    @Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
    String nickname,

    @NotNull( message = "내용이 입력되지 않았습니다...")
    @Size(max = 5000, message = "댓글은 최대 5000자까지 입력 가능합니다.")
    String text,

    @NotNull(message = "비밀번호가 입력되지 않았습니다...")
    @Size(min = 4, max = 15, message = "비밀번호는 4~15 사이로만 입력 가능합니다. ")
    String password,

    @NotNull
    Long targetId,

    @NotNull
    Target.TargetType type,

    Boolean isDeleted
){}

 

저는 DTO로 record객체를 사용했지만 일반적인 class에서도 동일합니다.

이렇게 옮기고 나니 아차 싶었습니다.

저는 CommentRequest라는 객체를 댓글을 저장하는 데에도, 불러오는 데에도, 수정하는데에도 사용하는데 닉네임과 본문 등은 댓글을 불러오는데에는 필요가 없었기 때문입니다.

엔티티에 저러한 notNull어노테이션을 지정했을때에는 데이터베이스에 저장하는 경우만 가정하기때문에 큰 문제가 없었던 것이었습니다.

어떤 방법이 있을까…

CreateCommentDTO, ReadCommentDTO를 따로 만드는 방법은 제가 좋아하는 방식이 아닙니다.

하지만 곧 어떤 방법을 알아냅니다.

Bean Validation에서는 그룹(groups)을 정의하여, 검증 규칙을 그룹별로 적용할 수 있습니다. 이 방법을 사용하면, 하나의 DTO에 여러 작업에 대한 검증 규칙을 정의할 수 있으며, 실행 시에는 특정 그룹의 검증 규칙만 적용됩니다.

 

@NotNull(groups = {CreateGroup.class, UpdateGroup.class},  message = "닉네임이 입력되지 않았습니다...")
@Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
String nickname,

 

**CreateGroup.class**와 같은 그룹은 검증 그룹을 정의하기 위해 사용되는 마커 인터페이스(marker interface)로, Bean Validation에서 사용되는 커스텀 그룹입니다. 이 인터페이스들은 별도의 메서드를 포함하지 않으며, 검증 규칙을 그룹화하는 데 사용됩니다. 각 검증 그룹은 서로 다른 검증 시나리오를 대표하며, 특정 그룹을 지정하여 그룹에 속한 검증 규칙만 실행할 수 있습니다.

그저 역할을 구분하는데만 쓰인다는 것입니다.

저는 dto패키지 아래에 validation패키지를 새로 만들고 다음과 같은 인터페이스들을 정의했습니다.

 

public interface CreateGroup {
    // 이곳에는 메서드나 필드를 정의하지 않습니다. 
	  //인터페이스의 이름 자체가 검증 그룹을 식별하는 데 사용됩니다.
}

public interface UpdateGroup {

}

public interface ReadGroup {
}

 

그러면 이러한 검증그룹을 어떻게 적용할 수 있을까요?

CommentController 내에서 해당 그룹을 지정하여 유효성 검증을 수행할 수 있도록 @Validated 어노테이션을 사용해야 합니다. @Validated 어노테이션은 @Valid 어노테이션과 유사하지만, 검증 그룹을 지정하는 기능을 추가로 제공합니다.

 

@PostMapping
public ResponseEntity<?> addComment(
        HttpServletRequest request, 
        @Validated(CreateGroup.class) @RequestBody CommentRequest commentRequest) {
    // 메서드 구현...
}

@GetMapping
public ResponseEntity<?> getComments(
        @Validated(ReadGroup.class) @ModelAttribute CommentRequest commentRequest, 
        Pageable pageable) {
    // 메서드 구현...
}

@PatchMapping
    public ResponseEntity<?> updateOrDeleteComment(
            @Validated(UpdateGroup.class) @RequestBody CommentRequest commentRequest) {
		// 메서드 구현...
}

 

이제 그룹별로 검증이 달리 적용됩니다.

그렇다면 검증에 실패했을 경우는 어떻게 처리해야 할까요?

**@Validated**를 사용하면 Spring MVC가 메서드 파라미터의 유효성 검사를 자동으로 수행하고, 유효성 검사에 실패하면 **MethodArgumentNotValidException**을 발생시킵니다.

이 예외를 catch로 잡아도 되겠지만, 모든 메서드에 이 예외를 처리하는 코드를 작성해야하는 일이 일어날 것입니다.

저는 한 클래스를 추가로 만들고, @ControllerAdvice 어노테이션을 달아 이 문제를 해결하고자 합니다.

@ControllerAdvice 어노테이션은 스프링 프레임워크의 컨트롤러 계층에서 발생하는 예외를 전역적으로 처리하는 메커니즘을 제공합니다. 이 어노테이션을 사용함으로써, 개별 컨트롤러 내에서 예외 처리 로직을 중복으로 작성할 필요 없이, 애플리케이션 전반에 걸쳐 일관된 예외 처리 방식을 적용할 수 있습니다. **@ControllerAdvice**가 적용된 클래스는 애플리케이션의 모든 컨트롤러에 대한 예외 처리, 데이터 바인딩, 모델 속성 추가 등의 작업을 담당할 수 있습니다.

 

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return ResponseEntity.badRequest().body(errors);
    }

}

 

handleValidationExceptions메서드는 MethodArgumentNotValidException 타입의 예외를 처리합니다.

메서드는 예외 인스턴스(ex)로부터 BindingResult 객체를 얻어내고, 이 객체에 포함된 모든 에러(getAllErrors)를 순회합니다. 각 에러는 FieldError 타입으로 캐스팅될 수 있으며, 이를 통해 발생한 에러의 필드명(getField)과 에러 메시지(getDefaultMessage)를 알 수 있습니다.

필드명과 에러 메시지를 매핑하여 Map<String, String> 타입의 객체에 저장합니다. 이 맵은 유효성 검증 실패에 대한 세부 정보를 클라이언트에 전달하기 위한 응답 본문으로 사용됩니다.

마지막으로, **ResponseEntity.badRequest().body(errors)**를 통해 HTTP 상태 코드 400(Bad Request)과 함께 에러 세부 정보를 포함한 응답을 클라이언트에 반환합니다. 이를 통해 클라이언트는 어떤 입력 값들이 유효성 검사를 통과하지 못했는지, 그리고 구체적인 에러 메시지는 무엇인지 파악할 수 있습니다.

 

 

입력값 검증과 예외처리에 대한 포스트는 여기까지 작성하도록 하겠습니다.

스프링부트로 미니프로젝트를 진행하다가 404 오류에 봉착했습니다.

그냥 평소대로 작업했던 것 같은데 다음 엔드포인트에 도무지 접근을 할 수 없었습니다

@RestController
@RequestMapping("/users")
@CrossOrigin // 이 컨트롤러의 모든 메소드에 대해 CORS 요청 허용
public class UserController {

    // 컨트롤러 메소드...
}

 

pom.xml에 각 의존성의 버전 호환을 확인해 봤으나 문제가 없었고,

application.properties파일의 설정도 괜찮은 듯 보였습니다.

아무튼 서버가 빌드는 잘 되는데 엔드포인트에 접근할 수 없었습니다

하지만 곧 원인을 찾았습니다

바보같게도 클래스 파일을 @SpringBootApllication 어노테이션이 붙은 클래스 밖에 위치시켰던 것입니다.

 

만약 컴포넌트가 @SpringBootApplication 어노테이션이 붙은 클래스의 패키지 또는 그 하위 패키지 바깥에 위치한다면, 스프링 부트의 자동 컴포넌트 스캔 메커니즘은 그 컴포넌트를 찾지 못하게 됩니다. 이는 스프링 부트의 디자인 원칙 중 하나로, 애플리케이션의 구성요소를 체계적으로 관리하기 위해 도입된 제약사항입니다.

예를 들어, com.example.app 패키지에 @SpringBootApplication 어노테이션이 붙은 메인 애플리케이션 클래스가 있다면, 스프링 부트는 com.example.app 패키지와 그 하위 패키지에서 컴포넌트를 검색합니다. 만약 컴포넌트가 com.example 패키지나 그 상위에 위치한다면, 이 컴포넌트는 자동 스캔 대상에서 제외됩니다.


이 문제를 해결하기 위한 몇 가지 방법은 다음과 같습니다:

1. 컴포넌트를 메인 애플리케이션 클래스가 있는 패키지 또는 그 하위 패키지로 이동
가장 간단한 해결책은 컴포넌트를 메인 애플리케이션 클래스가 위치한 패키지 또는 그 하위 패키지로 옮기는 것입니다. 이렇게 하면 컴포넌트가 자동으로 스캔되어 빈으로 등록됩니다.

2. 컴포넌트 스캔 범위 확장
@ComponentScan 어노테이션을 사용하여 스프링 부트의 컴포넌트 스캔 범위를 명시적으로 확장할 수 있습니다. 이 어노테이션을 @SpringBootApplication 어노테이션이 붙은 클래스에 추가하고, basePackages 속성을 사용하여 추가로 스캔할 패키지를 지정합니다.

 

@SpringBootApplication
@ComponentScan(basePackages = {"com.example.app", "com.other.package"})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

+ Recent posts