Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ spring_cloud_openfeign = "4.2.0"
openfeign_hc5 = "13.1"
openfeign_micrometer = "13.1"

# Resilience4j
resilience4j = "2.2.0"

# Bucket4j
bucket4j = "8.15.0"

Expand Down Expand Up @@ -186,6 +189,7 @@ epages_restdocs_api_spec_mock_mvc = { module = "com.epages:restdocs-api-spec-moc
epages_restdocs_api_spec_restassured = { module = "com.epages:restdocs-api-spec-restassured", version.ref = "epages_restdocs_api_spec" }

# Monitoring & Logging Libraries
micrometer_core = { module = "io.micrometer:micrometer-core" }
micrometer_tracing_bridge_brave = { module = "io.micrometer:micrometer-tracing-bridge-brave" }
micrometer_registry_prometheus = { module = "io.micrometer:micrometer-registry-prometheus" }
sentry_spring_boot_starter_jakarta = { module = "io.sentry:sentry-spring-boot-starter-jakarta", version.ref = "sentry" }
Expand All @@ -194,10 +198,18 @@ slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.9" }

# AWS Libraries
aws_sdk_s3 = { module = "software.amazon.awssdk:s3", version.ref = "aws" }
aws_sdk_url_connection_client = { module = "software.amazon.awssdk:url-connection-client", version.ref = "aws" }
aws_sdk_netty_nio_client = { module = "software.amazon.awssdk:netty-nio-client", version.ref = "aws" }

# Firebase
firebase = { module = "com.google.firebase:firebase-admin", version.ref = "firebase" }

# Resilience4j
resilience4j_circuitbreaker = { module = "io.github.resilience4j:resilience4j-circuitbreaker", version.ref = "resilience4j" }
resilience4j_bulkhead = { module = "io.github.resilience4j:resilience4j-bulkhead", version.ref = "resilience4j" }
resilience4j_spring_boot3 = { module = "io.github.resilience4j:resilience4j-spring-boot3", version.ref = "resilience4j" }
resilience4j_micrometer = { module = "io.github.resilience4j:resilience4j-micrometer", version.ref = "resilience4j" }

# Bucket4j
bucket4j_core = { module = "com.bucket4j:bucket4j_jdk17-core", version.ref = "bucket4j" }

Expand All @@ -206,7 +218,8 @@ caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "c

[bundles]
line_kotlin_jdsl = ["line_kotlin_jdsl_jpql_dsl", "line_kotlin_jdsl_jpql_render", "line_kotlin_jdsl_spring_data_jpa_support"]
aws_client = ["aws_sdk_s3"]
aws_client = ["aws_sdk_s3", "aws_sdk_url_connection_client", "aws_sdk_netty_nio_client"]
resilience4j = ["resilience4j_circuitbreaker", "resilience4j_bulkhead"]
openfeign = ["spring_cloud_openfeign", "openfeign_hc5", "openfeign_micrometer"]
jackson = ["jackson_kotlin", "jackson_datatype_jsr310"]
testcontainers_postgres = ["test_containers_postgres", "spring_boot_testcontainers", "test_containers_junit_jupiter", "spring_boot_starter_test"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.pida.client.airquality
import com.pida.support.error.ErrorException
import com.pida.support.error.ErrorType
import com.pida.support.extension.logger
import com.pida.support.resilience.ExternalDependency
import com.pida.support.resilience.ExternalDependencyPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

Expand All @@ -17,6 +19,7 @@ class AirKoreaClient internal constructor(
private val stationServiceKey: String,
private val airKoreaAirQualityApi: AirKoreaAirQualityApi,
private val airKoreaStationApi: AirKoreaStationApi,
private val externalDependencyPolicy: ExternalDependencyPolicy,
) {
private val logger by logger()

Expand All @@ -29,10 +32,12 @@ class AirKoreaClient internal constructor(
fun getAirQualityByStation(stationName: String): AirKoreaResponse =
try {
val response =
airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty(
serviceKey = airQualityServiceKey,
stationName = stationName,
)
externalDependencyPolicy.execute(ExternalDependency.AIR_KOREA) {
airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty(
serviceKey = airQualityServiceKey,
stationName = stationName,
)
}

if (response.response.header.resultCode != "00") {
logger.error("AirKorea API error: ${response.response.header.resultMsg}")
Expand Down Expand Up @@ -62,11 +67,13 @@ class AirKoreaClient internal constructor(

return try {
val response =
airKoreaStationApi.getNearbyMsrstnList(
serviceKey = stationServiceKey,
tmX = tmX,
tmY = tmY,
)
externalDependencyPolicy.execute(ExternalDependency.AIR_KOREA) {
airKoreaStationApi.getNearbyMsrstnList(
serviceKey = stationServiceKey,
tmX = tmX,
tmY = tmY,
)
}

if (response.response.header.resultCode != "00") {
logger.error("AirKorea station API error: ${response.response.header.resultMsg}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.pida.client.airquality

import com.pida.support.error.ErrorException
import com.pida.support.error.ErrorType
import com.pida.support.resilience.ExternalDependencyPolicy
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
Expand All @@ -13,7 +14,7 @@ class AirKoreaClientTest {
fun `근접 측정소 조회 성공 시 첫 번째 측정소명을 반환한다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaStationApi.getNearbyMsrstnList(
Expand Down Expand Up @@ -57,7 +58,7 @@ class AirKoreaClientTest {
fun `근접 측정소 조회 결과 코드가 실패면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaStationApi.getNearbyMsrstnList(
Expand Down Expand Up @@ -86,7 +87,7 @@ class AirKoreaClientTest {
fun `근접 측정소 조회 결과가 비어 있으면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaStationApi.getNearbyMsrstnList(
Expand Down Expand Up @@ -115,7 +116,7 @@ class AirKoreaClientTest {
fun `근접 측정소 조회 중 예외가 발생하면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaStationApi.getNearbyMsrstnList(
Expand All @@ -137,7 +138,7 @@ class AirKoreaClientTest {
fun `대기질 조회 결과 코드가 실패면 AIR_QUALITY_API_CALL_FAILED를 던진다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty(
Expand Down Expand Up @@ -165,7 +166,7 @@ class AirKoreaClientTest {
fun `대기질 조회 중 예외가 발생하면 AIR_QUALITY_API_CALL_FAILED를 던진다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty(
Expand All @@ -186,7 +187,7 @@ class AirKoreaClientTest {
fun `메서드별로 서로 다른 service key를 사용한다`() {
val airKoreaAirQualityApi = mockk<AirKoreaAirQualityApi>()
val airKoreaStationApi = mockk<AirKoreaStationApi>()
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi)
val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy())

every {
airKoreaStationApi.getNearbyMsrstnList(
Expand Down Expand Up @@ -251,4 +252,9 @@ class AirKoreaClientTest {
client.getNearbyStation("192968", "4667503")
client.getAirQualityByStation("종로구")
}

private fun passthroughPolicy(): ExternalDependencyPolicy =
mockk {
every { execute<Any>(any(), any()) } answers { secondArg<() -> Any>().invoke() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import java.net.URI
import java.time.Duration

@Configuration
class AwsConfig(
Expand All @@ -35,11 +39,26 @@ class AwsConfig(

@Bean(destroyMethod = "close") // 스프링 종료 시 커넥션 풀 정리
fun s3Client(): S3Client {
val connectionTimeout = Duration.ofMillis(awsProperties.connectionTimeout)
val socketTimeout = Duration.ofMillis(awsProperties.socketTimeout)
val client =
S3Client
.builder()
.credentialsProvider(credentialProvider())
.region(Region.of(awsProperties.region))
.httpClient(
UrlConnectionHttpClient
.builder()
.connectionTimeout(connectionTimeout)
.socketTimeout(socketTimeout)
.build(),
).overrideConfiguration(
ClientOverrideConfiguration
.builder()
.apiCallAttemptTimeout(socketTimeout)
.apiCallTimeout(socketTimeout.plusMillis(500))
.build(),
)
awsProperties.endpoint?.let {
client.endpointOverride(URI.create(awsProperties.endpoint))
}
Expand All @@ -49,11 +68,26 @@ class AwsConfig(

@Bean(destroyMethod = "close")
fun s3AsyncClient(): S3AsyncClient {
val connectionTimeout = Duration.ofMillis(awsProperties.connectionTimeout)
val socketTimeout = Duration.ofMillis(awsProperties.socketTimeout)
val client =
S3AsyncClient
.builder()
.credentialsProvider(credentialProvider())
.region(Region.of(awsProperties.region))
.httpClient(
NettyNioAsyncHttpClient
.builder()
.connectionTimeout(connectionTimeout)
.readTimeout(socketTimeout)
.build(),
).overrideConfiguration(
ClientOverrideConfiguration
.builder()
.apiCallAttemptTimeout(socketTimeout)
.apiCallTimeout(socketTimeout.plusMillis(500))
.build(),
)
awsProperties.endpoint?.let {
client.endpointOverride(URI.create(awsProperties.endpoint))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ data class AwsProperties(
val s3: S3Properties,
val region: String,
val endpoint: String?,
val connectionTimeout: Long,
val socketTimeout: Long,
)

data class CredentialsProperties(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.pida.support.aws.PresignedUrlRateLimiter
import com.pida.support.aws.S3ImageInfo
import com.pida.support.aws.S3ImageUrl
import com.pida.support.aws.S3UploadResult
import com.pida.support.resilience.ExternalDependency
import com.pida.support.resilience.ExternalDependencyPolicy
import org.springframework.stereotype.Component
import software.amazon.awssdk.services.s3.model.S3Object
import java.time.Duration
Expand All @@ -21,6 +23,7 @@ class ImageS3Processor(
private val awsProperties: AwsProperties,
private val imageFileConstructor: ImageFileConstructor,
private val rateLimiter: PresignedUrlRateLimiter,
private val externalDependencyPolicy: ExternalDependencyPolicy,
) : ImageS3Caller {
companion object {
private val SEOUL_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
Expand Down Expand Up @@ -56,53 +59,56 @@ class ImageS3Processor(
prefix: String,
prefixId: Long,
fileName: String?,
): List<S3ImageInfo> {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)
): List<S3ImageInfo> =
externalDependencyPolicy.executeSuspend(ExternalDependency.AWS_S3) {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)

return fileName
?.let {
listOf(presignedGet(imageFilePath, it)) // fileName이 있으면 특정 이미지 조회
} ?: listPresignedGets(imageFilePath) // 아니면 해당 경로 아래 모든 이미지 탐색
}
fileName
?.let {
listOf(presignedGet(imageFilePath, it))
} ?: listPresignedGets(imageFilePath)
}

override fun uploadImage(
prefix: String,
prefixId: Long,
subPath: String,
contentType: String,
bytes: ByteArray,
): S3UploadResult {
val filePath = imageFileConstructor.imageFilePath(prefix, prefixId)
val fileName = imageFileConstructor.imageFileName()
val s3Key = "$filePath/$subPath/$fileName"
): S3UploadResult =
externalDependencyPolicy.execute(ExternalDependency.AWS_S3) {
val filePath = imageFileConstructor.imageFilePath(prefix, prefixId)
val fileName = imageFileConstructor.imageFileName()
val s3Key = "$filePath/$subPath/$fileName"

awsS3Client.putObject(
bucketName = awsProperties.s3.bucket,
key = s3Key,
contentType = contentType,
bytes = bytes,
)
awsS3Client.putObject(
bucketName = awsProperties.s3.bucket,
key = s3Key,
contentType = contentType,
bytes = bytes,
)

return S3UploadResult(
s3Key = s3Key,
publicUrl = "${awsProperties.s3.imageOriginUrl}/$s3Key",
)
}
S3UploadResult(
s3Key = s3Key,
publicUrl = "${awsProperties.s3.imageOriginUrl}/$s3Key",
)
}

override suspend fun getPreviewImage(
prefix: String,
prefixId: Long,
): S3ImageInfo? {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)
): S3ImageInfo? =
externalDependencyPolicy.executeSuspend(ExternalDependency.AWS_S3) {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)

return awsS3AsyncClient
.listObjects(
bucketName = awsProperties.s3.bucket,
filePath = imageFilePath,
).filterNot { it.key().endsWith("/") }
.maxByOrNull(S3Object::lastModified)
?.toImageInfo(imageFilePath, Duration.ofSeconds(30))
}
awsS3AsyncClient
.listObjects(
bucketName = awsProperties.s3.bucket,
filePath = imageFilePath,
).filterNot { it.key().endsWith("/") }
.maxByOrNull(S3Object::lastModified)
?.toImageInfo(imageFilePath, Duration.ofSeconds(30))
}

override fun generatePresignedUrl(s3Key: String): String {
val filePath = s3Key.substringBeforeLast("/")
Expand Down
12 changes: 6 additions & 6 deletions pida-clients/aws-client/src/main/resources/aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ cloud:
image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL}
stack:
auto: false
connection-time-out: 5000
socket-time-out: 30000
connection-time-out: 1000
socket-time-out: 2000
---
spring:
config:
Expand All @@ -31,8 +31,8 @@ cloud:
image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL}
stack:
auto: false
connection-time-out: 900000
socket-time-out: 900000
connection-time-out: 1000
socket-time-out: 2000
---
spring:
config:
Expand All @@ -49,5 +49,5 @@ cloud:
image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL}
stack:
auto: false
connection-time-out: 900000
socket-time-out: 900000
connection-time-out: 1000
socket-time-out: 2000
Loading
Loading