From 7db389ae49e58d5b09fa9671bb73bb457e331427 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 21:09:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../region/business/RegionService.kt | 75 +++++++++++++++++++ .../roomescape/region/docs/RegionAPI.kt | 45 +++++++++++ .../region/exception/RegionException.kt | 21 ++++++ .../persistence/RegionRepository.kt | 65 +++++++++++++++- .../infrastructure/web/RegionController.kt | 53 +++++++++++++ .../region/infrastructure/web/RegionDTO.kt | 32 ++++++++ .../roomescape/region/RegionApiFailTest.kt | 65 ++++++++++++++++ .../roomescape/region/RegionApiSuccessTest.kt | 58 ++++++++++++++ 8 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/roomescape/region/business/RegionService.kt create mode 100644 src/main/kotlin/roomescape/region/docs/RegionAPI.kt create mode 100644 src/main/kotlin/roomescape/region/exception/RegionException.kt create mode 100644 src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt create mode 100644 src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt create mode 100644 src/test/kotlin/roomescape/region/RegionApiFailTest.kt create mode 100644 src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt diff --git a/src/main/kotlin/roomescape/region/business/RegionService.kt b/src/main/kotlin/roomescape/region/business/RegionService.kt new file mode 100644 index 00000000..bb2a994c --- /dev/null +++ b/src/main/kotlin/roomescape/region/business/RegionService.kt @@ -0,0 +1,75 @@ +package roomescape.region.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.region.exception.RegionErrorCode +import roomescape.region.exception.RegionException +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.region.infrastructure.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class RegionService( + private val regionRepository: RegionRepository +) { + @Transactional(readOnly = true) + fun readAllSido(): SidoListResponse { + log.info { "[RegionService.readAllSido] 모든 시/도 조회 시작" } + val result: List> = regionRepository.readAllSido() + + if (result.isEmpty()) { + log.warn { "[RegionService.readAllSido] 시/도 조회 실패" } + throw RegionException(RegionErrorCode.SIDO_CODE_NOT_FOUND) + } + + return SidoListResponse(result.map { SidoResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.readAllSido] ${it.sidoList.size}개의 시/도 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findSigunguBySido(sidoCode: String): SigunguListResponse { + log.info { "[RegionService.findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" } + val result: List> = regionRepository.findAllSigunguBySido(sidoCode) + + if (result.isEmpty()) { + log.warn { "[RegionService.findSigunguBySido] 시/군/구 조회 실패: sidoCode=${sidoCode}" } + throw RegionException(RegionErrorCode.SIGUNGU_CODE_NOT_FOUND) + } + + return SigunguListResponse(result.map { SigunguResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.findSigunguBySido] sidoCode=${sidoCode}인 ${it.sigunguList.size}개의 시/군/구 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findDongBySidoAndSigungu(sidoCode: String, sigunguCode: String): DongListResponse { + log.info { "[RegionService.findDongBySidoAndSigungu] 행정동 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } + val result: List> = regionRepository.findAllDongBySidoAndSigungu(sidoCode, sigunguCode) + + if (result.isEmpty()) { + log.warn { "[RegionService.findDongBySidoAndSigungu] 행정동 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } + throw RegionException(RegionErrorCode.DONG_CODE_NOT_FOUND) + } + + return DongListResponse(result.map { DongResponse(code = it.first, name = it.second) }).also { + log.info { "[RegionService.findDongBySidoAndSigungu] sidoCode=${sidoCode}, sigunguCode=${sigunguCode}인 ${it.dongList.size}개의 행정동 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findRegionCode(sidoCode: String, sigunguCode: String, dongCode: String): RegionCodeResponse { + log.info { "[RegionService.findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + + return regionRepository.findRegionCode(sidoCode, sigunguCode, dongCode)?.let { + log.info { "[RegionService.findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + RegionCodeResponse(it) + } ?: run { + log.warn { "[RegionService.findRegionCode] 지역 코드 조회 실패: sidoCode=${sidoCode} / sigunguCode=${sigunguCode} / dongCode=${dongCode}" } + throw RegionException(RegionErrorCode.REGION_CODE_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/region/docs/RegionAPI.kt b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt new file mode 100644 index 00000000..c1076376 --- /dev/null +++ b/src/main/kotlin/roomescape/region/docs/RegionAPI.kt @@ -0,0 +1,45 @@ +package roomescape.region.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestParam +import roomescape.auth.web.support.Public +import roomescape.common.dto.response.CommonApiResponse +import roomescape.region.infrastructure.web.DongListResponse +import roomescape.region.infrastructure.web.RegionCodeResponse +import roomescape.region.infrastructure.web.SidoListResponse +import roomescape.region.infrastructure.web.SigunguListResponse + +interface RegionAPI { + + @Public + @Operation(summary = "지역 코드 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findRegionCode( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String, + @RequestParam(name = "dongCode", required = true) dongCode: String, + ): ResponseEntity> + + @Public + @Operation(summary = "모든 시 / 도 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun readAllSido(): ResponseEntity> + + @Public + @Operation(summary = "모든 시 / 군 / 구 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findAllSigunguBySido( + @RequestParam(required = true) sidoCode: String + ): ResponseEntity> + + @Public + @Operation(summary = "모든 행정동 목록 조회") + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findAllDongBySigungu( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/region/exception/RegionException.kt b/src/main/kotlin/roomescape/region/exception/RegionException.kt new file mode 100644 index 00000000..9bee2e9a --- /dev/null +++ b/src/main/kotlin/roomescape/region/exception/RegionException.kt @@ -0,0 +1,21 @@ +package roomescape.region.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class RegionException( + override val errorCode: RegionErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class RegionErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + REGION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "지역 코드를 찾을 수 없어요."), + SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R002", "시/도 를 찾을 수 없어요."), + SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R003", "시/군/구 를 찾을 수 없어요."), + DONG_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "R004", "행정동을 찾을 수 없어요."), +} \ No newline at end of file diff --git a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt index d8eb4988..63043239 100644 --- a/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt +++ b/src/main/kotlin/roomescape/region/infrastructure/persistence/RegionRepository.kt @@ -1,5 +1,68 @@ package roomescape.region.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param -interface RegionRepository : JpaRepository \ No newline at end of file +interface RegionRepository : JpaRepository { + + @Query(""" + SELECT + new kotlin.Pair(r.sidoCode, r.sidoName) + FROM + RegionEntity r + GROUP BY + r.sidoCode + ORDER BY + r.sidoName + """) + fun readAllSido(): List> + + @Query(""" + SELECT + new kotlin.Pair(r.sigunguCode, r.sigunguName) + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + GROUP BY + r.sigunguCode + ORDER BY + r.sigunguName + """) + fun findAllSigunguBySido( + @Param("sidoCode") sidoCode: String + ): List> + + @Query(""" + SELECT + new kotlin.Pair(r.dongCode, r.dongName) + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + AND r.sigunguCode = :sigunguCode + ORDER BY + r.dongName + """) + fun findAllDongBySidoAndSigungu( + @Param("sidoCode") sidoCode: String, + @Param("sigunguCode") sigunguCode: String + ): List> + + @Query(""" + SELECT + r.code + FROM + RegionEntity r + WHERE + r.sidoCode = :sidoCode + AND r.sigunguCode = :sigunguCode + AND r.dongCode = :dongCode + """) + fun findRegionCode( + @Param("sidoCode") sidoCode: String, + @Param("sigunguCode") sigunguCode: String, + @Param("dongCode") dongCode: String, + ): String? +} diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt b/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt new file mode 100644 index 00000000..13e034a0 --- /dev/null +++ b/src/main/kotlin/roomescape/region/infrastructure/web/RegionController.kt @@ -0,0 +1,53 @@ +package roomescape.region.infrastructure.web + +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.region.business.RegionService +import roomescape.region.docs.RegionAPI + +@RestController +@RequestMapping("/regions") +class RegionController( + private val regionService: RegionService +) : RegionAPI { + @GetMapping("/code") + override fun findRegionCode( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String, + @RequestParam(name = "dongCode", required = true) dongCode: String, + ): ResponseEntity> { + val response = regionService.findRegionCode(sidoCode, sigunguCode, dongCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/sido") + override fun readAllSido(): ResponseEntity> { + val response = regionService.readAllSido() + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/sigungu") + override fun findAllSigunguBySido( + @RequestParam(required = true) sidoCode: String + ): ResponseEntity> { + val response = regionService.findSigunguBySido(sidoCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/dong") + override fun findAllDongBySigungu( + @RequestParam(name = "sidoCode", required = true) sidoCode: String, + @RequestParam(name = "sigunguCode", required = true) sigunguCode: String + ): ResponseEntity> { + val response = regionService.findDongBySidoAndSigungu(sidoCode, sigunguCode) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt b/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt new file mode 100644 index 00000000..ecbb8ec8 --- /dev/null +++ b/src/main/kotlin/roomescape/region/infrastructure/web/RegionDTO.kt @@ -0,0 +1,32 @@ +package roomescape.region.infrastructure.web + +data class SidoResponse( + val code: String, + val name: String, +) + +data class SidoListResponse( + val sidoList: List +) + +data class SigunguResponse( + val code: String, + val name: String, +) + +data class SigunguListResponse( + val sigunguList: List +) + +data class DongResponse( + val code: String, + val name: String, +) + +data class DongListResponse( + val dongList: List +) + +data class RegionCodeResponse( + val code: String +) diff --git a/src/test/kotlin/roomescape/region/RegionApiFailTest.kt b/src/test/kotlin/roomescape/region/RegionApiFailTest.kt new file mode 100644 index 00000000..eb84db13 --- /dev/null +++ b/src/test/kotlin/roomescape/region/RegionApiFailTest.kt @@ -0,0 +1,65 @@ +package roomescape.region + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.springframework.http.HttpMethod +import roomescape.region.exception.RegionErrorCode +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.runExceptionTest + +class RegionApiFailTest( + @MockkBean private val regionRepository: RegionRepository +): FunSpecSpringbootTest() { + init { + context("조회 실패") { + test("시/도") { + every { + regionRepository.readAllSido() + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/sido", + expectedErrorCode = RegionErrorCode.SIDO_CODE_NOT_FOUND, + ) + } + + test("시/군/구") { + every { + regionRepository.findAllSigunguBySido(any()) + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/sigungu?sidoCode=11", + expectedErrorCode = RegionErrorCode.SIGUNGU_CODE_NOT_FOUND, + ) + } + + test("행정동") { + every { + regionRepository.findAllDongBySidoAndSigungu(any(), any()) + } returns emptyList() + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/dong?sidoCode=11&sigunguCode=110", + expectedErrorCode = RegionErrorCode.DONG_CODE_NOT_FOUND, + ) + } + + test("지역 코드") { + every { + regionRepository.findRegionCode(any(), any(), any()) + } returns null + + runExceptionTest( + method = HttpMethod.GET, + endpoint = "/regions/code?sidoCode=11&sigunguCode=110&dongCode=10100", + expectedErrorCode = RegionErrorCode.REGION_CODE_NOT_FOUND, + ) + } + } + } +} diff --git a/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt b/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt new file mode 100644 index 00000000..6e4156bd --- /dev/null +++ b/src/test/kotlin/roomescape/region/RegionApiSuccessTest.kt @@ -0,0 +1,58 @@ +package roomescape.region + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import roomescape.region.exception.RegionErrorCode +import roomescape.region.infrastructure.persistence.RegionRepository +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.runExceptionTest +import roomescape.supports.runTest + +class RegionApiSuccessTest: FunSpecSpringbootTest() { + init { + context("시/도 -> 시/군/구 -> 행정동 -> 지역 코드 순으로 조회한다.") { + test("정상 응답") { + val sidoCode: String = runTest( + on = { + get("/regions/sido") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.sidoList[0].code") + + val sigunguCode: String = runTest( + on = { + get("/regions/sigungu?sidoCode=$sidoCode") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.sigunguList[0].code") + + val dongCode: String = runTest( + on = { + get("/regions/dong?sidoCode=$sidoCode&sigunguCode=$sigunguCode") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.dongList[0].code") + + val regionCode: String = runTest( + on = { + get("/regions/code?sidoCode=$sidoCode&sigunguCode=$sigunguCode&dongCode=${dongCode}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).extract().path("data.code") + + regionCode shouldBe "$sidoCode$sigunguCode$dongCode" + } + } + } +}