Java/외부 통신

[Java] Spring Cloud OpenFeign 이해하고 활용하기 -3 : 예외 처리 관리

adjh54 2025. 3. 3. 23:21
728x170
해당 글에서는 외부 통신을 위해 사용하는 Spring Cloud OpenFeign를 통한 통신을 수행할 때, 발생하는 에러에 대해 예외 처리를 하는 방법에 대해 알아봅니다

 

 

💡 [참고] Java에서 외부 통신을 하는 방법들에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다
분류주제링크
RestTemplateSpring Boot Web 활용 : RestTemplate 이해하기https://adjh54.tistory.com/234
   
WebClientSpring Boot Webflux 이해하기 -1 : 흐름 및 주요 특징 이해https://adjh54.tistory.com/232
WebClientSpring Boot Webflux 이해하기 -2 : 활용하기https://adjh54.tistory.com/233
   
OpenFeignSpring Cloud OpenFeign 이해하고 활용하기 -1: 주요 개념 및 환경 구성, 활용 예시https://adjh54.tistory.com/616
OpenFeignSpring Cloud OpenFeign 이해하고 활용하기 -2: 설정 중앙화 방법https://adjh54.tistory.com/667
OpenFeignSpring Cloud OpenFeign 이해하고 활용하기 -3: 예외처리 방법https://adjh54.tistory.com/670
Github외부 통신의 활용 방법을 담은 예제 Repositoryhttps://github.com/adjh54ir/blog-codes/tree/main/spring-boot-external-network

 
 

1) Spring Cloud OpenFeign


💡 Spring Cloud OpenFeign

- Netflix에서 개발한 HTTP 클라이언트 라이브러리를 Spring Cloud에서 통합한 선언적 HTTP 클라이언트 라이브러리입니다.

- Spring Cloud에서는 Open Feign을 스프링 MVC 어노테이션을 사용하여 웹 서비스 클라이언트를 쉽게 작성할 수 있도록 통합했습니다.
- OpenFeign은 마이크로서비스 아키텍처에서 특히 유용하며, 코드를 더 간결하고 유지보수하기 쉽게 만듭니다.

Spring Cloud OpenFeign

Feign is a declarative web service client. It makes writing web service clients easier. To use Feign create an interface and annotate it. It has pluggable annotation support including Feign annotations and JAX-RS annotations. Feign also supports pluggable

docs.spring.io

 

1. OpenFeign 사용목적


💡 OpenFeign 사용목적

1. 선언적 REST 클라이언트
- 인터페이스와 어노테이션만으로 HTTP 클라이언트를 구현할 수 있어 코드가 더 간결해집니다.

2. Spring Web 어노테이션 지원
- @GetMapping, @PostMapping 등 개발자에게 친숙한 Spring Web 어노테이션을 그대로 사용할 수 있습니다.

3. 설정의 중앙화
- URL, 인증 정보 등의 설정을 중앙에서 관리할 수 있어 유지보수가 용이합니다.

4. 재사용성 향상
- 정의된 인터페이스를 여러 서비스에서 쉽게 재사용할 수 있습니다.

5. 테스트 용이성
- Mock 객체를 사용한 테스트가 더 쉽고, 인터페이스 기반이라 테스트 코드 작성이 간편합니다.

6. 오류 처리 통합
- ErrorDecoder를 통해 예외 처리를 중앙화하고 일관성 있게 관리할 수 있습니다.
@FeignClient(name = "user-service", url = "http://api.example.com")
public interface UserClient {
    @GetMapping("/users/{id}")
    User getUser(@PathVariable("id") Long id);
    
    @PostMapping("/users")
    User createUser(@RequestBody User user);
}

 

 💡 [참고] RestTempalte 사용 예시
// RestTemplate 예시
@Service
public class UserService {
    private final RestTemplate restTemplate;
    private final String baseUrl = "http://api.example.com";
    
    public User getUser(Long id) {
        return restTemplate.getForObject(baseUrl + "/users/" + id, User.class);
    }
}

 
 

2) OpenFeign 예외처리


💡 OpenFeign 예외처리

- OpenFeign에서는 HTTP 통신 과정에서 발생할 수 있는 다양한 예외 상황을 처리하기 위한 여러 메커니즘을 제공합니다.
- 주로 ErrorDecoder를 통한 전역적 예외 처리와 try-catch를 통한 개별 예외 처리 방식을 활용할 수 있습니다.
- 또한 @ControllerAdvice를 사용하여 애플리케이션 전체의 예외 처리를 통합적으로 관리할 수 있습니다.

 

1. 주요 오류 처리 목적


목적설명
예외 상황 관리네트워크 오류, 타임아웃, 서버 오류 등 다양한 예외 상황을 적절히 처리
시스템 안정성오류가 발생해도 시스템이 계속 작동할 수 있도록 보장
사용자 경험오류 발생 시 적절한 피드백을 제공하여 사용자 경험 향상
디버깅 용이성상세한 오류 정보를 로깅하여 문제 해결을 용이하게 함

 
 

2. OpenFeign의 발생하는 오류 정보


💡 OpenFeign의 발생하는 오류 정보

- OpenFeign를 통해서 발생하는 예외 클래스에 대해 알아봅니다.
분류예외 클래스HTTP 상태설명
클라이언트 오류FeignException.BadRequest400잘못된 요청 형식이나 유효하지 않은 파라미터로 인한 에러
클라이언트 오류FeignException.Unauthorized401인증 정보가 없거나 유효하지 않은 경우
클라이언트 오류FeignException.Forbidden403권한이 없는 리소스에 접근 시도
클라이언트 오류FeignException.NotFound404요청한 리소스를 찾을 수 없음
클라이언트 오류FeignException.MethodNotAllowed405요청한 HTTP 메서드가 허용되지 않음
클라이언트 오류FeignException.NotAcceptable406클라이언트가 요청한 형식으로 응답할 수 없음
클라이언트 오류FeignException.Conflict409리소스 상태의 충돌로 인한 요청 실패
클라이언트 오류FeignException.UnsupportedMediaType415지원하지 않는 미디어 타입
    
서버 오류FeignException.ServiceUnavailable503서비스를 일시적으로 사용할 수 없음
서버 오류FeignException.GatewayTimeout504게이트웨이 타임아웃
    
연결 오류FeignException.ConnectException-서버 연결 시간 초과
연결 오류FeignException.ReadTimeoutException-응답 읽기 시간 초과
    
컨텐츠 오류FeignException.UnsupportedMediaType-지원하지 않는 미디어 타입
재시도 오류RetryableException-재시도 가능한 오류
    
파싱 오류DecodeException-응답 디코딩 실패
파싱 오류EncodeException-요청 인코딩 실패
    
통합 오류FeignClientException4xxFeign 클라이언트 관련 4xx 상태 코드에 대한 통합 오류
통합 오류FeignServerException5xxFeign 서버 관련 관련 5xx 상태 코드에 대한 통합 오류

 

3. 오류 처리 방식 종류


💡 오류 처리 방식 종류

- OpenFeign를 통한 외부 통신을 하는 과정에서 발생하는 오류를 처리하는 방식들을 아래와 같습니다.
오류 처리 방식사용 방법적용 범위장점
ErrorDecoderErrorDecoder 인터페이스 구현 클래스 작성 및 Bean 등록전역적 적용중앙화된 오류 처리, 일관된 예외 변환
try/catch개별 메서드에서 FeignException 하위 클래스 catch메서드 단위세밀한 예외 처리, 상황별 대응 가능
@ControllerAdvice@ExceptionHandler 메서드 구현애플리케이션 전체통합된 예외 처리, REST API 응답 표준화
Custom Exception사용자 정의 예외 클래스 생성 및 변환 로직 구현필요한 영역비즈니스 로직에 특화된 예외 처리

 

3) OpenFeign 예외처리 종류 -1 : ErrorDecoder 인터페이스 활용


💡 OpenFeign 예외처리 종류 -1 : ErrorDecoder 인터페이스 활용

- OpenFeign에서 HTTP 통신 중 발생하는 오류 응답을 처리하기 위한 인터페이스입니다.
- HTTP 응답이 오류 상태 코드(4xx, 5xx)를 반환할 때, 이를 적절한 Java Exception으로 변환하는 역할을 합니다.
오류 처리 방식사용 방법적용 범위장점
ErrorDecoderErrorDecoder 인터페이스 구현 클래스 작성 및 Bean 등록전역적 적용중앙화된 오류 처리, 일관된 예외 변환
try/catch개별 메서드에서 FeignException 하위 클래스 catch메서드 단위세밀한 예외 처리, 상황별 대응 가능
@ControllerAdvice@ExceptionHandler 메서드 구현애플리케이션 전체통합된 예외 처리, REST API 응답 표준화
Custom Exception사용자 정의 예외 클래스 생성 및 변환 로직 구현필요한 영역비즈니스 로직에 특화된 예외 처리

 

1. ErrorDecoder 인터페이스 특징


특징설명
중앙 집중화된 예외 처리모든 Feign 클라이언트의 에러 처리 로직을 한 곳에서 관리할 수 있습니다.
일관된 예외 변환HTTP 응답 코드를 애플리케이션의 커스텀 예외로 일관되게 변환할 수 있습니다.
세밀한 에러 핸들링HTTP 상태 코드별로 다른 예외 처리 로직을 구현할 수 있습니다.
로깅 통합에러 발생 시 로깅을 중앙에서 처리할 수 있어 모니터링이 용이합니다.
유연한 확장성새로운 에러 케이스 추가나 기존 처리 로직 수정이 용이합니다.

 

2. 일반적인 ErrorDecoder 구현


💡 일반적인 ErrorDecoder 구현

- ErrorDecoder 인터페이스의 구현체로 FeignClientCustomErrorDecoder라는 이름의 클래스를 구성하였습니다.
- 해당 구현체로 Exception decode(String methodKey, Response response)를 오버라이딩 받아서 이를 재 구현합니다.
- Response 객체 내에 status 메서드는 OpenFeign를 통해서 통신 결과 status 코드를 반환 받습니다.
- 이를 통해서 exception 처리를 수행합니다.

 

💡 FeignClientGlobalErrorDecoder

- 아래의 반환되는 status 코드에 따라 예외 클래스를 발생시키고 메시지를 추가하여 반환하도록 구성하였습니다.
Status코드 예외 클래스메시지
400BadRequestException잘못된 요청입니다.
404NotFoundException리소스를 찾을 수 없습니다.
500InternalServerException서버 내부 오류가 발생했습니다.
기타Exception알 수 없는 오류가 발생했습니다.
package com.blog.springbootkeycloak.config.feign.exception;

import feign.Response;
import feign.codec.ErrorDecoder;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
 * OpenFeign Client Error 전역 예외처리를 관리합니다.
 *
 * @author : jonghoon
 * @fileName : FeignClientGlobalErrorDecoder
 * @since : 25. 2. 20.
 */
@Slf4j
@Component
public class FeignClientGlobalErrorDecoder implements ErrorDecoder {

    /**
     * OpenFeign에서 발생하는 Status Code를 기반으로 오류를 커스텀 처리로 수행합니다.
     *
     * @param methodKey
     * @param response
     * @return
     */
    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()) {
            case 400:
                return new BadRequestException("잘못된 요청입니다.");
            case 404:
                return new NotFoundException("리소스를 찾을 수 없습니다.");
            case 500:
                return new InternalServerErrorException("서버 내부 오류가 발생했습니다.");
            default:
                return new Exception("알 수 없는 오류가 발생했습니다.");
        }
    }
}

 
 

3. Configuration 구현


💡 Configuration 구현

- OpenFeign의 에러 처리를 위한 Configuration 클래스입니다. 이 설정을 통해 OpenFeign 클라이언트에서 발생하는 HTTP 통신 오류를 일관된 방식으로 처리할 수 있습니다.
- 즉, 오류가 발생하면 등록한 FeignClientCustomErrorDecoder 클래스로 전달이 되어서 해당 클래스에서 중앙 관리를 수행합니다.
package com.blog.springbootkeycloak.config.feign;

import com.blog.springbootkeycloak.config.feign.exception.FeignClientCustomErrorDecoder;
import feign.Feign;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 예외처리를 수행하는 클래스를 등록합니다.
 *
 * @author : leejonghoon
 * @fileName : FeignClientDecoderConfig
 * @since : 2025. 2. 26.
 */
@Configuration
public class FeignClientDecoderConfig {

    @Bean
    public ErrorDecoder errorDecoder() {
        return new FeignClientCustomErrorDecoder();
    }
}

 
 

4. 적용 테스트


💡 적용 테스트

- 아래와 같이 OpenFeign를 통해 통신을 수행할때, 404 에러가 발생하도록 강제하였습니다.

 
 
 

5. 결과확인


💡 결과확인

- 위에서 지정한 404 에러가 발생하였을때, jakarta.ws.rs.NotFoundException 오류가 발생하며, 메시지로 “리소스를 찾을 수 없습니다”라는 메시지를 출력하였습니다.

 
 

4) OpenFeign 예외처리 종류 -2 : try/catch 사용 예외처리


💡 OpenFeign 예외처리 종류 -2 : try/catch 사용

- OpenFeign을 사용할 때 발생하는 예외를 직접 try/catch 블록으로 처리하는 방법입니다.
- 각 HTTP 상태 코드에 따른 FeignException의 하위 클래스를 개별적으로 캐치하여 처리할 수 있습니다.
- 특정 API 호출에 대해 맞춤형 예외 처리가 필요한 경우 유용합니다.
오류 처리 방식사용 방법적용 범위장점
ErrorDecoderErrorDecoder 인터페이스 구현 클래스 작성 및 Bean 등록전역적 적용중앙화된 오류 처리, 일관된 예외 변환
try/catch개별 메서드에서 FeignException 하위 클래스 catch메서드 단위세밀한 예외 처리, 상황별 대응 가능
@ControllerAdvice@ExceptionHandler 메서드 구현애플리케이션 전체통합된 예외 처리, REST API 응답 표준화
Custom Exception사용자 정의 예외 클래스 생성 및 변환 로직 구현필요한 영역비즈니스 로직에 특화된 예외 처리

 

1. try/catch 사용 예외처리 특징


특징설명
세밀한 제어각 API 호출마다 다양한 예외 유형에 대해 개별적인 처리 로직 적용 가능
상황별 대응HTTP 상태 코드별로 FeignException 하위 클래스를 구분하여 처리
직관적인 코드 흐름예외 발생 시 대체 로직이나 fallback 메커니즘을 명확하게 구현 가능
유연한 오류 변환외부 API 오류를 비즈니스 도메인에 맞는 사용자 정의 예외로 변환 가능
로컬 로깅특정 API 호출의 실패에 대해 상세한 컨텍스트와 함께 로깅 가능

 

2. try/catch 사용예시 -1 : KeycloakUserFeignClient


💡 try/catch 사용예시 -1 : KeycloakUserFeignClient

- Keycloak 서버와 통신을 하는 OpenFeign입니다. 여기서 users/{id}/groups 엔드포인트를 호출하는 과정에서 임의의 무작위의 URL 경로를 추가하였습니다.
- 이를 통해서, OpenFeign내에서 404 에러가 발생할 것으로 예상이 됩니다.
package com.blog.springbootkeycloak.service.feign;

import com.blog.springbootkeycloak.config.feign.exception.FeignClientGlobalErrorDecoder;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * Keycloak 서버와 통신하는 OpenFeign
 *
 * @author : leejonghoon
 * @fileName : KeycloakUserFeignClient
 * @since : 2025. 2. 10.
 */
@FeignClient(
        name = "keycloak-user-client",
        url = "${keycloak.auth-server-url}/admin/realms/${keycloak.realm}"

)
@Service
public interface KeycloakUserFeignClient {
    /**
     * 사용자의 그룹 목록 조회
     */
    @GetMapping("/users/{id}/groups3485903485203984203984")
    List<GroupRepresentation> getUserGroups(
            @RequestHeader("Authorization") String bearerToken,
            @PathVariable String id
    );
}

 
 

3. try/catch 사용예시 -2: KeycloakUserService


💡 try/catch 사용예시 -2: KeycloakUserService

- 해당 서비스에서는 위의 KeycloakUserFeignClient를 호출하여 재구성하는 서비스입니다.
- getUserGroups() 메서드 내에는 try/catch로 발생할 수 있는 에러들에 대해서 catch로 정의하고 로깅을 출력하도록 작성하였습니다.
- 위에서 예측이 되는 404 에러가 발생할 것이고 try / catch 과정에서 “FeignException.NotFound 문제를 만나서 잘못된 경로로 호출이 되었습니다.”라는 메시지가 출력될 예정입니다.
package com.blog.springbootkeycloak.service;

import com.blog.springbootkeycloak.dto.properties.KeycloakProperties;
import com.blog.springbootkeycloak.dto.*;
import com.blog.springbootkeycloak.service.feign.KeycloakAuthFeignClient;
import com.blog.springbootkeycloak.service.feign.KeycloakUserFeignClient;
import feign.FeignException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * Keycloak 비즈니스 관리를 수행합니다.
 *
 * @author : leejonghoon
 * @fileName : KeycloakUserService
 * @since : 2025. 2. 10.
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class KeycloakUserService {

    private final KeycloakProperties properties;                    // Keycloak 설정 정보
    private final KeycloakUserFeignClient keycloakUserFeignClient;  // Keycloak User 통신
    private final KeycloakAuthFeignClient keycloakAuthFeignClient;  // Keycloak Auth 통신

  
    /**
     * 사용자 그룹을 조회합니다.
     *
     * @param bearerToken
     * @param username
     * @return
     */
    public List<GroupRepresentation> getUserGroups(String bearerToken, String username) {
        // 1. [Keycloak] 토큰 유효성 체크
        this.validateToken(bearerToken);

        // 2. [Keycloak] username 기반 ID 조회
        String id = this.getKeycloakUserId(bearerToken, username);

        List<GroupRepresentation> result = new ArrayList<>();
        try {
            // OpenFeign 클라이언트를 통한 API 호출
            result = keycloakUserFeignClient.getUserGroups(bearerToken, id);
        } catch (FeignException.BadRequest e) {
            // 400 에러 처리
            log.error("[-] 잘못된 호출이 발생하였습니다. {}", e.getMessage());
        } catch (FeignException.NotFound e) {
            // 404 에러 처리
            log.error("[-] 잘못된 경로로 호출이 되었습니다.{}", e.getMessage());
        } catch (FeignException.Unauthorized | FeignException.Forbidden e) {
            // 401, 403 에러 처리
            log.error("[-] 인증 및 인가 오류가 발생하였습니다. {}", id);
        } catch (FeignException e) {
            // 기타 Feign 예외 처리
            log.error("[-] 기타 오류가 발생하였습니다. status={}, message={}", e.status(), e.getMessage());
        }
        return result;
    }

    /**
     * username 기반 id 조회
     *
     * @param bearerToken
     * @param username
     * @return
     */
    private String getKeycloakUserId(String bearerToken, String username) {

        // [Validation] 빈 값 체크
        if (username == null) {
            throw new IllegalArgumentException("username이 존재하지 않습니다.: ");
        }

        // [Validation] 빈 값 체크
        if (username.isEmpty()) {
            throw new IllegalArgumentException("username이 존재하지 않습니다.: ");
        }

        // [Keycloak] username 기반의 사용자 조회
        List<UserRepresentation> result = keycloakUserFeignClient.selectKeycloakUserDetail(
                bearerToken,
                null,
                null,
                null,
                username,
                null,
                null,
                true
        );

        // [Validation] 유효하지 않은 사용자
        if (result.isEmpty()) {
            throw new IllegalArgumentException("해당 username으로 등록된 사용자를 찾을 수 없습니다: " + username);
        }

        return result.get(0).getId();
    }

}

 
 

4. try/catch 사용예시 -3 : 결과 화면


💡 try/catch 사용예시 -3 : 결과 화면

- 위에 구성에 따라서 try/catch 부분에서 해당 문제가 잡히고 아래와 같이 error 로그가 출력됨을 확인하였습니다.

 
 
 

5) OpenFeign 예외처리 종류 -3 : @ControllerAdvice 사용 오류 처리


💡 OpenFeign 예외처리 종류 -3 : @ControllerAdvice 사용 오류 처리

- OpenFeign에서 발생하는 예외를 Spring의 @ControllerAdvice를 통해 전역적으로 처리하는 방법입니다. 이 방식은 클라이언트 코드를 try-catch로 복잡하게 만들지 않고도 일관된 오류 응답을 제공할 수 있습니다.
- 해당 예외처리의 범위는 애플리케이션 전체에서 발생하는 경우에 대해 오류를 처리합니다.
특징설명
세밀한 제어각 API 호출마다 다양한 예외 유형에 대해 개별적인 처리 로직 적용 가능
상황별 대응HTTP 상태 코드별로 FeignException 하위 클래스를 구분하여 처리
직관적인 코드 흐름예외 발생 시 대체 로직이나 fallback 메커니즘을 명확하게 구현 가능
유연한 오류 변환외부 API 오류를 비즈니스 도메인에 맞는 사용자 정의 예외로 변환 가능
로컬 로깅특정 API 호출의 실패에 대해 상세한 컨텍스트와 함께 로깅 가능

 

1. @ControllerAdvice 사용 오류처리


 
 

특징설명
중앙 집중화된 예외 처리애플리케이션 전체에서 발생하는 OpenFeign 관련 예외를 한 곳에서 일관되게 처리
비즈니스 로직과 예외 처리 분리서비스 코드에서 try-catch 블록 없이 깔끔한 코드 유지 가능
HTTP 상태 코드 매핑Feign 예외를 적절한 HTTP 응답 코드로 변환하여 일관된 API 응답 제공
세분화된 예외 처리FeignException의 다양한 하위 클래스(NotFound, BadRequest 등)에 대해 개별적인 핸들러 구현 가능
상세한 오류 메시지 구성클라이언트에게 도움이 되는 구조화된 오류 응답을 일관된 형식으로 제공
보안 강화민감한 오류 정보를 필터링하여 외부에 노출되지 않도록 제어
로깅 표준화모든 외부 API 호출 실패에 대한 로깅을 일관된 방식으로 구현

 
 

💡 [참고] 아래의 Global Exception / Business Exception 처리하는 과정과 비슷하게 처리가 됩니다. 이에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.

- 아래의 형태를 이용하여서 구성을 합니다.

[Java] Global Exception 이해하고 구성하기 : Controller Exception

해당 글에서는 Controller에서 발생하는 Exception을 Global Exception을 구성하여서 처리하는 방법에 대해서 공유합니다. 1) 개발 환경💡 Global Exception을 적용하는데 활용한 개발환경입니다.개발환경버

adjh54.tistory.com

[Java] Business Exception 이해하고 구성하기 : Service Exception

해당 글에서는 business Layer에서 발생하는 오류에 대해서 공통 처리를 위한 Business Exception 대한 구성 방법에 대해 이해하고 구성하는 방법에 대해서 공유합니다.  [참고] 이전에 작성한 Global Except

adjh54.tistory.com

 

2. @ControllerAdvice 사용예시 -1 : @ControllerAdvice 구현


💡 @ControllerAdvice 사용예시 -1 : @ControllerAdvice 구현

- @ControllerAdvice를 사용하여 Feign 예외를 처리하는 글로벌 예외 핸들러를 구현합니다.
- 이 핸들러는 FeignException을 포함한 다양한 OpenFeign 관련 예외를 잡아내고 적절한 오류 응답을 클라이언트에게 반환합니다.
package com.blog.springbootkeycloak.config.exception;

import com.blog.springbootkeycloak.dto.common.ErrorResponse;
import feign.FeignException;
import feign.RetryableException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * Controller 내에서 발생하는 Exception 대해서 Catch 하여 응답값(Response)을 보내주는 기능을 수행함.
 */
@Slf4j
@RestControllerAdvice
public class GlobalFeignExceptionHandler {

    private final HttpStatus httpStatusOK = HttpStatus.OK;

