5/14 Feign Clientㅡ data parsing과 예외처리, 로그인/재발급/로그아웃 통합테스트

dev_joo·2026년 5월 14일
post-thumbnail

내부 자격 조회 추가

리프레시 토큰을 발급 할 때는 비밀번호를 입력하지 않는다.
이에 대한 엔드포인트를 UserService에 추가해줬다.


Feign Clientㅡ data parsing과 예외처리

Internal API에 대한 공통 응답 형식을 정할 때 , APi Response 형식을 따르기로 했었다.

{
    "success": true,
    "data": {
        "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1ZDYxM2VjMS04NGU4LTRkNGYtOTJiOS04ZWEwNTJjOTE1YzgiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTc3NzM0NjQwMSwiZXhwIjoxNzc3NDMyODAxfQ.B23KE6Gdc6jit3_SgJxvjetBeWwKGCGI86_W1QSwWXaTl4IeJTqHxxcPtI2og9U19XOuJKchawy8xAx1c-V2Nw",
        "tokenType": "Bearer",
        "expiresIn": 86400
    }
}
{
    "success": false,
    "error": {
        "code": "INVALID_INPUT",
        "message": "입력값이 올바르지 않습니다.",
        "fieldErrors": [
            {
                "field": "phone",
                "rejectedValue": "010-9876-542",
                "reason": "전화번호 형식이 올바르지 않습니다. (예: 010-1234-5678)"
            },
            {
                "field": "email",
                "rejectedValue": "user4user.com",
                "reason": "유효한 이메일 형식이 아닙니다."
            }
        ]
    }
}

그러면 Internal 호출을 사용하는 쪽에서도 data 필드를 꺼내는 처리가 필요했다.
그래서 데이터를 파싱해주는 FeignClient의 Decorder를 커스텀하면, Feign을 호출하는 Provider의 코드를 더럽히지 않고 처리할 수 있다.

Config

GlobalFeignClientConfig - decoder

지금의 Auth Service는 내부 API 호출밖에 없어서 Feign 전역으로 설정 해주었다. (@Configuration)

/**
 * 디코더를 Bean으로 등록합니다. 전역 Feign Client에 설정합니다.
 *
 */
@Configuration
public class GlobalFeignClientConfig {

    @Bean
    public Decoder decoder(ObjectMapper objectMapper) {
        return new FeignApiResponseDecoder(objectMapper);
    }
}

UserServiceFeignConfig - errorDecoder

/**
 * UserServiceClient 전용 설정
 */
public class UserServiceFeignConfig {

    @Bean
    public ErrorDecoder errorDecoder(ObjectMapper objectMapper) {
        return new com.pagely.authservice.infrastructure.client.decoder.UserServiceClientErrorDecoder(objectMapper);
    }
}
/**
 * User Service 의 내부 API 호출용 Feign 클라이언트.
 */
@FeignClient(name = "userservice",
        configuration = UserServiceFeignConfig.class) // ErrorDecoder 설정
public interface UserServiceClient {

Decorder

{
    "success": true,
    "data": {  <-- [1] Decoder는 여기를 딱 집어서!
        "accessToken": "...",
        "tokenType": "Bearer",
        "expiresIn": 86400
    }          <-- [2] 이 내용만 추출한 뒤 DTO에 쏙 넣어줍니다.
}

JsonNode 사용

하지만 공통 모듈의ApiResponse의 data는 제네릭이 아닌 Object 타입으로 되어있었다.
Jackson이 Object 필드를 마주하면, 이게 내가 만든 응답 객체인지 아니면 단순한 LinkedHashMap인지 알 길이 없다. 그래서 일단 가장 안전한 Map 형태로 변환해버린다.

그래서 Feign 클라이언트에서 ClassCastException이 발생하거나 예상하지 못한 Map 객체가 튀어나오게 된다.

단순한 해결책은 공통모듈의 ApiResponse를 제네릭타입으로 바꾸는 것이었지만, 그렇게 되면 이미 개발이 진행된 상태에서 다른 서비스들까지 모두 영향을 주게 된다.

그래서 전체 응답을 JsonNode(트리 구조)로 읽은 뒤,data만 쏙 빼서 내가 원하는 타입으로 다시 변환하는 과정이 필요했다.


package com.pagely.authservice.infrastructure.client.decoder;

@Slf4j
@RequiredArgsConstructor
public class FeignApiResponseDecoder implements Decoder {
    private final ObjectMapper objectMapper;

