일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 스프링
- JIRA
- Spring
- 리프레시토큰
- springdataredis
- 국제화
- 티스토리챌린지
- 메시지
- 토이프로젝트
- AWS
- 도커
- 스프링부트
- 액세스토큰
- 오블완
- githubactions
- 소셜로그인
- yaml-resource-bundle
- springsecurity
- 트랜잭션
- springsecurityoauth2client
- oauth2
- 파이썬
- 백준
- 프로그래머스
- 데이터베이스
- docker
- java
- CI/CD
- 스프링시큐리티
- 재갱신
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 12. 이메일/사용자 아이디/닉네임 사용가능 여부 확인 API 구현 본문
[토이프로젝트] 게시판 시스템(board-system) 12. 이메일/사용자 아이디/닉네임 사용가능 여부 확인 API 구현
ttasjwi 2024. 10. 31. 11:35
이번 글에서는 이메일 사용가능 여부, 사용자아이디(username) 사용가능 여부, 닉네임 사용 가능 여부 API를 하나씩 구현해보겠습니다.
다만 데이터베이스와 접근해서 실제 데이터를 저장하고 조회하는 부분은 이번 글에서 다루지 않고 이후 글에서 다루겠습니다.
1. 표현계층 구현
표현계층부터 구현해보겠습니다.
EmailAvailableController 관점에서 생각해보겠습니다.
EmailAvailableController는 EmailAvailableUseCase 를 통해 이메일 유효성 확인을 위임하고 그 결과를 받아와서, Api 응답을 작성합니다. 이 과정에서는 MessageResolver 를 사용할겁니다.
그런데 이 컨트롤러 구현 관점에서는 실제 빈으로 등록한 구현체가 어떤지는 생각할 필요가 없습니다.
그저 인터페이스의 계약만 생각하면 되고, 테스트 관점에서는 테스트의 편의를 위해 Fixture를 작성해두고 Fixture가 어떤식으로 구현됐는지 정도만 참고해서 작성해주면 됩니다.
1.1 컨트롤러 작성
package com.ttasjwi.board.system.member.api
import com.ttasjwi.board.system.core.api.SuccessResponse
import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableResult
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableUseCase
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class EmailAvailableController(
private val useCase: EmailAvailableUseCase,
private val messageResolver: MessageResolver,
) {
@GetMapping("/api/v1/members/email-available")
fun checkEmailAvailable(request: EmailAvailableRequest): ResponseEntity<SuccessResponse<EmailAvailableResponse>> {
// 애플리케이션 서비스에 요청 처리를 위임
val result = useCase.checkEmailAvailable(request)
// 처리 결과로부터 응답 메시지 가공
val response = makeResponse(result)
// 200 상태코드와 함께 HTTP 응답
return ResponseEntity.ok(response)
}
private fun makeResponse(result: EmailAvailableResult): SuccessResponse<EmailAvailableResponse> {
val code = "EmailAvailableCheck.Complete"
return SuccessResponse(
code = code,
message = messageResolver.resolveMessage(code),
description = messageResolver.resolveDescription(code, listOf("$.data.emailAvailable")),
data = EmailAvailableResponse(
emailAvailable = EmailAvailableResponse.EmailAvailable(
email = result.email,
isAvailable = result.isAvailable,
reasonCode = result.reasonCode,
message = messageResolver.resolveMessage(result.reasonCode),
description = messageResolver.resolveDescription(result.reasonCode),
)
)
)
}
}
data class EmailAvailableResponse(
val emailAvailable: EmailAvailable,
) {
data class EmailAvailable(
val email: String,
val isAvailable: Boolean,
val reasonCode: String,
val message: String,
val description: String,
)
}
EmailAvailableController 코드를 작성해봤습니다. useCase 에 질의하고, 반환받은 결과를 가져다가 MessageResolver를 통해 가공처리를 하고 응답을 내려줍니다.
참고로 입력 파라미터로 사용된 request는 Application 계층에서 UseCase 의 입력 파라미터인 Request 객체입니다.
이 상태로 테스트를 실행해보면 컴파일 에러가 뜹니다. UseCase 및 MessageResolver 구현체 의존성을 설정해주지 않았기 때문이죠.
MessageResolver의 픽스쳐는 이미 core 모듈에 작성해뒀는데 EmailAvailableUseCase 의 픽스쳐는 아직 없습니다. 작성해보겠습니다.
1.2 유즈케이스 픽스쳐 작성
application-member 모듈의 testFixtures 소스셋에 EmailAvailableUseCaseFixture를 작성해볼게요.
package com.ttasjwi.board.system.member.application.usecase.fixture
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableResult
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableUseCase
class EmailAvailableUseCaseFixture : EmailAvailableUseCase {
override fun checkEmailAvailable(request: EmailAvailableRequest): EmailAvailableResult {
return EmailAvailableResult(request.email!!, true, "EmailAvailableCheck.Available")
}
}
정말 간단하게, 이메일 문자열을 전달받으면 사용가능하다는 결과만 전달하는 수준으로 작성할거에요.
이메일 값이 null 이 전달된다거나, 형식이 맞지 않거나, 중복되거나 등등의 경우도 있을 수 있지만 그건 ApplicationService를 구현할 때 걱정할 일이니까요.
package com.ttasjwi.board.system.member.application.usecase.fixture
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("EmailAvailableUseCase 픽스쳐 테스트")
class EmailAvailableUseCaseFixtureTest {
private lateinit var useCaseFixture: EmailAvailableUseCaseFixture
@BeforeEach
fun setup() {
useCaseFixture = EmailAvailableUseCaseFixture()
}
@Test
@DisplayName("전달받은 이메일이 올바르다는 결과를 내려준다.")
fun test() {
// given
val email = "hello@gmail.com"
val request = EmailAvailableRequest(email)
// when
val result = useCaseFixture.checkEmailAvailable(request)
// then
assertThat(result.email).isEqualTo(email)
assertThat(result.isAvailable).isTrue()
assertThat(result.reasonCode).isEqualTo("EmailAvailableCheck.Available")
}
}
픽스쳐 역시 테스트 대상이 되어야 하므로, 테스트 코드도 간단하게 작성했습니다.
application-member 모듈에 대해 테스트를 돌려보니, 테스트 픽스쳐가 잘 작동하는 것 같습니다.
참고로 지금 api-member 모듈은 컴파일 불가능한 상태인데
이 프로젝트가 만약 단일 모듈이였다면 전체 모듈이 컴파일되지 않았을테니 귀찮아졌을 것 같아요.
1.3 컨트롤러 테스트 작성
dependencies {
implementation(project(":board-system-api:api-core"))
implementation(project(":board-system-application:application-member"))
implementation(Dependencies.SPRING_BOOT_WEB.fullName)
implementation(Dependencies.KOTLIN_JACKSON.fullName)
testFixturesImplementation(testFixtures(project(":board-system-application:application-member")))
}
api-member 모듈에서는 application-member 모듈의 테스트 픽스쳐가 필요하므로 테스트 픽스쳐 의존성 설정을 추가적으로 작성합니다.
package com.ttasjwi.board.system.member.api
import com.ttasjwi.board.system.core.api.SuccessResponse
import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.core.message.fixture.MessageResolverFixture
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableUseCase
import com.ttasjwi.board.system.member.application.usecase.fixture.EmailAvailableUseCaseFixture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
@DisplayName("EmailAvailableController 테스트")
class EmailAvailableControllerTest {
private lateinit var controller: EmailAvailableController
private lateinit var useCase: EmailAvailableUseCase
private lateinit var messageResolver: MessageResolver
@BeforeEach
fun setup() {
useCase = EmailAvailableUseCaseFixture()
messageResolver = MessageResolverFixture()
controller = EmailAvailableController(useCase, messageResolver)
}
@Test
@DisplayName("유즈케이스를 호출하고 그 결과를 기반으로 200 코드와 함께 응답을 반환한다.")
fun test() {
// given
val request = EmailAvailableRequest(email = "hello@gmail.com")
// when
val responseEntity = controller.checkEmailAvailable(request)
val response = responseEntity.body as SuccessResponse<EmailAvailableResponse>
// then
assertThat(responseEntity.statusCode.value()).isEqualTo(HttpStatus.OK.value())
assertThat(response.isSuccess).isTrue()
assertThat(response.code).isEqualTo("EmailAvailableCheck.Complete")
assertThat(response.message).isEqualTo("EmailAvailableCheck.Complete.message")
assertThat(response.description).isEqualTo("EmailAvailableCheck.Complete.description(args=[$.data.emailAvailable])")
val emailAvailable = response.data.emailAvailable
assertThat(emailAvailable.email).isEqualTo(request.email)
assertThat(emailAvailable.isAvailable).isEqualTo(true)
assertThat(emailAvailable.reasonCode).isEqualTo("EmailAvailableCheck.Available")
assertThat(emailAvailable.message).isEqualTo("EmailAvailableCheck.Available.message")
assertThat(emailAvailable.description).isEqualTo("EmailAvailableCheck.Available.description(args=[])")
}
}
테스트 코드에서는 MessageResolverFixture 및 UseCaseFixture 가 간단하게 어떻게 동작하는지 아는 것을 전제로 코드를 작성합니다.
의존성 방향에 있는 MessageResolverFixture, UseCaseFixture는 이미 해당 픽스쳐를 작성한 모듈쪽에서 테스트를 했으니 잘 작동하는지 관심을 가질 필요 없고 컨트롤러가 다른 의존성들과 협력했을 때 자신의 기능을 잘 하고 있는지 정도만 보면 됩니다.
이제야 빌드테스트를 통과합니다. 표현계층 자체의 구현은 잘 된 것 같아요.
2. 애플리케이션 계층 구현
이번에는 애플리케이션 계층을 구현해보겠습니다.
UseCase의 구현체 ApplicationService 를 구현하면 되는데,
내부적으로 사용자 요청을 "애플리케이션 명령"으로 변환하는 Mapper
실제 "애플리케이션 명령"을 처리하는 Processor
그리고 트랜잭션 실행을 담당하는 TransactionRunner 가 processor의 메서드를 실제 트랜잭션을 통해 실행해주면 됩니다.
여기서 Mapper는 실제 Processor 에 전달하기 전에 입력값 유효성 검사도 함께 합니다.
일단 이렇게 말만 하면 애매하니 실제 코드를 작성하면서 이야기해보겠습니다.
2.1 유즈케이스
package com.ttasjwi.board.system.member.application.usecase
interface EmailAvailableUseCase {
fun checkEmailAvailable(request: EmailAvailableRequest): EmailAvailableResult
}
data class EmailAvailableRequest(
val email: String?
)
data class EmailAvailableResult(
val email: String,
val isAvailable: Boolean,
val reasonCode: String,
)
이전에 인터페이스를 작성하는 단계에서 만들어둔 EmailavailableUseCase 입니다.
이것의 구현체로 EmailAvailableApplicationService를 등록해뒀었죠.
2.2 애플리케이션 서비스
package com.ttasjwi.board.system.member.application.service
import com.ttasjwi.board.system.core.annotation.component.ApplicationService
import com.ttasjwi.board.system.core.application.TransactionRunner
import com.ttasjwi.board.system.logging.getLogger
import com.ttasjwi.board.system.member.application.mapper.EmailAvailableQueryMapper
import com.ttasjwi.board.system.member.application.processor.EmailAvailableProcessor
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableResult
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableUseCase
@ApplicationService
internal class EmailAvailableApplicationService(
private val queryMapper: EmailAvailableQueryMapper,
private val processor: EmailAvailableProcessor,
private val transactionRunner: TransactionRunner,
) : EmailAvailableUseCase {
companion object {
private val log = getLogger(EmailAvailableApplicationService::class.java)
}
override fun checkEmailAvailable(request: EmailAvailableRequest): EmailAvailableResult {
log.info{ "이메일이 우리 서비스에서 사용 가능한 지 확인합니다." }
// 유효성 검사를 거쳐서 '질의'로 변환
val query = queryMapper.mapToQuery(request)
// [트랜잭션] 프로세서에 질의 처리 위임
val result = transactionRunner.runReadOnly { processor.checkEmailAvailable(query) }
// 처리 결과 반환
log.info { "이메일 사용가능여부 확인을 마쳤습니다." }
return result
}
}
애플리케이션 서비스 코드입니다.
우선 QueryMapper(질의 매퍼)를 통해 Request 를 Query(아래에서 후술합니다.) 로 변환합니다.
TransactionRunner 에 함수를 전달하는데, 이 함수는 processor의 메서드를 실행하는 함수입니다.
실제 프로세서의 처리는 transactionRunner 를 통해 진행되는겁니다.
처리 결과를 받아다 그대로 반환합니다.
2.3 애플리케이션 명령/질의
package com.ttasjwi.board.system.member.application.dto
internal data class EmailAvailableQuery(
val email: String,
)
애플리케이션 명령/질의는 Processor의 실행을 위해 정의한 모듈 내의 DTO 클래스입니다.
입력값 검증이 끝난 값, 또는 입력값 검증 결과를 거치고 만들어진 도메인 개념 객체를 여기에 담습니다.
애플리케이션 명령/질의가 되었다면 이미 사용자 입력값 자체가 Api 요청으로 유효한 것으로 간주됩니다.
2.4 애플리케이션 명령/질의 변환기(Mapper)
package com.ttasjwi.board.system.member.application.mapper
import com.ttasjwi.board.system.core.annotation.component.ApplicationQueryMapper
import com.ttasjwi.board.system.core.exception.NullArgumentException
import com.ttasjwi.board.system.logging.getLogger
import com.ttasjwi.board.system.member.application.dto.EmailAvailableQuery
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableRequest
@ApplicationQueryMapper
internal class EmailAvailableQueryMapper {
companion object {
private val log = getLogger(EmailAvailableQueryMapper::class.java)
}
fun mapToQuery(request: EmailAvailableRequest): EmailAvailableQuery {
log.info { "요청이 유효한지 확인합니다." }
// email 이 null 이면 예외 발생
if (request.email == null) {
log.info { "이메일이 누락됐습니다." }
throw NullArgumentException("email")
}
log.info { "입력으로 전달된 이메일이 확인됐습니다. (email = ${request.email})" }
return EmailAvailableQuery(request.email)
}
}
EmailAvailableQueryMapper 입니다.
사용자 요청(Request) 를 애플리케이션 명령, 질의(Query, Command) 로 변환합니다.
이메일 유효성 검증 Api 의 경우 이메일의 형식이 이상한 경우도 정상 입력으로 간주해야하다보니,
email 필드값이 null 인지 아닌지 정도만 판단하고 null 이 아닐 경우 정상 입력으로 간주하고 통과시킵니다.
만약 사용자의 이메일이 우리 서비스에서 유효한 이메일 포맷인지 확인하고 도메인 규칙 객체로 변환해야한다면 여기서 변환을 하고 Query, Command 에 담습니다.
2.5 애플리케이션 프로세서(Processor)
package com.ttasjwi.board.system.member.application.processor
import com.ttasjwi.board.system.core.annotation.component.ApplicationProcessor
import com.ttasjwi.board.system.logging.getLogger
import com.ttasjwi.board.system.member.application.dto.EmailAvailableQuery
import com.ttasjwi.board.system.member.application.usecase.EmailAvailableResult
import com.ttasjwi.board.system.member.domain.service.EmailCreator
import com.ttasjwi.board.system.member.domain.service.MemberFinder
@ApplicationProcessor
internal class EmailAvailableProcessor(
private val emailCreator: EmailCreator,
private val memberFinder: MemberFinder,
) {
companion object {
private val log = getLogger(EmailAvailableProcessor::class.java)
}
fun checkEmailAvailable(query: EmailAvailableQuery): EmailAvailableResult {
val email = emailCreator.create(query.email)
.getOrElse {
log.info { "이메일의 포맷이 유효하지 않습니다. (email = ${query.email})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.InvalidFormat")
}
if (memberFinder.existsByEmail(email)) {
log.info { "이미 사용 중인 이메일입니다. (email = ${email.value})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.Taken")
}
log.info { "사용 가능한 이메일입니다. (email = ${email.value})" }
return EmailAvailableResult(query.email, true, "EmailAvailableCheck.Available")
}
}
앞에서 유효성 검증을 마친채로 만들어진, Command/Query 를 처리하는 프로세서입니다.
실제 도메인 서비스의 기능을 호출하고, 이를 조합하여 애플리케이션 기능을 실질적으로 처리합니다.
여기서는 이메일 문자열이 유효한지, 우리 서비스에서 사용중인지 중복 여부 확인을 합니다.
val email = emailCreator.create(query.email)
.getOrElse {
log.info { "이메일의 포맷이 유효하지 않습니다. (email = ${query.email})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.InvalidFormat")
}
여기서 이 getOrElse 는 어디서 나온건가 궁금해 하실 수 있는 부분이 있을 것 같아서 아주 간단하게 추가적인 설명을 하겠습니다.
package com.ttasjwi.board.system.member.domain.service
import com.ttasjwi.board.system.member.domain.model.Email
interface EmailCreator {
fun create(value: String): Result<Email>
}
제가 앞서 만들어둔 EmailCreator 인터페이스는 반환타입이 Result<Email> 입니다.
잘 보시면 import 문이 따로 없습니다. 이건 kotlin 자체적인 기능인데요.
Kotlin 에서는 Result 라는 타입을 통해 어떤 작업의 성공 결과를 성공 또는 실패 형태로 감싸서 처리할 수 있게 합니다. 성공이냐, 실패냐를 사용자 측에서 케이스 분류하여 처리하면 됩니다.
var optional = Optional.ofNullable(5);
var number = optional.getOrElse(0); // optional 에 담긴 값이 null 이면 0 을 꺼내와라.
java 에서 사용하는 Optional 을 생각하시면 되는데 Optional 은 내부적으로 Null 이거나 Null 이 아닌 객체를 두어 사용하는 측에서 Null 일 경우, Null 이 아닌 경우를 분기처리하는 것을 강제합니다.
여기서는 getOrElse, getOrThrow, ifPresent 등의 특수 함수를 제공하여 사용자가 편하게 후속처리를 할 수 있도록 하죠.
@Test("Result 가 성공적인 경우")
fun test() {
val result = Result.success("hello")
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo("hello")
result.onSuccess {
assertThat(it).isEqualTo("hello")
}
}
@Test("Result 안의 결과가 실패일 경우")
fun test() {
val result = Result.failure<String>(IllegalArgumentException("값이 이상해요"))
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isNotNull()
assertThrows<IllegalArgumentException>{ result.getOrThrows() }
}
예를 들어, Result.success 형태로, 성공 결과를 담을 수 있고 Result.failure 로 실패 결과(예외)를 담을 수 있습니다.
사용하는 측에서는 성공 또는 실패에 따라 케이스 분류하여 사용하면 됩니다.
Result 를 사용하는 방법에 대해 설명한 Baeldung 님의 블로그 및 Result Api 문서를 올리면서 이 부분에 대한 설명은 마칠게요.
3. 애플리케이션 계층 구현에 필요한 픽스쳐 작성
이제 애플리케이션 계층 테스트를 작성해야하는데, 애플리케이션 계층에서 의존하고 있는
TransactionRunner, EmailCreator, MemberFinder 에 대한 픽스쳐가 필요합니다.
3.1 TransactionRunner 픽스쳐
TransactionRunner 의 픽스쳐부터 작성해보겠습니다.
TransactionRunner 는 실제 트랜잭션 처리를 담당할 역할인데, 애플리케이션 계층 테스트에 있어서 데이터베이스 연동을 실제 끌어와서 기능을 구현할 필요는 없죠.
package com.ttasjwi.board.system.core.application.fixture
import com.ttasjwi.board.system.core.application.TransactionRunner
class TransactionRunnerFixture : TransactionRunner {
override fun <T> run(function: () -> T): T {
return function.invoke()
}
override fun <T> runReadOnly(function: () -> T): T {
return function.invoke()
}
}
그래서 실제로 픽스쳐 코드에서는 전달받은 함수만 실행하고 그 결과를 반환만 합니다.
package com.ttasjwi.board.system.core.application.fixture
import com.ttasjwi.board.system.core.application.TransactionRunner
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("TransactionRunner 픽스쳐 테스트")
class TransactionRunnerFixtureTest {
private lateinit var transactionRunner: TransactionRunner
@BeforeEach
fun setup() {
transactionRunner = TransactionRunnerFixture()
}
@Test
@DisplayName("run: 전달한 함수의 반환값을 그대로 반환한다.")
fun testRun() {
// given
val function = { "hello" }
// when
val result = transactionRunner.run(function)
// then
assertThat(result).isEqualTo(function.invoke())
}
@Test
@DisplayName("runReadOnly: 전달한 함수의 반환값을 그대로 반환한다.")
fun testRunReadOnly() {
// given
val function = { "hello" }
// when
val result = transactionRunner.runReadOnly(function)
// then
assertThat(result).isEqualTo(function.invoke())
}
}
픽스쳐의 테스트 코드는 정말 전달한 함수의 반환값을 그대로 반환하는 지 정도까지만 테스트하도록 합니다.
3.2 EmailCreator 픽스쳐 작성
EmailCreator(이메일 생성기)의 픽스쳐를 작성해보겠습니다.
사실 이메일 주소 인스턴스를 생성하는 것이긴 한데 편의상 이메일 생성기라 하겠습니다.
package com.ttasjwi.board.system.member.domain.service.fixture
import com.ttasjwi.board.system.core.exception.ErrorStatus
import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.fixture.emailFixture
import com.ttasjwi.board.system.member.domain.service.EmailCreator
class EmailCreatorFixture : EmailCreator {
companion object {
const val ERROR_EMAIL = "erro!@gmail.com"
}
override fun create(value: String): Result<Email> = kotlin.runCatching {
if (value == ERROR_EMAIL) {
throw customExceptionFixture(
status = ErrorStatus.INVALID_ARGUMENT,
code = "Error.InvalidEmailFormat",
args = emptyList(),
source = "email",
debugMessage = "이메일 포맷 예외 - 픽스쳐"
)
}
emailFixture(value)
}
}
이 픽스쳐는 특정 문자열이 전달됐을 때 커스텀예외(가짜 예외)를 발생시키고, 그 외의 경우에는 모두 Email 객체를 생성해서 반환합니다.
여기서 이메일 생성은 실제 Email 생성의 비즈니스 코드를 사용한게 아니라 emailFixture 를 통해 생성했습니다.
이 픽스쳐를 사용하는 개발자는 입력이 ERROR_EMAIL 인 경우, 그렇지 않은 경우로 나눠서 테스트를 작성하면 됩니다.
package com.ttasjwi.board.system.member.domain.service.fixture
import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.member.domain.service.EmailCreator
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.*
@DisplayName("EmailCreatorFixture 테스트")
class EmailCreatorFixtureTest {
private lateinit var emailCreator: EmailCreator
@BeforeEach
fun setup() {
emailCreator = EmailCreatorFixture()
}
@Nested
@DisplayName("createEmail: 문자열로 이메일을 생성하고 그 결과가 담긴 Result 를 반환한다.")
inner class CreateEmail {
@Test
@DisplayName("정상적인 이메일 문자열이 전달되면 이메일이 담긴 Result가 반환된다.")
fun createSuccess() {
val value = "hello@gmail.com"
val result = emailCreator.create(value)
val email = result.getOrThrow()
assertThat(result.isSuccess).isTrue()
assertThat(email).isNotNull
assertThat(email.value).isEqualTo(value)
}
@Test
@DisplayName("이메일 문자열이 ERROR_EMAIL 이면 예외가 담긴 실패 Result 를 반환한다.")
fun createFailure() {
val value = EmailCreatorFixture.ERROR_EMAIL
val result = emailCreator.create(value)
val exception = assertThrows<CustomException> { result.getOrThrow() }
assertThat(result.isFailure).isTrue()
assertThat(exception.debugMessage).isEqualTo("이메일 포맷 예외 - 픽스쳐")
}
}
}
테스트 픽스쳐의 테스트코드는 성공케이스/실패케이스를 나눠서 간단하게 테스트했습니다.
3.3 MemberFinder/MemberAppender 픽스쳐 작성
이번에는 MemberFinder 에 대한 픽스쳐를 작성해보겠습니다.
그런데 MemberFinder는 MemberAppender 와 유기적인 관계가 있는 기능이고 MemberAppender와 함께 기능을 구현한 MemberStorageFixture 형태로 구현해보겠습니다.
package com.ttasjwi.board.system.member.domain.service.fixture
import com.ttasjwi.board.system.member.domain.model.*
import com.ttasjwi.board.system.member.domain.model.fixture.memberIdFixture
import com.ttasjwi.board.system.member.domain.service.MemberAppender
import com.ttasjwi.board.system.member.domain.service.MemberFinder
import java.util.concurrent.atomic.AtomicLong
class MemberStorageFixture : MemberAppender, MemberFinder {
private val storage = mutableMapOf<MemberId, Member>()
private val sequence = AtomicLong(0)
override fun save(member: Member): Member {
if (member.id == null) {
val id = memberIdFixture(sequence.incrementAndGet())
member.initId(id)
}
storage[member.id!!] = member
return member
}
override fun findByIdOrNull(id: MemberId): Member? {
return storage[id]
}
override fun existsById(id: MemberId): Boolean {
return storage.containsKey(id)
}
override fun findByEmailOrNull(email: Email): Member? {
return storage.values.firstOrNull { it.email == email }
}
override fun existsByEmail(email: Email): Boolean {
return storage.values.any { it.email == email }
}
override fun findByUsernameOrNull(username: Username): Member? {
return storage.values.firstOrNull { it.username == username }
}
override fun existsByUsername(username: Username): Boolean {
return storage.values.any { it.username == username }
}
override fun findByNicknameOrNull(nickname: Nickname): Member? {
return storage.values.firstOrNull { it.nickname == nickname }
}
override fun existsByNickname(nickname: Nickname): Boolean {
return storage.values.any { it.nickname == nickname }
}
}
mutableMap 및 AtomicLong 을 사용하여 회원을 저장했습니다.
여기서 Key는 MemberId를 해뒀는데 MemberId는 앞서 Equals, HashCode 메서드를 오버라이드 해뒀기 때문에 해시테이블 자료구조의 Key로 사용해도 문제가 없습니다.
save는 저장, findByXXXOrNull 은 조회, existsByXXX은 존재여부 조회를 위한 메서드입니다.
이 때 save에서는 Member 인스턴스의 id가 null 인지 확인하여 null 이면 initId 를 통해 id 를 초기화하고(이때 초기화하는 id값은 AtomicLong 을 통해, 수행했습니다.)
@Nested
@DisplayName("findByEmailOrNull: 이메일로 회원을 조회하여 반환한다.")
inner class FindByEmailOrNullTest {
@DisplayName("가입된 회원의 이메일로 조회하면 회원이 조회됨")
@Test
fun test1() {
// given
val member = memberFixtureNotRegistered(email = "test1557@gmail.com")
val savedMember = memberStorageFixture.save(member)
// when
val findMember = memberStorageFixture.findByEmailOrNull(savedMember.email)!!
// then
assertThat(findMember).isNotNull
assertThat(findMember.email).isEqualTo(member.email)
assertThat(findMember.username).isEqualTo(member.username)
assertThat(findMember.nickname).isEqualTo(member.nickname)
assertThat(findMember.password.value).isEqualTo(member.password.value)
assertThat(findMember.role).isEqualTo(member.role)
assertThat(findMember.registeredAt).isEqualTo(member.registeredAt)
}
테스트 코드는 전부 작성하기엔 글이 길어져서 이 글에서는 생략합니다.
회원을 저장하고, 조회하는 부분을 모든 경우로 나눠서 테스트 했습니다.
4. 애플리케이션 서비스 테스트
dependencies {
implementation(project(":board-system-application:application-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-domain:domain-core"))
testImplementation(testFixtures(project(":board-system-application:application-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
}
application-member의 테스트 코드를 작성해보겠습니다.
앞서 작성한 픽스쳐 코드들을 사용해 application-member의 픽스쳐 작성에 사용할 일은 없고 테스트 코드작성에 사용하므로 testImplementation 의존성을 추가했습니다.
4.1 애플리케이션 프로세서 테스트 코드
@DisplayName("EmailAvailableProcessor: 이메일이 사용가능한 지 여부를 실질적으로 확인하는 처리자")
class EmailAvailableProcessorTest {
private lateinit var processor: EmailAvailableProcessor
private lateinit var savedMember: Member
@BeforeEach
fun setup() {
val memberStorageFixture = MemberStorageFixture()
val emailCreatorFixture = EmailCreatorFixture()
processor = EmailAvailableProcessor(
emailCreator = emailCreatorFixture,
memberFinder = memberStorageFixture
)
savedMember = memberStorageFixture.save(
memberFixtureNotRegistered(
email = "registered@gmail.com"
)
)
}
@Nested
@DisplayName("queryEmailAvailable: 이메일이 사용가능한 지 여부를 확인하고 그 결과를 반환한다.")
inner class QueryEmailAvailable {
@Test
@DisplayName("이메일 포맷이 유효하지 않을 때, 이메일 포맷이 유효하지 않다는 결과를 반환한다.")
fun testInvalidFormat() {
val query = EmailAvailableQuery(email = EmailCreatorFixture.ERROR_EMAIL)
val result = processor.checkEmailAvailable(query)
assertThat(result.email).isEqualTo(query.email)
assertThat(result.isAvailable).isFalse()
assertThat(result.reasonCode).isEqualTo("EmailAvailableCheck.InvalidFormat")
}
@Test
@DisplayName("포맷이 올바르지만 이미 사용중인 이메일이면, 이미 사용 중이라는 결과를 반환한다.")
fun testTaken() {
val query = EmailAvailableQuery(email = savedMember.email.value)
val result = processor.checkEmailAvailable(query)
assertThat(result.email).isEqualTo(query.email)
assertThat(result.isAvailable).isFalse()
assertThat(result.reasonCode).isEqualTo("EmailAvailableCheck.Taken")
}
@Test
@DisplayName("포맷이 올바르고, 사용 중이지 않다면, 사용 가능하다는 결과를 반환한다.")
fun testAvailable() {
val query = EmailAvailableQuery(email = "available@gmail.com")
val result = processor.checkEmailAvailable(query)
assertThat(result.email).isEqualTo(query.email)
assertThat(result.isAvailable).isTrue()
assertThat(result.reasonCode).isEqualTo("EmailAvailableCheck.Available")
}
}
}
애플리케이션 프로세서 테스트 코드입니다.
의존성은 Fixture를 사용합니다.
EmailCreator, MemberFinder의 사용원리나 검증여부 등은 이미 domain-member에서 검증이 충분히 됐으므로 해당부분은 따지지 않고,
이메일 포맷이 올바르지 않은경우(EmailCreatorFixture.ERROR_EMAIL), 회원가입이 되어 있는 이메일인 경우, 회원가입이 되어있지 않은 올바른 이메일 포맷인 경우로 나눠서 테스트를 작성했습니다.
email 이 null 이 아닌 경우는 이 상황에서는 다룰 필요가 없으므로(query의 email 이 이미 null 을 허용하지 않음) 테스트하지 않습니다.
4.2 쿼리매퍼 테스트 코드
@DisplayName("EmailAvailableQueryMapper: EmailAvailableRequest 를 EmailAvailableQuery 로 변환한다")
class EmailAvailableQueryMapperTest {
private lateinit var queryMapper: EmailAvailableQueryMapper
@BeforeEach
fun setup() {
queryMapper = EmailAvailableQueryMapper()
}
@Nested
@DisplayName("mapToQuery: 사용자 요청을 애플리케이션 질의로 변환한다.")
inner class MapToQuery {
@Test
@DisplayName("이메일이 누락되면 예외가 발생한다")
fun testNullEmail() {
val request = EmailAvailableRequest(email = null)
val exception = assertThrows<NullArgumentException> { queryMapper.mapToQuery(request) }
assertThat(exception.source).isEqualTo("email")
}
@Test
@DisplayName("이메일이 누락되지 않았다면 query 가 반환된다")
fun testSuccess() {
val request = EmailAvailableRequest(email = "test@gmail.com")
val query = queryMapper.mapToQuery(request)
assertThat(query).isNotNull
assertThat(query.email).isEqualTo(request.email)
}
}
}
쿼리 맵퍼는 사용자 요청이 유효한 요청인지 확인하고 애플리케이션 질의로 바꾸는 것을 책임지죠.
여기서는 email 이 null 인지, null 이 아닌지 두 경우만 따져서 테스트합니다.
4.3 애플리케이션 서비스 테스트
@DisplayName("EmailAvailableService: 이메일 사용 가능 여부 확인을 수행하는 애플리케이션 서비스")
class EmailAvailableApplicationServiceTest {
private lateinit var applicationService: EmailAvailableApplicationService
@BeforeEach
fun setup() {
applicationService = EmailAvailableApplicationService(
queryMapper = EmailAvailableQueryMapper(),
processor = EmailAvailableProcessor(
emailCreator = EmailCreatorFixture(),
memberFinder = MemberStorageFixture(),
),
transactionRunner = TransactionRunnerFixture()
)
}
@Test
@DisplayName("이메일 유효성 검사를 마친 결과를 성공적으로 반환한다.")
fun testSuccess() {
val request = EmailAvailableRequest("hello@gmail.com")
val result = applicationService.checkEmailAvailable(request)
assertThat(result).isNotNull
assertThat(result.email).isEqualTo(request.email)
assertThat(result.isAvailable).isTrue()
assertThat(result.reasonCode).isEqualTo("EmailAvailableCheck.Available")
}
}
그 다음은 애플리케이션 테스트 코드 부분입니다.
사실 단위 테스트라 하긴 애매한게, 내부의 Processor, QueryMapper 를 조립하는 부분도 통합해서 테스트가 돌아갑니다.
그런데 Processor, QueryMapper에서의 디테일한 상황에 대해서는 위에서 테스트가 충분히 됐으므로
전체 케이스 중 성공 케이스 수준만 테스트해서 ApplicationService 코드가 전체적으로 잘 돌아가는 지 정도만 테스트합니다.
이렇게 테스트를 실행해보면
EmailAvailableApplicationService, Processor, QueryMapper, Query 의 테스트 커버리지는 100%로 나옵니다.(intellij 기준)
애플리케이션 계층의 구현은 얼추 마무리 된 듯 합니다.
5. 이메일 포맷 검증 및 이메일 객체 생성
이제 TransactionRunner, EmailCreator, MemberFinder 구현체를 실제 작성해보겠습니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.service.EmailCreator
@DomainService
internal class EmailCreatorImpl : EmailCreator {
override fun create(value: String): Result<Email> = kotlin.runCatching {
// 이메일 값, 이메일 검증 라이브러리 호출 또는 콜백 함수 전달해서 이메일 생성
// Email.create(...) { 함수 }
}
}
Email 을 생성해야하는데, 이메일의 포맷이 올바른지 여부를 확인하는 작업이 필요합니다.
저는 이 부분을 자체구현하지 않고, 이메일 포맷 검증 라이브러리를 사용해서 구현해보려고 합니다.
5.1 외부 이메일 포맷 검증기 인터페이스
package com.ttasjwi.board.system.member.domain.external
interface ExternalEmailFormatChecker {
fun isValidFormatEmail(value: String): Boolean
}
외부 라이브러리를 통해 이메일 포맷이 유효한지 검증해야하는데, 이를 위한 인터페이스를 domain-core 모듈 내에 둡니다.
도메인 서비스의 구현을 보조하기 위한 외부 의존성을 추상화합니다.
이렇게 되면 외부 모듈에서 이 인터페이스를 의존하는게 가능하긴 한데, 이 인터페이스를 호출하지 않도록 팀 규약을 정하는게 좋습니다.
package com.ttasjwi.board.system.member.domain.external.fixture
import com.ttasjwi.board.system.member.domain.external.ExternalEmailFormatChecker
internal class ExternalEmailFormatCheckerFixture : ExternalEmailFormatChecker {
companion object {
const val INVALID_FORMAT_EMAIL = "wrong!@gmail.com"
}
override fun isValidFormatEmail(value: String): Boolean {
return value != INVALID_FORMAT_EMAIL
}
}
ExternalEmailFormatChecker 역시 픽스쳐를 만들어두고 내부 모듈에서 사용할 수 있게 해뒀습니다.
5.2 외부 이메일 포맷 검증기 구현
enum class Dependencies(
private val groupId: String,
private val artifactId: String,
private val version: String? = null,
private val classifier: String? = null,
) {
// 생략
// email-format-check
COMMONS_VALIDATOR(groupId="commons-validator", artifactId ="commons-validator" , version="1.9.0");
이메일 포맷 검증 라이브러리로, apache의 commons-validator:commons-validator 를 사용하겠습니다.
rootProject.name = "board-system"
include(
// external : 외부 기술 의존성
// 추가
"board-system-external:external-email-format-checker",
)
외부모듈로, external-email-format-checker 모듈을 새로 선언하고(settings.gradle.kts)
dependencies {
implementation(project(":board-system-domain:domain-core"))
implementation(Dependencies.COMMONS_VALIDATOR.fullName)
}
external-email-format-checker 의 의존성 설정을 합니다.
domain-core 및 commons-validator:commons-validator 를 의존합니다.
dependencies {
implementation(Dependencies.SPRING_BOOT_STARTER.fullName)
// api
implementation(project(":board-system-api:api-core"))
implementation(project(":board-system-api:api-member"))
implementation(project(":board-system-api:api-deploy"))
// external
implementation(project(":board-system-external:external-message"))
implementation(project(":board-system-external:external-db"))
implementation(project(":board-system-external:external-redis"))
implementation(project(":board-system-external:external-exception-handle"))
implementation(project(":board-system-external:external-email-format-checker"))
implementation(project(":board-system-external:external-email-sender"))
}
tasks.getByName("bootJar") {
enabled = true
}
container 실행 시 external-format-checker 를 사용할 수 있게 의존성 추가를 합니다.(container - build.gradle.kts)
package com.ttasjwi.board.system.member.domain.external
import com.ttasjwi.board.system.core.annotation.component.AppComponent
import org.apache.commons.validator.routines.EmailValidator
@AppComponent
internal class ApacheEmailFormatCheckerImpl : ExternalEmailFormatChecker {
private val apacheEmailValidator = EmailValidator.getInstance()
override fun isValidFormatEmail(value: String): Boolean {
return apacheEmailValidator.isValid(value)
}
}
구현체를 정의하고 @AppComponent 를 통해 빈으로 등록합니다.
내부적으로 apache의 EmailValidator 인스턴스를 생성하고, 이를 통해 이메일 검증을 위임합니다.
public boolean isValid(String email) {
if (email == null) {
return false;
} else if (email.endsWith(".")) {
return false;
} else {
Matcher emailMatcher = EMAIL_PATTERN.matcher(email);
if (!emailMatcher.matches()) {
return false;
} else if (!this.isValidUser(emailMatcher.group(1))) {
return false;
} else {
return this.isValidDomain(emailMatcher.group(2));
}
}
EmailValidator 구현은 내부적으로 포맷이 올바른지 아닌지에 따라 true, false 를 반환하는 식으로 되어있습니다.
package com.ttasjwi.borad.system.member.domain.external
import com.ttasjwi.board.system.member.domain.external.ApacheEmailFormatCheckerImpl
import com.ttasjwi.board.system.member.domain.external.ExternalEmailFormatChecker
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("외부 이메일 검증기(ApacheEmailFormatCheckerImpl) 테스트")
class ApacheEmailFormatCheckerImplTest {
private lateinit var externalEmailFormatChecker: ExternalEmailFormatChecker
@BeforeEach
fun setup() {
externalEmailFormatChecker = ApacheEmailFormatCheckerImpl()
}
@Test
@DisplayName("올바른 로컬파트@도메인으로 구성되어 있을 때 true 를 반환")
fun testFormatValid() {
val email = "local@domain.org"
val isValid = externalEmailFormatChecker.isValidFormatEmail(email)
assertThat(isValid).isTrue()
}
@Test
@DisplayName("온점이 연속으로 두개 포함된 경우 false 를 반환")
fun testDoubleDot() {
val email = "ttasj..wi@gmai.com"
val isValid = externalEmailFormatChecker.isValidFormatEmail(email)
assertThat(isValid).isFalse()
}
@Test
@DisplayName("@가 없는 문자열은 이메일 검증시 false 를 반환")
fun testNoAt() {
val email = "ttasjwigmail.com"
val isValid = externalEmailFormatChecker.isValidFormatEmail(email)
assertThat(isValid).isFalse()
}
@Test
@DisplayName("@가 2개 이상 있는 문자열은 이메일 검증시 false 를 반환")
fun testDoubleAt() {
val email = "ttasjwi@gmail@hello.com"
val isValid = externalEmailFormatChecker.isValidFormatEmail(email)
assertThat(isValid).isFalse()
}
}
모든 경우를 다 따지는건 무리지만, 대략 의도한 대로 잘 동작하는 지 정도만 테스트합니다.
여기서 더 깊게 따져서 테스트하는 것은 라이브러리의 신뢰성에 대한 검증의 영역인데
의미없다고 생각해서 여기까지 테스트하고 사용하기로 했습니다.
5.3 이메일 생성 기능 구현
이제 도메인 서비스의 EmailCreator 구현체 코드를 작성해보겠습니다.
package com.ttasjwi.board.system.member.domain.model
import com.ttasjwi.board.system.member.domain.exception.InvalidEmailFormatException
class Email
internal constructor(
val value: String
) {
companion object {
/**
* 기본 Email 객체 복원
*/
fun restore(value: String): Email {
return Email(value)
}
/**
* Email 객체 생성
*/
internal fun create(value: String, isEmailValid: (String) -> Boolean): Email {
if (!isEmailValid(value)) {
throw InvalidEmailFormatException(value)
}
return Email(value)
}
}
// 생략
}
이메일 클래스 내부에 create 메서드를 만들어 이메일 생성을 하도록 합니다.
이때 이메일 값만 전달받는게 아니라, 이메일 문자열을 검증하는 함수도 외부에서 전달받아서 이를 실행하도록 합니다.
package com.ttasjwi.board.system.member.domain.exception
import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus
class InvalidEmailFormatException(
emailValue: String,
) : CustomException(
status = ErrorStatus.INVALID_ARGUMENT,
code = "Error.InvalidEmailFormat",
args = listOf(emailValue),
source = "email",
debugMessage = "이메일의 형식이 올바르지 않습니다. (email = $emailValue)",
)
이메일이 유효하지 않으면 예외를 발생시키고, 그렇지 않다면 이메일 인스턴스를 생성합니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.external.ExternalEmailFormatChecker
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.service.EmailCreator
@DomainService
internal class EmailCreatorImpl(
private val externalEmailFormatChecker: ExternalEmailFormatChecker,
) : EmailCreator {
override fun create(value: String): Result<Email> {
return kotlin.runCatching {
Email.create(value) { externalEmailFormatChecker.isValidFormatEmail(it) }
}
}
}
도메인 서비스 구현체 코드에서는
Email.create 를 통해 이메일을 생성합니다. 생성 시, 외부 이메일 검증기를 호출하는 함수를 콜백으로 전달합니다.
이 때 kotlin.runCatching 을 사용하여 이메일 생성 로직을 감싸면(콜백으로 전달하면), 이메일 생성 로직이 실행된 결과 성공하면 Result.Success 가 반환되고, 예외가 발생하면 Result.Failure 가 반환되게 합니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.member.domain.exception.InvalidEmailFormatException
import com.ttasjwi.board.system.member.domain.external.ExternalEmailFormatChecker
import com.ttasjwi.board.system.member.domain.external.fixture.ExternalEmailFormatCheckerFixture
import com.ttasjwi.board.system.member.domain.model.fixture.emailFixture
import com.ttasjwi.board.system.member.domain.service.EmailCreator
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.*
@DisplayName("EmailCreatorImpl 테스트")
class EmailCreatorImplTest {
private lateinit var emailCreator: EmailCreator
private lateinit var externalEmailFormatChecker: ExternalEmailFormatChecker
@BeforeEach
fun setup() {
externalEmailFormatChecker = ExternalEmailFormatCheckerFixture()
emailCreator = EmailCreatorImpl(externalEmailFormatChecker)
}
@Nested
@DisplayName("create: 이메일을 생성하고 Result 에 담아 보낸다. 실패하면 예외 발생")
inner class Create {
@DisplayName("이메일 포맷이 올바르지 않으면 예외가 발생하여 Result.Failure 가 반환된다.")
@Test
fun testFailure() {
val emailValue = ExternalEmailFormatCheckerFixture.INVALID_FORMAT_EMAIL
val result = emailCreator.create(emailValue)
val exception = assertThrows<InvalidEmailFormatException> {
result.getOrThrow()
}
assertThat(result.isFailure).isTrue()
assertThat(exception).isInstanceOf(InvalidEmailFormatException::class.java)
assertThat(exception.args[0]).isEqualTo(emailValue)
}
@DisplayName("이메일 포맷이 올바르면 Result.Success 에 이메일이 담겨 반환된다.")
@Test
fun testSuccess() {
val emailValue = "test@gmail.com"
val result = emailCreator.create(emailValue)
val emailDomain = result.getOrThrow()
assertThat(result.isSuccess).isTrue()
assertThat(emailDomain).isNotNull
assertThat(emailDomain).isEqualTo(emailFixture(emailValue))
}
}
}
EmailCreator 테스트코드입니다. 실제 ExternalEmailFormatChecker 구현체를 사용하지 않고 픽스쳐만을 이용해 테스트를 하고 있습니다.
6. 애플리케이션 프로세서에서 도메인 서비스를 사용하지 않은 이유
@ApplicationProcessor
internal class EmailAvailableProcessor(
private val emailCreator: EmailCreator,
private val memberFinder: MemberFinder,
) {
companion object {
private val log = getLogger(EmailAvailableProcessor::class.java)
}
fun checkEmailAvailable(query: EmailAvailableQuery): EmailAvailableResult {
val email = emailCreator.create(query.email)
.getOrElse {
log.info { "이메일의 포맷이 유효하지 않습니다. (email = ${query.email})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.InvalidFormat")
}
if (memberFinder.existsByEmail(email)) {
log.info { "이미 사용 중인 이메일입니다. (email = ${email.value})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.Taken")
}
log.info { "사용 가능한 이메일입니다. (email = ${email.value})" }
return EmailAvailableResult(query.email, true, "EmailAvailableCheck.Available")
}
}
잠시 애플리케이션 프로세서의 코드를 다시 보겠습니다.
여기서 저는 이메일 생성을 도메인 서비스 EmailCreator 를 통해 수행했습니다.
@ApplicationProcessor
internal class EmailAvailableProcessor(
private val externalEmailFormatChecker: ExternalEmailFormatChecker,
private val memberFinder: MemberFinder,
) {
companion object {
private val log = getLogger(EmailAvailableProcessor::class.java)
}
fun checkEmailAvailable(query: EmailAvailableQuery): EmailAvailableResult {
val email: Email
try {
email = Email.create(query.email) { externalEmailFormatChecker.isValidEmail(it) }
} catch(e: Exception) {
log.info { "이메일의 포맷이 유효하지 않습니다. (email = ${query.email})" }
return EmailAvailableResult(query.email, false, "EmailAvailableCheck.InvalidFormat")
}
}
그런데 사실 이 코드는 Email 쪽에 있는 정적 팩터리 메서드를 public 으로 두고 구현하는 것도 가능했을겁니다.
하지만 그럼에도 불구하고 제가 이메일 생성에 대한 부분을 EmailCreator 인터페이스를 통해 추상화시킨 데에는 몇 가지 이유가 있습니다.
1. Email 생성에 대한 로직을 추상화할 수 있다.
지금 Email 생성 로직에 있어서는 외부 이메일 포맷 검증기를 사용하고 있습니다. 그런데 향후 이메일 생성 로직에 있어서 팀내 결정에 의해 외부 이메일 포맷 검증기를 사용하지 않고 우리 서비스에서 자체적으로 검증 로직을 구현해야한다고 하면 상황이 달라집니다. 이메일 생성을 위한 Email.create 코드도 변경해야할 뿐만 아니라 애플리케이션 프로세서 코드도 변경해야합니다.
2. Email.create(...) 방식은 테스트코드 Mocking 이 불가능하다.
도메인의 정적팩터리 메서드를 호출하여 직접 도메인을 생성하는 방법으로 코드를 작성하는데, 이를 테스트코드를 통해 애플리케이션 프로세스를 테스트하게 되면 Email.create 의 실제 구현이 완성되어야 테스트코드가 제대로 동작합니다. 그리고 개발자는 도메인의 실제 구현을 참조해서 구현해야하기 때문에 도메인의 구현을 기다려야합니다.
저는 위와 비슷한 이유로 향후 다른 코드들도 도메인의 클래스의 메서드를(public하게 열려있음) 직접 호출해서 비즈니스 호출을 하기보다
도메인 서비스를 통해 간접적으로 도메인의 메서드(internal 키워드로 막고)를 실행하도록 할 예정입니다.
7. TransactionRunner, MemberAppender, MemberFinder 간단 구현
TransactionRunnerImpl, 그리고 MemberStorage(MemberAppender, MemberFinder 구현체) 의 구현은 사실 데이터베이스 접근 설정을 하고나서 작성해야하는데요. 이 부분은 별도의 이슈로 작성해서 처리할 예정입니다.
하지만 구현을 안 해두면 API 가 전체적으로 잘 돌아가는지 확인하기 힘드니 일단 가짜 구현체라도 작성해보겠습니다.
dependencies {
implementation(project(":board-system-domain:domain-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-application:application-core"))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
}
domain-core, domain-member의 테스트 픽스쳐 의존성을 추가합니다.
package com.ttasjwi.board.system.core.application
import com.ttasjwi.board.system.core.annotation.component.AppComponent
@AppComponent
class TransactionRunnerImpl : TransactionRunner{
override fun <T> run(function: () -> T): T {
return function.invoke()
}
override fun <T> runReadOnly(function: () -> T): T {
return function.invoke()
}
}
TransactionRunnerImpl 은 일단, TransactionRunnerFixture 처럼, 전달받은 함수를 실행하기만 하도록 해놨습니다.
package com.ttasjwi.board.system.member.domain.external.db
import com.ttasjwi.board.system.core.annotation.component.AppComponent
import com.ttasjwi.board.system.member.domain.model.*
import com.ttasjwi.board.system.member.domain.service.MemberAppender
import com.ttasjwi.board.system.member.domain.service.MemberFinder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@AppComponent
internal class MemberStorage : MemberAppender, MemberFinder {
private val storage: MutableMap<MemberId, Member> = ConcurrentHashMap()
private val sequence = AtomicLong(0)
override fun save(member: Member): Member {
if (member.id == null) {
val id = MemberId.restore(sequence.incrementAndGet())
member.initId(id)
}
storage[member.id!!] = member
return member
}
override fun findByIdOrNull(id: MemberId): Member? {
return storage[id]
}
// 생략
}
MemberStorage 역시 기존 작성해둔 MemberStorageFixture의 코드를 어느정도 재사용해서 간단하게 돌아가게만 해놨습니다. 이렇게 해두면 DB에 저장을 하지 않고 메모리에 HashMap 형태로 저장하는데 DB에 저장하는 것과 유사하게 작동할겁니다. 다만 애플리케이션을 다시 켜면 저장된 데이터가 날아가겠죠.
8. 실행
로컬에서 애플리케이션(contianer > Main 클래스) 를 실행해보겠습니다.
EmailAvailableApplicationService : 이메일이 우리 서비스에서 사용 가능한 지 확인합니다.
EmailAvailableQueryMapper : 요청이 유효한지 확인합니다.
EmailAvailableQueryMapper : 입력으로 전달된 이메일이 확인됐습니다. (email = hello@gmail.com)
EmailAvailableProcessor : 사용 가능한 이메일입니다. (email = hello@gmail.com)
EmailAvailableApplicationService : 이메일 사용가능여부 확인을 마쳤습니다.
정상적인 형식의 이메일을 전달하면 애플리케이션이 의도한 대로 동작하는 것을 볼 수 있습니다.
EmailAvailableApplicationService : 이메일이 우리 서비스에서 사용 가능한 지 확인합니다.
EmailAvailableQueryMapper : 요청이 유효한지 확인합니다.
EmailAvailableQueryMapper : 입력으로 전달된 이메일이 확인됐습니다. (email = f!dadfa!@@@o@gmail.com)
EmailAvailableProcessor : 이메일의 포맷이 유효하지 않습니다. (email = f!dadfa!@@@o@gmail.com)
EmailAvailableApplicationService : 이메일 사용가능여부 확인을 마쳤습니다.
잘못된 포맷의 이메일에 대해 유효성 검사를 시도하면 의도한 대로 동작하는 것을 볼 수 있습니다.
이미 존재하는 회원에 대해 잘 동작하는 지 여부는 아직 회원가입 로직을 구현하지 않아서 실제 눈으로 보지는 못 하지만,
이미 EmailAvailableProcessor 쪽에서 테스트를 해놨기 때문에 잘 작동될 겁니다.
9. 사용자아이디 / 닉네임 사용가능 여부 확인 Api 구현
1~9와 마찬가지의 방법으로 사용자아이디/닉네임 사용가능 여부 확인 Api 역시 구현했습니다.
다만 차이점이 있다면 사용자아이디, 닉네임의 포맷이 유효한지 검사하는 부분인데 이 부분은 이메일과 다르게 저희 서비스 내부에서 자체적으로 검증 로직을 구현했습니다.
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.annotation.component.DomainService
import com.ttasjwi.board.system.member.domain.model.Username
import com.ttasjwi.board.system.member.domain.service.UsernameCreator
@DomainService
internal class UsernameCreatorImpl : UsernameCreator {
override fun create(value: String): Result<Username> {
return kotlin.runCatching {
Username.create(value)
}
}
}
예를 들어 사용자아이디(Username) 을 생성하는 UsernameCreatorImpll 에서는 내부적으로 Username.create 를 호출하는데,
/**
* 사용자 관점 아이디
*/
class Username
internal constructor(
val value: String
) {
companion object {
/**
* 영어 소문자, 숫자, 언더바만 허용. 공백 허용 안 됨.
*/
private val pattern = Regex("^[a-z0-9_]+\$")
internal const val MIN_LENGTH = 4
internal const val MAX_LENGTH = 15
// 생략
/**
* Username 생성
*/
internal fun create(value: String): Username {
if (value.length < MIN_LENGTH || value.length > MAX_LENGTH || !value.matches(pattern)) {
throw InvalidUsernameFormatException(value)
}
return Username(value)
}
}
여기 내부에 username 의 패턴은 어떠해야한다는 명세가 작성되어 있습니다.
사용자아이디는 영어소문자/숫자/언더바만 허용하도록 하고 4자 이상~15자 이하만 허용하도록 했습니다.
이 생성 로직은 internal 키워드로 감싸서, 모듈 내 구현에서만 사용할 수 있게 하고 외부 계층은 모르게 했습니다.
class Nickname
internal constructor(
val value: String
) {
companion object {
/**
* 한글, 영문자(대,소), 숫자만 허용
*/
private val pattern = Regex("^[ㄱ-힣|a-z|A-Z|0-9|]+\$")
internal const val MIN_LENGTH = 1
internal const val MAX_LENGTH = 15
fun restore(value: String): Nickname {
return Nickname(value)
}
internal fun create(value: String): Nickname {
if (value.length < MIN_LENGTH || value.length > MAX_LENGTH || !value.matches(pattern)) {
throw InvalidNicknameFormatException(value)
}
return Nickname(value)
}
}
닉네임 인스턴스 생성 로직 역시 비슷한 방식으로 구현했는데, 1자 이상 15자 이하의 영어/숫자만 허용하도록 설정하였습니다. 물론 이 로직은 외부 계층인 애플리케이션 계층에 노출되지 않습니다.
실제 로컬에서 실행해보면 API 구현 결과물은 잘 작동됩니다.
이렇게 해서 이메일/Username/닉네임 사용가능 여부 확인 API 구현을 마무리했습니다.
이어지는 글에서는 실제 데이터베이스 접근 설정을 다뤄보겠습니다. 감사합니다.
GitHub 리포지토리 : https://github.com/ttasjwi/board-system
이메일 사용가능여부 API PR: https://github.com/ttasjwi/board-system/pull/29
Username 사용가능여부 API PR: https://github.com/ttasjwi/board-system/pull/35
닉넹미 사용가능여부 API PR: https://github.com/ttasjwi/board-system/pull/36
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 14. 인증 이메일 발송 API 구현 (0) | 2024.11.13 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 13. 데이터베이스 접근기술 적용 (0) | 2024.11.04 |
[토이프로젝트] 게시판 시스템(board-system) 11. 멀티모듈과 테스트 픽스쳐 중복문제 (0) | 2024.10.29 |
[토이프로젝트] 게시판 시스템(board-system) 10. 애플리케이션 내부 아키텍처 설계 (0) | 2024.10.28 |
[토이프로젝트] 게시판 시스템(board-system) 9. API 예외 메시지 처리 (0) | 2024.10.21 |