diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index fa32a202..04fa5fdc 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -45,9 +45,9 @@ jobs: tags: ${{ secrets.DOCKER_HUB_REPOSITORY }}:prod deployment: - runs-on: ubuntu-latest - needs: [ build-docker-image-and-push ] - steps: + runs-on: ubuntu-latest + needs: [ build-docker-image-and-push ] + steps: - name: Deploy uses: appleboy/ssh-action@master with: @@ -57,6 +57,5 @@ jobs: script: | echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin cd api/bin/ - sudo docker compose down + sudo docker compose down --rmi all sudo docker compose up -d - sudo docker image prune -af diff --git a/.github/workflows/gradle-CI.yml b/.github/workflows/gradle-CI.yml index c997a05f..f035724f 100644 --- a/.github/workflows/gradle-CI.yml +++ b/.github/workflows/gradle-CI.yml @@ -2,8 +2,6 @@ name: CI With Pull Request on: push: - pull_request: - types: [opened, reopened] jobs: build: diff --git a/backend-submodule b/backend-submodule index 12a8c565..b5ec60b2 160000 --- a/backend-submodule +++ b/backend-submodule @@ -1 +1 @@ -Subproject commit 12a8c565130a65970918652f072a99e725d400df +Subproject commit b5ec60b26fa6290ba546f852da297abe0a892ad1 diff --git a/build.gradle.kts b/build.gradle.kts index 4a26d57e..5a7d6d0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,8 +29,11 @@ dependencies { implementation("com.linecorp.kotlin-jdsl:jpql-dsl:3.3.0") implementation("com.linecorp.kotlin-jdsl:jpql-render:3.3.0") + implementation("org.springframework.boot:spring-boot-starter-cache") + runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.h2database:h2") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.kotest:kotest-runner-junit5:5.4.2") testImplementation("io.kotest:kotest-assertions-core:5.4.2") diff --git a/src/main/kotlin/com/petqua/application/banner/BannerService.kt b/src/main/kotlin/com/petqua/application/banner/BannerService.kt new file mode 100644 index 00000000..4a969203 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/banner/BannerService.kt @@ -0,0 +1,21 @@ +package com.petqua.application.banner + +import com.petqua.application.banner.dto.BannerResponse +import com.petqua.domain.banner.BannerRepository +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional +@Service +class BannerService( + private val bannerRepository: BannerRepository, +) { + + @Cacheable("banners") + @Transactional(readOnly = true) + fun readAll(): List { + val banners = bannerRepository.findAll() + return banners.map { BannerResponse.from(it) } + } +} diff --git a/src/main/kotlin/com/petqua/application/banner/dto/BannerDtos.kt b/src/main/kotlin/com/petqua/application/banner/dto/BannerDtos.kt new file mode 100644 index 00000000..960d0647 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/banner/dto/BannerDtos.kt @@ -0,0 +1,19 @@ +package com.petqua.application.banner.dto + +import com.petqua.domain.banner.Banner + +data class BannerResponse( + val id: Long, + val imageUrl: String, + val linkUrl: String, +) { + companion object { + fun from(banner: Banner): BannerResponse { + return BannerResponse( + id = banner.id, + imageUrl = banner.imageUrl, + linkUrl = banner.linkUrl, + ) + } + } +} diff --git a/src/main/kotlin/com/petqua/common/cofig/CacheConfiguration.kt b/src/main/kotlin/com/petqua/common/cofig/CacheConfiguration.kt new file mode 100644 index 00000000..ae1bb709 --- /dev/null +++ b/src/main/kotlin/com/petqua/common/cofig/CacheConfiguration.kt @@ -0,0 +1,20 @@ +package com.petqua.common.cofig + +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.concurrent.ConcurrentMapCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@EnableCaching +@Configuration +class CacheConfiguration { + + @Bean + fun cacheManager(): CacheManager { + val cacheManager = ConcurrentMapCacheManager() + cacheManager.setCacheNames(listOf("banners")) + return cacheManager + } +} diff --git a/src/main/kotlin/com/petqua/domain/banner/Banner.kt b/src/main/kotlin/com/petqua/domain/banner/Banner.kt new file mode 100644 index 00000000..c83e6a42 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/banner/Banner.kt @@ -0,0 +1,20 @@ +package com.petqua.domain.banner + +import com.petqua.common.domain.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class Banner( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(nullable = false) + val imageUrl: String, + + @Column(nullable = false) + val linkUrl: String, +) : BaseEntity() diff --git a/src/main/kotlin/com/petqua/domain/banner/BannerRepository.kt b/src/main/kotlin/com/petqua/domain/banner/BannerRepository.kt new file mode 100644 index 00000000..2488bcc2 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/banner/BannerRepository.kt @@ -0,0 +1,6 @@ +package com.petqua.domain.banner + +import org.springframework.data.jpa.repository.JpaRepository + +interface BannerRepository: JpaRepository { +} diff --git a/src/main/kotlin/com/petqua/presentation/banner/BannerController.kt b/src/main/kotlin/com/petqua/presentation/banner/BannerController.kt new file mode 100644 index 00000000..c52c0cfa --- /dev/null +++ b/src/main/kotlin/com/petqua/presentation/banner/BannerController.kt @@ -0,0 +1,21 @@ +package com.petqua.presentation.banner + +import com.petqua.application.banner.BannerService +import com.petqua.application.banner.dto.BannerResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/banners") +@RestController +class BannerController( + private val bannerService: BannerService +) { + + @GetMapping + fun readAll(): ResponseEntity> { + val response = bannerService.readAll() + return ResponseEntity.ok(response) + } +} diff --git a/src/test/kotlin/com/petqua/application/banner/BannerServiceTest.kt b/src/test/kotlin/com/petqua/application/banner/BannerServiceTest.kt new file mode 100644 index 00000000..94bcbcfe --- /dev/null +++ b/src/test/kotlin/com/petqua/application/banner/BannerServiceTest.kt @@ -0,0 +1,44 @@ +package com.petqua.application.banner + +import com.petqua.domain.banner.Banner +import com.petqua.domain.banner.BannerRepository +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import org.mockito.Mockito.atMost +import org.mockito.Mockito.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import org.springframework.boot.test.mock.mockito.SpyBean + +@SpringBootTest(webEnvironment = NONE) +class BannerServiceTest( + private var bannerService: BannerService, + @SpyBean private var bannerRepository: BannerRepository, +) : BehaviorSpec({ + + Given("Banner 조회 테스트") { + bannerRepository.saveAll( + listOf( + Banner(imageUrl = "imageUrlA", linkUrl = "linkUrlA"), + Banner(imageUrl = "imageUrlB", linkUrl = "linkUrlB"), + Banner(imageUrl = "imageUrlC", linkUrl = "linkUrlC"), + ) + ) + + When("Banner를 전체 조회 하면") { + val results = bannerService.readAll() + + Then("모든 Banner가 조회 된다") { + results.size shouldBe 3 + } + } + + When("Banner가 캐싱 되어 있으면") { + repeat(5) { bannerService.readAll() } + + Then("퀴리가 발생 하지 않는다") { + verify(bannerRepository, atMost(1)).findAll() + } + } + } +}) diff --git a/src/test/kotlin/com/petqua/presentation/banner/BannerControllerTest.kt b/src/test/kotlin/com/petqua/presentation/banner/BannerControllerTest.kt new file mode 100644 index 00000000..7461cbf6 --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/banner/BannerControllerTest.kt @@ -0,0 +1,48 @@ +package com.petqua.presentation.banner + +import com.petqua.application.banner.dto.BannerResponse +import com.petqua.domain.banner.Banner +import com.petqua.domain.banner.BannerRepository +import com.petqua.test.ApiTestConfig +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.assertj.core.api.SoftAssertions.assertSoftly +import org.springframework.http.HttpStatus + +class BannerControllerTest( + private val bannerRepository: BannerRepository +) : ApiTestConfig() { + init { + Given("배너가 등록되어 있다.") { + val banner = bannerRepository.saveAll( + listOf( + Banner(imageUrl = "imageUrlC", linkUrl = "linkUrlA"), + Banner(imageUrl = "imageUrlB", linkUrl = "linkUrlB") + ) + ) + + When("배너 목록을 조회한다.") { + val response = Given { + log().all() + } When { + get("/banners") + } Then { + log().all() + } Extract { + response() + } + + Then("배너 목록을 응답한다.") { + val findBannerResponse = response.`as`(Array::class.java) + + assertSoftly { + it.assertThat(response.statusCode).isEqualTo(HttpStatus.OK.value()) + it.assertThat(findBannerResponse.size).isEqualTo(2) + } + } + } + } + } +} diff --git a/src/test/kotlin/com/petqua/test/ApiTestConfig.kt b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt new file mode 100644 index 00000000..13ca24a2 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt @@ -0,0 +1,24 @@ +package com.petqua.test + +import io.kotest.core.spec.style.BehaviorSpec +import io.restassured.RestAssured +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.boot.test.web.server.LocalServerPort + +@SpringBootTest(webEnvironment = RANDOM_PORT) +abstract class ApiTestConfig : BehaviorSpec() { + + @LocalServerPort + protected val port: Int = RestAssured.port + + @Autowired + private lateinit var dataCleaner: DataCleaner + + init { + afterContainer { + dataCleaner.clean() + } + } +}