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,
List<Long> adjustedApiIds,
String adjustedFromDate,
String adjustedToDate
String adjustedToDate,
Long adjustedDailyRequestLimit
) {
}

파일 보기

@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank;
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")
private LocalDateTime lastUsedAt;
@Column(name = "daily_request_limit")
private Long dailyRequestLimit;
@Builder
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
LocalDateTime expiresAt) {
LocalDateTime expiresAt, Long dailyRequestLimit) {
this.user = user;
this.apiKey = apiKey;
this.apiKeyPrefix = apiKeyPrefix;
@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity {
this.approvedBy = approvedBy;
this.approvedAt = approvedAt;
this.expiresAt = expiresAt;
this.dailyRequestLimit = dailyRequestLimit;
}
public void revoke() {

파일 보기

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

파일 보기

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

파일 보기

@ -10,6 +10,7 @@ import lombok.Getter;
public class ApiResponse<T> {
private final boolean success;
private final String code;
private final String message;
private final T data;
@ -34,4 +35,12 @@ public class ApiResponse<T> {
.message(message)
.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_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status;

파일 보기

@ -26,7 +26,7 @@ public class GlobalExceptionHandler {
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
return ResponseEntity
.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);
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
} 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 에러 응답 생성
*/
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
private ResponseEntity<byte[]> buildErrorResponse(int status, String code, String message) {
String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}";
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.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.util.AesEncryptor;
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.service.entity.SnpService;
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.WebClientResponseException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Enumeration;
import java.util.List;
@ -34,8 +36,9 @@ import java.util.Set;
public class GatewayService {
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(
"host", "x-api-key", "connection", "content-length"
"host", "connection", "content-length"
);
private final SnpApiKeyRepository snpApiKeyRepository;
@ -45,6 +48,7 @@ public class GatewayService {
private final AesEncryptor aesEncryptor;
private final WebClient webClient;
private final RequestLogService requestLogService;
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/**
* API Gateway 프록시 요청 처리
@ -70,8 +74,8 @@ public class GatewayService {
// 2. 대상 URL 조합 (실패 로그에도 사용)
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
// 3. API Key 추출
String rawKey = request.getHeader("X-API-KEY");
// 3. API Key 추출 (쿼리 파라미터 authKey)
String rawKey = request.getParameter(AUTH_KEY_PARAM);
if (rawKey == null || rawKey.isBlank()) {
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
}
@ -82,7 +86,10 @@ public class GatewayService {
// 5. Key 상태/만료 검증
validateApiKey(apiKey);
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
// 6. 일일 요청량 제한 검증
validateDailyLimit(apiKey);
// 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
@ -109,7 +116,8 @@ public class GatewayService {
} catch (BusinessException e) {
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().getMessage(), requestedAt);
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 구성
*/
@ -206,7 +244,13 @@ public class GatewayService {
String queryString = request.getQueryString();
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();
@ -327,9 +371,7 @@ public class GatewayService {
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
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();
}

파일 보기

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