From a0a4d1551909471b6969342d39173f02d4ed20da Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 24 Apr 2026 19:30:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=A0=80=EC=9E=A5=EA=B3=BC=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=9D=B8=ED=94=84=EB=9D=BC=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techmoa/application/port/MemberPort.kt | 2 ++ .../domain/exception/DomainException.kt | 5 ++++ .../techmoa/domain/exception/ErrorCode.kt | 3 +++ .../techmoa/domain/model/OauthProvider.kt | 3 ++- .../jpa/adapter/MemberAdapter.kt | 25 +++++++++++++++++++ .../infrastructure/jpa/entity/MemberEntity.kt | 8 +++++- .../jpa/repository/MemberRepository.kt | 2 ++ ...5__add_login_id_and_password_to_member.sql | 6 +++++ infrastructure/oauth/build.gradle.kts | 3 +++ .../presentation/common/error/ErrorType.kt | 2 ++ 10 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql diff --git a/application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt b/application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt index ae91ed7..5aefe12 100644 --- a/application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt +++ b/application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt @@ -8,4 +8,6 @@ import site.techmoa.domain.model.OauthProvider interface MemberPort { fun findByProviderAndSubject(provider: OauthProvider, subject: String): MemberLookupResult fun save(resource: MemberResource): Member + fun existsByLoginId(loginId: String): Boolean + fun saveLocal(loginId: String, encodedPassword: String): Member } \ No newline at end of file diff --git a/domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt b/domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt index efc0d49..6dc1308 100644 --- a/domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt +++ b/domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt @@ -31,6 +31,11 @@ class KidNotMatchException( override val cause: Throwable? = null, ) : DomainException(ErrorCode.KID_NOT_MATCH, message, cause) +class DuplicatedLoginIdException( + override val message: String, + override val cause: Throwable? = null, +) : DomainException(ErrorCode.DUPLICATED_LOGIN_ID, message, cause) + class DuplicatedWebhookException( override val message: String, override val cause: Throwable? = null, diff --git a/domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt b/domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt index 63d7208..72d2dc9 100644 --- a/domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt +++ b/domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt @@ -16,6 +16,9 @@ enum class ErrorCode{ // OIDC KID_NOT_MATCH, + // MEMBER + DUPLICATED_LOGIN_ID, + // WEBHOOK DUPLICATED_WEBHOOK, INVALID_WEBHOOK_PLATFORM, diff --git a/domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt b/domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt index d6ebba1..b30b741 100644 --- a/domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt +++ b/domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt @@ -1,5 +1,6 @@ package site.techmoa.domain.model enum class OauthProvider { - KAKAO + KAKAO, + LOCAL, } \ No newline at end of file diff --git a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt index c849677..6643c39 100644 --- a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt +++ b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt @@ -51,4 +51,29 @@ class MemberAdapter( ) } } + + @Transactional(readOnly = true) + override fun existsByLoginId(loginId: String): Boolean { + return memberRepository.existsByLoginId(loginId) + } + + @Transactional + override fun saveLocal(loginId: String, encodedPassword: String): Member { + return memberRepository.save( + MemberEntity( + email = "", + provider = OauthProvider.LOCAL, + subject = loginId, + loginId = loginId, + password = encodedPassword, + ) + ).let { + Member( + id = it.id, + email = it.email, + provider = it.provider, + subject = it.subject, + ) + } + } } \ No newline at end of file diff --git a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt index 6500270..9c5e3c4 100644 --- a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt +++ b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt @@ -25,5 +25,11 @@ class MemberEntity( val provider: OauthProvider, @Column(name = "subject", nullable = false, length = 64) - val subject: String + val subject: String, + + @Column(name = "login_id", nullable = true, length = 50) + val loginId: String? = null, + + @Column(name = "password", nullable = true, length = 100) + val password: String? = null, ) : BaseEntity() diff --git a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt index 5343aa0..e45bf0f 100644 --- a/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt +++ b/infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt @@ -9,4 +9,6 @@ import site.techmoa.infrastructure.jpa.entity.MemberEntity interface MemberRepository : JpaRepository { @Query("SELECT m FROM MemberEntity m WHERE m.provider = :provider and m.subject = :subject") fun findByProviderAndSubjectOrNull(@Param("provider") provider: OauthProvider, @Param("subject") subject: String): MemberEntity? + + fun existsByLoginId(loginId: String): Boolean } \ No newline at end of file diff --git a/infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql b/infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql new file mode 100644 index 0000000..c340add --- /dev/null +++ b/infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql @@ -0,0 +1,6 @@ +ALTER TABLE member + ADD COLUMN login_id VARCHAR(50) NULL AFTER subject, + ADD COLUMN password VARCHAR(100) NULL AFTER login_id; + +ALTER TABLE member + ADD CONSTRAINT uk_login_id UNIQUE (login_id); diff --git a/infrastructure/oauth/build.gradle.kts b/infrastructure/oauth/build.gradle.kts index de8a87b..52facc9 100644 --- a/infrastructure/oauth/build.gradle.kts +++ b/infrastructure/oauth/build.gradle.kts @@ -6,4 +6,7 @@ dependencies { // Source: https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt implementation("io.jsonwebtoken:jjwt:${jwtVersion}") + + // BCryptPasswordEncoder + implementation("org.springframework.security:spring-security-crypto") } \ No newline at end of file diff --git a/presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt b/presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt index 40d36aa..e0d626d 100644 --- a/presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt +++ b/presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt @@ -21,6 +21,8 @@ enum class ErrorType( KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, ErrorCode.KAKAO_SERVER_ERROR, "카카오 인증 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", LogLevel.ERROR), KID_NOT_MATCH(HttpStatus.UNAUTHORIZED, ErrorCode.KID_NOT_MATCH, "OIDC 토큰 검증에 실패했습니다.", LogLevel.WARN), + DUPLICATED_LOGIN_ID(HttpStatus.CONFLICT, ErrorCode.DUPLICATED_LOGIN_ID, "이미 사용 중인 아이디입니다.", LogLevel.INFO), + DUPLICATED_WEBHOOK(HttpStatus.BAD_REQUEST, ErrorCode.DUPLICATED_WEBHOOK, "이미 등록된 웹훅입니다.", LogLevel.INFO), INVALID_WEBHOOK_PLATFORM(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_WEBHOOK_PLATFORM, "지원하지 않는 웹훅 플랫폼입니다.", LogLevel.INFO), INVALID_WEBHOOK_URL(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_WEBHOOK_URL, "웹훅 URL 형식이 올바르지 않습니다.", LogLevel.INFO); From 236946936997c594e4bb1f3e9bc1737ed317c052 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 24 Apr 2026 19:30:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techmoa/application/dto/SignupCommand.kt | 6 + .../techmoa/application/port/PasswordPort.kt | 5 + .../application/service/SignupService.kt | 37 +++++ .../application/service/SignupServiceTest.kt | 140 +++++++++++++++++ .../oauth/adapter/PasswordAdapter.kt | 15 ++ .../controller/SignupControllerV1.kt | 40 +++++ .../controller/request/SignupRequest.kt | 6 + .../controller/SignupControllerV1Test.kt | 145 ++++++++++++++++++ 8 files changed, 394 insertions(+) create mode 100644 application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt create mode 100644 application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt create mode 100644 application/src/main/kotlin/site/techmoa/application/service/SignupService.kt create mode 100644 application/src/test/kotlin/site/techmoa/application/service/SignupServiceTest.kt create mode 100644 infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt create mode 100644 presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt create mode 100644 presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt create mode 100644 presentation/src/test/kotlin/site/techmoa/presentation/controller/SignupControllerV1Test.kt diff --git a/application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt b/application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt new file mode 100644 index 0000000..944e6e7 --- /dev/null +++ b/application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt @@ -0,0 +1,6 @@ +package site.techmoa.application.dto + +data class SignupCommand( + val loginId: String, + val password: String, +) diff --git a/application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt b/application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt new file mode 100644 index 0000000..3791ae4 --- /dev/null +++ b/application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt @@ -0,0 +1,5 @@ +package site.techmoa.application.port + +interface PasswordPort { + fun encode(rawPassword: String): String +} diff --git a/application/src/main/kotlin/site/techmoa/application/service/SignupService.kt b/application/src/main/kotlin/site/techmoa/application/service/SignupService.kt new file mode 100644 index 0000000..66bc995 --- /dev/null +++ b/application/src/main/kotlin/site/techmoa/application/service/SignupService.kt @@ -0,0 +1,37 @@ +package site.techmoa.application.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import site.techmoa.application.dto.AuthToken +import site.techmoa.application.dto.SignupCommand +import site.techmoa.application.port.AuthTokenPort +import site.techmoa.application.port.MemberPort +import site.techmoa.application.port.PasswordPort +import site.techmoa.domain.exception.DuplicatedLoginIdException + +@Service +class SignupService( + private val memberPort: MemberPort, + private val passwordPort: PasswordPort, + private val authTokenPort: AuthTokenPort, +) { + + private val log = LoggerFactory.getLogger(this.javaClass) + + fun process(command: SignupCommand): AuthToken { + log.info("[Signup] Processing signup for loginId: ${command.loginId.replace(Regex("[\r\n]"), "_")}") + + if (memberPort.existsByLoginId(command.loginId)) { + throw DuplicatedLoginIdException("이미 사용 중인 아이디입니다. loginId: ${command.loginId}") + } + + val encodedPassword = passwordPort.encode(command.password) + val member = memberPort.saveLocal(command.loginId, encodedPassword) + + log.info("[Signup] Member created - memberId: ${member.id}") + val authToken = authTokenPort.issue(member.id) + + log.info("[Signup] Token issued successfully") + return authToken + } +} diff --git a/application/src/test/kotlin/site/techmoa/application/service/SignupServiceTest.kt b/application/src/test/kotlin/site/techmoa/application/service/SignupServiceTest.kt new file mode 100644 index 0000000..0bfcdab --- /dev/null +++ b/application/src/test/kotlin/site/techmoa/application/service/SignupServiceTest.kt @@ -0,0 +1,140 @@ +package site.techmoa.application.service + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import site.techmoa.application.dto.AuthToken +import site.techmoa.application.dto.SignupCommand +import site.techmoa.application.port.AuthTokenPort +import site.techmoa.application.port.MemberPort +import site.techmoa.application.port.PasswordPort +import site.techmoa.domain.exception.DuplicatedLoginIdException +import site.techmoa.domain.exception.ErrorCode +import site.techmoa.domain.model.Member +import site.techmoa.domain.model.OauthProvider + +class SignupServiceTest : BehaviorSpec({ + + given("자체 회원가입 요청에서") { + + `when`("유효한 loginId와 password로 가입하면") { + val memberPort = mockk() + val passwordPort = mockk() + val authTokenPort = mockk() + val signupService = SignupService(memberPort, passwordPort, authTokenPort) + + every { memberPort.existsByLoginId(LOGIN_ID) } returns false + every { passwordPort.encode(RAW_PASSWORD) } returns ENCODED_PASSWORD + every { memberPort.saveLocal(LOGIN_ID, ENCODED_PASSWORD) } returns localMember() + every { authTokenPort.issue(MEMBER_ID) } returns AuthToken(ACCESS_TOKEN) + + then("토큰을 발급하여 반환한다") { + val result = signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + + result.accessToken shouldBe ACCESS_TOKEN + + verify(exactly = 1) { memberPort.existsByLoginId(LOGIN_ID) } + verify(exactly = 1) { passwordPort.encode(RAW_PASSWORD) } + verify(exactly = 1) { memberPort.saveLocal(LOGIN_ID, ENCODED_PASSWORD) } + verify(exactly = 1) { authTokenPort.issue(MEMBER_ID) } + } + } + + `when`("다른 유효한 loginId로 가입하면") { + val memberPort = mockk() + val passwordPort = mockk() + val authTokenPort = mockk() + val signupService = SignupService(memberPort, passwordPort, authTokenPort) + + val anotherLoginId = "another_user" + val anotherMemberId = 99L + val anotherToken = "token-for-another" + + every { memberPort.existsByLoginId(anotherLoginId) } returns false + every { passwordPort.encode(RAW_PASSWORD) } returns ENCODED_PASSWORD + every { memberPort.saveLocal(anotherLoginId, ENCODED_PASSWORD) } returns localMember( + id = anotherMemberId, + loginId = anotherLoginId, + ) + every { authTokenPort.issue(anotherMemberId) } returns AuthToken(anotherToken) + + then("해당 회원의 토큰을 발급하여 반환한다") { + val result = signupService.process(SignupCommand(anotherLoginId, RAW_PASSWORD)) + + result.accessToken shouldBe anotherToken + + verify(exactly = 1) { memberPort.existsByLoginId(anotherLoginId) } + verify(exactly = 1) { memberPort.saveLocal(anotherLoginId, ENCODED_PASSWORD) } + verify(exactly = 1) { authTokenPort.issue(anotherMemberId) } + } + } + + `when`("이미 존재하는 loginId로 가입하면") { + val memberPort = mockk() + val passwordPort = mockk() + val authTokenPort = mockk() + val signupService = SignupService(memberPort, passwordPort, authTokenPort) + + every { memberPort.existsByLoginId(LOGIN_ID) } returns true + + then("DuplicatedLoginIdException이 발생하고 저장은 수행되지 않는다") { + val exception = shouldThrow { + signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + } + + exception.errorCode shouldBe ErrorCode.DUPLICATED_LOGIN_ID + exception.message shouldBe "이미 사용 중인 아이디입니다. loginId: $LOGIN_ID" + + verify(exactly = 1) { memberPort.existsByLoginId(LOGIN_ID) } + verify(exactly = 0) { passwordPort.encode(any()) } + verify(exactly = 0) { memberPort.saveLocal(any(), any()) } + verify(exactly = 0) { authTokenPort.issue(any()) } + } + } + + `when`("비밀번호 인코딩 후 회원 저장이 실패하면") { + val memberPort = mockk() + val passwordPort = mockk() + val authTokenPort = mockk() + val signupService = SignupService(memberPort, passwordPort, authTokenPort) + + every { memberPort.existsByLoginId(LOGIN_ID) } returns false + every { passwordPort.encode(RAW_PASSWORD) } returns ENCODED_PASSWORD + every { memberPort.saveLocal(LOGIN_ID, ENCODED_PASSWORD) } throws RuntimeException("DB error") + + then("예외가 전파되고 토큰 발급은 수행되지 않는다") { + val exception = shouldThrow { + signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + } + + exception.message shouldBe "DB error" + + verify(exactly = 1) { memberPort.existsByLoginId(LOGIN_ID) } + verify(exactly = 1) { passwordPort.encode(RAW_PASSWORD) } + verify(exactly = 1) { memberPort.saveLocal(LOGIN_ID, ENCODED_PASSWORD) } + verify(exactly = 0) { authTokenPort.issue(any()) } + } + } + } +}) { + companion object { + private const val LOGIN_ID = "test_user" + private const val RAW_PASSWORD = "password123" + private const val ENCODED_PASSWORD = "\$2a\$10\$encodedPasswordHash" + private const val MEMBER_ID = 1L + private const val ACCESS_TOKEN = "jwt-access-token" + } +} + +private fun localMember( + id: Long = 1L, + loginId: String = "test_user", +): Member = Member( + id = id, + email = "", + provider = OauthProvider.LOCAL, + subject = loginId, +) diff --git a/infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt b/infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt new file mode 100644 index 0000000..59ac034 --- /dev/null +++ b/infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt @@ -0,0 +1,15 @@ +package site.techmoa.infrastructure.oauth.adapter + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.stereotype.Component +import site.techmoa.application.port.PasswordPort + +@Component +class PasswordAdapter : PasswordPort { + + private val encoder = BCryptPasswordEncoder() + + override fun encode(rawPassword: String): String { + return encoder.encode(rawPassword) + } +} diff --git a/presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt b/presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt new file mode 100644 index 0000000..1c370c8 --- /dev/null +++ b/presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt @@ -0,0 +1,40 @@ +package site.techmoa.presentation.controller + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +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 +import site.techmoa.application.dto.SignupCommand +import site.techmoa.application.service.SignupService +import site.techmoa.presentation.common.cookie.AuthCookieFactory +import site.techmoa.presentation.common.template.ApiResponse +import site.techmoa.presentation.controller.request.SignupRequest + +@RequestMapping("/v1/auth") +@RestController +class SignupControllerV1( + private val signupService: SignupService, + private val authCookieFactory: AuthCookieFactory, +) { + + private val log = LoggerFactory.getLogger(this.javaClass) + + @PostMapping("/signup") + fun signup(@RequestBody request: SignupRequest): ResponseEntity> { + log.info("[Signup] Signup request received for loginId: ${request.loginId.replace(Regex("[\r\n]"), "_")}") + val command = SignupCommand( + loginId = request.loginId, + password = request.password, + ) + val token = signupService.process(command) + + log.info("[Signup] Signup successful, access token issued") + val headers = authCookieFactory.accessTokenHeaders(token.accessToken) + return ResponseEntity.status(HttpStatus.CREATED) + .headers(headers) + .body(ApiResponse.success()) + } +} diff --git a/presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt b/presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt new file mode 100644 index 0000000..33d2718 --- /dev/null +++ b/presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt @@ -0,0 +1,6 @@ +package site.techmoa.presentation.controller.request + +data class SignupRequest( + val loginId: String, + val password: String, +) diff --git a/presentation/src/test/kotlin/site/techmoa/presentation/controller/SignupControllerV1Test.kt b/presentation/src/test/kotlin/site/techmoa/presentation/controller/SignupControllerV1Test.kt new file mode 100644 index 0000000..25d35fc --- /dev/null +++ b/presentation/src/test/kotlin/site/techmoa/presentation/controller/SignupControllerV1Test.kt @@ -0,0 +1,145 @@ +package site.techmoa.presentation.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import io.kotest.core.spec.style.BehaviorSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +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 +import site.techmoa.application.dto.AuthToken +import site.techmoa.application.dto.SignupCommand +import site.techmoa.application.service.SignupService +import site.techmoa.domain.exception.DuplicatedLoginIdException +import site.techmoa.presentation.common.cookie.AuthCookieFactory +import site.techmoa.presentation.common.error.GlobalExceptionHandler + +class SignupControllerV1Test : BehaviorSpec({ + val objectMapper = ObjectMapper() + val signupService = mockk() + val authCookieFactory = AuthCookieFactory() + val controller = SignupControllerV1(signupService, authCookieFactory) + val mockMvc: MockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(GlobalExceptionHandler()) + .build() + + beforeTest { + clearMocks(signupService) + } + + given("POST /v1/auth/signup 요청 처리 상황에서") { + + `when`("유효한 loginId와 password로 요청하면") { + then("201과 accessToken 쿠키를 반환한다") { + every { + signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + } returns AuthToken(ACCESS_TOKEN) + + mockMvc.post("/v1/auth/signup") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf("loginId" to LOGIN_ID, "password" to RAW_PASSWORD) + ) + } + .andExpect { + status { isCreated() } + jsonPath("$.resultType") { value("SUCCESS") } + jsonPath("$.data") { doesNotExist() } + header { exists("Set-Cookie") } + header { string("Set-Cookie", org.hamcrest.Matchers.containsString("accessToken=$ACCESS_TOKEN")) } + } + + verify(exactly = 1) { + signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + } + } + } + + `when`("다른 유효한 loginId로 요청하면") { + then("201과 해당 회원의 accessToken 쿠키를 반환한다") { + val anotherLoginId = "another_user" + val anotherToken = "token-for-another" + + every { + signupService.process(SignupCommand(anotherLoginId, RAW_PASSWORD)) + } returns AuthToken(anotherToken) + + mockMvc.post("/v1/auth/signup") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf("loginId" to anotherLoginId, "password" to RAW_PASSWORD) + ) + } + .andExpect { + status { isCreated() } + jsonPath("$.resultType") { value("SUCCESS") } + header { string("Set-Cookie", org.hamcrest.Matchers.containsString("accessToken=$anotherToken")) } + } + + verify(exactly = 1) { + signupService.process(SignupCommand(anotherLoginId, RAW_PASSWORD)) + } + } + } + + `when`("중복된 loginId로 요청하면") { + then("409와 DUPLICATED_LOGIN_ID를 반환한다") { + every { + signupService.process(SignupCommand(LOGIN_ID, RAW_PASSWORD)) + } throws DuplicatedLoginIdException("이미 사용 중인 아이디입니다. loginId: $LOGIN_ID") + + mockMvc.post("/v1/auth/signup") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf("loginId" to LOGIN_ID, "password" to RAW_PASSWORD) + ) + } + .andExpect { + status { isConflict() } + jsonPath("$.resultType") { value("ERROR") } + jsonPath("$.errorMessage.code") { value("DUPLICATED_LOGIN_ID") } + jsonPath("$.errorMessage.message") { value("이미 사용 중인 아이디입니다.") } + } + } + } + + `when`("요청 본문이 비어 있으면") { + then("400 에러를 반환한다") { + mockMvc.post("/v1/auth/signup") { + contentType = MediaType.APPLICATION_JSON + content = "" + } + .andExpect { + status { isBadRequest() } + } + + verify(exactly = 0) { signupService.process(any()) } + } + } + + `when`("Content-Type 없이 요청하면") { + then("415 에러를 반환한다") { + mockMvc.post("/v1/auth/signup") { + content = objectMapper.writeValueAsString( + mapOf("loginId" to LOGIN_ID, "password" to RAW_PASSWORD) + ) + } + .andExpect { + status { isUnsupportedMediaType() } + } + + verify(exactly = 0) { signupService.process(any()) } + } + } + } +}) { + companion object { + private const val LOGIN_ID = "test_user" + private const val RAW_PASSWORD = "password123" + private const val ACCESS_TOKEN = "jwt-access-token" + } +} From b590ce62baff5fcf39e0098ba6c03be234a7a9af Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 24 Apr 2026 19:32:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=9E=90=EC=B2=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=9E=91=EC=97=85=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EB=AC=BC=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../00_input.md" | 21 ++ .../01_architect_plan.md" | 226 ++++++++++++++++++ .../02_implementer_summary.md" | 60 +++++ .../03_test_summary.md" | 45 ++++ .../04_review_report.md" | 138 +++++++++++ 5 files changed, 490 insertions(+) create mode 100644 "_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/00_input.md" create mode 100644 "_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/01_architect_plan.md" create mode 100644 "_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/02_implementer_summary.md" create mode 100644 "_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/03_test_summary.md" create mode 100644 "_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/04_review_report.md" diff --git "a/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/00_input.md" "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/00_input.md" new file mode 100644 index 0000000..1d88276 --- /dev/null +++ "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/00_input.md" @@ -0,0 +1,21 @@ +# 요구사항: 자체 회원가입 기능 + +## 개요 +자체 회원가입 기능을 개발한다. 소셜 로그인이 아닌, id와 password만으로 회원가입을 진행하는 기능이다. + +## 입력 +- **id**: 사용자 식별자 (로그인 시 사용) +- **password**: 비밀번호 + +## 기능 요구사항 +1. id, password를 입력받아 회원가입을 처리한다 +2. 비밀번호는 안전하게 해싱하여 저장한다 +3. 중복 id 검증을 수행한다 +4. 회원가입 성공 시 적절한 응답을 반환한다 + +## 비기능 요구사항 +- 헥사고날 아키텍처 준수 (domain → application → presentation → infrastructure) +- 기존 멀티모듈 구조에 맞게 구현 + +## 개발 방식 +- SDD (Subagent-Driven Development) 파이프라인 사용 diff --git "a/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/01_architect_plan.md" "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/01_architect_plan.md" new file mode 100644 index 0000000..9699719 --- /dev/null +++ "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/01_architect_plan.md" @@ -0,0 +1,226 @@ +# 구현 계획: 자체 회원가입 기능 + +## 1. 요구사항 요약 + +- id(로그인 식별자)와 password만으로 자체 회원가입을 처리한다. +- 비밀번호는 BCrypt로 해싱하여 저장한다. +- 중복 id 검증을 수행한다. +- 회원가입 성공 시 JWT 토큰을 발급하여 쿠키로 반환한다. + +## 2. 설계 결정사항 + +### 2.1 기존 Member 도메인과의 관계 + +기존 `Member`는 OAuth 전용 구조(`provider`, `subject`, `email`)이다. 자체 회원가입은 `loginId`와 `password`를 사용하므로 기존 member 테이블을 확장한다. + +- `OauthProvider` enum에 `LOCAL` 값을 추가한다. +- member 테이블에 `login_id`(VARCHAR 50, nullable)와 `password`(VARCHAR 100, nullable) 컬럼을 추가한다. +- LOCAL 회원: `provider=LOCAL`, `subject=loginId`, `login_id=loginId`, `password=해시값`, `email`은 빈 문자열. +- OAuth 회원: 기존과 동일, `login_id=NULL`, `password=NULL`. +- `login_id` 컬럼에 유니크 인덱스를 추가하여 중복 검증을 DB 레벨에서 보장한다. + +### 2.2 비밀번호 해싱 전략 + +- Spring Security의 `BCryptPasswordEncoder`를 사용한다. +- application 레이어에 `PasswordPort` 인터페이스를 정의하고, infrastructure 레이어에서 BCrypt 구현체를 제공한다. +- domain 모듈은 순수 Kotlin을 유지하므로 해싱 로직을 넣지 않는다. + +### 2.3 중복 ID 검증 전략 + +- `MemberPort`에 `existsByLoginId(loginId: String): Boolean` 메서드를 추가한다. +- 서비스 레이어에서 저장 전 중복 검증을 수행한다. +- DB 유니크 인덱스로 동시성 문제를 방어한다. + +## 3. 영향받는 모듈 목록 + +| 모듈 | 변경 유형 | +|------|----------| +| domain | 모델 수정 (`OauthProvider`에 LOCAL 추가), 예외 추가 | +| application | 포트 추가/수정, 서비스 생성, DTO 추가 | +| presentation | 컨트롤러 추가, request/response 추가, ErrorType 추가 | +| infrastructure:jpa | 엔티티 수정, 리포지토리 수정, 어댑터 수정/추가 | +| infrastructure:mysql | Flyway 마이그레이션 추가 | +| infrastructure:oauth | PasswordPort 구현 어댑터 추가, build.gradle.kts 의존성 추가 | + +## 4. API 스펙 + +### POST /v1/auth/signup + +**Request Body:** +```json +{ + "loginId": "string (3~50자, 영문 소문자+숫자+언더스코어)", + "password": "string (8~30자)" +} +``` + +**Response (201 Created):** +- Set-Cookie 헤더에 accessToken 포함 +```json +{ + "resultType": "SUCCESS", + "data": null, + "errorMessage": null +} +``` + +**Error Responses:** +- 400 Bad Request: 입력값 검증 실패 +- 409 Conflict: 중복된 loginId (`DUPLICATED_LOGIN_ID`) + +## 5. DB 스키마 변경 + +### V1.15__add_login_id_and_password_to_member.sql + +```sql +ALTER TABLE member + ADD COLUMN login_id VARCHAR(50) NULL AFTER subject, + ADD COLUMN password VARCHAR(100) NULL AFTER login_id; + +ALTER TABLE member + ADD CONSTRAINT uk_login_id UNIQUE (login_id); +``` + +## 6. 레이어별 구현 계획 + +### 6.1 domain 모듈 + +#### 수정: `domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt` +- `LOCAL` 값을 enum에 추가한다. + +#### 수정: `domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt` +- `DUPLICATED_LOGIN_ID` 추가. + +#### 수정: `domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt` +- `DuplicatedLoginIdException` 클래스 추가. + +--- + +### 6.2 application 모듈 + +#### 생성: `application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt` +```kotlin +interface PasswordPort { + fun encode(rawPassword: String): String +} +``` +- 비밀번호 해싱을 추상화하는 포트. + +#### 수정: `application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt` +- `existsByLoginId(loginId: String): Boolean` 메서드 추가. +- `saveLocal(loginId: String, encodedPassword: String): Member` 메서드 추가. + +#### 생성: `application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt` +```kotlin +data class SignupCommand( + val loginId: String, + val password: String, +) +``` +- 회원가입 요청 데이터를 전달하는 DTO. + +#### 생성: `application/src/main/kotlin/site/techmoa/application/service/SignupService.kt` +```kotlin +@Service +class SignupService( + private val memberPort: MemberPort, + private val passwordPort: PasswordPort, + private val authTokenPort: AuthTokenPort, +) { + fun process(command: SignupCommand): AuthToken { + // 1. 중복 loginId 검증 + // 2. 비밀번호 해싱 + // 3. 회원 저장 (provider=LOCAL, subject=loginId) + // 4. JWT 토큰 발급 + } +} +``` +- 자체 회원가입 유스케이스 오케스트레이션. + +--- + +### 6.3 presentation 모듈 + +#### 생성: `presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt` +```kotlin +data class SignupRequest( + val loginId: String, + val password: String, +) +``` +- 요청 바인딩 및 검증. + +#### 생성: `presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt` +```kotlin +@RequestMapping("/v1/auth") +@RestController +class SignupControllerV1( + private val signupService: SignupService, + private val authCookieFactory: AuthCookieFactory, +) { + @PostMapping("/signup") + fun signup(@RequestBody request: SignupRequest): ResponseEntity> { + // 1. request -> SignupCommand 변환 + // 2. signupService.process() 호출 + // 3. 쿠키에 accessToken 설정, 201 반환 + } +} +``` + +#### 수정: `presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt` +- `DUPLICATED_LOGIN_ID(HttpStatus.CONFLICT, ErrorCode.DUPLICATED_LOGIN_ID, "이미 사용 중인 아이디입니다.", LogLevel.INFO)` 추가. + +--- + +### 6.4 infrastructure:jpa 모듈 + +#### 수정: `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt` +- `loginId` (nullable VARCHAR 50) 필드 추가. +- `password` (nullable VARCHAR 100) 필드 추가. + +#### 수정: `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt` +- `existsByLoginId(loginId: String): Boolean` 메서드 추가. + +#### 수정: `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt` +- `existsByLoginId()` 구현. +- `saveLocal()` 구현: `MemberEntity`를 LOCAL provider로 저장. + +--- + +### 6.5 infrastructure:oauth 모듈 + +#### 생성: `infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt` +```kotlin +@Component +class PasswordAdapter : PasswordPort { + private val encoder = BCryptPasswordEncoder() + override fun encode(rawPassword: String): String = encoder.encode(rawPassword) +} +``` + +#### 수정: `infrastructure/oauth/build.gradle.kts` +- `org.springframework.security:spring-security-crypto` 의존성 추가 (BCryptPasswordEncoder 사용). + +--- + +### 6.6 infrastructure:mysql 모듈 + +#### 생성: `infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql` +- 위 5번 참조. + +## 7. 구현 순서 + +1. **domain**: `OauthProvider.LOCAL` 추가, `ErrorCode.DUPLICATED_LOGIN_ID` 추가, `DuplicatedLoginIdException` 추가 +2. **infrastructure:mysql**: Flyway 마이그레이션 `V1.15` 추가 +3. **infrastructure:jpa**: `MemberEntity` 필드 추가, `MemberRepository` 메서드 추가, `MemberAdapter` 수정 +4. **application**: `PasswordPort` 생성, `MemberPort` 수정, `SignupCommand` 생성, `SignupService` 생성 +5. **infrastructure:oauth**: `PasswordAdapter` 생성, build.gradle.kts 의존성 추가 +6. **presentation**: `SignupRequest` 생성, `SignupControllerV1` 생성, `ErrorType` 수정 + +## 8. 기존 코드와의 통합 지점 + +- `Member` 도메인 모델 자체는 변경하지 않는다 (LOCAL 회원도 동일한 `Member` 모델로 표현). +- `OauthProvider`에 `LOCAL`을 추가하여 기존 OAuth 흐름과 호환을 유지한다. +- `AuthTokenPort`, `AuthCookieFactory`는 기존 것을 그대로 재사용한다. +- `MemberPort`에 메서드를 추가하되, 기존 `findByProviderAndSubject`, `save` 메서드는 변경하지 않는다. +- `GlobalExceptionHandler`는 `DomainException` 기반이므로 추가 설정 없이 새 예외를 처리한다. diff --git "a/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/02_implementer_summary.md" "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/02_implementer_summary.md" new file mode 100644 index 0000000..38757fa --- /dev/null +++ "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/02_implementer_summary.md" @@ -0,0 +1,60 @@ +# 구현 완료 요약: 자체 회원가입 기능 + +## 빌드 상태 +- `./gradlew compileKotlin` : BUILD SUCCESSFUL + +## 변경/생성 파일 목록 + +### domain (수정 3건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `domain/src/main/kotlin/site/techmoa/domain/model/OauthProvider.kt` | 수정 | `LOCAL` 값 추가 | +| `domain/src/main/kotlin/site/techmoa/domain/exception/ErrorCode.kt` | 수정 | `DUPLICATED_LOGIN_ID` 추가 (MEMBER 섹션) | +| `domain/src/main/kotlin/site/techmoa/domain/exception/DomainException.kt` | 수정 | `DuplicatedLoginIdException` 클래스 추가 | + +### infrastructure:mysql (생성 1건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `infrastructure/mysql/src/main/resources/db/migration/V1.15__add_login_id_and_password_to_member.sql` | 생성 | member 테이블에 login_id(VARCHAR 50), password(VARCHAR 100) nullable 컬럼 추가 + uk_login_id 유니크 제약 | + +### infrastructure:jpa (수정 3건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt` | 수정 | `loginId`(nullable), `password`(nullable) 필드 추가 (기본값 null) | +| `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/repository/MemberRepository.kt` | 수정 | `existsByLoginId(loginId: String): Boolean` 메서드 추가 | +| `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/adapter/MemberAdapter.kt` | 수정 | `existsByLoginId()`, `saveLocal()` 구현 추가 | + +### application (생성 3건, 수정 1건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `application/src/main/kotlin/site/techmoa/application/port/PasswordPort.kt` | 생성 | `encode(rawPassword): String` 인터페이스 | +| `application/src/main/kotlin/site/techmoa/application/port/MemberPort.kt` | 수정 | `existsByLoginId()`, `saveLocal()` 메서드 추가 | +| `application/src/main/kotlin/site/techmoa/application/dto/SignupCommand.kt` | 생성 | `loginId`, `password` 필드를 가진 data class | +| `application/src/main/kotlin/site/techmoa/application/service/SignupService.kt` | 생성 | 중복 검증 -> 비밀번호 해싱 -> 회원 저장 -> 토큰 발급 흐름 | + +### infrastructure:oauth (생성 1건, 수정 1건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `infrastructure/oauth/src/main/kotlin/site/techmoa/infrastructure/oauth/adapter/PasswordAdapter.kt` | 생성 | BCryptPasswordEncoder로 PasswordPort 구현 | +| `infrastructure/oauth/build.gradle.kts` | 수정 | `spring-security-crypto` 의존성 추가 | + +### presentation (생성 2건, 수정 1건) + +| 파일 | 변경 유형 | 내용 | +|------|----------|------| +| `presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt` | 생성 | `loginId`, `password` 필드를 가진 data class | +| `presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt` | 생성 | `POST /v1/auth/signup`, 201 Created 응답 + 쿠키 설정 | +| `presentation/src/main/kotlin/site/techmoa/presentation/common/error/ErrorType.kt` | 수정 | `DUPLICATED_LOGIN_ID(CONFLICT, 409)` 추가 | + +## 충돌 사항 +- 없음 + +## 참고 사항 +- MemberEntity에 loginId, password 필드를 nullable + 기본값 null로 추가하여 기존 OAuth 회원 생성 흐름에 영향 없음 +- MemberPort에 메서드를 추가했으나 기존 메서드는 변경하지 않음 +- spring-security-crypto 버전은 Spring BOM에 의해 자동 관리됨 diff --git "a/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/03_test_summary.md" "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/03_test_summary.md" new file mode 100644 index 0000000..fd43f58 --- /dev/null +++ "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/03_test_summary.md" @@ -0,0 +1,45 @@ +# 테스트 완료 요약: 자체 회원가입 기능 + +## 실행 결과 +- `./gradlew :application:test --tests "site.techmoa.application.service.SignupServiceTest"` : **4/4 PASSED** +- `./gradlew :presentation:test --tests "site.techmoa.presentation.controller.SignupControllerV1Test"` : **5/5 PASSED** + +## 테스트 목록 + +### 1. SignupServiceTest (application 레이어 - 단위 테스트) + +| # | 테스트 케이스 | 유형 | 검증 항목 | +|---|-------------|------|----------| +| 1 | 유효한 loginId와 password로 가입하면 토큰을 발급하여 반환한다 | 성공 | accessToken 값, 포트 호출 순서 | +| 2 | 다른 유효한 loginId로 가입하면 해당 회원의 토큰을 발급하여 반환한다 | 성공 | 다른 회원의 accessToken, memberId별 토큰 발급 | +| 3 | 이미 존재하는 loginId로 가입하면 DuplicatedLoginIdException이 발생하고 저장은 수행되지 않는다 | 실패 | ErrorCode.DUPLICATED_LOGIN_ID, 메시지, 후속 포트 미호출 | +| 4 | 비밀번호 인코딩 후 회원 저장이 실패하면 예외가 전파되고 토큰 발급은 수행되지 않는다 | 실패 | RuntimeException 전파, 토큰 발급 미호출 | + +### 2. SignupControllerV1Test (presentation 레이어 - 통합 테스트) + +| # | 테스트 케이스 | 유형 | 검증 항목 | +|---|-------------|------|----------| +| 1 | 유효한 loginId와 password로 요청하면 201과 accessToken 쿠키를 반환한다 | 성공 | HTTP 201, resultType=SUCCESS, Set-Cookie 헤더 | +| 2 | 다른 유효한 loginId로 요청하면 201과 해당 회원의 accessToken 쿠키를 반환한다 | 성공 | HTTP 201, 다른 토큰의 쿠키 | +| 3 | 중복된 loginId로 요청하면 409와 DUPLICATED_LOGIN_ID를 반환한다 | 실패 | HTTP 409, ErrorCode, ErrorType 메시지 | +| 4 | 요청 본문이 비어 있으면 400 에러를 반환한다 | 실패 | HTTP 400, 서비스 미호출 | +| 5 | Content-Type 없이 요청하면 415 에러를 반환한다 | 실패 | HTTP 415, 서비스 미호출 | + +## 생성 파일 + +| 파일 | 테스트 수 | +|------|----------| +| `application/src/test/kotlin/site/techmoa/application/service/SignupServiceTest.kt` | 4 | +| `presentation/src/test/kotlin/site/techmoa/presentation/controller/SignupControllerV1Test.kt` | 5 | + +## 스킵 항목 + +### MemberAdapter (infrastructure:jpa) +- `existsByLoginId()`, `saveLocal()` 메서드는 JPA 리포지토리 위임 + 엔티티 매핑으로 구성되어 있어, 실제 DB가 필요한 통합 테스트가 적절하다. +- 현재 프로젝트에 `@DataJpaTest` 기반 테스트 인프라(Testcontainers 등)가 구성되어 있지 않으므로 스킵하였다. +- 향후 Testcontainers 기반 통합 테스트 인프라 도입 시 추가 권장. + +## 커버리지 요약 +- **SignupService.process()**: 모든 분기(중복 검증 성공/실패, 저장 성공/실패) 커버 +- **SignupControllerV1.signup()**: 정상 응답, 비즈니스 예외 매핑, 바인딩 에러 커버 +- **ErrorType.DUPLICATED_LOGIN_ID**: HTTP 409 + ErrorCode 매핑 검증 완료 diff --git "a/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/04_review_report.md" "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/04_review_report.md" new file mode 100644 index 0000000..e42ca66 --- /dev/null +++ "b/_workspace/145-\355\232\214\354\233\220\352\260\200\354\236\205/04_review_report.md" @@ -0,0 +1,138 @@ +# 리뷰 리포트: 자체 회원가입 기능 + +## 최종 판정: PASS (조건부) + +CRITICAL 이슈 0건, MAJOR 이슈 1건, MINOR 이슈 3건. +MAJOR 이슈는 보안 관련이므로 배포 전 수정을 권장한다. + +--- + +## 1. 아키텍처 준수 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| domain 모듈에 Spring 의존성 없음 | PASS | `domain/build.gradle.kts`에 의존성 없음, import 확인 완료 | +| application -> domain 방향 의존성만 존재 | PASS | SignupService는 domain 예외만 참조 | +| Port가 application에 정의, Adapter가 infrastructure에서 구현 | PASS | PasswordPort(application) -> PasswordAdapter(oauth), MemberPort(application) -> MemberAdapter(jpa) | +| presentation은 application의 Service만 호출 | PASS | SignupControllerV1은 SignupService만 호출 | + +## 2. 레이어 간 정합성 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| Controller Request DTO <-> Service Command 필드 일치 | PASS | SignupRequest(loginId, password) -> SignupCommand(loginId, password) | +| JPA Entity <-> Domain Model 매핑 | PASS | MemberEntity -> Member 변환이 MemberAdapter.saveLocal()에서 올바르게 수행됨 | +| Flyway DDL <-> JPA Entity 컬럼 매핑 | PASS | V1.15: login_id VARCHAR(50) NULL, password VARCHAR(100) NULL == MemberEntity 필드 | +| ErrorCode <-> ErrorType <-> HTTP Status 매핑 | PASS | DUPLICATED_LOGIN_ID -> HttpStatus.CONFLICT(409) | +| Port 인터페이스 <-> Adapter 구현 일치 | PASS | MemberPort.existsByLoginId(), saveLocal() 모두 MemberAdapter에서 구현됨 | +| MemberEntity uniqueConstraint vs DDL | PASS | uk_login_id는 DDL에서만 정의, JPA @Table에는 uk_provider_subject만 있음. JPA 유니크 제약은 DDL 생성용이므로 Flyway 사용 시 DDL에만 있어도 정상 | + +## 3. 기존 기능 호환성 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| 기존 MemberPort 메서드 변경 없음 | PASS | findByProviderAndSubject, save 메서드 시그니처 유지 | +| 기존 MemberAdapter.save() 영향 없음 | PASS | 기존 save()는 loginId, password를 전달하지 않으므로 null 기본값 사용 | +| MemberEntity 생성자 기본값 | PASS | loginId=null, password=null로 기본값 설정되어 기존 코드 호환 | +| OauthProvider.LOCAL 추가 | PASS | 기존 KAKAO 값에 영향 없음 | +| login_id 컬럼 nullable + unique | PASS | 기존 OAuth 회원은 login_id=NULL이므로 유니크 제약에 걸리지 않음 (MySQL에서 NULL은 UNIQUE 비교에서 제외) | + +## 4. 보안 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| 비밀번호 BCrypt 해싱 | PASS | PasswordAdapter에서 BCryptPasswordEncoder 사용 | +| 해싱된 비밀번호만 DB 저장 | PASS | SignupService에서 encode() 후 saveLocal()에 encodedPassword 전달 | +| 입력값 검증 (loginId) | **MAJOR** | SignupRequest에 validation annotation 없음 -- 아래 이슈 #1 참조 | +| 로그에 비밀번호 노출 없음 | PASS | 로그에 loginId만 기록, password는 기록하지 않음 | + +## 5. DB 마이그레이션 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| 마이그레이션 버전 번호 순서 | PASS | V1.14 다음 V1.15, 충돌 없음 | +| DDL 안전성 | PASS | ALTER TABLE ADD COLUMN은 nullable이므로 기존 데이터에 영향 없음 | +| 유니크 인덱스 | PASS | login_id에 uk_login_id 유니크 제약 추가 | +| password 컬럼 길이 | PASS | VARCHAR(100)은 BCrypt 해시($2a$10$..., 60자)를 충분히 수용 | + +## 6. 테스트 품질 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| 성공 케이스 >= 2개 | PASS | SignupServiceTest 2개, SignupControllerV1Test 2개 | +| 실패 케이스 >= 2개 | PASS | SignupServiceTest 2개, SignupControllerV1Test 3개 | +| Assertion이 핵심 결과 검증 | PASS | accessToken 값, ErrorCode, HTTP Status 모두 검증 | +| BehaviorSpec 스타일 일관성 | PASS | given/when/then 구조 사용 | +| Fixture가 companion object에 정의 | PASS | 상수는 companion object, 팩토리 함수는 파일 레벨에 정의 | +| ErrorCode + ErrorType 교차 검증 | PASS | Controller 테스트에서 409 + DUPLICATED_LOGIN_ID + 메시지 검증 | + +## 7. 코드 품질 검증 + +| 항목 | 결과 | 비고 | +|------|------|------| +| 기존 컨벤션 준수 | PASS | AuthControllerV1, LoginService 패턴과 동일한 구조 | +| 트랜잭션 관리 패턴 | PASS | Adapter 레벨에서 @Transactional 사용 (LoginService와 동일 패턴) | +| 로깅 패턴 | PASS | 기존 AuthControllerV1, LoginService와 동일한 로깅 스타일 | +| 불필요한 코드 변경 없음 | PASS | 기존 파일은 필요한 부분만 수정 | + +--- + +## 발견된 이슈 목록 + +### 이슈 #1 [MAJOR] -- 입력값 검증 누락 + +**파일:** `presentation/src/main/kotlin/site/techmoa/presentation/controller/request/SignupRequest.kt:3-6` + +**설명:** 설계 문서(01_architect_plan.md)에서 loginId는 "3~50자, 영문 소문자+숫자+언더스코어", password는 "8~30자"로 명시했으나, SignupRequest에 어떤 검증 로직도 없다. 빈 문자열, 1000자 문자열, SQL injection 패턴 등이 그대로 서비스 레이어로 전달된다. + +**심각도:** MAJOR -- 빈 loginId("")로 가입 시 subject=""인 회원이 생성되고, 이후 동일한 빈 loginId로 재가입 시 유니크 제약 위반이 발생하는 등 예기치 않은 동작 가능. + +**수정 제안:** 두 가지 방법 중 하나 선택: +1. (Jakarta Validation) SignupRequest에 `@field:NotBlank`, `@field:Size`, `@field:Pattern` 추가 + 컨트롤러에 `@Valid` 추가 +2. (기존 프로젝트 패턴) SaveWebhookRequest처럼 SignupRequest 내부에 검증 메서드를 추가하고, 검증 실패 시 DomainException 하위 예외를 throw + +기존 프로젝트가 SaveWebhookRequest에서 수동 검증 패턴을 사용하므로 방법 2가 컨벤션에 부합한다. + +--- + +### 이슈 #2 [MINOR] -- SignupService에 @Transactional 부재로 인한 잠재적 정합성 문제 + +**파일:** `application/src/main/kotlin/site/techmoa/application/service/SignupService.kt:21` + +**설명:** `existsByLoginId()`와 `saveLocal()`이 각각 별도의 트랜잭션(@Transactional이 MemberAdapter에 있음)으로 실행된다. 두 호출 사이에 동일 loginId로 다른 요청이 들어오면 중복 검증을 통과한 후 DB 유니크 제약 위반이 발생한다. + +**심각도:** MINOR -- DB 유니크 인덱스(uk_login_id)가 최종 방어선 역할을 하므로 데이터 손상은 발생하지 않는다. 다만 DataIntegrityViolationException이 GlobalExceptionHandler에서 500으로 처리될 수 있다. + +**수정 제안:** 현재 LoginService도 동일 패턴이므로 컨벤션상 허용 가능하나, 향후 DB 유니크 위반 예외를 409로 변환하는 처리를 추가하면 더 견고해진다. + +--- + +### 이슈 #3 [MINOR] -- MemberEntity에 uk_login_id 유니크 제약이 JPA 레벨에 누락 + +**파일:** `infrastructure/jpa/src/main/kotlin/site/techmoa/infrastructure/jpa/entity/MemberEntity.kt:6-13` + +**설명:** DDL(V1.15)에서 `uk_login_id UNIQUE (login_id)`를 추가했으나, MemberEntity의 `@Table` 어노테이션에는 해당 유니크 제약이 없다. Flyway를 사용하므로 런타임에 문제는 없으나, JPA 스키마 검증(`validate` 모드)이나 코드 가독성 측면에서 불일치가 있다. + +**심각도:** MINOR -- Flyway가 DDL을 관리하므로 실질적 영향 없음. + +**수정 제안:** `@Table`의 `uniqueConstraints`에 `UniqueConstraint(name = "uk_login_id", columnNames = ["login_id"])` 추가. + +--- + +### 이슈 #4 [MINOR] -- SignupControllerV1을 별도 컨트롤러로 분리한 설계 결정 + +**파일:** `presentation/src/main/kotlin/site/techmoa/presentation/controller/SignupControllerV1.kt` + +**설명:** 기존 인증 관련 컨트롤러로 AuthControllerV1(`/v1/oauth`)이 있다. SignupControllerV1은 `/v1/auth`를 사용하며 별도 클래스로 분리되었다. 기능적 문제는 없으나, 향후 자체 로그인(POST /v1/auth/login) 등이 추가되면 이 컨트롤러에 모이게 될 것이므로 현재 분리는 합리적이다. + +**심각도:** MINOR -- 설계 결정에 대한 참고 사항. + +**수정 제안:** 없음. 현재 구조가 적절하다. + +--- + +## 권장 사항 + +1. **[우선] 이슈 #1 수정** -- 입력값 검증을 추가하라. 기존 SaveWebhookRequest 패턴에 따라 SignupRequest 내부에 검증 로직을 추가하는 것을 권장한다. +2. **[선택] 이슈 #2** -- DB 유니크 제약 위반(DataIntegrityViolationException)을 409 응답으로 변환하는 예외 처리를 GlobalExceptionHandler에 추가하면 동시성 상황에서 사용자 경험이 개선된다. +3. **[선택] 이슈 #3** -- MemberEntity에 JPA 유니크 제약을 추가하여 코드와 DDL의 일관성을 유지하라.