    /**
     * 클라이언트 오류: 400 Bad Request
     */
    @ExceptionHandler(FeignException.BadRequest.class)
    public ResponseEntity<ErrorResponse> handleBadRequestException(FeignException.BadRequest ex) {
        log.error("잘못된 요청 형식 오류: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "잘못된 요청",
                "요청 형식이 올바르지 않거나 유효하지 않은 파라미터입니다"
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
    }

    /**
     * 클라이언트 오류: 401 Unauthorized
     */
    @ExceptionHandler(FeignException.Unauthorized.class)
    public ResponseEntity<ErrorResponse> handleUnauthorizedException(FeignException.Unauthorized ex) {
        log.error("인증 오류: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.UNAUTHORIZED.value(),
                "인증 실패",
                "유효한 인증 정보가 필요합니다"
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED);
    }

    /**
     * 클라이언트 오류: 403 Forbidden
     */
    @ExceptionHandler(FeignException.Forbidden.class)
    public ResponseEntity<ErrorResponse> handleForbiddenException(FeignException.Forbidden ex) {
        log.error("권한 없음: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.FORBIDDEN.value(),
                "접근 거부",
                "요청한 리소스에 접근할 권한이 없습니다"
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN);
    }

    /**
     * 클라이언트 오류: 404 Not Found
     */
    @ExceptionHandler(FeignException.NotFound.class)
    public ResponseEntity<ErrorResponse> handleNotFoundException(FeignException.NotFound ex) {
        log.error("리소스를 찾을 수 없음: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                "리소스 없음",
                "요청한 리소스가 존재하지 않습니다: " + ex.request().url()
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    /**
     * 클라이언트 오류: 405 Method Not Allowed
     */
    @ExceptionHandler(FeignException.MethodNotAllowed.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowedException(FeignException.MethodNotAllowed ex) {
        log.error("허용되지 않는 메서드: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.METHOD_NOT_ALLOWED.value(),
                "메서드 불허용",
                "요청한 HTTP 메서드가 이 리소스에서 지원되지 않습니다"
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.METHOD_NOT_ALLOWED);
    }

    /**
     * 서버 오류: 503 Service Unavailable
     */
    @ExceptionHandler(FeignException.ServiceUnavailable.class)
    public ResponseEntity<ErrorResponse> handleServiceUnavailableException(FeignException.ServiceUnavailable ex) {
        log.error("서비스 일시 중단: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.SERVICE_UNAVAILABLE.value(),
                "서비스 이용 불가",
                "현재 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요."
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE);
    }

    /**
     * 재시도 가능한 예외 처리
     */
    @ExceptionHandler(RetryableException.class)
    public ResponseEntity<ErrorResponse> handleRetryableException(RetryableException ex) {
        log.error("재시도 가능한 오류 발생: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.SERVICE_UNAVAILABLE.value(),
                "일시적 서비스 장애",
                "외부 서비스와 통신 중 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
        );

        return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE);
    }

    /**
     * 기본 FeignException 처리 (위에서 처리되지 않은 모든 Feign 예외)
     */
    @ExceptionHandler(FeignException.class)
    public ResponseEntity<ErrorResponse> handleFeignException(FeignException ex) {
        log.error("Feign 클라이언트 오류: {}", ex.getMessage());

        // HTTP 상태 코드 추출
        int status = ex.status();
        HttpStatus httpStatus = HttpStatus.valueOf(status != -1 ? status : 500);

        ErrorResponse errorResponse = new ErrorResponse(
                httpStatus.value(),
                "외부 서비스 통신 오류",
                ex.getMessage()
        );

        return new ResponseEntity<>(errorResponse, httpStatus);
    }

    /**
     * 응답 디코딩 오류
     */
    @ExceptionHandler(feign.codec.DecodeException.class)
    public ResponseEntity<ErrorResponse> handleDecodeException(feign.codec.DecodeException ex) {
        log.error("응답 디코딩 오류: {}", ex.getMessage());

        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "응답 처리 오류",
                "외부 서비스의 응답을 처리하는 데 문제가 발생했습니다"
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

 
 

3. @ControllerAdvice 사용예시 -2 : ErrorResponse


💡 @ControllerAdvice 사용예시 -2 : ErrorResponse

- @ControllerAdvice 내에서 공통적으로 발생하는 에러에 대해서 클라이언트에게 응답해주는 구조입니다.
- status는 상태 코드를 전달하고, error는 발생한 에러, messages는 상세한 메시지를 전달합니다.
package com.blog.springbootkeycloak.dto.common;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
 * Global Feign Exception Handler 발생한 에러에 대한 응답 처리를 관리
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
    private int status;
    private String error;
    private String message;

    @Builder
    public ErrorResponse(int status, String error, String message) {
        this.status = status;
        this.error = error;
        this.message = message;
    }
}

 
 

4. @ControllerAdvice 사용예시 -3 : KeycloakUserFeignClient


💡@ControllerAdvice 사용예시 -3 : KeycloakUserFeignClient

- Keycloak 서버와 통신을 하는 OpenFeign입니다.
- 여기서 users/{id}/groups 엔드포인트를 호출하는 과정에서 임의의 무작위의 URL 경로를 추가하였습니다.
- 이를 통해서, OpenFeign내에서 404 에러가 발생할 것으로 예상이 됩니다.
package com.blog.springbootkeycloak.service.feign;

import com.blog.springbootkeycloak.config.feign.exception.FeignClientGlobalErrorDecoder;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * Keycloak 서버와 통신하는 OpenFeign
 *
 * @author : leejonghoon
 * @fileName : KeycloakUserFeignClient
 * @since : 2025. 2. 10.
 */
@FeignClient(
        name = "keycloak-user-client",
        url = "${keycloak.auth-server-url}/admin/realms/${keycloak.realm}"

)
@Service
public interface KeycloakUserFeignClient {
    /**
     * 사용자의 그룹 목록 조회
     */
    @GetMapping("/users/{id}/groups3485903485203984203984")
    List<GroupRepresentation> getUserGroups(
            @RequestHeader("Authorization") String bearerToken,
            @PathVariable String id
    );
}

 
 

5. @ControllerAdvice 사용예시 -4 : KeycloakUserService


💡 @ControllerAdvice 사용예시 -4 : KeycloakUserService

- 이제 호출이 되는 getUserGroups() 메서드 내에서는 404 에러가 발생하지만 별도의 Exception 처리를 수행하지 않은 상태입니다.
package com.blog.springbootkeycloak.service;

import com.blog.springbootkeycloak.dto.properties.KeycloakProperties;
import com.blog.springbootkeycloak.dto.*;
import com.blog.springbootkeycloak.service.feign.KeycloakAuthFeignClient;
import com.blog.springbootkeycloak.service.feign.KeycloakUserFeignClient;
import feign.FeignException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * Keycloak 비즈니스 관리를 수행합니다.
 *
 * @author : leejonghoon
 * @fileName : KeycloakUserService
 * @since : 2025. 2. 10.
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class KeycloakUserService {

    private final KeycloakProperties properties;                    // Keycloak 설정 정보
    private final KeycloakUserFeignClient keycloakUserFeignClient;  // Keycloak User 통신
    private final KeycloakAuthFeignClient keycloakAuthFeignClient;  // Keycloak Auth 통신

    /**
     * 사용자 그룹을 조회합니다.
     *
     * @param bearerToken
     * @param username
     * @return
     */
    public List<GroupRepresentation> getUserGroups(String bearerToken, String username) {
      
        // [Keycloak] username 기반 ID 조회
        String id = this.getKeycloakUserId(bearerToken, username);

				// [Keycloak] 사용자 소속 그룹 조회
        return keycloakUserFeignClient.getUserGroups(bearerToken, id);
    }

    /**
     * username 기반 id 조회
     *
     * @param bearerToken
     * @param username
     * @return
     */
    private String getKeycloakUserId(String bearerToken, String username) {

        // [Validation] 빈 값 체크
        if (username == null) {
            throw new IllegalArgumentException("username이 존재하지 않습니다.: ");
        }

        // [Validation] 빈 값 체크
        if (username.isEmpty()) {
            throw new IllegalArgumentException("username이 존재하지 않습니다.: ");
        }

        // [Keycloak] username 기반의 사용자 조회
        List<UserRepresentation> result = keycloakUserFeignClient.selectKeycloakUserDetail(
                bearerToken,
                null,
                null,
                null,
                username,
                null,
                null,
                true
        );

        // [Validation] 유효하지 않은 사용자
        if (result.isEmpty()) {
            throw new IllegalArgumentException("해당 username으로 등록된 사용자를 찾을 수 없습니다: " + username);
        }

        return result.get(0).getId();
    }
}

 
 

5. @ControllerAdvice 사용예시 -5 : 결과 확인


💡 @ControllerAdvice 사용예시 -5 : 결과 확인

- 아래와 같이 사용자 정의가 된 응답 결과를 확인할 수 있습니다.

 
 
 

6) OpenFeign 예외처리 종류 -3 : Custom Exception 사용 예외처리


💡 OpenFeign 예외처리 종류 -3 : Custom Exception 사용 예외처리

- 사용자 정의 예외 클래스를 생성하여서 동일한 응답으로 반환하는 예외처리를 처리하는 방식입니다. 또한, 특정 @FeignClient 별로 적용이 가능합니다.
- 각 API 엔드포인트마다 예상되는 오류 유형과 처리 방식이 다를 때 세밀한 제어가 가능하며, 여러 마이크로서비스와 통신할 때 각 서비스의 특성에 맞게 예외 처리를 할 수 있다는 점입니다.
오류 처리 방식사용 방법적용 범위장점
ErrorDecoderErrorDecoder 인터페이스 구현 클래스 작성 및 Bean 등록전역적 적용중앙화된 오류 처리, 일관된 예외 변환
try/catch개별 메서드에서 FeignException 하위 클래스 catch메서드 단위세밀한 예외 처리, 상황별 대응 가능
@ControllerAdvice@ExceptionHandler 메서드 구현애플리케이션 전체통합된 예외 처리, REST API 응답 표준화
Custom Exception사용자 정의 예외 클래스 생성 및 변환 로직 구현필요한 영역비즈니스 로직에 특화된 예외 처리

 

💡 [참고] 이전 Global Exception / Business Exception 처리하는 과정과 비슷하게 처리가 됩니다. 이에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.

- 아래의 형태를 이용하여서 구성을 합니다.

[Java] Global Exception 이해하고 구성하기 : Controller Exception

해당 글에서는 Controller에서 발생하는 Exception을 Global Exception을 구성하여서 처리하는 방법에 대해서 공유합니다. 1) 개발 환경💡 Global Exception을 적용하는데 활용한 개발환경입니다.개발환경버

adjh54.tistory.com

 

[Java] Business Exception 이해하고 구성하기 : Service Exception

해당 글에서는 business Layer에서 발생하는 오류에 대해서 공통 처리를 위한 Business Exception 대한 구성 방법에 대해 이해하고 구성하는 방법에 대해서 공유합니다.  [참고] 이전에 작성한 Global Except

adjh54.tistory.com

 

1. Exception 처리 과정 확인


 

1.1. API Exception 발생 시 처리 과정


💡 API Exception 발생 시 처리 과정

- 이전에는 클라이언트에서 서버로 요청이 들어왔을때, 아래와 같이 처리가 되었습니다. Service Layer 내에서 비즈니스 로직 예외처리가 발생한 경우에 이에 대한 동일한 응답값으로 클라이언트에게 메시지를 전달해 주는 형태였습니다.
- OpenFeign 내에서도 서버-서버간의 통신을 수행할 때, 에러가 발생했을 때 동일한 에러형태로 반환을 해준다면 서로 서버 간의 공통된 처리를 할 수 있다는 점이 있습니다.

 
 
 

1.2. Server To Server OpenFeign Custom Error 발생 시 처리 과정


💡 Server To Server OpenFeign Custom Error 발생 시 처리 과정

- 위에서 처리된 Service 단계에서 Exception 처리에 대해서 이를 이용하여서 OpenFeign내에서 발생하는 에러에 대해서도 현재 API Server 내에서 처리되고 있는 방식을 활용하여 동일한 응답 결과를 반환하도록 합니다.
- 또한, 이러한 과정에서 반대로 B Server에서 A Server로 처리할때도 동일하게 응답 결과를 받을 수 있다는 장점이 있습니다.

 
 

2. Custom Exception 사용 예외처리 -1 : FeignClientCustomErrorDecoder


💡 Custom Exception 사용 예외처리 -1 : FeignClientCustomErrorDecoder

- 최초 OpenFeign 내에서 발생한 오류를 해당 부분에서 캐치를 하고 그대로 이를 반환하도록 구성하였습니다.
- 이 과정을 통해서 발생한 FeignException은 GlobalFeignExceptionHandler 내에서 이를 처리하도록 이관합니다.
/**
 * OpenFeign Client Error 지역 예외처리를 관리합니다.
 *
 * @author : leejonghoon
 * @fileName : FeignClientCustomErrorDecoder
 * @since : 2025. 3. 2.
 */
@Slf4j
@Component
public class FeignClientCustomErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        /*
         * FeignException 발생 시 이를 그대로 반환합니다.
         * 이렇게 구성하면 GlobalFeignExceptionHandler 내에서 처리하도록 이관합니다.
         */
        return FeignException.errorStatus(methodKey, response);
    }
}

 
 

3. Custom Exception 사용 예외처리 -2: GlobalFeignExceptionHandler


💡 Custom Exception 사용 예외처리 -2: GlobalFeignExceptionHandler

- 발생하는 Exception 대해서 Catch 하여 응답값(Response)을 보내주는 기능을 수행하는 클래스입니다.
- 해당 응답값에 HttpStatus.OK를 적용한 이유는 동일하게 통신은 성공하면서 문제가 되는 코드에 대해 잘못된 부분을 반환해 주도록 구성하였습니다.
예외 유형처리 메서드로그 메시지에러 코드
FeignException.BadRequesthandleBadRequestException잘못된 요청 형식 오류FEIGN_BAD_REQUEST_ERROR
FeignException.UnauthorizedhandleUnauthorizedException인증 오류FEIGN_UNAUTHORIZED_ERROR
FeignException.ForbiddenhandleForbiddenException권한 없음FEIGN_FORBIDDEN_ERROR
FeignException.NotFoundhandleNotFoundException리소스를 찾을 수 없음FEIGN_NOT_FOUND_ERROR
FeignException.MethodNotAllowedhandleMethodNotAllowedException허용되지 않는 메서드FEIGN_METHOD_NOT_ALLOWED_ERROR
FeignException.ServiceUnavailablehandleServiceUnavailableException서비스 일시 중단FEIGN_SERIALIZATION_ERROR
RetryableExceptionhandleRetryableException재시도 가능한 오류 발생FEIGN_RETRY_ERROR
FeignExceptionhandleFeignExceptionFeign 클라이언트 오류FEIGN_COMMUNICATION_ERROR
feign.codec.DecodeExceptionhandleDecodeException응답 디코딩 오류FEIGN_DECODING_ERROR
package com.blog.springbootkeycloak.config.exception;

import com.blog.springbootkeycloak.dto.common.ErrorCode;
import com.blog.springbootkeycloak.dto.common.ErrorResponse;
import feign.FeignException;
import feign.RetryableException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 발생하는 Exception 대해서 Catch 하여 응답값(Response)을 보내주는 기능을 수행함.
 */
@Slf4j
@RestControllerAdvice
public class GlobalFeignExceptionHandler {

    private final HttpStatus httpStatusOK = HttpStatus.OK;

