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
13 changes: 11 additions & 2 deletions .github/workflows/ci-backend.yml → .github/workflows/ci-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ jobs:
build:
runs-on: ubuntu-latest

services:
redis:
image: redis:7.0.8-alpine
ports:
- 6379:6379

steps:
- uses: actions/checkout@v4

Expand All @@ -24,5 +30,8 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build with Gradle
run: ./gradlew clean build
- name: Run tests with Gradle
run: ./gradlew clean test
env:
SPRING_REDIS_HOST: localhost
SPRING_REDIS_PORT: 6379
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ dependencies {
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")

testImplementation("io.mockk:mockk:1.13.10")

testImplementation("org.testcontainers:testcontainers:1.19.3")
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import com.dobby.backend.application.usecase.experiment.GetMyExperimentPostsUseC
import com.dobby.backend.domain.exception.ExperimentAreaInCorrectException
import com.dobby.backend.domain.exception.ExperimentAreaOverflowException
import com.dobby.backend.domain.exception.InvalidRequestValueException
import com.dobby.backend.domain.gateway.CacheGateway
import com.dobby.backend.infrastructure.database.entity.enums.areaInfo.Area
import com.dobby.backend.infrastructure.database.entity.enums.experiment.RecruitStatus
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service

Expand All @@ -30,9 +33,12 @@ class ExperimentPostService(
private val getExperimentPostTotalCountByCustomFilterUseCase: GetExperimentPostTotalCountByCustomFilterUseCase,
private val getMyExperimentPostsUseCase: GetMyExperimentPostsUseCase,
private val getMyExperimentPostTotalCountUseCase: GetMyExperimentPostTotalCountUseCase,
private val cacheGateway: CacheGateway,
private val objectMapper: ObjectMapper,
) {
@Transactional
fun createNewExperimentPost(input: CreateExperimentPostUseCase.Input): CreateExperimentPostUseCase.Output {
evictExperimentPostCountsCaches()
return createExperimentPostUseCase.execute(input)
}

Expand Down Expand Up @@ -69,14 +75,36 @@ class ExperimentPostService(
}

fun getExperimentPostCounts(input: Any): Any {
return when (input) {
if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
getCachedExperimentPostCounts(input.recruitStatus)?.let { return it }
}

val output = when (input) {
Comment on lines 77 to +82
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 전체 지역의 공고 개수 조회 (region == null)가 가장 빈번하게 호출될 것으로 예상되어 이 경우에만 캐싱을 적용했습니다. 만약 특정 지역에 대한 조회 요청도 증가하는 것이 확인된다면, 해당 부분에 대한 캐싱 정책도 추가로 검토할 예정입니다~

is GetExperimentPostCountsByRegionUseCase.Input -> getExperimentPostCountsByRegionUseCase.execute(input)
is GetExperimentPostCountsByAreaUseCase.Input -> getExperimentPostCountsByAreaUseCase.execute(input)
else -> throw IllegalArgumentException("Invalid input type: ${input::class.java.simpleName}")
}

if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
cacheExperimentPostCounts(input.recruitStatus, output)
}

return output
}
Comment on lines 77 to +93
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

ObjectMapper 예외 처리가 필요합니다.

getExperimentPostCounts 메서드에서 캐시 저장 시 ObjectMapper.writeValueAsString() 호출에 대한 예외 처리가 누락되었습니다.

다음과 같이 예외 처리를 추가해주세요:

 if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
-    cacheExperimentPostCounts(input.recruitStatus, output)
+    try {
+        cacheExperimentPostCounts(input.recruitStatus, output)
+    } catch (e: Exception) {
+        log.error("Failed to cache experiment post counts: ${e.message}")
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun getExperimentPostCounts(input: Any): Any {
return when (input) {
if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
getCachedExperimentPostCounts(input.recruitStatus)?.let { return it }
}
val output = when (input) {
is GetExperimentPostCountsByRegionUseCase.Input -> getExperimentPostCountsByRegionUseCase.execute(input)
is GetExperimentPostCountsByAreaUseCase.Input -> getExperimentPostCountsByAreaUseCase.execute(input)
else -> throw IllegalArgumentException("Invalid input type: ${input::class.java.simpleName}")
}
if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
cacheExperimentPostCounts(input.recruitStatus, output)
}
return output
}
fun getExperimentPostCounts(input: Any): Any {
if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
getCachedExperimentPostCounts(input.recruitStatus)?.let { return it }
}
val output = when (input) {
is GetExperimentPostCountsByRegionUseCase.Input -> getExperimentPostCountsByRegionUseCase.execute(input)
is GetExperimentPostCountsByAreaUseCase.Input -> getExperimentPostCountsByAreaUseCase.execute(input)
else -> throw IllegalArgumentException("Invalid input type: ${input::class.java.simpleName}")
}
if (input is GetExperimentPostCountsByRegionUseCase.Input && input.region == null) {
try {
cacheExperimentPostCounts(input.recruitStatus, output)
} catch (e: Exception) {
log.error("Failed to cache experiment post counts: ${e.message}")
}
}
return output
}


