From e92878a84a0786655c968552a1c09e0c3b9547d6 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 4 Aug 2025 16:52:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20member=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20Finder,=20Writer,=20Validator=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/business/MemberService.kt | 64 +++++--------- .../member/implement/MemberFinder.kt | 47 ++++++++++ .../member/implement/MemberWriter.kt | 34 ++++++++ .../persistence/MemberRepository.kt | 4 +- .../member/controller/MemberControllerTest.kt | 8 +- .../member/implement/MemberFinderTest.kt | 85 +++++++++++++++++++ .../member/implement/MemberValidatorTest.kt | 44 ++++++++++ .../member/implement/MemberWriterTest.kt | 65 ++++++++++++++ 8 files changed, 303 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/roomescape/member/implement/MemberFinder.kt create mode 100644 src/main/kotlin/roomescape/member/implement/MemberWriter.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt create mode 100644 src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt index d1a9e0ed..0783b999 100644 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ b/src/main/kotlin/roomescape/member/business/MemberService.kt @@ -1,72 +1,52 @@ package roomescape.member.business -import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.config.next -import roomescape.member.exception.MemberErrorCode -import roomescape.member.exception.MemberException +import roomescape.member.implement.MemberFinder +import roomescape.member.implement.MemberWriter import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.Role import roomescape.member.web.* private val log = KotlinLogging.logger {} @Service -@Transactional(readOnly = true) class MemberService( - private val tsidFactory: TsidFactory, - private val memberRepository: MemberRepository, + private val memberWriter: MemberWriter, + private val memberFinder: MemberFinder, ) { + @Transactional(readOnly = true) fun findMembers(): MemberRetrieveListResponse { - log.debug { "[MemberService.findMembers] 회원 조회 시작" } + log.info { "[MemberService.findMembers] 시작" } - return memberRepository.findAll() - .also { log.info { "[MemberService.findMembers] 회원 ${it.size}명 조회 완료" } } + return memberFinder.findAll() .toRetrieveListResponse() + .also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } } } + @Transactional(readOnly = true) fun findById(memberId: Long): MemberEntity { - return fetchOrThrow("findById", "memberId=$memberId") { - memberRepository.findByIdOrNull(memberId) - } + log.info { "[MemberService.findById] 시작" } + + return memberFinder.findById(memberId) + .also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } } } + @Transactional(readOnly = true) fun findByEmailAndPassword(email: String, password: String): MemberEntity { - return fetchOrThrow("findByEmailAndPassword", "email=$email, password=$password") { - memberRepository.findByEmailAndPassword(email, password) - } + log.info { "[MemberService.findByEmailAndPassword] 시작" } + + return memberFinder.findByEmailAndPassword(email, password) + .also { log.info { "[MemberService.findByEmailAndPassword] 완료. email=${email}, memberId=${it.id}" } } } @Transactional fun createMember(request: SignupRequest): SignupResponse { - memberRepository.findByEmail(request.email)?.let { - log.info { "[MemberService.createMember] 회원가입 실패(이메일 중복): email=${request.email}" } - throw MemberException(MemberErrorCode.DUPLICATE_EMAIL) - } + log.info { "[MemberService.createMember] 시작" } - val member = MemberEntity( - _id = tsidFactory.next(), - name = request.name, - email = request.email, - password = request.password, - role = Role.MEMBER - ) - - return memberRepository.save(member).toSignupResponse() - .also { log.info { "[MemberService.create] 회원가입 완료: email=${request.email} memberId=${it.id}" } } - } - - private fun fetchOrThrow(calledBy: String, params: String, block: () -> MemberEntity?): MemberEntity { - log.debug { "[MemberService.$calledBy] 회원 조회 시작: $params" } - return block() - ?.also { log.info { "[MemberService.$calledBy] 회원 조회 완료: memberId=${it.id}" } } - ?: run { - log.info { "[MemberService.$calledBy] 회원 조회 실패: $params" } - throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) - } + return memberWriter.create(request.name, request.email, request.password, Role.MEMBER) + .toSignupResponse() + .also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } } } } diff --git a/src/main/kotlin/roomescape/member/implement/MemberFinder.kt b/src/main/kotlin/roomescape/member/implement/MemberFinder.kt new file mode 100644 index 00000000..4c68dfcf --- /dev/null +++ b/src/main/kotlin/roomescape/member/implement/MemberFinder.kt @@ -0,0 +1,47 @@ +package roomescape.member.implement + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class MemberFinder( + private val memberRepository: MemberRepository +) { + + fun findAll(): List { + log.debug { "[MemberFinder.findAll] 회원 조회 시작" } + + return memberRepository.findAll() + .also { log.debug { "[MemberFinder.findAll] 회원 ${it.size}명 조회 완료" } } + } + + fun findById(id: Long): MemberEntity { + log.debug { "[MemberFinder.findById] 조회 시작: memberId=$id" } + + return memberRepository.findByIdOrNull(id) + ?.also { log.debug { "[MemberFinder.findById] 조회 완료: memberId=$id, email=${it.email}" } } + ?: run { + log.info { "[MemberFinder.findById] 조회 실패: id=$id" } + throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + } + } + + fun findByEmailAndPassword(email: String, password: String): MemberEntity { + log.debug { "[MemberFinder.findByEmailAndPassword] 조회 시작: email=$email, password=$password" } + + return memberRepository.findByEmailAndPassword(email, password) + ?.also { log.debug { "[MemberFinder.findByEmailAndPassword] 조회 완료: email=${email}, memberId=${it.id}" } } + ?: run { + log.info { "[MemberFinder.findByEmailAndPassword] 조회 실패: email=${email}, password=${password}" } + throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/member/implement/MemberWriter.kt b/src/main/kotlin/roomescape/member/implement/MemberWriter.kt new file mode 100644 index 00000000..a4d78fc1 --- /dev/null +++ b/src/main/kotlin/roomescape/member/implement/MemberWriter.kt @@ -0,0 +1,34 @@ +package roomescape.member.implement + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.common.config.next +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository +import roomescape.member.infrastructure.persistence.Role + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class MemberWriter( + private val tsidFactory: TsidFactory, + private val memberValidator: MemberValidator, + private val memberRepository: MemberRepository +) { + fun create(name: String, email: String, password: String, role: Role): MemberEntity { + memberValidator.validateCanSignup(email) + + val member = MemberEntity( + _id = tsidFactory.next(), + name = name, + email = email, + password = password, + role = role + ) + + return memberRepository.save(member) + .also { log.info { "[MemberWriter.create] 회원 저장 완료: email=$email, memberId=${it.id}" } } + } +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt index 13e2ac6d..5b5fd828 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt @@ -3,7 +3,7 @@ package roomescape.member.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository interface MemberRepository : JpaRepository { - fun findByEmailAndPassword(email: String, password: String): MemberEntity? + fun existsByEmail(email: String): Boolean - fun findByEmail(email: String): MemberEntity? + fun findByEmailAndPassword(email: String, password: String): MemberEntity? } diff --git a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt index 8eaef1c0..7a8cb84f 100644 --- a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt +++ b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt @@ -96,8 +96,8 @@ class MemberControllerTest( ) `when`("같은 이메일이 없으면") { every { - memberRepository.findByEmail(request.email) - } returns null + memberRepository.existsByEmail(request.email) + } returns false every { memberRepository.save(any()) @@ -124,8 +124,8 @@ class MemberControllerTest( `when`("같은 이메일이 있으면") { every { - memberRepository.findByEmail(request.email) - } returns mockk() + memberRepository.existsByEmail(request.email) + } returns true then("에러 응답") { val expectedError = MemberErrorCode.DUPLICATE_EMAIL diff --git a/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt b/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt new file mode 100644 index 00000000..e1be29f7 --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberFinderTest.kt @@ -0,0 +1,85 @@ +package roomescape.member.implement + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.repository.findByIdOrNull +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberRepository + +class MemberFinderTest : FunSpec({ + + val memberRepository: MemberRepository = mockk() + val memberFinder = MemberFinder(memberRepository) + + context("findAll") { + test("모든 회원을 조회한다.") { + every { + memberRepository.findAll() + } returns listOf(mockk(), mockk(), mockk()) + + memberFinder.findAll() shouldHaveSize 3 + } + } + + context("findById") { + val memberId = 1L + test("동일한 ID인 회원을 찾아 응답한다.") { + every { + memberRepository.findByIdOrNull(memberId) + } returns mockk() + + memberFinder.findById(memberId) + + verify(exactly = 1) { + memberRepository.findByIdOrNull(memberId) + } + } + + test("동일한 ID인 회원이 없으면 실패한다.") { + every { + memberRepository.findByIdOrNull(memberId) + } returns null + + shouldThrow { + memberFinder.findById(memberId) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + } + + context("findByEmailAndPassword") { + val email = "email" + val password = "password" + + test("동일한 이메일과 비밀번호를 가진 회원을 찾아 응답한다.") { + every { + memberRepository.findByEmailAndPassword(email, password) + } returns mockk() + + memberFinder.findByEmailAndPassword(email, password) + + verify(exactly = 1) { + memberRepository.findByEmailAndPassword(email, password) + } + } + + test("동일한 이메일과 비밀번호를 가진 회원이 없으면 실패한다.") { + every { + memberRepository.findByEmailAndPassword(email, password) + } returns null + + shouldThrow { + memberFinder.findByEmailAndPassword(email, password) + }.also { + it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt b/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt new file mode 100644 index 00000000..ade55e4b --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberValidatorTest.kt @@ -0,0 +1,44 @@ +package roomescape.member.implement + +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberRepository + +class MemberValidatorTest : FunSpec({ + + val memberRepository: MemberRepository = mockk() + val memberValidator = MemberValidator(memberRepository) + + context("validateCanSignup") { + val email = "email@email.com" + + test("같은 이메일을 가진 회원이 있으면 예외를 던진다.") { + every { + memberRepository.existsByEmail(email) + } returns true + + shouldThrow { + memberValidator.validateCanSignup(email) + }.also { + it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL + } + } + + test("같은 이메일을 가진 회원이 없으면 종료한다.") { + every { + memberRepository.existsByEmail(email) + } returns false + + shouldNotThrow { + memberValidator.validateCanSignup(email) + } + } + } +}) diff --git a/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt b/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt new file mode 100644 index 00000000..16e09201 --- /dev/null +++ b/src/test/kotlin/roomescape/member/implement/MemberWriterTest.kt @@ -0,0 +1,65 @@ +package roomescape.member.implement + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.MemberRepository +import roomescape.member.infrastructure.persistence.Role +import roomescape.util.MemberFixture +import roomescape.util.TsidFactory + +class MemberWriterTest : FunSpec({ + val memberRepository: MemberRepository = mockk() + val memberValidator = MemberValidator(memberRepository) + val memberWriter = MemberWriter(TsidFactory, memberValidator, memberRepository) + + context("create") { + val name = "name" + val email = "email" + val password = "password" + val role = Role.MEMBER + + test("중복된 이메일이 있으면 실패한다.") { + every { + memberRepository.existsByEmail(any()) + } returns true + + shouldThrow { + memberWriter.create(name, email, password, role) + }.also { + it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL + } + } + + test("중복된 이메일이 없으면 저장한다.") { + every { + memberRepository.existsByEmail(any()) + } returns false + + every { + memberRepository.save(any()) + } returns MemberFixture.create( + name = name, + account = email, + password = password, + role = role + ) + + memberWriter.create(name, email, password, role) + + verify(exactly = 1) { + memberRepository.save(any()) + } + } + } +})