1. API 스펙 설계, API-암복호화 개발
- 유저의 정보들을 받아서 심사를 요청하는 API
- 해당 유저의 심사 요청 결과를 조회하는 API
API 스펙 설계
GenerateKey
UUID에서 -를 제외한 유저 키 생성
UUID는 중복될 가능성이 낮기 때문에, 유저 키로 사용하기에 적합하다.
package happyprogfrog.api.loan
import org.springframework.stereotype.Component
import java.util.*
@Component
class GenerateKey {
fun generateUserKey() = UUID.randomUUID().toString().replace("-", "")
}
확장 가능성이 있는 Service, Controller는 인터페이스를 작성해 주는 것이 좋다.
UserInfoDto
package happyprogfrog.api.loan.request
import happyprogfrog.domain.domain.UserInfo
data class UserInfoDto (
val userKey: String,
val userName: String,
val userRegistrationNumber: String,
val userIncomeAmount: Long
){
fun toEntity(): UserInfo = UserInfo(userKey, userRegistrationNumber, userName, userIncomeAmount)
}
LoanRequestDto
package happyprogfrog.api.loan.request
class LoanRequestDto {
data class LoanRequestInputDto(
val userName: String,
val userIncomeAmount: Long,
var userRegistrationNumber: String
) {
fun toUserInfoDto(userKey: String) =
UserInfoDto(
userKey, userName, userRegistrationNumber, userIncomeAmount
)
}
data class LoanRequestResponseDto(
val userKey: String,
)
}
LoanRequestService
package happyprogfrog.api.loan.request
import happyprogfrog.domain.domain.UserInfo
interface LoanRequestService {
fun loanRequestMain(
loanRequestInputDto: LoanRequestDto.LoanRequestInputDto
): LoanRequestDto.LoanRequestResponseDto
fun saveUserInfo(userInfoDto: UserInfoDto): UserInfo
fun loanRequestReview(userKey: String)
}
LoanRequestServiceImpl
package happyprogfrog.api.loan.request
import happyprogfrog.api.loan.GenerateKey
import happyprogfrog.api.loan.encrypt.EncryptComponent
import happyprogfrog.domain.repository.UserInfoRepository
import org.springframework.stereotype.Service
@Service
class LoanRequestServiceImpl(
private val generateKey: GenerateKey,
private val userInfoRepository: UserInfoRepository,
private val encryptComponent: EncryptComponent
): LoanRequestService {
/**
* 대출 심사 요청
*/
override fun loanRequestMain(
loanRequestInputDto: LoanRequestDto.LoanRequestInputDto
): LoanRequestDto.LoanRequestResponseDto {
// 유저 키 생성
val userKey = generateKey.generateUserKey()
// 주민 번호 암호화
loanRequestInputDto.userRegistrationNumber =
encryptComponent.encryptString(loanRequestInputDto.userRegistrationNumber)
// 유저 정보 저장
saveUserInfo(loanRequestInputDto.toUserInfoDto(userKey))
// 카프카를 통해서 유저 심사 요청
loanRequestReview(userKey)
return LoanRequestDto.LoanRequestResponseDto(userKey)
}
/**
* 유저 정보 저장
*/
override fun saveUserInfo(userInfoDto: UserInfoDto) = userInfoRepository.save(userInfoDto.toEntity())
/**
* 카프카를 통해서 유저 심사 요청
*/
override fun loanRequestReview(userKey: String) {
// TODO
}
}
LoanRequestController
package happyprogfrog.api.loan.request
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/fintech/api/v1")
class LoanRequestController(
private val loanRequestServiceImpl: LoanRequestServiceImpl
) {
@PostMapping("/request")
fun loanRequest(
@RequestBody loanRequestInputDto: LoanRequestDto.LoanRequestInputDto
): ResponseEntity<LoanRequestDto.LoanRequestResponseDto> {
return ResponseEntity.ok(loanRequestServiceImpl.loanRequestMain(loanRequestInputDto))
}
}
API-암복호화 개발
package happyprogfrog.api.loan.encrypt
import org.springframework.stereotype.Component
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
// 참고) 애노테이션으로 만들면 편하다!
@Component
class EncryptComponent {
companion object {
private const val SECRET_KEY = "12345678901234561234567890123456"
}
private val encoder = Base64.getEncoder();
private val decoder = Base64.getDecoder();
fun encryptString(encryptString: String): String {
// 암호화할 String 을 받아서, ByteArray 로 변환을 하고, 암호화를 한 다음에
val encryptedString = cipherPkcs5(Cipher.ENCRYPT_MODE, SECRET_KEY).doFinal(encryptString.toByteArray(Charsets.UTF_8))
// 인코딩을 해서 리턴
return String(encoder.encode(encryptedString))
}
fun decryptString(decryptString: String): String {
// 복호화는 위와 반대
// 받은 String 을 디코딩을 먼저해준 다음에
val byteString = decoder.decode(decryptString.toByteArray(Charsets.UTF_8))
// 복호화를 한 후에 리턴
return String(cipherPkcs5(Cipher.DECRYPT_MODE, SECRET_KEY).doFinal(byteString))
}
fun cipherPkcs5(opMode: Int, secretKey: String): Cipher {
val c = Cipher.getInstance("AES/CBC/PKCS5Padding")
val sk = SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "AES")
val iv = IvParameterSpec(secretKey.substring(0, 16).toByteArray(Charsets.UTF_8))
c.init(opMode, sk, iv)
return c
}
}
- 이 코드는 문자열을 AES 알고리즘을 사용하여 암호화하고 복호화하는 기능을 수행한다.
- encryptString()
- 주어진 문자열을 AES 알고리즘을 사용하여 암호화하고, 결과를 Base64로 인코딩
- decryptString()
- 암호화된 문자열을 복호화하고 원래 문자열을 반환
2. Swagger 적용
문서를 따로 작성하지 않아도 API 스펙 문서를 생성할 수 있다.
build.gradle.kts에 의존성 추가
implementation("io.springfox:springfox-boot-starter:3.0.0")
SwaggerConfig 생성
package happyprogfrog.api.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import springfox.documentation.builders.ApiInfoBuilder
import springfox.documentation.builders.PathSelectors
import springfox.documentation.builders.RequestHandlerSelectors
import springfox.documentation.service.ApiInfo
import springfox.documentation.spi.DocumentationType
import springfox.documentation.spring.web.plugins.Docket
@Configuration
class SwaggerConfig {
// http://localhost:8080/swagger-ui/index.html
@Bean
fun api(): Docket {
return Docket(DocumentationType.OAS_30)
.useDefaultResponseMessages(false)
.select()
.apis(RequestHandlerSelectors.basePackage("happyprogfrog.api"))
.paths(PathSelectors.any())
.build()
.apiInfo(apiInfo())
}
private fun apiInfo(): ApiInfo {
return ApiInfoBuilder()
.title("Swagger")
.description("fintect")
.version("1.0")
.build()
}
}
스프링 2.6부터 컨트롤러 패스 매칭을 하는 전략이 바뀌었기 때문에, application.yml에 별도로 명시
spring:
profiles:
include:
- domain
mvc:
pathmatch:
matching-strategy: ant_path_matcher
3. API 개발
심사 결과를 받는 API를 개발
LoanReviewDto
package happyprogfrog.api.loan.review
class LoanReviewDto {
data class LoanReviewResponseDto(
val userKey: String,
val loanResult: LoanResult
)
data class LoanResult(
val userLimitAmount: Long,
val userLoanInterest: Double
)
data class LoanReview(
val userKey: String,
val userLimitAmount: Long,
val userLoanInterest: Double
)
}
LoanReviewRepository
package happyprogfrog.domain.repository
import happyprogfrog.domain.domain.LoanReview
import org.springframework.data.jpa.repository.JpaRepository
interface LoanReviewRepository : JpaRepository<LoanReview, Long> {
fun findByUserKey(userKey: String): LoanReview
}
LoanReviewService
package happyprogfrog.api.loan.review
import happyprogfrog.domain.domain.LoanReview
interface LoanReviewService {
fun loanReviewMain(userKey: String): LoanReviewDto.LoanReviewResponseDto
fun getLoanResult(userKey: String): LoanReviewDto.LoanReview?
}
LoanReviewServiceImpl
package happyprogfrog.api.loan.review
import happyprogfrog.domain.repository.LoanReviewRepository
import org.springframework.stereotype.Service
@Service
class LoanReviewServiceImpl(
private val loanReviewRepository: LoanReviewRepository
): LoanReviewService {
override fun loanReviewMain(userKey: String): LoanReviewDto.LoanReviewResponseDto {
val loanResult = getLoanResult(userKey);
return LoanReviewDto.LoanReviewResponseDto(
userKey = userKey,
loanResult = LoanReviewDto.LoanResult(
userLimitAmount = loanResult.userLimitAmount,
userLoanInterest = loanResult.userLoanInterest
)
)
}
override fun getLoanResult(userKey: String) : LoanReviewDto.LoanReview {
val loanReview = loanReviewRepository.findByUserKey(userKey);
return LoanReviewDto.LoanReview(
loanReview.userKey,
loanReview.loanLimitedAmount,
loanReview.loanInterest
);
}
}
LoanReviewController
package happyprogfrog.api.loan.review
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/fintech/api/v1")
class LoanReviewController(
private val loanReviewServiceImpl: LoanReviewServiceImpl
){
@GetMapping("/review/{userKey}")
fun getReviewData(
@PathVariable("userKey") userKey: String
): ResponseEntity<LoanReviewDto.LoanReviewResponseDto>{
return ResponseEntity.ok(loanReviewServiceImpl.loanReviewMain(userKey))
}
}
4. 테스트 코드 작성
Test
- Unit Test
- 단위 테스트
- 기능의 최소 단위(보통 메서드)로 테스트
- 필요한 빈만 띄워서 검증
- 하나의 클래스에서 기능 최소화(단일 책임)인지도 확인 가능
- 너무 많은 DI가 걸려있으면 문제가 있다고 인지할 수 있음
- 결합도 최소화
- DI를 할 때 생성자를 사용하는데, 생성자를 사용하면 단위 테스트할 때도 좋다!
- 필드 주입은 외부에서 DI를 해줄 수 없고, 따라서 클래스 간 결합도가 높아진다.
- Integration Test
- 통합 테스트
- 환경 요인을 테스트할 수 있다(DB 커넥션, 외부 모듈이 정상적으로 동작하는지)
- Acceptance Test
- 기능 테스트
- 시나리오를 기반으로 애플리케이션의 기능을 테스트
build.gradle.kts
// test
testImplementation("io.mockk:mockk:1.12.0")
runtimeOnly("com.h2database:h2")
- mock 라이브러리는 mockk을 사용
- 테스트 환경에서는 MySQL 대신 h2를 사용하기 위해 의존성 추가
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.+")
- 코틀린 모듈이 있기 때문에 기본 생성자가 없어도 매퍼가 시리얼라이즈/디시리얼라이즈를 해준다.
테스트용 application.yml 별도로 생성
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:~/test
username: fintech
password: fintech
LoanRequestControllerTest 생성
package happyprogfrog.api.loan.request
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import happyprogfrog.api.loan.GenerateKey
import happyprogfrog.api.loan.encrypt.EncryptComponent
import happyprogfrog.domain.domain.UserInfo
import happyprogfrog.domain.repository.UserInfoRepository
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
@WebMvcTest(LoanRequestController::class)
class LoanRequestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var loanRequestController: LoanRequestController
private lateinit var generateKey: GenerateKey
private lateinit var encryptComponent: EncryptComponent
private val userInfoRepository: UserInfoRepository = mockk()
private lateinit var mapper: ObjectMapper
@MockBean
private lateinit var loanRequestServiceImpl: LoanRequestServiceImpl
companion object {
private const val BASE_URL = "/fintech/api/v1"
}
@BeforeEach
fun init() {
generateKey = GenerateKey()
encryptComponent = EncryptComponent()
loanRequestServiceImpl = LoanRequestServiceImpl(
generateKey, userInfoRepository, encryptComponent
)
loanRequestController = LoanRequestController(loanRequestServiceImpl)
mockMvc = MockMvcBuilders.standaloneSetup(loanRequestController).build()
mapper = ObjectMapper().registerModule(KotlinModule.Builder().build()) // 모듈이 있으므로 기본 생성자가 없어도 매퍼가 시리얼라이즈/디시리얼라이즈를 해준다
}
@Test
@DisplayName("유저 요청이 들어오면 정상 응답을 주어야 한다.")
fun testNormalCase() {
// given
val loanRequestInfoDto: LoanRequestDto.LoanRequestInputDto =
LoanRequestDto.LoanRequestInputDto(
userName = "TEST",
userIncomeAmount = 10000,
userRegistrationNumber = "000101-1234567"
)
every { userInfoRepository.save(any()) } returns UserInfo("", "", "", 1)
// when
// then
mockMvc.post(
"$BASE_URL/request"
) {
contentType = MediaType.APPLICATION_JSON
accept = MediaType.APPLICATION_JSON
content = mapper.writeValueAsString(loanRequestInfoDto)
}.andExpect {
status { isOk() }
}
}
}
JPA 관련된 부분은 별도의 클래스에 따로 적용
main에 다 걸려있는 것 보다, 이렇게 쪼개야 테스트할 때도 문제가 없고 관리가 쉽다.
package happyprogfrog.api.config
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = ["happyprogfrog.domain"])
class JpaAuditingConfiguration
5. Log Aspect 구현
AOP를 통해 로그 처리하기
프로세스가 진행되는 단계단계에 조인 포인트가 있고, AOP에서 어떤 조인 포인트를 가지고 와서 조인 포인트에서 어떤 행동을 하고 다음 조인 포인트로 넘어가게끔 할 수 있다. 이를 이용해서 로그를 출력해 보자!
build.gradle.kts에 의존성 추가
// AOP
implementation("org.springframework.boot:spring-boot-starter-aop")
// Logging
implementation("io.github.microutils:kotlin-logging-jvm:3.0.4")
- kotlin-logging: 로그를 찍을 때 지연 연산이 가능하다. 코틀린 진영에서 많이 사용된다고!
LogAspect
package happyprogfrog.api.aop
import mu.KotlinLogging
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
import org.springframework.stereotype.Component
import org.springframework.util.StopWatch
@Aspect
@Component
class LogAspect {
val logger = KotlinLogging.logger { }
@Pointcut("within(happyprogfrog.api..*)")
private fun isApi() {}
@Around("isApi()")
fun loggingAspect(joinPoint: ProceedingJoinPoint): Any {
val stopWatch = StopWatch();
stopWatch.start()
val result = joinPoint.proceed()
stopWatch.stop()
logger.info { "${joinPoint.signature.name} ${joinPoint.args[0]} ${stopWatch.lastTaskTimeMillis}" }
return result
}
}
- 조인 포인트를 가져오는 부분 + 조인 포인트를 가져와 어떤 행동을 할 것인지(ex. 로그 출력)
- @Pointcut
- 특정 시점 가져오기
- 애노테이션 작성 시 해당 애노테이션 호출 시 조인 포인트 가져오기
- 패키지 호출 시 조인 포인트 가져오기
- 메서드 실행 시 조인 포인트 가져오기
- @Pointcut("within(happyprogfrog.api..*)")
- api 패키지와 그 하위 패키지에 속하는 모든 클래스 메서드가 대상
- 특정 시점 가져오기
- @Around("isApi()")
- @Pointcut에 정의된 조인포인트에서 메서드 실행 전후에 이 애드바이스가 실행됨을 의미
6. 예외처리(Controller Advice)
서비스 코드 내에서 try ~ catch 구문 사용 시에 처리해야 할 서비스와 예외가 너무 많기 때문에 복잡해진다. 서비스 로직에 집중할 수 없음...Controller Advice와 Exception Handler에 대해 알아보자!
- @ControllerAdvice: 전역적으로 Controller나 RestController에 공통적인 예외처리에 필요한 애노테이션
- @ExceptionHandler: 같은 클래스 내에서 특정 에러에 대해 처리하는 애노테이션
예를 들어, 심사 결과가 없는 유저가 심사 결과를 요청한다면...?
구현해 보자!
CustomErrorCode
어떤 http status 응답을 줄 것인지 + 에러 코드 별로 다른 처리(에러 페이지를 다른 걸 보여주거나 유저 노티를 다르게 등) + 에러 메시지
package happyprogfrog.api.exception
import org.springframework.http.HttpStatus
enum class CustomErrorCode (
val statusCode: HttpStatus,
val errorCode: String,
val errorMessage: String
) {
RESULT_NOT_FOUND(HttpStatus.BAD_REQUEST, "E001", errorMessage = "result not found")
}
CustomException
특정 예외를 발생시켜 일반적인 RuntimeException과 구분시켜 준다.
package happyprogfrog.api.exception
class CustomException(val customErrorCode: CustomErrorCode): RuntimeException()
ErrorResponse
클라이언트에게 어떤 응답으로 내려줄 것인지 정의해 준다.
package happyprogfrog.api.exception
import org.springframework.http.ResponseEntity
import java.time.LocalDateTime
class ErrorResponse(
private val customException: CustomException
) {
fun toResponseEntity(): ResponseEntity<ErrorResponseDto> {
return ResponseEntity.status(customException.customErrorCode.statusCode)
.body(
ErrorResponseDto(
errorCode = customException.customErrorCode.errorCode,
errorMessage = customException.customErrorCode.errorMessage
)
)
}
data class ErrorResponseDto(
val errorCode: String,
val errorMessage: String
) {
val timeStamp = LocalDateTime.now()
}
}
기존 코드들 수정
package happyprogfrog.domain.repository
import happyprogfrog.domain.domain.LoanReview
import org.springframework.data.jpa.repository.JpaRepository
interface LoanReviewRepository : JpaRepository<LoanReview, Long> {
fun findByUserKey(userKey: String): LoanReview?
}
package happyprogfrog.api.loan.review
import happyprogfrog.domain.domain.LoanReview
interface LoanReviewService {
fun loanReviewMain(userKey: String): LoanReviewDto.LoanReviewResponseDto
fun getLoanResult(userKey: String): LoanReview?
}
package happyprogfrog.api.loan.review
import happyprogfrog.api.exception.CustomErrorCode
import happyprogfrog.api.exception.CustomException
import happyprogfrog.domain.domain.LoanReview
import happyprogfrog.domain.repository.LoanReviewRepository
import org.springframework.stereotype.Service
@Service
class LoanReviewServiceImpl(
private val loanReviewRepository: LoanReviewRepository
): LoanReviewService {
override fun loanReviewMain(userKey: String): LoanReviewDto.LoanReviewResponseDto {
return LoanReviewDto.LoanReviewResponseDto(
userKey = userKey,
loanResult = getLoanResult(userKey)?.toResponseDto()
?: throw CustomException(CustomErrorCode.RESULT_NOT_FOUND)
)
}
override fun getLoanResult(userKey: String) =
loanReviewRepository.findByUserKey(userKey)
private fun LoanReview.toResponseDto() =
LoanReviewDto.LoanResult(
userLimitAmount = this.loanLimitedAmount,
userLoanInterest = this.loanInterest
)
}
LoanReviewControllerAdvice
서비스 밖에서 공통적으로 예외처리를 했다는 점이 중요! 관리 포인트도 줄어들고 유지보수도 수월
package happyprogfrog.api.loan.review
import happyprogfrog.api.exception.CustomException
import happyprogfrog.api.exception.ErrorResponse
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice(basePackageClasses = [LoanReviewController::class])
class LoanReviewControllerAdvice {
@ExceptionHandler(CustomException::class)
fun customExceptionHandler(customException: CustomException) =
ErrorResponse(customException).toResponseEntity()
}