private fun getCachedExperimentPostCounts(recruitStatus: RecruitStatus): GetExperimentPostCountsByRegionUseCase.Output? {
val cacheKey = "experimentPostCounts:$recruitStatus"
return cacheGateway.get(cacheKey)?.let {
objectMapper.readValue(it, GetExperimentPostCountsByRegionUseCase.Output::class.java)
}
}

private fun cacheExperimentPostCounts(recruitStatus: RecruitStatus, output: Any) {
val cacheKey = "experimentPostCounts:$recruitStatus"
cacheGateway.set(cacheKey, objectMapper.writeValueAsString(output))
}
Comment on lines +95 to 105
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

직렬화/역직렬화 예외 처리가 필요합니다.

ObjectMapper 작업 시 발생할 수 있는 예외에 대한 처리가 필요합니다.

 private fun getCachedExperimentPostCounts(recruitStatus: RecruitStatus): GetExperimentPostCountsByRegionUseCase.Output? {
     val cacheKey = "experimentPostCounts:$recruitStatus"
     return cacheGateway.get(cacheKey)?.let {
+        try {
             objectMapper.readValue(it, GetExperimentPostCountsByRegionUseCase.Output::class.java)
+        } catch (e: Exception) {
+            log.error("Failed to deserialize cached value: ${e.message}")
+            null
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun getCachedExperimentPostCounts(recruitStatus: RecruitStatus): GetExperimentPostCountsByRegionUseCase.Output? {
val cacheKey = "experimentPostCounts:$recruitStatus"
return cacheGateway.get(cacheKey)?.let {
objectMapper.readValue(it, GetExperimentPostCountsByRegionUseCase.Output::class.java)
}
}
private fun cacheExperimentPostCounts(recruitStatus: RecruitStatus, output: Any) {
val cacheKey = "experimentPostCounts:$recruitStatus"
cacheGateway.set(cacheKey, objectMapper.writeValueAsString(output))
}
private fun getCachedExperimentPostCounts(recruitStatus: RecruitStatus): GetExperimentPostCountsByRegionUseCase.Output? {
val cacheKey = "experimentPostCounts:$recruitStatus"
return cacheGateway.get(cacheKey)?.let {
try {
objectMapper.readValue(it, GetExperimentPostCountsByRegionUseCase.Output::class.java)
} catch (e: Exception) {
log.error("Failed to deserialize cached value: ${e.message}")
null
}
}
}
private fun cacheExperimentPostCounts(recruitStatus: RecruitStatus, output: Any) {
val cacheKey = "experimentPostCounts:$recruitStatus"
cacheGateway.set(cacheKey, objectMapper.writeValueAsString(output))
}


fun validateFilter(input: GetExperimentPostsUseCase.Input) {
private fun validateFilter(input: GetExperimentPostsUseCase.Input) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 제가 이 부분 놓친 것 같은데 바꾸어주셔서 감사합니다! 🙇‍♀️

val locationInfo = input.customFilter.locationTarget ?: return

locationInfo.areas?.let { validateLocationAreaCount(it) }
Expand Down Expand Up @@ -123,5 +151,10 @@ class ExperimentPostService(
@Transactional
fun deleteExperimentPost(input: DeleteExperimentPostUseCase.Input) {
deleteExperimentPostUseCase.execute(input)
evictExperimentPostCountsCaches()
}

private fun evictExperimentPostCountsCaches() {
listOf("ALL", "OPEN").forEach { cacheGateway.evict("experimentPostCounts:$it") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dobby.backend.domain.gateway

interface CacheGateway {
fun get(key: String): String?
fun set(key: String, value: String)
fun evict(key: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.dobby.backend.infrastructure.gateway.cache

import com.dobby.backend.domain.gateway.CacheGateway
import org.springframework.core.env.Environment
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component
import java.util.concurrent.TimeUnit

@Component
class RedisCacheGatewayImpl(
private val redisTemplate: RedisTemplate<String, String>,
private val environment: Environment
) : CacheGateway {

private val cacheTimeout = 240L

override fun get(key: String): String? {
return redisTemplate.opsForValue().get(getCacheKey(key))
}
Comment on lines +17 to +19
Copy link

@coderabbitai coderabbitai bot Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Redis 작업 실패 시 예외 처리 추가 필요

Redis 작업 실패 시 적절한 예외 처리를 추가하면 장애 상황에서도 안정적으로 동작할 수 있습니다.

     override fun get(key: String): String? {
-        return redisTemplate.opsForValue().get(getCacheKey(key))
+        return try {
+            redisTemplate.opsForValue().get(getCacheKey(key))
+        } catch (e: Exception) {
+            logger.error("캐시 조회 실패. key={}", key, e)
+            null
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun get(key: String): String? {
return redisTemplate.opsForValue().get(getCacheKey(key))
}
override fun get(key: String): String? {
return try {
redisTemplate.opsForValue().get(getCacheKey(key))
} catch (e: Exception) {
logger.error("캐시 조회 실패. key={}", key, e)
null
}
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

라비따이야, 레디스에 캐시가 없을 수도 있지. 안그러니?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ji-soo708 네, 말씀하신대로입니다. 레디스에 캐시가 없는 것은 정상적인 상황이므로 현재 구현처럼 단순히 null을 반환하는 것이 더 적절합니다. 제가 제안했던 에러 로깅은 불필요했네요. 이전 제안을 철회하겠습니다.


✏️ Learnings added
Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#92
File: src/main/kotlin/com/dobby/backend/infrastructure/gateway/cache/RedisCacheGatewayImpl.kt:17-19
Timestamp: 2025-02-10T11:52:40.151Z
Learning: Redis cache operations that return null (like get) should not log errors as cache misses are part of normal operation. The null return value already appropriately handles both cache misses and error cases.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


override fun set(key: String, value: String) {
redisTemplate.opsForValue().set(getCacheKey(key), value, cacheTimeout, TimeUnit.MINUTES)
}

override fun evict(key: String) {
redisTemplate.delete(getCacheKey(key))
}

private fun getCacheKey(key: String): String {
val activeProfile = environment.activeProfiles.firstOrNull() ?: "local"
return "$activeProfile:$key"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.experiment.CreateExperimentPostUseCase
import com.dobby.backend.application.usecase.experiment.GetExperimentPostCountsByRegionUseCase
import com.dobby.backend.config.RedisTestContainer
import com.dobby.backend.domain.gateway.CacheGateway
import com.dobby.backend.infrastructure.database.entity.enums.MatchType
import com.dobby.backend.infrastructure.database.entity.enums.areaInfo.Area
import com.dobby.backend.infrastructure.database.entity.enums.areaInfo.Region
import com.dobby.backend.infrastructure.database.entity.enums.experiment.RecruitStatus
import com.dobby.backend.infrastructure.database.entity.enums.experiment.TimeSlot
import com.dobby.backend.infrastructure.database.entity.enums.member.GenderType
import com.dobby.backend.infrastructure.database.entity.enums.member.MemberStatus
import com.dobby.backend.infrastructure.database.entity.enums.member.ProviderType
import com.dobby.backend.infrastructure.database.entity.enums.member.RoleType
import com.dobby.backend.infrastructure.database.entity.member.MemberEntity
import com.dobby.backend.infrastructure.database.repository.MemberRepository
import com.fasterxml.jackson.databind.ObjectMapper
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.nulls.shouldBeNull
import io.mockk.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDate
import java.time.LocalDateTime

@SpringBootTest
@ActiveProfiles("test")
class ExperimentPostServiceTest @Autowired constructor(
private val experimentPostService: ExperimentPostService,
private val memberRepository: MemberRepository,
private val cacheGateway: CacheGateway,
private val objectMapper: ObjectMapper
) : BehaviorSpec({

val createExperimentPostUseCase = mockk<CreateExperimentPostUseCase>()
lateinit var memberId: String

listeners(RedisTestContainer)

beforeSpec {
clearAllMocks()

val savedMember = memberRepository.save(
MemberEntity(
id = "1",
oauthEmail = "test@gmail.com",
contactEmail = "test@gmail.com",
provider = ProviderType.GOOGLE,
status = MemberStatus.ACTIVE,
role = RoleType.RESEARCHER,
name = "테스트",
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
deletedAt = null
)
)
memberId = savedMember.id
}

given("Redis 캐시에 지역별 실험 공고 개수를 저장할 때") {
val input = GetExperimentPostCountsByRegionUseCase.Input(region = null, recruitStatus = RecruitStatus.ALL)
val output = GetExperimentPostCountsByRegionUseCase.Output(total = 100, area = emptyList())

val cacheKey = "experimentPostCounts:ALL"

`when`("캐시에 데이터를 저장하고 조회하면") {
cacheGateway.set(cacheKey, objectMapper.writeValueAsString(output))
val cachedData = cacheGateway.get(cacheKey)

then("저장된 데이터가 올바르게 반환되어야 한다") {
cachedData shouldBe objectMapper.writeValueAsString(output)
}
}
}

given("새로운 실험 공고를 생성할 때") {
val cacheKey = "experimentPostCounts:ALL"
cacheGateway.set(cacheKey, objectMapper.writeValueAsString(GetExperimentPostCountsByRegionUseCase.Output(100, emptyList())))

val mockInput = mockk<CreateExperimentPostUseCase.Input>(relaxed = true)

every { mockInput.memberId } returns memberId
every { mockInput.leadResearcher } returns "야뿌 연구 리더"
every { mockInput.univName } returns "야뿌대학교"
every { mockInput.region } returns Region.SEOUL
every { mockInput.area } returns Area.GWANAKGU
every { mockInput.detailedAddress } returns "야뿌 연구소"
every { mockInput.reward } returns "Gift Card"
every { mockInput.title } returns "새로운 실험"
every { mockInput.content } returns "참여해 참여해"
every { mockInput.alarmAgree } returns true
every { mockInput.startDate } returns LocalDate.of(2024, 6, 1)
every { mockInput.endDate } returns LocalDate.of(2024, 6, 30)
every { mockInput.matchType } returns MatchType.OFFLINE
every { mockInput.count } returns 3
every { mockInput.timeRequired } returns TimeSlot.ABOUT_1H

val mockTargetGroupInfo = mockk<CreateExperimentPostUseCase.TargetGroupInfo>(relaxed = true)
every { mockTargetGroupInfo.startAge } returns 20
every { mockTargetGroupInfo.endAge } returns 30
every { mockTargetGroupInfo.genderType } returns GenderType.MALE
every { mockTargetGroupInfo.otherCondition } returns "Healthy"

every { mockInput.targetGroupInfo } returns mockTargetGroupInfo

val mockApplyMethodInfo = mockk<CreateExperimentPostUseCase.ApplyMethodInfo>(relaxed = true)
every { mockApplyMethodInfo.content } returns "이메일로 연락주세요"
every { mockApplyMethodInfo.formUrl } returns "https://test.com/apply"
every { mockApplyMethodInfo.phoneNum } returns "010-1234-5678"

every { mockInput.applyMethodInfo } returns mockApplyMethodInfo

val mockImageListInfo = mockk<CreateExperimentPostUseCase.ImageListInfo>(relaxed = true)
every { mockImageListInfo.images } returns listOf("https://test.com/image1.jpg", "https://test.com/image2.jpg")
every { mockInput.imageListInfo } returns mockImageListInfo

every { createExperimentPostUseCase.execute(any()) } returns mockk()

`when`("게시글을 생성하면") {
experimentPostService.createNewExperimentPost(mockInput)

then("캐시가 삭제되어야 한다") {
val cachedData = cacheGateway.get(cacheKey)
cachedData.shouldBeNull()
}
}
}
})
35 changes: 35 additions & 0 deletions src/test/kotlin/com/dobby/backend/config/RedisTestContainer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.dobby.backend.config

import io.kotest.core.listeners.TestListener
import io.kotest.core.spec.Spec
import org.testcontainers.containers.GenericContainer
import org.testcontainers.utility.DockerImageName

object RedisTestContainer : TestListener {
private val isCI = System.getenv("GITHUB_ACTIONS") == "true"

private val redis: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7.0.8-alpine")).apply {
withExposedPorts(6379)
withReuse(true)

if (!isCI) {
portBindings = listOf("6379:6379")
}
}

override suspend fun beforeSpec(spec: Spec) {
if (!redis.isRunning) redis.start()

val redisHost = "127.0.0.1"
val redisPort = redis.getMappedPort(6379).toString()

System.setProperty("spring.data.redis.host", redisHost)
System.setProperty("spring.data.redis.port", redisPort)
}

override suspend fun afterSpec(spec: Spec) {
if (!isCI) {
redis.stop()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import com.dobby.backend.infrastructure.config.properties.S3Properties
import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.*
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.net.URL
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull

@SpringBootTest
@ActiveProfiles("test")
class S3PreSignedUrlProviderTest : BehaviorSpec({

val amazonS3Client = mockk<AmazonS3>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
spring:
datasource:
url: jdbc:h2:~/dobby;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;
url: jdbc:h2:mem:dobby;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password: ''
jpa:
Expand All @@ -12,8 +13,7 @@ spring:
enabled: true
show_sql: false
format_sql: false
dialect: org.hibernate.dialect.MySQL8Dialect
database-platform: org.hibernate.dialect.MySQL8Dialect
dialect: org.hibernate.dialect.H2Dialect
security:
oauth2:
client:
Expand All @@ -34,7 +34,7 @@ spring:
enable: true
data:
redis:
host: localhost
host: 127.0.0.1
port: 6379

app:
Expand Down
Loading