    /**
     * 클라이언트 오류: 400 Bad Request
     */
    @ExceptionHandler(FeignException.BadRequest.class)
    public ResponseEntity<ErrorResponse> handleBadRequestException(FeignException.BadRequest ex) {
        log.error("잘못된 요청 형식 오류: {}", ex.getMessage());

        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_BAD_REQUEST_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 클라이언트 오류: 401 Unauthorized
     */
    @ExceptionHandler(FeignException.Unauthorized.class)
    public ResponseEntity<ErrorResponse> handleUnauthorizedException(FeignException.Unauthorized ex) {
        log.error("인증 오류: {}", ex.getMessage());

        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_UNAUTHORIZED_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 클라이언트 오류: 403 Forbidden
     */
    @ExceptionHandler(FeignException.Forbidden.class)
    public ResponseEntity<ErrorResponse> handleForbiddenException(FeignException.Forbidden ex) {
        log.error("권한 없음: {}", ex.getMessage());
        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_FORBIDDEN_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 클라이언트 오류: 404 Not Found
     */
    @ExceptionHandler(FeignException.NotFound.class)
    public ResponseEntity<ErrorResponse> handleNotFoundException(FeignException.NotFound ex) {
        log.error("리소스를 찾을 수 없음: {}", ex.getMessage());

        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_NOT_FOUND_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 클라이언트 오류: 405 Method Not Allowed
     */
    @ExceptionHandler(FeignException.MethodNotAllowed.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowedException(FeignException.MethodNotAllowed ex) {
        log.error("허용되지 않는 메서드: {}", ex.getMessage());

        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_METHOD_NOT_ALLOWED_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 서버 오류: 503 Service Unavailable
     */
    @ExceptionHandler(FeignException.ServiceUnavailable.class)
    public ResponseEntity<ErrorResponse> handleServiceUnavailableException(FeignException.ServiceUnavailable ex) {
        log.error("서비스 일시 중단: {}", ex.getMessage());

        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_SERIALIZATION_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 재시도 가능한 예외 처리
     */
    @ExceptionHandler(RetryableException.class)
    public ResponseEntity<ErrorResponse> handleRetryableException(RetryableException ex) {
        log.error("재시도 가능한 오류 발생: {}", ex.getMessage());
        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_RETRY_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 기본 FeignException 처리 (위에서 처리되지 않은 모든 Feign 예외)
     */
    @ExceptionHandler(FeignException.class)
    public ResponseEntity<ErrorResponse> handleFeignException(FeignException ex) {
        log.error("Feign 클라이언트 오류: {}", ex.getMessage());
        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_COMMUNICATION_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }

    /**
     * 응답 디코딩 오류
     */
    @ExceptionHandler(feign.codec.DecodeException.class)
    public ResponseEntity<ErrorResponse> handleDecodeException(feign.codec.DecodeException ex) {
        log.error("응답 디코딩 오류: {}", ex.getMessage());
        final ErrorResponse response = ErrorResponse.of(ErrorCode.FEIGN_DECODING_ERROR, ex.getMessage());
        return new ResponseEntity<>(response, httpStatusOK);
    }
}

 
 

4. Custom Exception 사용 예외처리-3 : ErrorCode 구성


💡 Custom Exception 사용 예외처리-3 : ErrorCode 구성

- 기존의 처리되는 로직 내에 OpenFeign에서 발생할 수 있는 에러코드를 반환부분을 추가하였습니다.
에러 코드HTTP 상태 코드Custom Code설명
FEIGN_CONNECTION_TIMEOUT503F001OpenFeign 통신 타임아웃 에러
FEIGN_RESPONSE_ERROR500F002OpenFeign 응답 처리 중 에러
FEIGN_SERVICE_UNAVAILABLE503F003OpenFeign 서비스 호출 실패
FEIGN_BAD_REQUEST400F004OpenFeign 요청 형식 에러
FEIGN_UNAUTHORIZED401F005OpenFeign 인증 에러
FEIGN_CLIENT_ERROR400F006OpenFeign 요청 처리 중 에러
FEIGN_SERVER_ERROR500F007OpenFeign 서버 에러
FEIGN_COMMUNICATION_ERROR502F008OpenFeign 서비스 간 통신 에러
FEIGN_REQUEST_CANCELLED499F009OpenFeign 요청 취소 에러
FEIGN_SERIALIZATION_ERROR400F010OpenFeign 데이터 직렬화/역직렬화 에러
package com.adjh.springbootexternalnetwork.dto.common;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
 * [공통 코드] API 통신에 대한 '에러 코드'를 Enum 형태로 관리를 한다.
 * Global Error CodeList : 전역으로 발생하는 에러코드를 관리한다.
 * Custom Error CodeList : 업무 페이지에서 발생하는 에러코드를 관리한다
 * Error Code Constructor : 에러코드를 직접적으로 사용하기 위한 생성자를 구성한다.
 * OpenFeign Error CodeList : OpenFeign 통신 중 발생하는 에러코드를 관리한다.
 *
 * @author : jonghoon
 * @fileName : ErrorCodeDto
 * @since : 25. 2. 20.
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public enum ErrorCode {

    /*
     * ******************************* Global Error CodeList ***************************************
     * IN Server HTTP Status Code
     * 400 : Bad Request
     * 401 : Unauthorized
     * 403 : Forbidden
     * 404 : Not Found
     * 500 : Internal Server Error
     * *********************************************************************************************
     */

    /************************************************************************************************
     ********************************* HTTP Status Error *********************************************
     * HTTP 통신 과정에서 발생하는 오류에 대해서 코드 기반으로 관리합니다.
     *************************************************************************************************/
    // [400] 잘못된 서버 요청
    BAD_REQUEST_ERROR(400, "S001", "Bad Request Exception"),

    // [401] 인증되지 않은 요청
    UNAUTHORIZED_ERROR(401, "S002", "Unauthorized request occurred Exception"),

    // [403] 권한이 없음
    FORBIDDEN_ERROR(403, "S003", "Forbidden Exception"),

    // [404] 서버로 요청한 리소스가 존재하지 않음
    NOT_FOUND_ERROR(404, "S004", "Not Found Exception"),

    // [405] 지원하지 않는 HTTP 메소드 오류
    METHOD_NOT_ALLOWED_ERROR(405, "S005", "The requested HTTP method is not allowed for this endpoint Exception"),

    // [500] 서버 내부 오류
    INTERNAL_SERVER_ERROR(500, "S006", "Internal Server Error Exception"),

    /************************************************************************************************
     ********************************* Custom Transaction Error  ************************************
     * 비즈니스 로직 내에서 트랜잭션을 수행했을때 오류가 발생하는 부분에 대해 코드 기반으로 관리합니다.
     *************************************************************************************************/

    // Business Exception Error
    BUSINESS_EXCEPTION_ERROR(500, "T001", "Business Exception Error"),

    // Transaction Insert Error
    INSERT_ERROR(500, "T002", "Insert Transaction Error Exception"),

    // Transaction Update Error
    UPDATE_ERROR(500, "T003", "Update Transaction Error Exception"),

    // Transaction Delete Error
    DELETE_ERROR(500, "T004", "Delete Transaction Error Exception"),

    /************************************************************************************************
     ********************************* Custom Business Error *****************************************
     * 비즈니스 로직에서 발생하는 오류들을 코드기반으로 관리합니다
     *************************************************************************************************/

    // 요청 본문 누락 오류
    REQUEST_BODY_MISSING_ERROR(400, "G002", "Required request body is missing Exception"),

    // 타입 변환 오류
    INVALID_TYPE_VALUE(400, "G003", "Invalid Type Value Exception"),

    // 요청 파라미터 누락 오류
    MISSING_REQUEST_PARAMETER_ERROR(400, "G004", "Missing Servlet RequestParameter Exception"),

    // 입출력 처리 오류
    IO_ERROR(500, "G008", "I/O Exception"),

    // JSON 파싱 오류 (Gson)
    JSON_PARSE_ERROR(400, "G009", "JsonParseException"),

    // Jackson 처리 오류
    JACKSON_PROCESS_ERROR(500, "G010", "com.fasterxml.jackson.core Exception"),

    // Null 참조 오류
    NULL_POINT_ERROR(500, "G006", "Null Point Exception"),

    // 요청 값 유효성 검증 실패
    NOT_VALID_ERROR(400, "G007", "handle Validation Exception"),

    // 헤더 값 유효성 검증 실패
    NOT_VALID_HEADER_ERROR(400, "G007", "Header에 데이터가 존재하지 않는 경우"),

    // 인증 정보 누락
    AUTH_IS_NULL_ERROR(401, "A404", "AUTH_IS_NULL"),

    // 인증 토큰 실패
    AUTH_TOKEN_FAIL_ERROR(401, "A405", "AUTH_TOKEN_FAIL"),

    // 유효하지 않은 인증 토큰
    AUTH_TOKEN_INVALID_ERROR(401, "A406", "AUTH_TOKEN_INVALID"),

    // 인증 토큰 불일치
    AUTH_TOKEN_NOT_MATCH_ERROR(401, "A407", "AUTH_TOKEN_NOT_MATCH"),

    // 인증 토큰 누락
    AUTH_TOKEN_IS_NULL_ERROR(401, "A408", "AUTH_TOKEN_IS_NULL"),

    /************************************************************************************************
     ********************************* OpenFeign Error CodeList **************************************
     *************************************************************************************************/

    // [400] 잘못된 요청 형식 오류
    FEIGN_BAD_REQUEST_ERROR(400, "F004", "OpenFeign bad request format Exception"),

    // [401] 인증 실패 오류
    FEIGN_UNAUTHORIZED_ERROR(401, "F005", "OpenFeign unauthorized access Exception"),

    // [403] 권한 부족 오류
    FEIGN_FORBIDDEN_ERROR(403, "F003", "OpenFeign forbidden access Exception"),

    // [404] 리소스 찾을 수 없음 오류
    FEIGN_NOT_FOUND_ERROR(404, "F004", "OpenFeign not found Exception"),

    // [405] 지원하지 않는 HTTP 메소드 오류
    FEIGN_METHOD_NOT_ALLOWED_ERROR(405, "F005", "OpenFeign method not allowed Exception"),

    // [499] 요청 취소 오류
    FEIGN_REQUEST_CANCELLED_ERROR(499, "F006", "OpenFeign request cancelled Exception"),

    // [400] 데이터 변환 오류
    FEIGN_SERIALIZATION_ERROR(400, "F007", "OpenFeign data serialization/deserialization Exception"),

    // [500] 서버 내부 오류
    FEIGN_SERVER_ERROR(500, "F008", "OpenFeign server error during request processing"),

    // [500] 서비스 간 통신 오류
    FEIGN_COMMUNICATION_ERROR(500, "F009", "OpenFeign inter-service communication Exception"),

    // [500] 재시도 실패 오류
    FEIGN_RETRY_ERROR(500, "F010", "OpenFeign retry Exception"),

    // [500] 응답 디코딩 오류
    FEIGN_DECODING_ERROR(500, "F011", "OpenFeign decoding Exception"),

    // [503] 연결 타임아웃 오류
    FEIGN_CONNECTION_TIMEOUT_ERROR(503, "F012", "OpenFeign connection timeout Exception"),

    // [500] 응답 처리 오류
    FEIGN_RESPONSE_ERROR(500, "F013", "OpenFeign response processing Exception"),

    // [503] 서비스 이용 불가 오류
    FEIGN_SERVICE_UNAVAILABLE_ERROR(503, "F014", "OpenFeign service unavailable Exception"),

    ; // End

    /**
     * ******************************* Error Code Constructor ***************************************
     */
    // 에러 코드의 '코드 상태'을 반환한다.
    private int status;

    // 에러 코드의 '코드간 구분 값'을 반환한다.
    private String code;

    // 에러 코드의 '코드 메시지'을 반환한다.
    private String message;

    // 생성자 구성
    ErrorCode(final int status, final String code, final String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}

 
 

5. Custom Exception 사용 예외처리 -4 : ErrorResponse


 💡 Custom Exception 사용 예외처리 -4 : ErrorResponse

- 이 클래스는 Spring Cloud OpenFeign에서 발생하는 다양한 예외 상황을 일관된 형식으로 클라이언트에게 전달하기 위한 응답 포맷을 정의합니다.
package com.blog.springbootkeycloak.dto.common;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.validation.BindingResult;

import java.util.ArrayList;
import java.util.List;

/**
 * OpenFeign 에러 응답 처리 클래스
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {

    private int status;             // 상태 코드
    private String errorCode;       // 에러 구분 코드
    private String errorMsg;        // 에러 메시지
    private String errorDtlMsg;     // 에러 상세 메시지

    /**
     * 기본 생성자 - ErrorCode만 받는 경우
     *
     * @param code
     */
    @Builder
    protected ErrorResponse(final ErrorCode code) {
        this.errorMsg = code.getMessage();
        this.status = code.getStatus();
        this.errorCode = code.getCode();
    }

    /**
     * 생성자 - ErrorCode와 reason을 받는 경우
     *
     * @param code
     * @param reason
     */
    @Builder
    protected ErrorResponse(final ErrorCode code, final String reason) {
        this.errorMsg = code.getMessage();
        this.status = code.getStatus();
        this.errorCode = code.getCode();
        this.errorDtlMsg = reason;
    }

    /**
     * 생성자 - ErrorCode와 필드 에러 목록을 받는 경우
     *
     * @param code
     * @param errors
     */
    @Builder
    protected ErrorResponse(final ErrorCode code, final List<FieldError> errors) {
        this.errorMsg = code.getMessage();
        this.status = code.getStatus();
        this.errorCode = code.getCode();
    }

    /**
     * 팩토리 메소드 - BindingResult에서 ErrorResponse 생성
     *
     * @param code
     * @param bindingResult
     * @return
     */
    public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) {
        return new ErrorResponse(code, FieldError.of(bindingResult));
    }

    /**
     * 팩토리 메소드 - BindingResult에서 ErrorResponse 생성
     *
     * @param code
     * @return
     */
    public static ErrorResponse of(final ErrorCode code) {
        return new ErrorResponse(code);
    }

    /**
     * 팩토리 메소드 - ErrorCode와 상세 이유로 ErrorResponse 생성
     *
     * @param code
     * @param reason
     * @return
     */
    public static ErrorResponse of(final ErrorCode code, final String reason) {
        return new ErrorResponse(code, reason);
    }

    /**
     * 유효성 검증 오류 정보를 담는 내부 클래스
     */
    @Getter
    public static class FieldError {
        private final String field;     // 오류 필드명
        private final String value;     // 오류 값
        private final String reason;    // 오류 이유

        // 단일 필드 오류 생성
        public static List<FieldError> of(final String field, final String value, final String reason) {
            List<FieldError> fieldErrors = new ArrayList<>();
            fieldErrors.add(new FieldError(field, value, reason));
            return fieldErrors;
        }

        // BindingResult에서 필드 오류 목록 추출
        private static List<FieldError> of(final BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors = bindingResult.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(
                            error.getField(),
                            error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                            error.getDefaultMessage()))
                    .toList();
        }

        @Builder
        FieldError(String field, String value, String reason) {
            this.field = field;
            this.value = value;
            this.reason = reason;
        }
    }
}

 
 

6. Custom Exception 사용 예외처리 -5 : KeycloakUserFeignClient


 💡 Custom Exception 사용 예외처리 -5 : KeycloakUserFeignClient

- OpenFeign를 이용하여서 Keycloak 서버로 호출을 수행합니다.
- 예외처리 발생을 위해서 @PostMapping이지만 @DeleteMapping로 오류를 발생시켰습니다.
package com.blog.springbootkeycloak.service.feign;

import com.blog.springbootkeycloak.config.feign.exception.FeignClientGlobalErrorDecoder;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * Keycloak 서버와 통신하는 OpenFeign
 *
 * @author : leejonghoon
 * @fileName : KeycloakUserFeignClient
 * @since : 2025. 2. 10.
 */
@FeignClient(
        name = "keycloak-user-client",
        url = "${keycloak.auth-server-url}/admin/realms/${keycloak.realm}",
        configuration = FeignClientGlobalErrorDecoder.class
)
@Service
public interface KeycloakUserFeignClient {
    /**
     * 사용자의 그룹 목록 조회
     */
    @DeleteMapping("/users/{id}/groups")
    List<GroupRepresentation> getUserGroups(
            @RequestHeader("Authorization") String bearerToken,
            @PathVariable String id
    );
}

 
 
 

7. Custom Exception 사용 예외처리 -6 : 결과 확인


 💡 Custom Exception 사용 예외처리 -6 : 결과 확인

- API 통신 자체의 통과를 위해 200에 대한 Status 코드를 제공하면서, 발생한 문제에 대해서 status에서는 404를 반환하고, 구성한 커스텀 코드인 errorCode가 F004로 반환했습니다.
- 또한, 발생한 에러를 errorMsg와 구체적인 errorDtlMsg 형태로 반환하여 최종적으로 서버간의 동일한 에러코드로 반환하도록 처리를 하였습니다.

 
 
 
 
 
 
오늘도 감사합니다 😀 

그리드형