Compare commits

4 Commits
Author SHA1 Message Date
jcchoi a6e590b993 Update README.md 2025-04-03 11:34:51 +09:00
jcchoi 95396e2eb6 Update README.md 2025-04-03 11:25:05 +09:00
jcchoi 1454ce14d2 Update README.md 2025-04-03 11:20:23 +09:00
jcchoi f1024aa4b9 apigateway 프로그램 생성 2025-04-03 10:59:58 +09:00
32 changed files with 1997 additions and 1 deletions
+126 -1
View File
@@ -1,3 +1,128 @@
# IIPBS
백엔드 저장소
![Spring](https://img.shields.io/badge/Spring_Boot-F2F4F9?style=for-the-badge&logo=spring-boot)
![Gradle](https://img.shields.io/badge/Gradle-02303A.svg?style=for-the-badge&logo=Gradle&logoColor=white)
![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)
![NextJS](https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white)
![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)
![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)
![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)
'클라우드 네이티브 기반 행정·공공 서비스 확산 지원(2021)' 사업의 일환으로 제작된 MSA(Microservices Architecture) 템플릿을 활용하여 유역하수도정보스시템 백엔드 시스템을 개발한 소스코드이다.
## 디렉토리 구조
```
├─apigateway
├─attach-service
├─board-service
├─config-service
├─cms-service
├─discovery
├─module-common
├─user-service
├─config
├─docker-compose
│ ├─app
│ │ ├─mesh
│ │ └─service
│ ├─elk
│ │ ├─kibana
│ │ │ └─config
│ │ └─logstash
│ │ ├─config
│ │ └─pipeline
│ └─opensearch
│ └─logstash
│ ├─config
│ └─pipeline
└─k8s
├─applications
│ └─backend
│ ├─apigateway
│ │ └─ingress
│ ├─board-service
│ ├─config-service
│ ├─discovery
│ │ └─ingress
│ ├─cms-service
│ ├─attach-service
│ └─user-service
└─environments
├─configmaps
├─jenkins
├─logging
│ └─elk
│ ├─elasticsearch
│ ├─kibana
│ │ └─ingress
│ └─logstash
├─nfs
├─rabbitmq
│ └─ingress
├─storage
├─vagrant
└─zipkin
└─ingress
```
### 디렉토리 설명
- `/: Spring Boot를 기반으로 백앤드에 올려지는 서비스들
- `/apigateway`: Microservice에 대한 API 관리 및 모니터링 서비스
- `/board-service`: 게시판 서비스 (게시판, 게시물, 첨부파일 관리)
- `/config-service`: 별도의 통합된 설정 관리 서비스 제공을 통해 환경 독립적 서비스 제공
- `/discovery`: 마이크로서비스들을 등록하여 관리하고 요청 시 해당 서비스를 찾아 호출
- `/user-service`: 사용자 서비스 ( 인가, 로그인, 회원가입, 비밀번호 찾기, 관리자)
- `/cms-service`: CMS 공통 서비스 (메뉴, 코드, 컨텐츠, 권한, 배너, 서비스, 사이트 관리)
- `/attach-service`: TUS 첨부파일 공통 서비스 (첨부파일 관리)
- `/config`: backend 의 config 서버에서 사용하는 설정 yaml 파일 모음 폴더
- `/docker-compose`: Docker에 여러 컨테이너의 실행을 관리하는 yaml 구성 파일들
- `/k8s`: Kubernetes 환경 배포를 위한 Resource 템플릿 yaml 파일
- `/k8s/applications`: Backend와 Frontend 애플리케이션의 k8s Resource 템플릿 yaml 파일
- `/k8s/environments/configmaps`: Pods에서 사용하는 환경변수로서 Key-Value 쌍의 형태로 저장된 객체 정의
- `/k8s/environments/jenkins`: Jenkins 관련 객체 정의
- `/k8s/environments/logging`: Centralized Logging을 위한 ELK 관련 객체 정의
- `/k8s/environments/nfs`: NFS 노드 볼륨 구성
- `/k8s/environments/rabbitmq`: Message Broker 관련 객체 정의
- `/k8s/environments/storage`: PersistentVolumeClaim 정의
- `/k8s/environments/vagrant`: 가상화 소프트웨어 개발환경의 생성을 쉽게 구성하도록 하는 Vagrant 관련 정의
- `/k8s/environments/zipkin`: 분산 트랜잭션 추적을 위한 Zipkin 관련 정의
## 백앤드 구동 방법
- 개발환경 Eclipse IDE 를 실행한다.
- Eclipse IDE 메뉴에서 File>Import… 를 클릭한다.
- Import 창이 열리면 Gradle>Existing Gradle Project 를 선택하고 Next 버튼을 클릭한다.
- Import Gradle Project 창이 열리면 Next 버튼을 클릭한다.
- Project root directory 에서 ${home}/config를 선택하고 Finish 버튼을 클릭한다.
- 위의 과정을 반복하여 아래의 프로젝트를 import 한다. (소규모는 1-6, 대규모는 1-9)
1. config-service
2. discovery
3. apigateway
4. attach-service
5. cms-service
6. user-service
7. board-service
- 모든 프로젝트를 import 하고 Project Explorer 를 확인하면 board-service, portal-service, user-service 프로젝트에 오류 표시가 출력된다. querydsl 로 generate 되는 클래스들을 build path 에 추가해야 한다.
- Window>Show View>Other 을 클릭해서 열린 창에서 Gradle>Gradle Tasks 를 선택하고 Open 버튼을 클릭하면 Gradle Tasks 탭이 열린다.
- Gradle Tasks 오른쪽 윗부분의 View Menu 버튼을 클릭해서 Show All Tasks 를 체크한다.
- Gradle Tasks 에서 portal-service>other>compileQuerydsl 을 더블클릭 또는 우클릭 후 Run Gradle Tasks 를 클릭하면 build 가 시작된다.
- Project Explorer 에서 board-service, portal-service, user-service 를 선택하고 F5 또는 우클릭 후 Refresh 를 클릭해서 프로젝트를 새로고침한다.
- Project Explorer 에서 board-service, portal-service, user-service 를 우클릭하고 Properties 를 클릭한다.
- Properties 창이 열리면 왼쪽 메뉴에서 Java Build Path를 선택하고 오른쪽 Source 탭에서 Add Folder… 버튼을 클릭한다.
- Source Folder Selection 창이 열리면 build>generated>querydsl 을 체크하고 OK 버튼을 클릭한다.
- Properties 창에서 Apply and Close 버튼을 클릭하면 창이 닫히면서 프로젝트를 다시 빌드하고 오류 표시는 사라진다.
(ELK 설정, Config 설정, OAuth 2.0 설정, API 호출 및 JUnit 테스트 관련해서는 [02.MSA템플릿 백엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084631/02.MSA._.pdf) 파일을 참조한다.)
## 교육 교재
- [01.MSA템플릿 개요 및 개발환경 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084628/01.MSA._._.pdf)
- [01.MSA템플릿 개요 및 개발환경 이론.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084629/01.MSA._._.pdf)
- [02.MSA템플릿 백엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084631/02.MSA._.pdf)
- [03.MSA템플릿 프론트엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084632/03.MSA._.pdf)
- [04.MSA템플릿 이벤트스트림구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084633/04.MSA._.pdf)
+15
View File
@@ -0,0 +1,15 @@
# openjdk8 base image
FROM openjdk:8-jre-alpine
# directory 생성
RUN mkdir -p /usr/app/msa-attach-volume/messages
# jar 파일이 복사되는 위치
ENV APP_HOME=/usr/app/
# 작업 시작 위치
WORKDIR $APP_HOME
# jar 파일 복사
COPY build/libs/*.jar apigateway.jar
# application port
EXPOSE 8000
# 실행
CMD ["java", "-Dspring.profiles.active=${profile:default}", "-jar", "apigateway.jar"]
+4
View File
@@ -0,0 +1,4 @@
FROM openjdk:8-jre-alpine
WORKDIR /app/
COPY build/libs/*.jar app.jar
CMD ["java", "-Dlogstash.url=logstash.default.svc.cluster.local:8088", "-jar", "app.jar"]
+48
View File
@@ -0,0 +1,48 @@
// https://github.com/jenkinsci/kubernetes-plugin
podTemplate(
name: "jenkins-slave",
serviceAccount: "jenkins",
containers: [
containerTemplate(name: "docker", image: "docker", ttyEnabled: true, command: 'cat'),
containerTemplate(name: "kubectl", image: "lachlanevenson/k8s-kubectl:v1.17.5", ttyEnabled: true, command: 'cat')
],
volumes: [
hostPathVolume(mountPath: "/var/run/docker.sock", hostPath: "/var/run/docker.sock")
]
) {
node(POD_LABEL) {
def now = java.time.LocalDateTime.now()
def projectId = "apigateway" // 프로젝트 아이디
// docker
def dockerhubCredentialsId = "username-password-dockerhub" // 젠킨스 도커허브 자격증명
def dockerImage = "egovframe/egovframe-msa-edu-backend-${projectId}" // 도커허브 이미지
def dockerImageTag = "latest" // 도커허브 이미지 태그
stage("git") {
checkout scm
}
stage("gradle") {
sh "./gradlew wrap --gradle-version=7.3 --daemon"
sh "./gradlew bootJar"
}
stage("docker") {
container("docker") {
withCredentials([usernamePassword(credentialsId: "${dockerhubCredentialsId}", passwordVariable: "password", usernameVariable: "username")]) {
sh "docker login -u $username -p $password"
sh "docker build -f DockerfileK8s -t ${dockerImage}:${dockerImageTag} ."
sh "docker push ${dockerImage}:${dockerImageTag}"
}
}
}
stage( "kubernetes" ) {
container("kubectl") {
sh "kubectl rollout restart deployment ${projectId}-deployment"
sh "kubectl annotate deployment.apps/${projectId}-deployment kubernetes.io/change-cause='Image updated at ${now} by ${env.BUILD_TAG}.'"
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
plugins {
id 'org.springframework.boot' version '2.7.12'
id 'org.sonarqube' version '3.5.0.2730'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
group = 'org.egovframe.cloud'
version = '1.0.0'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', '2021.0.7')
set('log4j2.version', '2.20.0') // log4j 보안 패치
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-config' // config
implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' // config
implementation 'org.springframework.cloud:spring-cloud-starter-bus-amqp' // bus
implementation 'org.apache.tomcat.embed:tomcat-embed-el:9.0.73'
implementation 'net.logstash.logback:logstash-logback-encoder:7.4' // logstash logback
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
// Resilience4j 의존성 추가
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.0'
implementation 'io.github.resilience4j:resilience4j-reactor:1.7.0'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springdoc:springdoc-openapi-webflux-ui:1.7.0'
implementation 'com.github.ben-manes.caffeine:caffeine'
// macOS용 Netty DNS 리졸버 네이티브 라이브러리 추가
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-x86_64'
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-aarch_64'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
test {
useJUnitPlatform()
}
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
---
applications:
- name: egov-apigateway # CF push 시 생성되는 이름
# memory: 512M # 메모리
instances: 1 # 인스턴스 수
host: egov-apigateway # host 명으로 유일해야 함
path: build/libs/apigateway-0.1.jar # build 후 생성된 jar 위치
buildpack: java_buildpack # cf buildpacks 명령어로 java buildpack 이름 확인
services:
- egov-discovery-provided-service # discovery service binding
env:
spring_profiles_active: cf
spring_cloud_config_uri: http://localhost:8888
app_name: egov-apigateway # logstash custom app name
TZ: Asia/Seoul
JAVA_OPTS: -Xss349k
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'apigateway'
@@ -0,0 +1,33 @@
package org.egovframe.cloud.apigateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
/**
* org.egovframe.cloud.apigateway.ApigatewayApplication
* <p>
* 게이트웨이 어플리케이션 클래스
* Eureka Client 로 설정했기 때문에 Eureka Server 가 먼저 기동되어야 한다.
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/06/30
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/06/30 jaeyeolkim 최초 생성
* </pre>
*/
@EnableDiscoveryClient
@SpringBootApplication
public class ApigatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApigatewayApplication.class, args);
}
}
@@ -0,0 +1,43 @@
package org.egovframe.cloud.apigateway.api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.Locale;
/**
* org.egovframe.cloud.apigateway.api.MessageSourceApiController
* <p>
* MessageSource 정상 확인을 위한 컨트롤러
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/08/10
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/08/10 jaeyeolkim 최초 생성
* 2024/09/24 이백행 컨트리뷰션 이클립스 문제(Problems) 해결
* </pre>
*/
@Slf4j
@RequiredArgsConstructor
@RestController
public class MessageSourceApiController {
private final MessageSource messageSource;
@GetMapping("/api/v1/messages/{code}/{lang}")
public String getMessage(@PathVariable String code, @PathVariable String lang) {
Locale locale = "en".equals(lang)? Locale.ENGLISH : Locale.KOREAN;
String message = messageSource.getMessage(code, null, locale);
log.info("code={}, lang={}, message={}", code, lang, message);
return message;
}
}
@@ -0,0 +1,64 @@
package org.egovframe.cloud.apigateway.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* org.egovframe.cloud.apigateway.config.MessageSourceConfig
* <p>
* Spring MessageSource 설정
* Message Domain 이 있는 portal-service 에서 messages.properties 를 공유 가능한 외부 위치에 생성한다.
* 각 서비스에서 해당 파일을 통해 다국어를 지원하도록 한다.
* module-common.jar 를 포함하지 않는 서비스에서는 이 configuration을 추가해주어야 한다.
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/08/09
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/08/09 jaeyeolkim 최초 생성
* </pre>
*/
@Slf4j
@Configuration
public class MessageSourceConfig {
@Value("${messages.directory}")
private String messagesDirectory;
@Value("${spring.profiles.active:default}")
private String profile;
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
final String MESSAGES = "/messages";
if ("default".equals(profile)) {
Path fileStorageLocation = Paths.get(messagesDirectory).toAbsolutePath().normalize();
String dbMessages = StringUtils.cleanPath("file://" + fileStorageLocation + MESSAGES);
log.info("DB MessageSource location = {}", dbMessages);
messageSource.setBasenames(dbMessages);
} else {
messageSource.setBasenames(messagesDirectory + MESSAGES);
}
messageSource.getBasenameSet().forEach(s -> log.info("messageSource getBasenameSet={}", s));
messageSource.setCacheSeconds(60); // 메세지 파일 변경 감지 간격
messageSource.setUseCodeAsDefaultMessage(true); // 메세지가 없으면 코드를 메세지로 한다
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
return messageSource;
}
}
@@ -0,0 +1,38 @@
package org.egovframe.cloud.apigateway.config;
import org.springdoc.core.GroupedOpenApi;
import org.springdoc.core.SwaggerUiConfigParameters;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@Configuration
public class OpenApiDocConfig {
@Bean
@Lazy(false)
public List<GroupedOpenApi> apis(SwaggerUiConfigParameters swaggerUiConfigParameters, RouteDefinitionLocator locator) {
List<GroupedOpenApi> groups = new ArrayList<>();
List<RouteDefinition> definitions = locator.getRouteDefinitions().log("OpenApiDocConfig").collectList().block();
Optional.ofNullable(definitions)
.map(Collection::stream)
.orElseGet(Stream::empty)
.filter(routeDefinition -> routeDefinition.getId().matches(".*-service"))
.forEach(routeDefinition -> {
String name = routeDefinition.getId();
swaggerUiConfigParameters.addGroup(name);
GroupedOpenApi.builder().pathsToMatch("/" + name + "/**").group(name).build();
});
return groups;
}
}
@@ -0,0 +1,79 @@
package org.egovframe.cloud.apigateway.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;
/**
* org.egovframe.cloud.apigateway.config.RateLimiterConfig
* <p>
* 요청 제한(Rate Limiting) 설정 클래스
* 동일한 IP 또는 경로에 대한 과도한 요청을 제한하기 위한 설정
*
* @author changwng
* @version 1.0
* @since 2025/03/19
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2025/03/19 changwng 최초 생성
* </pre>
*/
@Slf4j
@Configuration
public class RateLimiterConfig {
/**
* IP 주소 기반 요청 제한을 위한 KeyResolver 빈
* 클라이언트 IP 주소를 키로 사용하여 요청 제한을 적용
*
* @return KeyResolver IP 주소 기반 키 리졸버
*/
// @Bean 주석 처리 - 다중 KeyResolver 빈 문제 해결
public KeyResolver ipKeyResolver() {
return exchange -> {
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
log.debug("Request IP: {}", ip);
return Mono.just(ip);
};
}
/**
* 요청 경로 기반 요청 제한을 위한 KeyResolver 빈
* 요청 경로를 키로 사용하여 요청 제한을 적용
*
* @return KeyResolver 요청 경로 기반 키 리졸버
*/
// @Bean 주석 처리 - 다중 KeyResolver 빈 문제 해결
public KeyResolver pathKeyResolver() {
return exchange -> {
String path = exchange.getRequest().getPath().value();
log.debug("Request Path: {}", path);
return Mono.just(path);
};
}
/**
* IP와 경로를 조합한 요청 제한을 위한 KeyResolver 빈
* IP 주소와 요청 경로를 조합하여 키로 사용함으로써 더 세밀한 요청 제한 적용 가능
*
* @return KeyResolver IP와 경로 조합 기반 키 리졸버
*/
@Bean
@Primary // 기본 KeyResolver 빈으로 지정
public KeyResolver ipAndPathKeyResolver() {
return exchange -> {
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
String path = exchange.getRequest().getPath().value();
String key = ip + ":" + path;
log.debug("Request Key: {}", key);
return Mono.just(key);
};
}
}
@@ -0,0 +1,188 @@
package org.egovframe.cloud.apigateway.config;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.RequestPath;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.access.AuthorizationServiceException;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
/**
* org.egovframe.cloud.apigateway.config.ReactiveAuthorization
* <p>
* Spring Security 에 의해 요청 url에 대한 사용자 인가 서비스를 수행하는 클래스 요청에 대한 사용자의 권한여부 체크하여 true/false 리턴한다
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/07/19
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/07/19 jaeyeolkim 최초 생성
* </pre>
*/
@Slf4j
@Component
public class ReactiveAuthorization implements ReactiveAuthorizationManager<AuthorizationContext> {
private final WebClient.Builder webClientBuilder;
private final MeterRegistry meterRegistry;
private final CircuitBreaker authCircuitBreaker = CircuitBreaker.of("auth-service",
CircuitBreakerConfig.custom()
.failureRateThreshold(70) // 실패율 70% 이상이면 서킷 오픈
.slowCallRateThreshold(50) // 50% 이상의 요청이 느린 경우 서킷 오픈
.slowCallDurationThreshold(Duration.ofSeconds(3)) // 3초 이상 걸리면 느린 요청으로 간주
.waitDurationInOpenState(Duration.ofSeconds(10)) // 서킷 오픈 후 10초 대기
.permittedNumberOfCallsInHalfOpenState(10) // 반열림 상태에서 10개 요청 허용
.slidingWindowSize(50) // 최근 50개 요청 기준으로 실패율 계산
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 카운트 기반 윈도우
.build());
private final Timer authServiceTimer;
public ReactiveAuthorization(WebClient.Builder webClientBuilder, MeterRegistry meterRegistry) {
this.webClientBuilder = webClientBuilder;
this.meterRegistry = meterRegistry;
this.authServiceTimer = Timer.builder("auth.service.request.time")
.description("인증 서비스 요청 시간 측정")
.register(meterRegistry);
}
@Value("${apigateway.host:http://localhost:8000}")
private String APIGATEWAY_HOST;
@Value("${token.secret}")
private String TOKEN_SECRET;
// org.egovframe.cloud.common.config.GlobalConstant 값도 같이 변경해주어야 한다.
public static final String AUTHORIZATION_URI = "/user-service" + "/api/v1/authorizations/check";
public static final String REFRESH_TOKEN_URI = "/user-service" + "/api/v1/users/token/refresh";
/**
* 요청에 대한 사용자의 권한여부 체크하여 true/false 리턴한다 헤더에 토큰이 있으면 유효성을 체크한다.
* 리액티브 패턴을 사용하여 논블로킹 방식으로 구현
*
* @param authentication
* @param context
* @return
* @see WebFluxSecurityConfig
*/
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
ServerHttpRequest request = context.getExchange().getRequest();
RequestPath requestPath = request.getPath();
HttpMethod httpMethod = request.getMethod();
String requestId = request.getId();
String baseUrl =
APIGATEWAY_HOST + AUTHORIZATION_URI + "?httpMethod=" + httpMethod + "&requestPath="
+ requestPath;
log.info("baseUrl={}", baseUrl);
List<String> authorizations =
request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION) ?
request.getHeaders().get(HttpHeaders.AUTHORIZATION) : null;
// 인증 헤더가 없거나 유효하지 않은 경우
if (authorizations == null || authorizations.isEmpty()
|| !StringUtils.hasLength(authorizations.get(0))
|| "undefined".equals(authorizations.get(0))) {
return Mono.just(new AuthorizationDecision(false));
}
String authorizationHeader = authorizations.get(0);
// JWT 토큰 검증
try {
String jwt = authorizationHeader.replace("Bearer", "").trim();
String subject = Jwts.parser().setSigningKey(TOKEN_SECRET)
.parseClaimsJws(jwt)
.getBody()
.getSubject();
// refresh token 요청 시 토큰 검증만 하고 인가 처리 한다.
if (REFRESH_TOKEN_URI.equals(requestPath.toString())) {
return Mono.just(new AuthorizationDecision(true));
}
if (subject == null || subject.isEmpty()) {
log.error("토큰 인증 오류: 유효하지 않은 subject");
return Mono.just(new AuthorizationDecision(false));
}
} catch (IllegalArgumentException e) {
log.error("토큰 헤더 오류: {}", e.getMessage());
return Mono.just(new AuthorizationDecision(false));
} catch (ExpiredJwtException e) {
log.error("토큰 유효기간이 만료되었습니다: {}", e.getMessage());
return Mono.just(new AuthorizationDecision(false));
} catch (Exception e) {
log.error("토큰 인증 오류: {}", e.getMessage());
return Mono.just(new AuthorizationDecision(false));
}
// 인가 서비스 호출 시간 측정을 위한 시작 시간 기록
long start = System.nanoTime();
// 인가 서비스 호출 (논블로킹 방식)
return webClientBuilder.build()
.get()
.uri(baseUrl)
.headers(httpHeaders -> httpHeaders.add(HttpHeaders.AUTHORIZATION, authorizationHeader))
.retrieve()
.bodyToMono(Boolean.class)
.name("auth-service-call")
.metrics()
.transform(CircuitBreakerOperator.of(authCircuitBreaker)) //서킷 브레이커를 통해 반복적인 실패 시 빠르게 실패 처리하여 시스템 부하를 줄입니다.,구성된 서킷 브레이커는 50% 실패율에 10초 대기 시간을 가지고 있습니다.
.timeout(Duration.ofSeconds(5)) //5초 내에 응답이 없으면 타임아웃 처리됩니다. 2초내에
.doOnError(TimeoutException.class, e ->
log.error("인가 서비스 요청 타임아웃: {}", e.getMessage()))
.doOnError(Exception.class, e ->
log.error("인가 서비스 요청 오류: {}", e.getMessage()))
.onErrorResume(TimeoutException.class, e -> {
log.error("인가 서비스 요청 타임아웃 - request ID: {}, 경로: {}", requestId, requestPath);
return Mono.just(false); // Fallback 처리
})
.onErrorResume(Exception.class, e -> {
log.error("인가 서비스 요청 실패 - request ID: {}, 오류: {}", requestId, e.getMessage());
return Mono.just(false);
})
/*.retryWhen(Retry.backoff(3, Duration.ofMillis(500)) //오류 발생 시 최대 3번까지 500ms의 지연을 두고 재시도합니다.
.filter(throwable -> !(throwable instanceof AuthorizationServiceException)))*/
.defaultIfEmpty(false) // 응답이 비어있거나(empty) 오류가 발생한 경우 기본적으로 `false`를 반환합니다.
// .onErrorReturn(false)//이는 인가 실패로 처리되어 사용자의 접근이 거부됩니다.
.flatMap(granted -> {
long duration = System.nanoTime() - start;
authServiceTimer.record(duration, TimeUnit.NANOSECONDS);
log.info("인가 서비스 요청 시간: request ID: {}, {}ms, granted={}, requestPath={}",
requestId, String.format("%.2f", duration / 1_000_000.0), granted, requestPath.toString());
return Mono.just(new AuthorizationDecision(granted));
});
}
}
@@ -0,0 +1,81 @@
package org.egovframe.cloud.apigateway.config;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* org.egovframe.cloud.apigateway.config.WebClientConfig
* <p>
* WebClient 설정 클래스
* 타임아웃 및 연결 풀 설정을 통해 안정적인 외부 서비스 호출을 지원
*
* @author IIPBS 개발팀
* @version 1.0
* @since 2025/03/19
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2025/03/19 IIPBS 최초 생성
* </pre>
*/
@Configuration
public class WebClientConfig {
/**
* 커넥션 풀 설정을 통해 효율적인 리소스 관리
*
* @return ConnectionProvider
*/
@Bean
public ConnectionProvider connectionProvider() {
return ConnectionProvider.builder("http-pool")
.maxConnections(500)
.maxIdleTime(Duration.ofSeconds(60))
.maxLifeTime(Duration.ofSeconds(300))
.pendingAcquireTimeout(Duration.ofSeconds(30))
.evictInBackground(Duration.ofSeconds(30))
.build();
}
/**
* 타임아웃 설정이 적용된 HttpClient 빈 생성
*
* @param connectionProvider 커넥션 풀 설정
* @return HttpClient
*/
@Bean
public HttpClient httpClient(ConnectionProvider connectionProvider) {
return HttpClient.create(connectionProvider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));
}
/**
* 타임아웃 및 커넥션 풀 설정이 적용된 WebClient.Builder 빈 생성
*
* @param httpClient 설정된 HttpClient
* @return WebClient.Builder
*/
@Bean
public WebClient.Builder webClientBuilder(HttpClient httpClient) {
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024));
}
}
@@ -0,0 +1,67 @@
package org.egovframe.cloud.apigateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint;
import org.springframework.security.web.server.authorization.AuthorizationContext;
/**
* org.egovframe.cloud.apigateway.config.WebFluxSecurityConfig
* <p>
* Spring Security Config 클래스
* ReactiveAuthorizationManager<AuthorizationContext> 구현체 ReactiveAuthorization 클래스를 통해 인증/인가 처리를 구현한다.
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/06/30
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/06/30 jaeyeolkim 최초 생성
* </pre>
*/
@EnableWebFluxSecurity // Spring Security 설정들을 활성화시켜 준다
public class WebFluxSecurityConfig {
private final static String[] PERMITALL_ANTPATTERNS = {
ReactiveAuthorization.AUTHORIZATION_URI, "/", "/csrf",
"/user-service/login", "/?*-service/api/v1/messages/**", "/api/v1/messages/**",
"/?*-service/actuator/?*", "/actuator/?*",
"/v3/api-docs/**", "/?*-service/v3/api-docs", "**/configuration/*", "/swagger*/**", "/webjars/**"
};
private final static String USER_JOIN_ANTPATTERNS = "/user-service/api/v1/users";
/**
* WebFlux 스프링 시큐리티 설정
*
* @see ReactiveAuthorization
* @param http
* @param check check(Mono<Authentication> authentication, AuthorizationContext context)
* @return
* @throws Exception
*/
@Bean
public SecurityWebFilterChain configure(ServerHttpSecurity http, ReactiveAuthorizationManager<AuthorizationContext> check) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.formLogin().disable()
.httpBasic().authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)) // login dialog disabled & 401 HttpStatus return
.and()
.authorizeExchange()
.pathMatchers(PERMITALL_ANTPATTERNS).permitAll()
.pathMatchers(HttpMethod.POST, USER_JOIN_ANTPATTERNS).permitAll()
.anyExchange().access(check);
return http.build();
}
}
@@ -0,0 +1,97 @@
package org.egovframe.cloud.apigateway.exception;
import lombok.extern.slf4j.Slf4j;
import org.egovframe.cloud.apigateway.exception.dto.ErrorCode;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.reactive.function.server.ServerRequest;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* org.egovframe.cloud.apigateway.exception.ReactiveExceptionHandlerConfig
* <p>
* 에러 발생 시 에러 정보 중 필요한 내용만 반환한다
* ErrorCode 에서 status, code, message 세 가지 속성을 의존한다
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/07/16
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/07/16 jaeyeolkim 최초 생성
* 2024/09/24 이백행 컨트리뷰션 이클립스 문제(Problems) 해결
* </pre>
*/
@Slf4j
//@Configuration
public class ReactiveExceptionHandlerConfig {
private final MessageSource messageSource;
public ReactiveExceptionHandlerConfig(MessageSource messageSource) {
this.messageSource = messageSource;
}
/**
* 에러 발생 시 에러 정보 중 필요한 내용만 반환한다
*
* @return
*/
// @Bean
public ErrorAttributes errorAttributes() {
return new DefaultErrorAttributes() {
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
Map<String, Object> defaultMap = super.getErrorAttributes(request, options);
Map<String, Object> errorAttributes = new LinkedHashMap<>();
int status = (int) defaultMap.get("status");
ErrorCode errorCode = getErrorCode(status);
String message = messageSource.getMessage(errorCode.getMessage(), null, LocaleContextHolder.getLocale());
errorAttributes.put("timestamp", LocalDateTime.now());
errorAttributes.put("message", message);
errorAttributes.put("status", status);
errorAttributes.put("code", errorCode.getCode());
// API Gateway 에서 FieldError는 처리하지 않는다.
log.error("getErrorAttributes()={}", defaultMap);
return errorAttributes;
}
};
}
/**
* 상태코드로부터 ErrorCode 를 매핑하여 리턴한다.
*
* @param status
* @return
*/
private ErrorCode getErrorCode(int status) {
switch (status) {
case 400:
return ErrorCode.ENTITY_NOT_FOUND;
case 401:
return ErrorCode.UNAUTHORIZED;
case 403:
return ErrorCode.ACCESS_DENIED;
case 404:
return ErrorCode.NOT_FOUND;
case 405:
return ErrorCode.METHOD_NOT_ALLOWED;
case 422:
return ErrorCode.UNPROCESSABLE_ENTITY;
default:
return ErrorCode.INTERNAL_SERVER_ERROR;
}
}
}
@@ -0,0 +1,59 @@
package org.egovframe.cloud.apigateway.exception.dto;
/**
* org.egovframe.cloud.common.exception.dto.ErrorCode
* <p>
* REST API 요청에 대한 오류 반환값을 정의
* ErrorResponse 클래스에서 status, code, message 세 가지 속성을 의존한다
* message 는 MessageSource 의 키 값을 정의하여 다국어 처리를 지원한다
*
* @author 표준프레임워크센터 jaeyeolkim
* @version 1.0
* @since 2021/07/16
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2021/07/16 jaeyeolkim 최초 생성
* </pre>
*/
public enum ErrorCode {
INVALID_INPUT_VALUE(400, "E001", "err.invalid.input.value"), // Bad Request
INVALID_TYPE_VALUE(400, "E002", "err.invalid.type.value"), // Bad Request
ENTITY_NOT_FOUND(400, "E003", "err.entity.not.found"), // Bad Request
UNAUTHORIZED(401, "E004", "err.unauthorized"), // The request requires an user authentication
ACCESS_DENIED(403, "E005", "err.access.denied"), // Forbidden, Access is Denied
NOT_FOUND(404, "E007", "err.not.found"), // Not found
METHOD_NOT_ALLOWED(405, "E008", "err.method.not.allowed"), // 요청 방법이 서버에 의해 알려졌으나, 사용 불가능한 상태
UNPROCESSABLE_ENTITY(422, "E009", "err.unprocessable.entity"), // Unprocessable Entity
INTERNAL_SERVER_ERROR(500, "E999", "err.internal.server"), // Server Error
SERVICE_UNAVAILABLE(503, "E010", "err.service.unavailable") // Service Unavailable
;
private final int status;
private final String code;
private final String message;
ErrorCode(final int status, final String code, final String message) {
this.status = status;
this.code = code;
this.message = message;
}
public int getStatus() {
return status;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
@@ -0,0 +1,53 @@
/*
기본필터를 삭제 처리함 (Global Filter)
package org.egovframe.cloud.apigateway.filter;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// Pre filter
return ((exchange, chain) -> {
// Netty 비동기 방식 서버 사용시에는 ServerHttpRequest 를 사용해야 한다.
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (config.isPreLogger()) {
log.info("[GlobalFilter Start] request ID: {}, method: {}, path: {}", request.getId(), request.getMethod(), request.getPath());
}
// Post Filter
// 비동기 방식의 단일값 전달시 Mono 사용(Webflux)
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if (config.isPostLogger()) {
log.info("[GlobalFilter End ] request ID: {}, method: {}, path: {}, statusCode: {}", request.getId(), request.getMethod(), request.getPath(), response.getStatusCode());
}
}));
});
}
@Data
public static class Config {
// put the configure
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
*/
@@ -0,0 +1,235 @@
package org.egovframe.cloud.apigateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* org.egovframe.cloud.apigateway.filter.RateLimitingFilter
* <p>
* 요청 제한(Rate Limiting) 필터
* 동일한 경로에 대한 과도한 요청을 제한하기 위한 필터
*
* @author changwng
* @version 1.0
* @since 2025/03/19
*
* <pre>
* << 개정이력(Modification Information) >>
*
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2025/03/19 changwng 최초 생성
* </pre>
*/
@Slf4j
@Component
public class RateLimitingFilter extends AbstractGatewayFilterFactory<RateLimitingFilter.Config> {
// 요청 경로별 카운터 맵 (메모리 기반 구현)
private final Map<String, PathCounter> pathCounters = new ConcurrentHashMap<>();
// 요청 제한 임계값 (기본값: 10)
private static final int DEFAULT_REQUEST_LIMIT = 10;
// 요청 제한 시간 윈도우 (기본값: 1초 = 1000ms)
private static final long DEFAULT_TIME_WINDOW = 1000L;
// 차단 유지 시간 (기본값: 3분 = 180초)
private static final long DEFAULT_BLOCK_DURATION = 180L;
// 차단된 클라이언트 정보를 저장하는 맵
private final Map<String, LocalDateTime> blockedClients = new ConcurrentHashMap<>();
public RateLimitingFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
// 요청 경로 추출
String path = request.getPath().value();
// 클라이언트 IP 주소 추출
String ip = request.getRemoteAddress().getAddress().getHostAddress();
// IP와 경로를 조합한 키 생성 (같은 IP에서 동일한 엔드포인트 요청 제한)
String key = ip + ":" + path;
// 요청 제한 임계값 설정
int requestLimit = config.getRequestLimit() > 0 ? config.getRequestLimit() : DEFAULT_REQUEST_LIMIT;
// 요청 제한 시간 윈도우 설정
long timeWindow = config.getTimeWindow() > 0 ? config.getTimeWindow() : DEFAULT_TIME_WINDOW;
// 차단 유지 시간 설정 (초 단위)
long blockDuration = config.getBlockDuration() > 0 ? config.getBlockDuration() : DEFAULT_BLOCK_DURATION;
// IP+경로별 카운터 가져오기 (없으면 생성)
PathCounter counter = pathCounters.computeIfAbsent(key, k -> new PathCounter());
// 이미 차단된 클라이언트인지 확인
if (counter.isBlocked()) {
// 차단 상태인 경우 남은 시간 계산
Duration remainingTime = Duration.between(LocalDateTime.now(), counter.getBlockedUntil());
long remainingSeconds = Math.max(1, remainingTime.getSeconds()); // 최소 1초
log.warn("Client is blocked - IP: {}, Path: {}, Remaining time: {} seconds", ip, path, remainingSeconds);
// 에러 메시지 생성
String errorMessage = String.format(
"{\"error\":\"요청 제한 차단\",\"message\":\"짧은 시간 내에 너무 많은 요청이 발생하여 일시적으로 차단되었습니다. %d초 후에 다시 시도해주세요.\",\"path\":\"%s\"}",
remainingSeconds, path);
// 응답 설정
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
DataBuffer buffer = response.bufferFactory().wrap(errorMessage.getBytes(StandardCharsets.UTF_8));
// 에러 응답 반환
return response.writeWith(Mono.just(buffer));
}
// 현재 시간
long currentTime = System.currentTimeMillis();
// 시간 윈도우가 지났으면 카운터 초기화
if (currentTime - counter.getLastResetTime() > timeWindow) {
counter.reset(currentTime);
}
// 요청 카운트 증가
int currentCount = counter.incrementAndGet();
// 로그 기록
log.debug("IP: {}, Path: {}, Count: {}/{}, Window: {}ms", ip, path, currentCount, requestLimit, timeWindow);
// 요청 제한 초과 시 차단 처리 및 에러 응답 반환
if (currentCount > requestLimit) {
// 차단 상태로 설정 (3분 = 180초)
counter.block(blockDuration);
log.warn("Rate limit exceeded, client blocked - IP: {} and path: {}, blocked until: {}",
ip, path, counter.getBlockedUntil());
// 에러 메시지 생성
String errorMessage = String.format(
"{\"error\":\"요청 제한 초과\",\"message\":\"짧은 시간 내에 너무 많은 요청이 발생하여 %d분 동안 차단되었습니다. %d분 후에 다시 시도해주세요.\",\"path\":\"%s\"}",
blockDuration / 60, blockDuration / 60, path);
// 응답 설정
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
DataBuffer buffer = response.bufferFactory().wrap(errorMessage.getBytes(StandardCharsets.UTF_8));
// 에러 응답 반환
return response.writeWith(Mono.just(buffer));
}
// 정상 요청 처리
return chain.filter(exchange);
};
}
/**
* 경로별 요청 카운터 클래스
*/
private static class PathCounter {
private int count;
private long lastResetTime;
private boolean blocked;
private LocalDateTime blockedUntil;
public PathCounter() {
this.count = 0;
this.lastResetTime = System.currentTimeMillis();
this.blocked = false;
this.blockedUntil = null;
}
public synchronized int incrementAndGet() {
return ++count;
}
public synchronized void reset(long resetTime) {
this.count = 0;
this.lastResetTime = resetTime;
}
public synchronized void block(long blockDurationSeconds) {
this.blocked = true;
this.blockedUntil = LocalDateTime.now().plusSeconds(blockDurationSeconds);
}
public synchronized boolean isBlocked() {
if (!blocked) {
return false;
}
// 차단 시간이 지났는지 확인
if (LocalDateTime.now().isAfter(blockedUntil)) {
// 차단 시간이 지나면 차단 해제
this.blocked = false;
this.blockedUntil = null;
return false;
}
return true;
}
public LocalDateTime getBlockedUntil() {
return blockedUntil;
}
public long getLastResetTime() {
return lastResetTime;
}
}
/**
* 필터 설정 클래스
*/
public static class Config {
private int requestLimit;
private long timeWindow;
private long blockDuration;
public int getRequestLimit() {
return requestLimit;
}
public void setRequestLimit(int requestLimit) {
this.requestLimit = requestLimit;
}
public long getTimeWindow() {
return timeWindow;
}
public void setTimeWindow(long timeWindow) {
this.timeWindow = timeWindow;
}
public long getBlockDuration() {
return blockDuration;
}
public void setBlockDuration(long blockDuration) {
this.blockDuration = blockDuration;
}
}
}
@@ -0,0 +1,89 @@
package org.egovframe.cloud.apigateway.filter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class RequestTimingFilter implements GlobalFilter, Ordered {
private final MeterRegistry meterRegistry;
public RequestTimingFilter(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 요청 정보 추출
ServerHttpRequest request = exchange.getRequest();
String path = request.getPath().value();
String method = request.getMethod().name();
// 시작 시간 기록
long startTime = System.nanoTime();
// 요청 진행 및 완료 후 처리
return chain.filter(exchange)
.doFinally(signalType -> {
// 종료 시간 계산 및 경과 시간 측정
long endTime = System.nanoTime();
long duration = endTime - startTime;
double durationInMs = duration / 1_000_000.0;
// 요청 처리 시간 기록 (밀리초)
String serviceName = extractServiceName(path);
// 메트릭 기록 - 경로별 타이머
Timer pathTimer = Timer.builder("gateway.request.time")
.tag("path", path)
.tag("method", method)
.tag("service", serviceName)
.description("API Gateway 요청 처리 시간")
.register(meterRegistry);
pathTimer.record(duration, TimeUnit.NANOSECONDS);
String pathQuery = path+(request.getURI().getQuery() == null ? "" : "?" + request.getURI().getQuery());
// 로그 기록
log.info("Request completed -request ID: {}, Method: {}, Path: {}, Service: {}, Duration: {}ms",
request.getId(), method, pathQuery , serviceName, String.format("%.2f",durationInMs));
});
}
@Override
public int getOrder() {
// 다른 필터들보다 먼저 실행되어야 함
return Ordered.HIGHEST_PRECEDENCE;
}
/**
* 요청 경로에서 서비스 이름 추출
* 예: /user-service/api/v1/users -> user-service
*/
private String extractServiceName(String path) {
if (path == null || path.isEmpty() || !path.startsWith("/")) {
return "unknown";
}
// 첫 번째 '/' 제거
String trimmedPath = path.substring(1);
// 다음 '/'까지가 서비스 이름
int nextSlash = trimmedPath.indexOf('/');
if (nextSlash > 0) {
return trimmedPath.substring(0, nextSlash);
} else {
return trimmedPath.isEmpty() ? "root" : trimmedPath;
}
}
}
@@ -0,0 +1,91 @@
server:
port: 8000
spring:
application:
name: apigateway
cloud:
loadbalancer:
cache:
caffeine:
spec: expireAfterWrite=5s
gateway:
httpclient:
connect-timeout: 5000 # 3초 내에 연결되지 않으면 실패
response-timeout: 10s # 5초 내에 응답이 없으면 타임아웃
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- id: board-service
uri: lb://BOARD-SERVICE
predicates:
- Path=/board-service/**
filters:
- RewritePath=/board-service/(?<segment>.*), /$\{segment}
- id: cms-service
uri: lb://CMS-SERVICE
predicates:
- Path=/cms-service/**
filters:
- RewritePath=/cms-service/(?<segment>.*), /$\{segment}
- id: attach-service
uri: lb://ATTACH-SERVICE
predicates:
- Path=/attach-service/**
filters:
- RewritePath=/attach-service/(?<segment>.*), /$\{segment}
- id: openapi
uri: http://localhost:${server.port}
predicates:
- Path=/v3/api-docs/**
filters:
- RewritePath=/v3/api-docs/(?<segment>.*), /$\{segment}/v3/api-docs
default-filters:
# - name: GlobalFilter
# args:
# preLogger: true
# postLogger: true
# - name: RateLimitingFilter #같은 Ip에서 대량의 데이터 요청시에 차단 처리
# args:
# requestLimit: 10
# timeWindow: 1000
# blockDuration: 180
discovery:
locator:
enabled: true
globalcors:
corsConfigurations:
'[/**]':
allowedOriginPatterns:
- "*"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
- HEAD
- PATCH
allowedHeaders:
- "*"
allowCredentials: true
# config server actuator
management:
endpoints:
web:
exposure:
include: refresh, health, beans, info
info:
app:
name: Spring Cloud Gateway
description: API 라우팅 및 보안, 모니터링/메트릭 등의 기능을 간단하고 효과적인 방법으로 제공
swagger-ui: http://localhost:8000/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config
messages:
directory: ${user.dir}/msa-attach-volume/messages
@@ -0,0 +1,5 @@
spring:
cloud:
config:
uri: http://localhost:8888
name: application
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xml>
<Configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) - [%thread{10}, %X{traceId:-}, %X{spanId:-}] %cyan(%logger{20}): %msg%n</pattern>
</encoder>
</appender>
<!-- 로컬에서는 로그를 전송하지 않도록 설정 -->
<springProfile name="default">
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</springProfile>
<springProfile name="!default">
<!-- java -Ddestination="localhost:5001" 와 같이 변경할 수 있다. cf 환경에서는 manifest.yml 파일에 환경변수로 추가 -->
<property name="destination" value="${logstash_hostname:-localhost:5001}" />
<property name="app_name" value="${app_name:-apigateway}" />
<!-- ELK - Logstash 로 로그를 전송하기 위한 appender -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>${destination}</destination>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<mdc/>
<context/>
<logLevel/>
<loggerName/>
<pattern>
<pattern>
{"appName": "${app_name}"}
</pattern>
</pattern>
<threadName/>
<message/>
<logstashMarkers/>
<stackTrace/>
</providers>
</encoder>
</appender>
<root level="WARN">
<appender-ref ref="LOGSTASH" />
<appender-ref ref="STDOUT" />
</root>
</springProfile>
</Configuration>
@@ -0,0 +1,76 @@
server:
port: 8000
spring:
application:
name: apigateway
cloud:
loadbalancer:
cache:
caffeine:
spec: expireAfterWrite=5s
gateway:
httpclient:
connect-timeout: 5000 # 3초 내에 연결되지 않으면 실패
response-timeout: 10s # 5초 내에 응답이 없으면 타임아웃
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- id: board-service
uri: lb://BOARD-SERVICE
predicates:
- Path=/board-service/**
filters:
- RewritePath=/board-service/(?<segment>.*), /$\{segment}
- id: cms-service
uri: lb://CMS-SERVICE
predicates:
- Path=/cms-service/**
filters:
- RewritePath=/cms-service/(?<segment>.*), /$\{segment}
- id: attach-service
uri: lb://ATTACH-SERVICE
predicates:
- Path=/attach-service/**
filters:
- RewritePath=/attach-service/(?<segment>.*), /$\{segment}
- id: openapi
uri: http://localhost:${server.port}
predicates:
- Path=/v3/api-docs/**
filters:
- RewritePath=/v3/api-docs/(?<segment>.*), /$\{segment}/v3/api-docs
default-filters:
# - name: GlobalFilter
# args:
# preLogger: true
# postLogger: true
# - name: RateLimitingFilter #같은 Ip에서 대량의 데이터 요청시에 차단 처리
# args:
# requestLimit: 10
# timeWindow: 1000
# blockDuration: 180
discovery:
locator:
enabled: true
# config server actuator
management:
endpoints:
web:
exposure:
include: refresh, health, beans, info
info:
app:
name: Spring Cloud Gateway
description: API 라우팅 및 보안, 모니터링/메트릭 등의 기능을 간단하고 효과적인 방법으로 제공
swagger-ui: http://localhost:8000/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config
messages:
directory: ${user.dir}/msa-attach-volume/messages
@@ -0,0 +1,15 @@
package org.egovframe.cloud.apigateway;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles(profiles = "test")
@SpringBootTest
class ApigatewayApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,27 @@
package org.egovframe.cloud.apigateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@ActiveProfiles(profiles = "test")
@SpringBootTest(webEnvironment = RANDOM_PORT)
class MessageSourceConfigTest {
@Autowired
TestRestTemplate restTemplate;
@Test
public void 메세지를_외부위치에서_읽어온다() throws Exception {
// when
String message = restTemplate.getForObject("/api/v1/messages/common.login/ko", String.class);
// then
assertThat(message).isEqualTo("로그인");
}
}
@@ -0,0 +1,34 @@
package org.egovframe.cloud.apigateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableConfigurationProperties
@TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"})
@ActiveProfiles(profiles = "test")
class ReactiveAuthorizationTest {
@Autowired
private WebTestClient webTestClient;
@Test
public void API요청시_토큰인증_만료된다() throws Exception {
// given
String notValidToken = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI2NWEwMGY2NS04NDYwLTQ5YWYtOThlYy0wNDI5NzdlNTZmNGIiLCJhdXRob3JpdGllcyI6IlJPTEVfVVNFUiIsImV4cCI6MTYyNjc4MjQ0N30.qiScvtr1m88SHPLpHqcJiklXFyIQ7WBJdiFcdcb2B8YSWC59QcdRRgMtXDGSZnjBgF194W-GRBpHUta6VCkrfQ";
// when, then
webTestClient
.get()
.uri("/user-service/api/v1/users")
.header(HttpHeaders.AUTHORIZATION, notValidToken)
.exchange().expectStatus().isUnauthorized()
;
}
}
@@ -0,0 +1,25 @@
server:
port: 8000
spring:
application:
name: apigateway
file:
directory: ${user.home}/msa-attach-volume
messages:
directory: ${file.directory}/messages
# jwt token
token:
secret: egovframe_user_token
# ftp server
ftp:
enabled: false # ftp 사용 여부, FTP 서버에 최상위 디렉토리 자동 생성 및 구현체를 결정하게 된다.
# eureka 가 포함되면 eureka server 도 등록되므로 해제한다.
eureka:
client:
register-with-eureka: false
fetch-registry: false