    @Override
    public Object decode(Response response, Type type) throws IOException, FeignException {

		// 0. (최적화) 반환 타입이 void면 바디를 읽을 필요조차 없음
    	if (void.class.equals(type) || Void.class.equals(type)) {
        	return null;
    	}
        
        // 1. 응답 바디가 없으면 null 반환
        if (response.body() == null) {
            return null;
        }

        // 2. ApiResponse 객체로 바로 읽지 않고, 일단 트리 구조(JsonNode)로 읽어옵니다.
        JsonNode rootNode = objectMapper.readTree(response.body().asInputStream());

        // 3. ApiResponse 껍데기 필드값 추출
        boolean success = rootNode.has("success") && rootNode.get("success").asBoolean();

        // 4. HTTP 2xx로 왔는데 success가 false인 비정상적인 상황 방어
        if (!success) {
            log.error("Feign 2xx Response but success is false");
            // Decoder에서는 return이 아니라 throw를 해야 예외가 터집니다.
            throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR, "API 응답이 성공(2xx)이나 success가 false입니다.");
        }

        // 5. data 필드가 null 이거나 없으면 null 반환
        JsonNode dataNode = rootNode.get("data");
        if (dataNode == null || dataNode.isNull()) {
            return null;
        }

        // 6. 실제 원하는 타입(T)으로 변환
        return objectMapper.convertValue(dataNode, objectMapper.getTypeFactory().constructType(type));
    }
}

ErrorDecoder

Error 응답의 경우 값을 읽고 해당 예외를 처리한다.

{
    "success": false,
    "error": {
        "code": "INVALID_INPUT",
        "message": "..."
    }
}
package com.pagely.authservice.infrastructure.client.decoder;

@Slf4j
@RequiredArgsConstructor
public class UserServiceClientErrorDecoder implements ErrorDecoder {
    private final ObjectMapper objectMapper;
    private final ErrorDecoder defaultErrorDecoder = new Default();

    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.body() == null) {
            return defaultErrorDecoder.decode(methodKey, response);
        }

        try (InputStream inputStream = response.body().asInputStream()) {
            JsonNode rootNode = objectMapper.readTree(inputStream);
            JsonNode errorNode = rootNode.get("error");

            if (errorNode != null) {
                String remoteErrorCode = errorNode.has("code") ? errorNode.get("code").asText() : "UNKNOWN_CODE";
                String remoteMessage = errorNode.has("message") ? errorNode.get("message").asText() : "Unknown Error";

                log.debug("UserService 통신 실패 - code: {}, message: {}", remoteErrorCode, remoteMessage);

                // 1. 자격 검증 실패 (401)
                if ("INVALID_CREDENTIALS".equals(remoteErrorCode) || "USER_NOT_FOUND".equals(remoteErrorCode)) {
                    return new BusinessException(AuthErrorCode.INVALID_CREDENTIALS, remoteMessage);
                }

                // 2. 정지된 유저 (403)
                if ("USER_SUSPENDED".equals(remoteErrorCode)) {
                    return new BusinessException(AuthErrorCode.USER_SUSPENDED, remoteMessage);
                }

                // 3. 그 외에 authservice가 굳이 세분화할 필요가 없는 유저 서비스의 기타 비즈니스 에러들
                return new BusinessException(AuthErrorCode.USER_SERVICE_BUSINESS_ERROR, remoteMessage);
            }

        } catch (IOException e) {
            log.error("FeignDecode (User Service Error) 파싱 실패", e);
        }
        // 파싱에 실패했거나 규격에 안 맞는 에러는 Feign 기본 예외로 처리
        return defaultErrorDecoder.decode(methodKey, response);
    }
}

Provider에서는 최소한의 에러처리만 남긴다.
(Decoder가 던진 BusinessException은 상위로 던져지고, 여기서는 처리되지 못한 예기치 않은 통신 에러(FeignException)만 최후 방어한다)

@Slf4j
@Component
@RequiredArgsConstructor
public class UserCredentialVerifyProviderAdapter implements UserCredentialProvider {

    private final UserServiceClient userServiceClient;

    @Override
    public Result verify(String loginId, String password) {
        try {
            UserAuthInfoResponse response = userServiceClient.verifyCredentials(
                    new CredentialVerificationRequest(loginId, password)
            );
            return new Result(response.userId(), response.role());

        } catch (FeignException e) {
            log.error("User Service 통신 오류 — status={}, message={}", e.status(), e.getMessage());
            throw new BusinessException(AuthErrorCode.USER_SERVICE_UNAVAILABLE);
        }
    }

    @Override
    public Result findIdentity(UUID userId) {
        try {
            UserAuthInfoResponse response = userServiceClient.getAuthInfo(userId);
            return response.toResult();
        } catch (FeignException e) {
            log.error("User Service 통신 오류 — status={}, message={}", e.status(), e.getMessage());
            throw new BusinessException(AuthErrorCode.USER_SERVICE_UNAVAILABLE);
        }

    }

로그인


모바일 로그인

재발급


로그아웃


로그아웃 후 재발급

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글