feat(gateway): API 인증 쿼리파라미터 변경 및 일일 요청량 제한

- API Key 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경
- 일일 요청량 제한 기능 (daily_request_limit, HTTP 429)
- 인증/권한 거부 로그 상태 DENIED 분리 (기존 FAIL에서 분리)
- 에러 응답에 code 필드 추가 (ApiResponse, GatewayController)
- API Key 생성/검토 시 dailyRequestLimit 설정 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-14 13:58:18 +09:00
부모 dd1ac022d2
커밋 5ce1ca233d
11개의 변경된 파일84개의 추가작업 그리고 16개의 파일을 삭제

파일 보기

@ -9,6 +9,7 @@ public record ApiKeyRequestReviewDto(
String reviewComment, String reviewComment,
List<Long> adjustedApiIds, List<Long> adjustedApiIds,
String adjustedFromDate, String adjustedFromDate,
String adjustedToDate String adjustedToDate,
Long adjustedDailyRequestLimit
) { ) {
} }

파일 보기

@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public record CreateApiKeyRequest( public record CreateApiKeyRequest(
@NotBlank String keyName @NotBlank String keyName,
Long dailyRequestLimit
) { ) {
} }

파일 보기

@ -60,10 +60,13 @@ public class SnpApiKey extends BaseEntity {
@Column(name = "last_used_at") @Column(name = "last_used_at")
private LocalDateTime lastUsedAt; private LocalDateTime lastUsedAt;
@Column(name = "daily_request_limit")
private Long dailyRequestLimit;
@Builder @Builder
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName, public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt, ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
LocalDateTime expiresAt) { LocalDateTime expiresAt, Long dailyRequestLimit) {
this.user = user; this.user = user;
this.apiKey = apiKey; this.apiKey = apiKey;
this.apiKeyPrefix = apiKeyPrefix; this.apiKeyPrefix = apiKeyPrefix;
@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity {
this.approvedBy = approvedBy; this.approvedBy = approvedBy;
this.approvedAt = approvedAt; this.approvedAt = approvedAt;
this.expiresAt = expiresAt; this.expiresAt = expiresAt;
this.dailyRequestLimit = dailyRequestLimit;
} }
public void revoke() { public void revoke() {

파일 보기

@ -150,6 +150,11 @@ public class ApiKeyRequestService {
String prefix = rawKey.substring(0, PREFIX_LENGTH); String prefix = rawKey.substring(0, PREFIX_LENGTH);
String encryptedKey = aesEncryptor.encrypt(rawKey); String encryptedKey = aesEncryptor.encrypt(rawKey);
// 일일 요청 제한: 검토자가 조정한 > 신청자 입력값 > null(무제한)
Long dailyLimit = dto.adjustedDailyRequestLimit() != null
? dto.adjustedDailyRequestLimit()
: request.getDailyRequestEstimate();
SnpApiKey apiKey = SnpApiKey.builder() SnpApiKey apiKey = SnpApiKey.builder()
.user(request.getUser()) .user(request.getUser())
.apiKey(encryptedKey) .apiKey(encryptedKey)
@ -159,6 +164,7 @@ public class ApiKeyRequestService {
.approvedBy(reviewer) .approvedBy(reviewer)
.approvedAt(LocalDateTime.now()) .approvedAt(LocalDateTime.now())
.expiresAt(request.getUsageToDate()) .expiresAt(request.getUsageToDate())
.dailyRequestLimit(dailyLimit)
.build(); .build();
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey); SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);

파일 보기

@ -75,6 +75,7 @@ public class ApiKeyService {
.apiKeyPrefix(prefix) .apiKeyPrefix(prefix)
.keyName(request.keyName()) .keyName(request.keyName())
.status(ApiKeyStatus.ACTIVE) .status(ApiKeyStatus.ACTIVE)
.dailyRequestLimit(request.dailyRequestLimit())
.build(); .build();
SnpApiKey saved = snpApiKeyRepository.save(apiKey); SnpApiKey saved = snpApiKeyRepository.save(apiKey);

파일 보기

@ -10,6 +10,7 @@ import lombok.Getter;
public class ApiResponse<T> { public class ApiResponse<T> {
private final boolean success; private final boolean success;
private final String code;
private final String message; private final String message;
private final T data; private final T data;
@ -34,4 +35,12 @@ public class ApiResponse<T> {
.message(message) .message(message)
.build(); .build();
} }
public static <T> ApiResponse<T> error(String code, String message) {
return ApiResponse.<T>builder()
.success(false)
.code(code)
.message(message)
.build();
}
} }

파일 보기

@ -31,6 +31,7 @@ public enum ErrorCode {
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"), GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"), GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"), GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다"); INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status; private final int status;

파일 보기

@ -26,7 +26,7 @@ public class GlobalExceptionHandler {
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage()); log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
return ResponseEntity return ResponseEntity
.status(errorCode.getStatus()) .status(errorCode.getStatus())
.body(ApiResponse.error(errorCode.getMessage())); .body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
} }
/** /**

파일 보기

@ -37,7 +37,7 @@ public class GatewayController {
String remainingPath = extractRemainingPath(serviceCode, request); String remainingPath = extractRemainingPath(serviceCode, request);
return gatewayService.proxyRequest(serviceCode, remainingPath, request); return gatewayService.proxyRequest(serviceCode, remainingPath, request);
} catch (BusinessException e) { } catch (BusinessException e) {
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage()); return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getCode(), e.getErrorCode().getMessage());
} }
} }
@ -60,8 +60,8 @@ public class GatewayController {
/** /**
* Gateway 소비자용 JSON 에러 응답 생성 * Gateway 소비자용 JSON 에러 응답 생성
*/ */
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) { private ResponseEntity<byte[]> buildErrorResponse(int status, String code, String message) {
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}"; String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}";
return ResponseEntity.status(status) return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.body(json.getBytes()); .body(json.getBytes());

파일 보기

@ -8,6 +8,7 @@ import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode; import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.common.util.AesEncryptor; import com.gcsc.connection.common.util.AesEncryptor;
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
import com.gcsc.connection.monitoring.service.RequestLogService; import com.gcsc.connection.monitoring.service.RequestLogService;
import com.gcsc.connection.service.entity.SnpService; import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi; import com.gcsc.connection.service.entity.SnpServiceApi;
@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.List; import java.util.List;
@ -34,8 +36,9 @@ import java.util.Set;
public class GatewayService { public class GatewayService {
private static final int API_KEY_PREFIX_LENGTH = 8; private static final int API_KEY_PREFIX_LENGTH = 8;
private static final String AUTH_KEY_PARAM = "authKey";
private static final Set<String> EXCLUDED_HEADERS = Set.of( private static final Set<String> EXCLUDED_HEADERS = Set.of(
"host", "x-api-key", "connection", "content-length" "host", "connection", "content-length"
); );
private final SnpApiKeyRepository snpApiKeyRepository; private final SnpApiKeyRepository snpApiKeyRepository;
@ -45,6 +48,7 @@ public class GatewayService {
private final AesEncryptor aesEncryptor; private final AesEncryptor aesEncryptor;
private final WebClient webClient; private final WebClient webClient;
private final RequestLogService requestLogService; private final RequestLogService requestLogService;
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/** /**
* API Gateway 프록시 요청 처리 * API Gateway 프록시 요청 처리
@ -70,8 +74,8 @@ public class GatewayService {
// 2. 대상 URL 조합 (실패 로그에도 사용) // 2. 대상 URL 조합 (실패 로그에도 사용)
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request); targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
// 3. API Key 추출 // 3. API Key 추출 (쿼리 파라미터 authKey)
String rawKey = request.getHeader("X-API-KEY"); String rawKey = request.getParameter(AUTH_KEY_PARAM);
if (rawKey == null || rawKey.isBlank()) { if (rawKey == null || rawKey.isBlank()) {
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING); throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
} }
@ -82,7 +86,10 @@ public class GatewayService {
// 5. Key 상태/만료 검증 // 5. Key 상태/만료 검증
validateApiKey(apiKey); validateApiKey(apiKey);
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원) // 6. 일일 요청량 제한 검증
validateDailyLimit(apiKey);
// 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath; String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod()); SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
@ -109,7 +116,8 @@ public class GatewayService {
} catch (BusinessException e) { } catch (BusinessException e) {
int responseTime = (int) (System.currentTimeMillis() - startTime); int responseTime = (int) (System.currentTimeMillis() - startTime);
saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL", String logStatus = isDeniedError(e.getErrorCode()) ? "DENIED" : "FAIL";
saveLog(request, service, apiKey, targetUrl, gatewayPath, logStatus,
e.getErrorCode().getStatus(), responseTime, 0L, e.getErrorCode().getStatus(), responseTime, 0L,
e.getErrorCode().getMessage(), requestedAt); e.getErrorCode().getMessage(), requestedAt);
throw e; throw e;
@ -192,6 +200,36 @@ public class GatewayService {
} }
} }
private static final Set<ErrorCode> DENIED_ERROR_CODES = Set.of(
ErrorCode.GATEWAY_API_KEY_MISSING,
ErrorCode.GATEWAY_API_KEY_INVALID,
ErrorCode.GATEWAY_API_KEY_EXPIRED,
ErrorCode.GATEWAY_PERMISSION_DENIED,
ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED
);
private boolean isDeniedError(ErrorCode errorCode) {
return DENIED_ERROR_CODES.contains(errorCode);
}
/**
* 일일 요청량 제한 검증
*/
private void validateDailyLimit(SnpApiKey apiKey) {
Long limit = apiKey.getDailyRequestLimit();
if (limit == null) {
return;
}
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
long todayCount = snpApiRequestLogRepository
.countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(apiKey.getApiKeyId(), startOfDay);
if (todayCount >= limit) {
throw new BusinessException(ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED);
}
}
/** /**
* 대상 URL 구성 * 대상 URL 구성
*/ */
@ -206,7 +244,13 @@ public class GatewayService {
String queryString = request.getQueryString(); String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) { if (queryString != null && !queryString.isEmpty()) {
url.append("?").append(queryString); // authKey 파라미터는 프록시 대상에 전달하지 않음
String filtered = java.util.Arrays.stream(queryString.split("&"))
.filter(p -> !p.startsWith(AUTH_KEY_PARAM + "="))
.collect(java.util.stream.Collectors.joining("&"));
if (!filtered.isEmpty()) {
url.append("?").append(filtered);
}
} }
return url.toString(); return url.toString();
@ -327,9 +371,7 @@ public class GatewayService {
Enumeration<String> headerNames = request.getHeaderNames(); Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) { while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement(); String name = headerNames.nextElement();
if (!"x-api-key".equalsIgnoreCase(name)) { sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
}
} }
return sb.toString(); return sb.toString();
} }

파일 보기

@ -12,6 +12,9 @@ import java.util.List;
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>, public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
JpaSpecificationExecutor<SnpApiRequestLog> { JpaSpecificationExecutor<SnpApiRequestLog> {
/** API Key별 일일 요청 건수 */
long countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(Long apiKeyId, LocalDateTime startOfDay);
/** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */ /** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */
@Query(value = "SELECT COUNT(*) as total, " + @Query(value = "SELECT COUNT(*) as total, " +
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " + "COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " +