From c54d3a4141d806fcfdba5dfc0fdcd18dbfc01d8b Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Thu, 23 Apr 2026 19:20:29 +0900 Subject: [PATCH 01/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20Configure=20Eureka=20?= =?UTF-8?q?and=20Config=20client=20connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 포트 8080 지정 - spring.config.import로 Config Server 연결 (optional) - Eureka 클라이언트 설정 (localhost:8000, hostname 기반) - spring.profiles.active=dev로 Config Server dev 브랜치 참조 Related to: #1 --- src/main/resources/application.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d0a679f..77b7f2e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,20 @@ +server: + port: 8080 + spring: application: - name: gatewayserver + name: gateway + config: + import: "optional:configserver:http://localhost:8888" + profiles: + active: dev + +eureka: + instance: + prefer-ip-address: false + hostname: ${HOSTNAME:localhost} + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_SERVER_URL:http://localhost:8000/eureka/} From 99d17d0629c825bd63e8d21b21ad3c8999ebea64 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Thu, 23 Apr 2026 22:12:46 +0900 Subject: [PATCH 02/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20API=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EC=84=9C=20=EB=B0=98=EC=98=81=ED=95=9C=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?Resolves:#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 77b7f2e..ad74d98 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,6 +9,67 @@ spring: profiles: active: dev + cloud: + gateway: + server: + webflux: + routes: + # AI Service + - id: ai-service + uri: http://localhost:19021 + predicates: + - Path=/api/v1/ai/** + + # User Service (유저 + 인증) + - id: user-service + uri: http://localhost:19001 + predicates: + - Path=/api/v1/users/**, /api/v1/auth/** + + # Market Service (마켓) + - id: market-service + order: 2 + uri: http://localhost:19031 + predicates: + - Path=/api/v1/sale-posts/**, /api/v1/orders/**, /api/v1/sellers/**, /api/v1/buyers/** + + # Payment Service (결제 + Webhook + 구매자 결제내역) + - id: payment-service + order: 1 # 숫자가 낮을수록 우선순위가 높음 (buyers) + uri: http://localhost:19041 + predicates: + - Path=/api/v1/payments/**, /api/v1/payment/webhook, /api/v1/buyers/me/payments + + # Book Service (도서 조회/좋아요) + - id: book-service + uri: http://localhost:19051 + predicates: + - Path=/api/v1/books/** + + # Report Service (독후감) + - id: report-service + uri: http://localhost:19061 + predicates: + - Path=/api/v1/reports/** + + # Meeting Service (모임) + - id: meeting-service + uri: http://localhost:19011 + predicates: + - Path=/api/v1/meetings/** + + # Map Service (지도) : MVP 이후 수정 + - id: map-service + uri: http://localhost:19071 + predicates: + - Path=/api/v1/map/** + + # Chat Service (모임 채팅 / 거래 채팅) : MVP 이후 수정 + - id: chat-service + uri: http://localhost:19081 + predicates: + - Path=/api/v1/chat/** + eureka: instance: prefer-ip-address: false From bf6cbbcba0109422a70a3f6fe64b704ebec729cf Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Thu, 23 Apr 2026 23:33:11 +0900 Subject: [PATCH 03/23] Chore: modify settings.gradle project name --- settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index a239fa0..a9f5265 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'gateway' +rootProject.name = 'gatewayserver' From d369ba646d8cd8ad6c61ad4d2daafbfcfef6b031 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 02:12:16 +0900 Subject: [PATCH 04/23] =?UTF-8?q?Chore:=20jjwt=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20(jjwtVersion=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/build.gradle b/build.gradle index ef5233e..2c832f4 100644 --- a/build.gradle +++ b/build.gradle @@ -1,24 +1,25 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.13' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.13' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.pagley' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } repositories { - mavenCentral() + mavenCentral() } ext { - set('springCloudVersion', "2025.0.2") + set('springCloudVersion', "2025.0.2") + jjwtVersion = '0.12.6' } dependencies { @@ -34,22 +35,27 @@ dependencies { implementation 'io.micrometer:micrometer-tracing-bridge-brave' implementation 'io.zipkin.reporter2:zipkin-reporter-brave' // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'io.projectreactor:reactor-test' - testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testAnnotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // jjwt + implementation 'io.jsonwebtoken:jjwt-api' + runtimeOnly 'io.jsonwebtoken:jjwt-impl' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testAnnotationProcessor 'org.projectlombok:lombok' } dependencyManagement { - imports { - mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - } + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + mavenBom "io.jsonwebtoken:jjwt-root:${jjwtVersion}" + } } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } From 4fdb06dbba95ec17f1aeff6b2045ad974910494b Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 11:16:10 +0900 Subject: [PATCH 05/23] =?UTF-8?q?=F0=9F=A9=B9=20Fix:=20jjwt=20BOM=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2c832f4..ef45448 100644 --- a/build.gradle +++ b/build.gradle @@ -52,7 +52,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - mavenBom "io.jsonwebtoken:jjwt-root:${jjwtVersion}" + mavenBom "io.jsonwebtoken:jjwt:${jjwtVersion}" } } From 1506b9471b660cf4fce7579b89b6f0f54ccef834 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 11:48:13 +0900 Subject: [PATCH 06/23] =?UTF-8?q?=F0=9F=A9=B9=20Fix:=20jjwt=20BOM=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(=EB=B2=84=EC=A0=84=20=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index ef45448..4ea70d7 100644 --- a/build.gradle +++ b/build.gradle @@ -37,10 +37,10 @@ dependencies { // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - // jjwt - implementation 'io.jsonwebtoken:jjwt-api' - runtimeOnly 'io.jsonwebtoken:jjwt-impl' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson' + // jjwt - 직접 버전 명시 + implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' @@ -52,7 +52,6 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - mavenBom "io.jsonwebtoken:jjwt:${jjwtVersion}" } } From 64149ae474e55782972c4a5a197e48f21b7e04cb Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 11:49:41 +0900 Subject: [PATCH 07/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20JWT=5FSECRET=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(env=20=ED=8C=8C=EC=9D=BC=20import)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ src/main/resources/application.yml | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c2065bc..0f862d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.env + HELP.md .gradle build/ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ad74d98..4409fdc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,7 +5,9 @@ spring: application: name: gateway config: - import: "optional:configserver:http://localhost:8888" + import: + - optional:configserver:http://localhost:8888 + - optional:classpath:.env[.properties] profiles: active: dev @@ -79,3 +81,9 @@ eureka: fetch-registry: true serviceUrl: defaultZone: ${EUREKA_SERVER_URL:http://localhost:8000/eureka/} + + +jwt: + secret: ${JWT_SECRET} + # 생성 명령: openssl rand -base64 64 + From 702df3718acfef8df66ff5bf818391235080e865 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 12:06:53 +0900 Subject: [PATCH 08/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20JwtTokenProvider=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(HMAC=20HS256=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT 토큰 검증 및 클레임 파싱 책임을 담당하는 컴포넌트. JwtTokenProvider: - HMAC HS256 알고리즘 (대칭키 방식) - jwt.secret 환경변수에서 키 초기화 (256비트 이상 보장) - validateToken(): 서명/만료/형식 검증, 5가지 예외 구분 로깅 - parseClaims(): sub(UUID) + role(String) 추출 JwtClaims (record): - 불변 DTO, 파싱 결과 전달용 application.yml: - jwt.secret 환경변수 패턴 추가 (운영 시 외부 주입) - 기본값은 개발 전용 build.gradle: - jjwt 0.12.6 의존성 추가 (api/impl/jackson) - ext 변수 jjwtVersion 으로 단일 관리 Gateway는 토큰 검증만 책임, 생성은 User(Auth) Service 담당. 양쪽이 동일한 JWT_SECRET 공유. Related to: #5 --- .../infrastructure/security/JwtClaims.java | 14 +++ .../security/JwtTokenProvider.java | 106 ++++++++++++++++++ .../pagley/gateway/JwtTestTokenGenerator.java | 70 ++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java create mode 100644 src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java create mode 100644 src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java b/src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java new file mode 100644 index 0000000..6bf8666 --- /dev/null +++ b/src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java @@ -0,0 +1,14 @@ +package com.pagley.gateway.infrastructure.security; + +import java.util.UUID; + +/** + * JWT 토큰에서 추출한 클레임 정보. + * + *

표준 클레임 sub는 유저 UUID, 커스텀 클레임 role은 권한 문자열.

+ */ +public record JwtClaims( + UUID userId, + String role +) { +} diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java b/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java new file mode 100644 index 0000000..854ff7d --- /dev/null +++ b/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java @@ -0,0 +1,106 @@ +package com.pagley.gateway.infrastructure.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * JWT 토큰 검증 및 클레임 파싱. + * + *

Gateway는 토큰의 검증만 책임. 토큰 생성은 User Service의 로그인 API가 담당. + * 양쪽 서비스가 동일한 jwt.secret 환경변수를 공유해야 한다.

+ * + *

HMAC HS256 사용

+ *
    + *
  • 대칭키 방식 (생성/검증 동일 키)
  • + *
  • 에 따라 키는 256비트(32바이트) 이상
  • + *
  • 환경변수 JWT_SECRET 으로 주입 (운영)
  • + *
+ */ +@Slf4j +@Component +public class JwtTokenProvider { + + private static final String CLAIM_ROLE = "role"; + + private final SecretKey secretKey; + + public JwtTokenProvider(@Value("${jwt.secret}") String secret) { + // UTF-8 바이트로 변환하여 HMAC 키 생성 + // 256비트 미만이면 jjwt 가 WeakKeyException 발생 + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 토큰 유효성 검증 (서명, 만료, 형식). + * + * @return true = 정상 토큰, false = 검증 실패 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.debug("JWT 만료: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.debug("지원하지 않는 JWT 형식: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.debug("잘못된 JWT 형식: {}", e.getMessage()); + } catch (SignatureException e) { + log.debug("JWT 서명 검증 실패: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.debug("JWT 클레임이 비어있음: {}", e.getMessage()); + } catch (JwtException e) { + log.debug("JWT 검증 실패: {}", e.getMessage()); + } + return false; + } + + /** + * 토큰에서 클레임 추출. + * + *

호출 전 {@link #validateToken(String)}으로 검증 필수. + * 검증 실패한 토큰을 파싱하면 예외 발생.

+ * + * @return JwtClaims (userId UUID, role String) + * @throws JwtException 파싱 실패 시 + * @throws IllegalStateException sub 클레임이 유효한 UUID 아닐 때 + */ + public JwtClaims parseClaims(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + UUID userId = parseUserId(claims.getSubject()); + String role = claims.get(CLAIM_ROLE, String.class); + + return new JwtClaims(userId, role); + } + + private UUID parseUserId(String subject) { + if (subject == null || subject.isBlank()) { + throw new IllegalStateException("JWT sub 클레임이 비어있습니다."); + } + try { + return UUID.fromString(subject); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("JWT sub 클레임이 유효한 UUID가 아닙니다: " + subject, e); + } + } +} diff --git a/src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java b/src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java new file mode 100644 index 0000000..e02480a --- /dev/null +++ b/src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java @@ -0,0 +1,70 @@ +package com.pagley.gateway; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Properties; +import javax.crypto.SecretKey; + +/** + * 개발용 JWT 토큰 생성 도구. main 메서드 실행하여 콘솔에 토큰 출력 → 테스트에 사용. + * + *

실제 로그인 API 구현 전까지 임시 사용. User Service 로그인 이슈 + * 완료되면 이 클래스 제거.

+ */ +public class JwtTestTokenGenerator { + + public static void main(String[] args) { + // application.yml의 기본 secret과 동일해야 함 + String secret = null; + + try { + // 1. .env 파일 경로 지정 (프로젝트 루트 기준) + // 만약 src/main/resources 아래에 있다면 아래 경로를 사용하세요. + String envPath = "src/main/resources/.env"; + + Properties props = new Properties(); + props.load(new FileInputStream(envPath)); + + // 2. .env 파일에서 JWT_SECRET 키로 값을 가져옴 + secret = props.getProperty("JWT_SECRET"); + + } catch (IOException e) { + System.err.println(".env 파일을 찾을 수 없습니다: " + e.getMessage()); + } + if (secret == null || secret.isBlank()) { + throw new IllegalStateException( + "JWT_SECRET 환경변수가 설정되지 않았습니다. " + + "예: export JWT_SECRET=$(openssl rand -base64 64)" + ); + } + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + + // 정상 토큰 (1시간 유효) + String validToken = Jwts.builder() + .subject("550e8400-e29b-41d4-a716-446655440000") + .claim("role", "USER") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3600_000)) + .signWith(key) + .compact(); + + // 만료 토큰 + String expiredToken = Jwts.builder() + .subject("550e8400-e29b-41d4-a716-446655440000") + .claim("role", "USER") + .issuedAt(new Date(System.currentTimeMillis() - 7200_000)) + .expiration(new Date(System.currentTimeMillis() - 3600_000)) // 1시간 전 만료 + .signWith(key) + .compact(); + + System.out.println("=== 정상 토큰 ==="); + System.out.println(validToken); + System.out.println(); + System.out.println("=== 만료 토큰 ==="); + System.out.println(expiredToken); + } +} From dff7a2453959ae0d3f36ad9c152b00e1148ddc41 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 12:38:46 +0900 Subject: [PATCH 09/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20JwtProperties=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20(ConfigurationProperties=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: #6 --- .../pagley/gateway/GatewayApplication.java | 9 ++++++--- .../security/JwtProperties.java | 19 +++++++++++++++++++ .../security/JwtTokenProvider.java | 7 ++----- src/main/resources/application.yml | 6 +++++- 4 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java diff --git a/src/main/java/com/pagley/gateway/GatewayApplication.java b/src/main/java/com/pagley/gateway/GatewayApplication.java index 9365833..530279f 100644 --- a/src/main/java/com/pagley/gateway/GatewayApplication.java +++ b/src/main/java/com/pagley/gateway/GatewayApplication.java @@ -1,13 +1,16 @@ package com.pagley.gateway; +import com.pagley.gateway.infrastructure.security.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties(JwtProperties.class) public class GatewayApplication { - public static void main(String[] args) { - SpringApplication.run(GatewayApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } } diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java b/src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java new file mode 100644 index 0000000..a7ea3ff --- /dev/null +++ b/src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java @@ -0,0 +1,19 @@ +package com.pagley.gateway.infrastructure.security; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JWT 관련 설정 매핑. + * + *

application.yml 의 jwt 섹션을 자동 바인딩.

+ * + * @param secret JWT 검증용 비밀키 (HS256, 32바이트 이상) + * @param publicPaths JWT 검증을 우회할 경로 패턴 목록 + */ +@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가 +public record JwtProperties( + String secret, + List publicPaths +) { +} diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java b/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java index 854ff7d..93cdb42 100644 --- a/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java +++ b/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java @@ -12,7 +12,6 @@ import java.util.UUID; import javax.crypto.SecretKey; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** @@ -36,10 +35,8 @@ public class JwtTokenProvider { private final SecretKey secretKey; - public JwtTokenProvider(@Value("${jwt.secret}") String secret) { - // UTF-8 바이트로 변환하여 HMAC 키 생성 - // 256비트 미만이면 jjwt 가 WeakKeyException 발생 - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + public JwtTokenProvider(JwtProperties jwtProperties) { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); } /** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4409fdc..d53fda9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -86,4 +86,8 @@ eureka: jwt: secret: ${JWT_SECRET} # 생성 명령: openssl rand -base64 64 - + public-paths: + - /api/v1/users + - /api/v1/auth/** + - /actuator/** + - /api/v1/payment/webhook From 0a8aa7b157dbcf5a74eef84bc63dad3e736e2d25 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 15:06:29 +0900 Subject: [PATCH 10/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20AuthenticationFilter?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(Reactive=20GlobalFilter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT 인증 필터를 Spring Cloud Gateway의 GlobalFilter로 구현. JwtProperties (record): - @ConfigurationProperties로 jwt 섹션 자동 바인딩 - secret + publicPaths 통합 관리 AuthenticationFilter: - Public Path 매칭 시 인증 우회 (AntPathMatcher 사용) - Bearer 형식 강제 (RFC 6750) - 검증 실패 시 401 + JSON 에러 응답 - 검증 성공 시 X-User-Id, X-User-Role 헤더 주입 - @Order HIGHEST_PRECEDENCE+100 으로 가장 일찍 실행 application.yml: - jwt.public-paths 추가 (회원가입/로그인/actuator/webhook) JwtTokenProvider: - @Value 주입에서 JwtProperties 의존으로 변경 (일관성) GatewayApplication: - @EnableConfigurationProperties(JwtProperties.class) Related to: #6 --- .../security/AuthenticationFilter.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java b/src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java new file mode 100644 index 0000000..b50c796 --- /dev/null +++ b/src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java @@ -0,0 +1,155 @@ +package com.pagley.gateway.infrastructure.security; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.JwtException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +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.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * JWT 인증 필터 (Reactive GlobalFilter). + * + *

처리 흐름

+ *
    + *
  1. 요청 경로가 Public Path 매칭 → 통과
  2. + *
  3. Authorization 헤더 추출 (없으면 401)
  4. + *
  5. "Bearer " prefix 검증 (없으면 401)
  6. + *
  7. JWT 서명/만료 검증 (실패 시 401)
  8. + *
  9. 클레임 추출하여 X-User-Id, X-User-Role 헤더 주입
  10. + *
  11. 다운스트림 서비스로 전달
  12. + *
+ * + *

Public Path 매칭은 Ant 패턴 (예: /api/v1/auth/**) 지원.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthenticationFilter implements GlobalFilter, Ordered { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_USER_ROLE = "X-User-Role"; + + private final JwtTokenProvider jwtTokenProvider; + private final JwtProperties jwtProperties; + private final ObjectMapper objectMapper; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + // [1] Public Path 매칭 → 인증 우회 + if (isPublicPath(path)) { + log.debug("Public path 인증 우회: {}", path); + return chain.filter(exchange); + } + + // [2] Authorization 헤더 추출 + String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader == null || authHeader.isBlank()) { + return unauthorized(exchange, "MISSING_TOKEN", "인증 토큰이 필요합니다."); + } + + // [3] Bearer prefix 검증 + if (!authHeader.startsWith(BEARER_PREFIX)) { + return unauthorized(exchange, "INVALID_TOKEN_FORMAT", + "Authorization 헤더는 'Bearer {token}' 형식이어야 합니다."); + } + + String token = authHeader.substring(BEARER_PREFIX.length()).trim(); + if (token.isEmpty()) { + return unauthorized(exchange, "EMPTY_TOKEN", "토큰이 비어있습니다."); + } + + // [4] JWT 검증 + if (!jwtTokenProvider.validateToken(token)) { + return unauthorized(exchange, "INVALID_TOKEN", + "토큰이 유효하지 않거나 만료되었습니다."); + } + + // [5] 클레임 추출 + JwtClaims claims; + try { + claims = jwtTokenProvider.parseClaims(token); + } catch (JwtException | IllegalStateException e) { + log.warn("JWT 파싱 실패 (검증은 통과): {}", e.getMessage()); + return unauthorized(exchange, "INVALID_TOKEN", "토큰 정보를 읽을 수 없습니다."); + } + + // [6] 헤더 주입 후 다음 필터로 + ServerHttpRequest mutatedRequest = request.mutate() + .header(HEADER_USER_ID, claims.userId().toString()) + .header(HEADER_USER_ROLE, claims.role()) + .build(); + + log.debug("인증 성공: userId={}, role={}, path={}", + claims.userId(), claims.role(), path); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + /** + * 요청 경로가 Public Path 패턴 중 하나에 매칭되는지. + */ + private boolean isPublicPath(String path) { + List publicPaths = jwtProperties.publicPaths(); + if (publicPaths == null || publicPaths.isEmpty()) { + return false; + } + return publicPaths.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + /** + * 401 Unauthorized 응답 반환. + */ + private Mono unauthorized(ServerWebExchange exchange, String error, String message) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + Map body = Map.of( + "error", error, + "message", message, + "timestamp", LocalDateTime.now().toString() + ); + + try { + byte[] bytes = objectMapper.writeValueAsBytes(body); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } catch (JsonProcessingException e) { + log.error("401 응답 직렬화 실패", e); + DataBuffer fallback = response.bufferFactory() + .wrap("{\"error\":\"INTERNAL_ERROR\"}".getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(fallback)); + } + } + + /** + * 다른 GlobalFilter 보다 일찍 실행. + */ + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 100; + } +} From 7db6170081264d241967c35d9ce3e976d5063b97 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 15:33:43 +0900 Subject: [PATCH 11/23] =?UTF-8?q?Chore:=20CI=20=EC=84=A4=EC=A0=95=20(?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=20secret=20=EC=82=AC=EC=9A=A9=20-=20JWT-SECR?= =?UTF-8?q?ET)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: #12 --- .github/auto_assign.yml | 18 +++++++++++++ .github/workflows/auto-assign.yml | 18 +++++++++++++ .github/workflows/branch-name-check.yml | 27 +++++++++++++++++++ .github/workflows/ci.yml | 35 +++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 .github/auto_assign.yml create mode 100644 .github/workflows/auto-assign.yml create mode 100644 .github/workflows/branch-name-check.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 0000000..95de81c --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,18 @@ +addReviewers: true + +addAssignees: author + +reviewers: + - M1Nj1M + - soo96 + - jsh9057 + - gnoesnooj + - hhegi + - mimimya + +numberOfReviewers: 6 + +numberOfAssignees: 1 + +skipKeywords: + - wip diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..e7fcaca --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,18 @@ +name: Auto Assign Reviewers + +on: + pull_request: + types: [ opened, ready_for_review ] + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Auto Assign Action + uses: kentaro-m/auto-assign-action@v1.2.5 + with: + configuration-path: ".github/auto_assign.yml" diff --git a/.github/workflows/branch-name-check.yml b/.github/workflows/branch-name-check.yml new file mode 100644 index 0000000..caf7d95 --- /dev/null +++ b/.github/workflows/branch-name-check.yml @@ -0,0 +1,27 @@ +name: Branch Naming Check + +on: + push: + branches-ignore: + - main + - dev + - init + - release + +jobs: + check-branch-name: + runs-on: ubuntu-latest + + steps: + - name: Check branch name format + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + REGEX="^(feat|fix|refactor|docs|chore|test)\/[0-9]+-.+$" + + if [[ ! $BRANCH_NAME =~ $REGEX ]]; then + echo "❌ ERROR: 브랜치 이름 형식이 틀렸습니다!" + echo "형식: 타입/이슈번호-설명 (예: feat/12-login)" + exit 1 + fi + + echo "✅ 브랜치 이름 형식이 올바릅니다: $BRANCH_NAME" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b228e81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: build + +on: + pull_request: + branches: + - dev + - main + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + env: + JWT_SECRET: ${{secrets.JWT_SECRET}} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew clean build --stacktrace From 247d1c57845a62142fe2a17937021a7ed2c9153b Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 15:51:08 +0900 Subject: [PATCH 12/23] Fix: .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0f862d5..b012df5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +src/main/resources/.env HELP.md .gradle From 2fb67ac4ea6ffab1b5b50dbce8a92bab10e3a692 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 27 Apr 2026 23:43:52 +0900 Subject: [PATCH 13/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../{pagley => pagely}/gateway/GatewayApplication.java | 4 ++-- .../infrastructure/security/AuthenticationFilter.java | 2 +- .../gateway/infrastructure/security/JwtClaims.java | 2 +- .../gateway/infrastructure/security/JwtProperties.java | 2 +- .../gateway/infrastructure/security/JwtTokenProvider.java | 2 +- .../gateway/GatewayApplicationTests.java | 8 ++++---- .../{pagley => pagely}/gateway/JwtTestTokenGenerator.java | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) rename src/main/java/com/{pagley => pagely}/gateway/GatewayApplication.java (82%) rename src/main/java/com/{pagley => pagely}/gateway/infrastructure/security/AuthenticationFilter.java (99%) rename src/main/java/com/{pagley => pagely}/gateway/infrastructure/security/JwtClaims.java (82%) rename src/main/java/com/{pagley => pagely}/gateway/infrastructure/security/JwtProperties.java (91%) rename src/main/java/com/{pagley => pagely}/gateway/infrastructure/security/JwtTokenProvider.java (98%) rename src/test/java/com/{pagley => pagely}/gateway/GatewayApplicationTests.java (68%) rename src/test/java/com/{pagley => pagely}/gateway/JwtTestTokenGenerator.java (98%) diff --git a/build.gradle b/build.gradle index 4ea70d7..b97a608 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.pagley' +group = 'com.pagely' version = '0.0.1-SNAPSHOT' java { diff --git a/src/main/java/com/pagley/gateway/GatewayApplication.java b/src/main/java/com/pagely/gateway/GatewayApplication.java similarity index 82% rename from src/main/java/com/pagley/gateway/GatewayApplication.java rename to src/main/java/com/pagely/gateway/GatewayApplication.java index 530279f..6139aca 100644 --- a/src/main/java/com/pagley/gateway/GatewayApplication.java +++ b/src/main/java/com/pagely/gateway/GatewayApplication.java @@ -1,6 +1,6 @@ -package com.pagley.gateway; +package com.pagely.gateway; -import com.pagley.gateway.infrastructure.security.JwtProperties; +import com.pagely.gateway.infrastructure.security.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java similarity index 99% rename from src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java rename to src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java index b50c796..a0de2fe 100644 --- a/src/main/java/com/pagley/gateway/infrastructure/security/AuthenticationFilter.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java @@ -1,4 +1,4 @@ -package com.pagley.gateway.infrastructure.security; +package com.pagely.gateway.infrastructure.security; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java similarity index 82% rename from src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java rename to src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java index 6bf8666..c7e2269 100644 --- a/src/main/java/com/pagley/gateway/infrastructure/security/JwtClaims.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java @@ -1,4 +1,4 @@ -package com.pagley.gateway.infrastructure.security; +package com.pagely.gateway.infrastructure.security; import java.util.UUID; diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java similarity index 91% rename from src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java rename to src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java index a7ea3ff..613999e 100644 --- a/src/main/java/com/pagley/gateway/infrastructure/security/JwtProperties.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java @@ -1,4 +1,4 @@ -package com.pagley.gateway.infrastructure.security; +package com.pagely.gateway.infrastructure.security; import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java similarity index 98% rename from src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java rename to src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java index 93cdb42..ce7ded3 100644 --- a/src/main/java/com/pagley/gateway/infrastructure/security/JwtTokenProvider.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java @@ -1,4 +1,4 @@ -package com.pagley.gateway.infrastructure.security; +package com.pagely.gateway.infrastructure.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; diff --git a/src/test/java/com/pagley/gateway/GatewayApplicationTests.java b/src/test/java/com/pagely/gateway/GatewayApplicationTests.java similarity index 68% rename from src/test/java/com/pagley/gateway/GatewayApplicationTests.java rename to src/test/java/com/pagely/gateway/GatewayApplicationTests.java index b3c9635..b82b348 100644 --- a/src/test/java/com/pagley/gateway/GatewayApplicationTests.java +++ b/src/test/java/com/pagely/gateway/GatewayApplicationTests.java @@ -1,4 +1,4 @@ -package com.pagley.gateway; +package com.pagely.gateway; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -6,8 +6,8 @@ @SpringBootTest class GatewayApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java b/src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java similarity index 98% rename from src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java rename to src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java index e02480a..c10937a 100644 --- a/src/test/java/com/pagley/gateway/JwtTestTokenGenerator.java +++ b/src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java @@ -1,4 +1,4 @@ -package com.pagley.gateway; +package com.pagely.gateway; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; From b78e43987a75ec3a536e41782d778c3103c67630 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 4 May 2026 12:24:00 +0900 Subject: [PATCH 14/23] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20public=20path=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/security/AuthenticationFilter.java | 7 ++++--- src/main/resources/application.yml | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java index a0de2fe..507b4c2 100644 --- a/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java @@ -56,10 +56,11 @@ public class AuthenticationFilter implements GlobalFilter, Ordered { public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); + HttpMethod method = request.getMethod(); - // [1] Public Path 매칭 → 인증 우회 - if (isPublicPath(path)) { - log.debug("Public path 인증 우회: {}", path); + // [1] Public Path 매칭 / 회원가입 경로 (POST /users/) → 인증 우회 + if (isPublicPath(path) || + "/api/v1/users".equals(path) && HttpMethod.POST.equals(method)) { return chain.filter(exchange); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d53fda9..c26fda0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -87,7 +87,6 @@ jwt: secret: ${JWT_SECRET} # 생성 명령: openssl rand -base64 64 public-paths: - - /api/v1/users - /api/v1/auth/** - /actuator/** - /api/v1/payment/webhook From 238aa061ce8b63976583e3a5776325cab9c490b0 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 4 May 2026 12:51:04 +0900 Subject: [PATCH 15/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=ED=98=95=ED=83=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GatewayExceptionHandler 작성 - 서비스 공통모듈과 같은 형태 적용 --- .../domain/exception/BusinessException.java | 18 +++++ .../domain/exception/CommonErrorCode.java | 36 ++++++++++ .../gateway/domain/exception/ErrorCode.java | 11 +++ .../domain/exception/ErrorResponse.java | 42 +++++++++++ .../domain/exception/GatewayErrorCode.java | 20 ++++++ .../infrastructure/config/ErrorConfig.java | 20 ++++++ .../exception/GatewayExceptionHandler.java | 72 +++++++++++++++++++ .../security/AuthenticationFilter.java | 56 +++------------ 8 files changed, 230 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/pagely/gateway/domain/exception/BusinessException.java create mode 100644 src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java create mode 100644 src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java create mode 100644 src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java create mode 100644 src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java create mode 100644 src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java create mode 100644 src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java diff --git a/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java b/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java new file mode 100644 index 0000000..a35fb9f --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java @@ -0,0 +1,18 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String detailMessage) { + super(detailMessage); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java new file mode 100644 index 0000000..cc29ba5 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java @@ -0,0 +1,36 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 공통 에러 코드. + */ +@Getter +public enum CommonErrorCode implements ErrorCode { + + // 4xx + INVALID_INPUT("입력값이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + RESOURCE_NOT_FOUND("요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + UNAUTHORIZED("인증이 필요합니다.", HttpStatus.UNAUTHORIZED), + FORBIDDEN("접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + CONFLICT("요청이 현재 상태와 충돌합니다.", HttpStatus.CONFLICT), + UNSUPPORTED_MEDIA_TYPE("지원하지 않는 미디어 타입입니다.", HttpStatus.UNSUPPORTED_MEDIA_TYPE), + TOO_MANY_REQUESTS("요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", HttpStatus.TOO_MANY_REQUESTS), + + // 5xx + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + SERVICE_UNAVAILABLE("서비스를 일시적으로 사용할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE), + EVENT_PUBLISH_FAILURE("이벤트 발행에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + + + private final String code; + private final String message; + private final HttpStatus httpStatus; + + CommonErrorCode(String message, HttpStatus httpStatus) { + this.code = this.name(); + this.message = message; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java new file mode 100644 index 0000000..47532c2 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java @@ -0,0 +1,11 @@ +package com.pagely.gateway.domain.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + String getCode(); + + String getMessage(); + + HttpStatus getHttpStatus(); +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java b/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java new file mode 100644 index 0000000..7ab1235 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java @@ -0,0 +1,42 @@ +package com.pagely.gateway.domain.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 에러 상세 정보 DTO. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + + private String code; + private String message; + private List fieldErrors; + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(code, message, null); + } + + public static ErrorResponse of(String code, String message, List fieldErrors) { + return new ErrorResponse(code, message, fieldErrors); + } + + /** + * Bean Validation 필드 오류 정보. + */ + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor(staticName = "of") + public static class FieldError { + private String field; + private Object rejectedValue; + private String reason; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java new file mode 100644 index 0000000..4cad298 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java @@ -0,0 +1,20 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum GatewayErrorCode implements ErrorCode { + INVALID_TOKEN_FORMAT("Authorization 헤더는 'Bearer {token}' 형식이어야 합니다.", HttpStatus.UNAUTHORIZED), + EMPTY_TOKEN("토큰이 비어있습니다.", HttpStatus.UNAUTHORIZED), + INVALID_TOKEN("토큰이 유효하지 않거나 만료되었습니다.", HttpStatus.UNAUTHORIZED); + private final String code; + private final String message; + private final HttpStatus httpStatus; + + GatewayErrorCode(String message, HttpStatus httpStatus) { + this.code = this.name(); + this.message = message; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java b/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java new file mode 100644 index 0000000..43bcca0 --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java @@ -0,0 +1,20 @@ +package com.pagely.gateway.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pagely.gateway.infrastructure.exception.GatewayExceptionHandler; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; + +@Configuration +public class ErrorConfig { + + @Bean + @Primary // 같은 타입의 빈이 여러 개일 때 이 녀석을 1순위로! + @Order(-2) // 기본 핸들러(-1)보다 높은 순위 + public ErrorWebExceptionHandler gatewayExceptionHandler(ObjectMapper objectMapper) { + return new GatewayExceptionHandler(objectMapper); + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java new file mode 100644 index 0000000..453510d --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java @@ -0,0 +1,72 @@ +package com.pagely.gateway.infrastructure.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pagely.gateway.domain.exception.BusinessException; +import com.pagely.gateway.domain.exception.CommonErrorCode; +import com.pagely.gateway.domain.exception.ErrorCode; +import com.pagely.gateway.domain.exception.ErrorResponse; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Gateway는 WebFlux 기반의 정적인 라이브러리 환경이기 때문에, 일반적인 MVC의 @RestControllerAdvice를 사용할 수 없습니다. + * + */ +@Slf4j +@RequiredArgsConstructor +public class GatewayExceptionHandler implements ErrorWebExceptionHandler { + + private final ObjectMapper objectMapper; + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + log.info("[GatewayExceptionHandler] 진입 - 예외: {}", ex.getMessage()); // 이 로그가 찍히는지 확인이 핵심입니다! + ServerHttpResponse response = exchange.getResponse(); + + if (response.isCommitted()) { + return Mono.error(ex); + } + + // 1. 에러 코드 추출 + ErrorCode errorCode = determineErrorCode(ex); + + // 2. 응답 설정 + response.setStatusCode(errorCode.getHttpStatus()); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + // 3. 공통 에러 응답 바디 + Map apiResponse = Map.of( + "success", false, + "error", ErrorResponse.of( + errorCode.getCode(), + ex instanceof BusinessException ? ex.getMessage() : errorCode.getMessage() + ) + ); + + return response.writeWith(Mono.fromSupplier(() -> { + try { + // 이 부분이 정확히 apiResponse 변수를 직렬화하는지 다시 확인! + byte[] bytes = objectMapper.writeValueAsBytes(apiResponse); + return response.bufferFactory().wrap(bytes); + } catch (Exception e) { + log.error("Error writing response", e); + return response.bufferFactory() + .wrap("{\"success\":false,\"error\":{\"code\":\"INTERNAL_SERVER_ERROR\"}}".getBytes()); + } + })); + } + + private ErrorCode determineErrorCode(Throwable ex) { + if (ex instanceof BusinessException) { + return ((BusinessException) ex).getErrorCode(); + } + // Gateway 자체에서 발생하는 예외들에 대한 매핑 + return CommonErrorCode.INTERNAL_SERVER_ERROR; + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java index 507b4c2..d88e80f 100644 --- a/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java +++ b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java @@ -1,23 +1,18 @@ package com.pagely.gateway.infrastructure.security; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.pagely.gateway.domain.exception.BusinessException; +import com.pagely.gateway.domain.exception.CommonErrorCode; +import com.pagely.gateway.domain.exception.GatewayErrorCode; import io.jsonwebtoken.JwtException; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; 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.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; +import org.springframework.http.HttpMethod; import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.web.server.ServerWebExchange; @@ -49,7 +44,6 @@ public class AuthenticationFilter implements GlobalFilter, Ordered { private final JwtTokenProvider jwtTokenProvider; private final JwtProperties jwtProperties; - private final ObjectMapper objectMapper; private final AntPathMatcher pathMatcher = new AntPathMatcher(); @Override @@ -67,24 +61,22 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { // [2] Authorization 헤더 추출 String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (authHeader == null || authHeader.isBlank()) { - return unauthorized(exchange, "MISSING_TOKEN", "인증 토큰이 필요합니다."); + return Mono.error(new BusinessException(CommonErrorCode.UNAUTHORIZED)); } // [3] Bearer prefix 검증 if (!authHeader.startsWith(BEARER_PREFIX)) { - return unauthorized(exchange, "INVALID_TOKEN_FORMAT", - "Authorization 헤더는 'Bearer {token}' 형식이어야 합니다."); + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN_FORMAT)); } String token = authHeader.substring(BEARER_PREFIX.length()).trim(); if (token.isEmpty()) { - return unauthorized(exchange, "EMPTY_TOKEN", "토큰이 비어있습니다."); + return Mono.error(new BusinessException(GatewayErrorCode.EMPTY_TOKEN)); } // [4] JWT 검증 if (!jwtTokenProvider.validateToken(token)) { - return unauthorized(exchange, "INVALID_TOKEN", - "토큰이 유효하지 않거나 만료되었습니다."); + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN)); } // [5] 클레임 추출 @@ -93,7 +85,7 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { claims = jwtTokenProvider.parseClaims(token); } catch (JwtException | IllegalStateException e) { log.warn("JWT 파싱 실패 (검증은 통과): {}", e.getMessage()); - return unauthorized(exchange, "INVALID_TOKEN", "토큰 정보를 읽을 수 없습니다."); + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN)); } // [6] 헤더 주입 후 다음 필터로 @@ -102,8 +94,8 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { .header(HEADER_USER_ROLE, claims.role()) .build(); - log.debug("인증 성공: userId={}, role={}, path={}", - claims.userId(), claims.role(), path); +/* log.debug("인증 성공: userId={}, role={}, path={}", + claims.userId(), claims.role(), path);*/ return chain.filter(exchange.mutate().request(mutatedRequest).build()); } @@ -120,32 +112,6 @@ private boolean isPublicPath(String path) { .anyMatch(pattern -> pathMatcher.match(pattern, path)); } - /** - * 401 Unauthorized 응답 반환. - */ - private Mono unauthorized(ServerWebExchange exchange, String error, String message) { - ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.UNAUTHORIZED); - response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - - Map body = Map.of( - "error", error, - "message", message, - "timestamp", LocalDateTime.now().toString() - ); - - try { - byte[] bytes = objectMapper.writeValueAsBytes(body); - DataBuffer buffer = response.bufferFactory().wrap(bytes); - return response.writeWith(Mono.just(buffer)); - } catch (JsonProcessingException e) { - log.error("401 응답 직렬화 실패", e); - DataBuffer fallback = response.bufferFactory() - .wrap("{\"error\":\"INTERNAL_ERROR\"}".getBytes(StandardCharsets.UTF_8)); - return response.writeWith(Mono.just(fallback)); - } - } - /** * 다른 GlobalFilter 보다 일찍 실행. */ From 172689abff593db4e85786cc3f9d090b1204c04f Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Mon, 4 May 2026 12:59:38 +0900 Subject: [PATCH 16/23] =?UTF-8?q?=F0=9F=94=A8Script:=20pr=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=8C=EC=B9=98=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20bug=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/branch-name-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/branch-name-check.yml b/.github/workflows/branch-name-check.yml index caf7d95..0a3b912 100644 --- a/.github/workflows/branch-name-check.yml +++ b/.github/workflows/branch-name-check.yml @@ -16,7 +16,7 @@ jobs: - name: Check branch name format run: | BRANCH_NAME=${GITHUB_REF#refs/heads/} - REGEX="^(feat|fix|refactor|docs|chore|test)\/[0-9]+-.+$" + REGEX="^(feat|fix|refactor|docs|chore|test|bug)\/[0-9]+-.+$" if [[ ! $BRANCH_NAME =~ $REGEX ]]; then echo "❌ ERROR: 브랜치 이름 형식이 틀렸습니다!" From e85d0ead46e34e161e5fada67561a96329e7a619 Mon Sep 17 00:00:00 2001 From: SEONJU PARK <101613808+mimimya@users.noreply.github.com> Date: Thu, 7 May 2026 00:02:37 +0900 Subject: [PATCH 17/23] =?UTF-8?q?Chore:=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: SEONJU PARK <101613808+mimimya@users.noreply.github.com> --- .../infrastructure/exception/GatewayExceptionHandler.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java index 453510d..8e10a01 100644 --- a/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java +++ b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java @@ -26,7 +26,6 @@ public class GatewayExceptionHandler implements ErrorWebExceptionHandler { @Override public Mono handle(ServerWebExchange exchange, Throwable ex) { - log.info("[GatewayExceptionHandler] 진입 - 예외: {}", ex.getMessage()); // 이 로그가 찍히는지 확인이 핵심입니다! ServerHttpResponse response = exchange.getResponse(); if (response.isCommitted()) { @@ -51,7 +50,6 @@ public Mono handle(ServerWebExchange exchange, Throwable ex) { return response.writeWith(Mono.fromSupplier(() -> { try { - // 이 부분이 정확히 apiResponse 변수를 직렬화하는지 다시 확인! byte[] bytes = objectMapper.writeValueAsBytes(apiResponse); return response.bufferFactory().wrap(bytes); } catch (Exception e) { From b1e9541b3ab542636e23f4963b47477945152aca Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Thu, 14 May 2026 20:48:21 +0900 Subject: [PATCH 18/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20Auth=20Service=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves:#17 --- src/main/resources/application.yml | 42 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c26fda0..90fa333 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,53 +16,59 @@ spring: server: webflux: routes: - # AI Service + # Auth Service (19091) + - id: auth-service + uri: lb://authservice + predicates: + - Path=/api/v1/auth/** + + # AI Service (19021) - id: ai-service - uri: http://localhost:19021 + uri: lb://aiservice predicates: - Path=/api/v1/ai/** - # User Service (유저 + 인증) + # User Service (19001) - id: user-service - uri: http://localhost:19001 + uri: lb://userservice predicates: - - Path=/api/v1/users/**, /api/v1/auth/** + - Path=/api/v1/users/** - # Market Service (마켓) + # Market Service (19031) - id: market-service order: 2 - uri: http://localhost:19031 + uri: lb://marketservice predicates: - Path=/api/v1/sale-posts/**, /api/v1/orders/**, /api/v1/sellers/**, /api/v1/buyers/** - # Payment Service (결제 + Webhook + 구매자 결제내역) + # Payment Service (19041 결제 + Webhook + 구매자 결제내역) - id: payment-service order: 1 # 숫자가 낮을수록 우선순위가 높음 (buyers) - uri: http://localhost:19041 + uri: lb://paymentservice predicates: - Path=/api/v1/payments/**, /api/v1/payment/webhook, /api/v1/buyers/me/payments - # Book Service (도서 조회/좋아요) + # Book Service (19051) - id: book-service - uri: http://localhost:19051 + uri: lb://bookservice predicates: - Path=/api/v1/books/** - # Report Service (독후감) + # Report Service (19061) - id: report-service - uri: http://localhost:19061 + uri: lb://reportservice predicates: - Path=/api/v1/reports/** - # Meeting Service (모임) + # Meeting Service (19011) - id: meeting-service - uri: http://localhost:19011 + uri: lb://meetingservice predicates: - Path=/api/v1/meetings/** - # Map Service (지도) : MVP 이후 수정 + # Map Service (19071) : MVP 이후 수정 - id: map-service - uri: http://localhost:19071 + uri: lb://mapservice predicates: - Path=/api/v1/map/** @@ -74,7 +80,7 @@ spring: eureka: instance: - prefer-ip-address: false + prefer-ip-address: true hostname: ${HOSTNAME:localhost} client: register-with-eureka: true From 6348ef46b486c64426eaa8813c8fd2ccac98ff37 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Sun, 17 May 2026 14:44:26 +0900 Subject: [PATCH 19/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20Dockerfile=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to:#19 --- Dockerfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f1abe4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /workspace +COPY . . +ARG GPR_USER +ARG GPR_KEY +ENV GPR_USER=${GPR_USER} +ENV GPR_KEY=${GPR_KEY} +RUN chmod +x gradlew +RUN ./gradlew bootJar --no-daemon -Pgpr.user="${GPR_USER}" -Pgpr.key="${GPR_KEY}" +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /workspace/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","/app/app.jar"] From 29bdbd1e5f74a5159b838c008b993a5df6646a43 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Sun, 17 May 2026 15:22:18 +0900 Subject: [PATCH 20/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20Prod=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=9D=98=20application=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-prod.yml Related to:#19 --- src/main/resources/application-prod.yml | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/main/resources/application-prod.yml diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..881abe1 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,93 @@ +server: + port: 8080 + +spring: + application: + name: gateway + config: + import: + - optional:configserver:http://pagely-config:8888 + - optional:classpath:.env[.properties] + profiles: + active: prod + + cloud: + gateway: + server: + webflux: + routes: + # Auth Service (19091) + - id: auth-service + uri: lb://authservice + predicates: + - Path=/api/v1/auth/** + + # AI Service (19021) + - id: ai-service + uri: lb://aiservice + predicates: + - Path=/api/v1/ai/** + + # User Service (19001) + - id: user-service + uri: lb://userservice + predicates: + - Path=/api/v1/users/** + + # Market Service (19031) + - id: market-service + order: 2 + uri: lb://marketservice + predicates: + - Path=/api/v1/sale-posts/**, /api/v1/orders/**, /api/v1/sellers/**, /api/v1/buyers/** + + # Payment Service (19041 결제 + Webhook + 구매자 결제내역) + - id: payment-service + order: 1 # 숫자가 낮을수록 우선순위가 높음 (buyers) + uri: lb://paymentservice + predicates: + - Path=/api/v1/payments/**, /api/v1/payment/webhook, /api/v1/buyers/me/payments + + # Book Service (19051) + - id: book-service + uri: lb://bookservice + predicates: + - Path=/api/v1/books/** + + # Report Service (19061) + - id: report-service + uri: lb://reportservice + predicates: + - Path=/api/v1/reports/** + + # Meeting Service (19011) + - id: meeting-service + uri: lb://meetingservice + predicates: + - Path=/api/v1/meetings/** + + # Map Service (19071) : MVP 이후 수정 + - id: map-service + uri: lb://mapservice + predicates: + - Path=/api/v1/map/** + + +eureka: + instance: + prefer-ip-address: true + hostname: ${HOSTNAME:localhost} + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_SERVER_URL:http://pagely-eureka:8000/eureka/} + + +jwt: + secret: ${JWT_SECRET} + # 생성 명령: openssl rand -base64 64 + public-paths: + - /api/v1/auth/** + - /actuator/** + - /api/v1/payment/webhook From 293e0996189a2596fe49ec2bf6e2b1993dc6981b Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Sun, 17 May 2026 15:51:45 +0900 Subject: [PATCH 21/23] =?UTF-8?q?=F0=9F=A9=B9=20Fix:=20Prod=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=9D=98=20application=20=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONFIG_SERVER_URL 환경변수 기반 설정으로 변경 Related to:#19 --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 881abe1..bc5d641 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,7 +6,7 @@ spring: name: gateway config: import: - - optional:configserver:http://pagely-config:8888 + - optional:configserver:${CONFIG_SERVER_URL:http://pagely-config:8888} - optional:classpath:.env[.properties] profiles: active: prod From 8af841aa228307eb89947bb4701f957fe8abc6fe Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Sun, 17 May 2026 16:31:26 +0900 Subject: [PATCH 22/23] =?UTF-8?q?=F0=9F=A9=B9=20Fix:=20Dockerfile=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=98=B5=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통모듈 미사용으로 GPR_* 사용 제거 Related to:#19 --- Dockerfile | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1f1abe4..93c220a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /workspace COPY . . -ARG GPR_USER -ARG GPR_KEY -ENV GPR_USER=${GPR_USER} -ENV GPR_KEY=${GPR_KEY} + +# [나중에 공통모듈 쓸 때 주석 해제] +# ARG GPR_USER +# ARG GPR_KEY +# ENV GPR_USER=${GPR_USER} +# ENV GPR_KEY=${GPR_KEY} + RUN chmod +x gradlew -RUN ./gradlew bootJar --no-daemon -Pgpr.user="${GPR_USER}" -Pgpr.key="${GPR_KEY}" + +# 현재는 순수하게 빌드만 진행 (공통모듈 미사용 버전) +RUN ./gradlew bootJar --no-daemon + +# [나중에 공통모듈 쓸 때 주석 해제할 라인] +# RUN ./gradlew bootJar --no-daemon -Pgpr.user="${GPR_USER}" -Pgpr.key="${GPR_KEY}" + FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --from=builder /workspace/build/libs/*.jar app.jar From d5ca0c08d81663314863c2676be71a874a1a52f9 Mon Sep 17 00:00:00 2001 From: SEOUNJU PARK Date: Sun, 17 May 2026 17:14:13 +0900 Subject: [PATCH 23/23] =?UTF-8?q?=E2=9C=A8=20Feat:=20Git=20actions=20cd=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to:#19 --- .github/workflows/cd.yml | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/cd.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..d806375 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,77 @@ +name: Continuous Deployment + +on: + push: + branches: + - prod + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: 1. 코드가져오기 (Checkout) + uses: actions/checkout@v4 + + - name: 2. JDK 21 설정 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: 3. .env 파일 생성 + run: | + cat << EOF > .env + ${{ secrets.PROD_ENV_FILE }} + EOF + + - name: 4. Docker Hub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 5. Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + build-args: | + GPR_USER=${{ secrets.GPR_USER }} + GPR_KEY=${{ secrets.GPR_KEY }} + tags: ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest + + - name: 6. SSH를 통해 배포 서버 접속 및 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.APP_EC2_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 서버 내부의 배포 디렉토리 이동 (없으면 생성) + mkdir -p ~/app/gateway + cd ~/app/gateway + + # 서버 호스트에 .env 파일 작성 + cat << 'EOF' > .env + ${{ secrets.PROD_ENV_FILE }} + EOF + + # 최신 Docker 이미지 다운로드 + docker pull ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest + + # 기존에 돌고 있는 게이트웨이 컨테이너 중지 및 삭제 + docker stop gateway || true + docker rm gateway || true + + # 불필요한 구버전 이미지 정리 + docker image prune -f + + # 새 컨테이너 실행 (--env-file 옵션으로 뭉텅이 .env 주입) + docker run -d \ + --name gateway \ + -p 8080:8080 \ + --env-file .env \ + --restart always \ + ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest