feat: member 도메인에 Finder, Writer, Validator 추가 및 서비스 로직 수정

This commit is contained in:
이상진 2025-08-04 16:52:59 +09:00
parent d3e22888ed
commit e92878a84a
8 changed files with 303 additions and 48 deletions

View File

@ -1,72 +1,52 @@
package roomescape.member.business package roomescape.member.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.member.implement.MemberFinder
import roomescape.member.exception.MemberErrorCode import roomescape.member.implement.MemberWriter
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.* import roomescape.member.web.*
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@Service @Service
@Transactional(readOnly = true)
class MemberService( class MemberService(
private val tsidFactory: TsidFactory, private val memberWriter: MemberWriter,
private val memberRepository: MemberRepository, private val memberFinder: MemberFinder,
) { ) {
@Transactional(readOnly = true)
fun findMembers(): MemberRetrieveListResponse { fun findMembers(): MemberRetrieveListResponse {
log.debug { "[MemberService.findMembers] 회원 조회 시작" } log.info { "[MemberService.findMembers] 시작" }
return memberRepository.findAll() return memberFinder.findAll()
.also { log.info { "[MemberService.findMembers] 회원 ${it.size}명 조회 완료" } }
.toRetrieveListResponse() .toRetrieveListResponse()
.also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } }
} }
@Transactional(readOnly = true)
fun findById(memberId: Long): MemberEntity { fun findById(memberId: Long): MemberEntity {
return fetchOrThrow("findById", "memberId=$memberId") { log.info { "[MemberService.findById] 시작" }
memberRepository.findByIdOrNull(memberId)
} return memberFinder.findById(memberId)
.also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } }
} }
@Transactional(readOnly = true)
fun findByEmailAndPassword(email: String, password: String): MemberEntity { fun findByEmailAndPassword(email: String, password: String): MemberEntity {
return fetchOrThrow("findByEmailAndPassword", "email=$email, password=$password") { log.info { "[MemberService.findByEmailAndPassword] 시작" }
memberRepository.findByEmailAndPassword(email, password)
} return memberFinder.findByEmailAndPassword(email, password)
.also { log.info { "[MemberService.findByEmailAndPassword] 완료. email=${email}, memberId=${it.id}" } }
} }
@Transactional @Transactional
fun createMember(request: SignupRequest): SignupResponse { fun createMember(request: SignupRequest): SignupResponse {
memberRepository.findByEmail(request.email)?.let { log.info { "[MemberService.createMember] 시작" }
log.info { "[MemberService.createMember] 회원가입 실패(이메일 중복): email=${request.email}" }
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
}
val member = MemberEntity( return memberWriter.create(request.name, request.email, request.password, Role.MEMBER)
_id = tsidFactory.next(), .toSignupResponse()
name = request.name, .also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } }
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)
}
} }
} }

View File

@ -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<MemberEntity> {
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)
}
}
}

View File

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

View File

@ -3,7 +3,7 @@ package roomescape.member.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<MemberEntity, Long> { interface MemberRepository : JpaRepository<MemberEntity, Long> {
fun findByEmailAndPassword(email: String, password: String): MemberEntity? fun existsByEmail(email: String): Boolean
fun findByEmail(email: String): MemberEntity? fun findByEmailAndPassword(email: String, password: String): MemberEntity?
} }

View File

@ -96,8 +96,8 @@ class MemberControllerTest(
) )
`when`("같은 이메일이 없으면") { `when`("같은 이메일이 없으면") {
every { every {
memberRepository.findByEmail(request.email) memberRepository.existsByEmail(request.email)
} returns null } returns false
every { every {
memberRepository.save(any()) memberRepository.save(any())
@ -124,8 +124,8 @@ class MemberControllerTest(
`when`("같은 이메일이 있으면") { `when`("같은 이메일이 있으면") {
every { every {
memberRepository.findByEmail(request.email) memberRepository.existsByEmail(request.email)
} returns mockk() } returns true
then("에러 응답") { then("에러 응답") {
val expectedError = MemberErrorCode.DUPLICATE_EMAIL val expectedError = MemberErrorCode.DUPLICATE_EMAIL

View File

@ -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<MemberException> {
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<MemberException> {
memberFinder.findByEmailAndPassword(email, password)
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
}
})

View File

@ -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<MemberException> {
memberValidator.validateCanSignup(email)
}.also {
it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL
}
}
test("같은 이메일을 가진 회원이 없으면 종료한다.") {
every {
memberRepository.existsByEmail(email)
} returns false
shouldNotThrow<MemberException> {
memberValidator.validateCanSignup(email)
}
}
}
})

View File

@ -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<MemberException> {
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())
}
}
}
})