feat: 지역 코드 조회 API 및 테스트 추가

This commit is contained in:
이상진 2025-09-13 21:09:40 +09:00
parent 5658f6c31f
commit 7db389ae49
8 changed files with 413 additions and 1 deletions

View File

@ -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<Pair<String, String>> = 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<Pair<String, String>> = 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<Pair<String, String>> = 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)
}
}
}

View File

@ -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<CommonApiResponse<RegionCodeResponse>>
@Public
@Operation(summary = "모든 시 / 도 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun readAllSido(): ResponseEntity<CommonApiResponse<SidoListResponse>>
@Public
@Operation(summary = "모든 시 / 군 / 구 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAllSigunguBySido(
@RequestParam(required = true) sidoCode: String
): ResponseEntity<CommonApiResponse<SigunguListResponse>>
@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<CommonApiResponse<DongListResponse>>
}

View File

@ -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", "행정동을 찾을 수 없어요."),
}

View File

@ -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<RegionEntity, String>
interface RegionRepository : JpaRepository<RegionEntity, String> {
@Query("""
SELECT
new kotlin.Pair(r.sidoCode, r.sidoName)
FROM
RegionEntity r
GROUP BY
r.sidoCode
ORDER BY
r.sidoName
""")
fun readAllSido(): List<Pair<String, String>>
@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<Pair<String, String>>
@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<Pair<String, String>>
@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?
}

View File

@ -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<CommonApiResponse<RegionCodeResponse>> {
val response = regionService.findRegionCode(sidoCode, sigunguCode, dongCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/sido")
override fun readAllSido(): ResponseEntity<CommonApiResponse<SidoListResponse>> {
val response = regionService.readAllSido()
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/sigungu")
override fun findAllSigunguBySido(
@RequestParam(required = true) sidoCode: String
): ResponseEntity<CommonApiResponse<SigunguListResponse>> {
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<CommonApiResponse<DongListResponse>> {
val response = regionService.findDongBySidoAndSigungu(sidoCode, sigunguCode)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -0,0 +1,32 @@
package roomescape.region.infrastructure.web
data class SidoResponse(
val code: String,
val name: String,
)
data class SidoListResponse(
val sidoList: List<SidoResponse>
)
data class SigunguResponse(
val code: String,
val name: String,
)
data class SigunguListResponse(
val sigunguList: List<SigunguResponse>
)
data class DongResponse(
val code: String,
val name: String,
)
data class DongListResponse(
val dongList: List<DongResponse>
)
data class RegionCodeResponse(
val code: String
)

View File

@ -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,
)
}
}
}
}

View File

@ -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"
}
}
}
}