From cc5349dc9a2c68c37dfcddd6135647c3c88e1a5c Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 11:09:19 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=ED=9B=84=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=93=B1=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원 탈퇴 시 afterCommit 콜백으로 WithdrawalPolicy 적용 로직 추가 - 트랜잭션이 활성화되지 않은 경우 즉시 콜백 처리하도록 수정 - 테스트에 TransactionSynchronization 초기화 및 검증 로직 추가 - 회원 탈퇴 서비스 테스트에 tearDown 메서드로 트랜잭션 정리 코드 추가 --- .../member/service/MemberWithdrawalService.kt | 22 ++++++++++++++++- .../service/MemberWithdrawalServiceTest.kt | 24 ++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt index abc5459..eaea98d 100644 --- a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt +++ b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt @@ -8,6 +8,8 @@ import com.unit.member.withdrawal.MemberWithdrawalPolicy import com.unit.platform.error.BusinessException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime @Service @@ -35,6 +37,24 @@ class MemberWithdrawalService( member.withdraw(now) refreshTokenUseCase.revokeAll(context.memberId) - withdrawalPolicies.forEach { it.apply(context) } + registerAfterCommit { + withdrawalPolicies.forEach { it.apply(context) } + } } + + private fun registerAfterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + action() + } + }, + ) + } + } \ No newline at end of file diff --git a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt index ad5ca0e..9565fdc 100644 --- a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt @@ -15,7 +15,9 @@ import io.mockk.slot import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.DisplayName +import org.springframework.transaction.support.TransactionSynchronizationManager import kotlin.test.Test @DisplayName("회원 탈퇴 서비스 테스트") @@ -31,6 +33,13 @@ class MemberWithdrawalServiceTest { withdrawalPolicies = listOf(withdrawalPolicy), ) + @AfterEach + fun tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + } + @Test @DisplayName("탈퇴 가능 회원이면 정책 검증 후 회원을 탈퇴 처리하고 Refresh Token을 모두 폐기한다") fun withdraw() { @@ -48,6 +57,7 @@ class MemberWithdrawalServiceTest { every { refreshTokenUseCase.revokeAll(1L) } just Runs every { withdrawalPolicy.apply(capture(applyContext)) } just Runs + TransactionSynchronizationManager.initSynchronization() memberWithdrawalService.withdraw(1L) assertThat(member.status).isEqualTo(MemberStatus.DELETED) @@ -60,11 +70,19 @@ class MemberWithdrawalServiceTest { assertThat(member.emailHash).isNotNull() assertThat(validateContext.captured.memberId).isEqualTo(1L) - assertThat(applyContext.captured.memberId).isEqualTo(1L) - assertThat(applyContext.captured.requestedAt).isEqualTo(validateContext.captured.requestedAt) verify(exactly = 1) { withdrawalPolicy.validate(any()) } verify(exactly = 1) { refreshTokenUseCase.revokeAll(1L) } + verify(exactly = 0) { withdrawalPolicy.apply(any()) } + + val synchronizations = TransactionSynchronizationManager.getSynchronizations() + assertThat(synchronizations).hasSize(1) + + synchronizations.forEach { it.afterCommit() } + + assertThat(applyContext.captured.memberId).isEqualTo(1L) + assertThat(applyContext.captured.requestedAt).isEqualTo(validateContext.captured.requestedAt) + verify(exactly = 1) { withdrawalPolicy.apply(any()) } } @@ -132,4 +150,4 @@ class MemberWithdrawalServiceTest { status = status, ) } -} \ No newline at end of file +} From 811390e4b7b175c2ca544c36f4ffd4c61e9685d0 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 11:26:41 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=ED=9B=84=EC=B2=98=EB=A6=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탈퇴 시 트랜잭션 동기화가 없을 경우 즉시 후처리 동작 검증 - WithdrawalPolicy validate, apply 호출 및 Refresh Token 폐기 로직 검증 - 회원 상태 변경(MemberStatus.DELETED) 및 요청 컨텍스트 처리 확인 --- .../service/MemberWithdrawalServiceTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt index 9565fdc..b3832db 100644 --- a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt @@ -86,6 +86,34 @@ class MemberWithdrawalServiceTest { verify(exactly = 1) { withdrawalPolicy.apply(any()) } } + @Test + @DisplayName("트랜잭션 동기화가 없으면 탈퇴 후처리를 즉시 실행한다") + fun withdrawWithoutTransactionSynchronization() { + val member = createMember() + val validateContext = slot() + val applyContext = slot() + + every { + memberRepository.findByIdAndStatusInAndDeletedAtIsNull( + id = 1L, + statuses = listOf(MemberStatus.PENDING, MemberStatus.ACTIVE), + ) + } returns member + every { withdrawalPolicy.validate(capture(validateContext)) } just Runs + every { refreshTokenUseCase.revokeAll(1L) } just Runs + every { withdrawalPolicy.apply(capture(applyContext)) } just Runs + + memberWithdrawalService.withdraw(1L) + + assertThat(member.status).isEqualTo(MemberStatus.DELETED) + assertThat(applyContext.captured.memberId).isEqualTo(1L) + assertThat(applyContext.captured.requestedAt).isEqualTo(validateContext.captured.requestedAt) + + verify(exactly = 1) { withdrawalPolicy.validate(any()) } + verify(exactly = 1) { refreshTokenUseCase.revokeAll(1L) } + verify(exactly = 1) { withdrawalPolicy.apply(any()) } + } + @Test @DisplayName("탈퇴 가능한 회원을 찾을 수 없으면 예외가 발생한다") fun withdrawWithNotFoundMember() { From 7695a7a6e4e8a3856f8601739ff35b2dd8be817e Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 11:27:18 +0900 Subject: [PATCH 03/10] =?UTF-8?q?test:=20RefreshToken=20Repository?= =?UTF-8?q?=EC=9D=98=20PESSIMISTIC=5FWRITE=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshTokenRepository에 PESSIMISTIC_WRITE 잠금 설정 및 테스트 케이스 작성 - 동시에 동일 RefreshToken 조회 시 대기 및 상태 갱신(ROTATED) 검증 - 트랜잭션 종료 후 다른 조회가 정상 동작하는지 확인 - 테스트 환경 설정에 flyway 비활성화 및 JPA ddl-auto=create-drop 추가 --- .../repository/RefreshTokenRepository.kt | 4 + .../RefreshTokenRepositoryLockTest.kt | 115 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/test/kotlin/com/unit/member/repository/RefreshTokenRepositoryLockTest.kt diff --git a/src/main/kotlin/com/unit/member/repository/RefreshTokenRepository.kt b/src/main/kotlin/com/unit/member/repository/RefreshTokenRepository.kt index fa41751..da2ed20 100644 --- a/src/main/kotlin/com/unit/member/repository/RefreshTokenRepository.kt +++ b/src/main/kotlin/com/unit/member/repository/RefreshTokenRepository.kt @@ -2,9 +2,13 @@ package com.unit.member.repository import com.unit.member.entity.RefreshToken import com.unit.member.enums.RefreshTokenStatus +import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock interface RefreshTokenRepository : JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) fun findByTokenHashAndStatus( tokenHash: ByteArray, status: RefreshTokenStatus = RefreshTokenStatus.ACTIVE, diff --git a/src/test/kotlin/com/unit/member/repository/RefreshTokenRepositoryLockTest.kt b/src/test/kotlin/com/unit/member/repository/RefreshTokenRepositoryLockTest.kt new file mode 100644 index 0000000..64e903a --- /dev/null +++ b/src/test/kotlin/com/unit/member/repository/RefreshTokenRepositoryLockTest.kt @@ -0,0 +1,115 @@ +package com.unit.member.repository + +import com.unit.member.entity.RefreshToken +import com.unit.member.enums.RefreshTokenStatus +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@DataJpaTest +@TestPropertySource( + properties = [ + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + ], +) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@DisplayName("RefreshToken Repository lock 테스트") +class RefreshTokenRepositoryLockTest @Autowired constructor( + private val refreshTokenRepository: RefreshTokenRepository, + transactionManager: PlatformTransactionManager +) { + + private val transactionTemplate = TransactionTemplate(transactionManager) + + @AfterEach + fun tearDown() { + refreshTokenRepository.deleteAll() + } + + @Test + @DisplayName("ACTIVE Refresh Token 조회는 트랜잭션 종료 전까지 다른 rotation 조회를 대기시킨다") + fun lockTest() { + + val tokenHash = ByteArray(32) { 1 } + val savedToken = refreshTokenRepository.saveAndFlush( + createRefreshToken(tokenHash = tokenHash), + ) + + val firstTransactionLocked = CountDownLatch(1) + val releaseFirstTransaction = CountDownLatch(1) + val secondTransactionStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + + try { + val first = executor.submit { + transactionTemplate.executeWithoutResult { + val token = requireNotNull( + refreshTokenRepository.findByTokenHashAndStatus(tokenHash), + ) + + firstTransactionLocked.countDown() + assertThat(releaseFirstTransaction.await(3, TimeUnit.SECONDS)).isTrue() + + token.rotate(LocalDateTime.now()) + } + } + + assertThat(firstTransactionLocked.await(3, TimeUnit.SECONDS)).isTrue() + + val second = executor.submit { + secondTransactionStarted.countDown() + + transactionTemplate.execute { + refreshTokenRepository.findByTokenHashAndStatus(tokenHash) + } + } + + assertThat(secondTransactionStarted.await(3, TimeUnit.SECONDS)).isTrue() + + Thread.sleep(200) + assertThat(second.isDone).isFalse() + + releaseFirstTransaction.countDown() + + first.get(3, TimeUnit.SECONDS) + val secondResult = second.get(3, TimeUnit.SECONDS) + + assertThat(secondResult).isNull() + + val foundToken = refreshTokenRepository.findById(requireNotNull(savedToken.id)).orElseThrow() + assertThat(foundToken.status).isEqualTo(RefreshTokenStatus.ROTATED) + } finally { + releaseFirstTransaction.countDown() + executor.shutdownNow() + } + + } + + private fun createRefreshToken( + memberId: Long = 1L, + tokenHash: ByteArray = ByteArray(32) { 1 }, + status: RefreshTokenStatus = RefreshTokenStatus.ACTIVE, + expiresAt: LocalDateTime = LocalDateTime.of(2026, 5, 15, 12, 0), + ): RefreshToken { + return RefreshToken( + memberId = memberId, + tokenHash = tokenHash, + status = status, + expiresAt = expiresAt, + ) + } +} \ No newline at end of file From b6317ad95eee99ede95beb6ba335c552d0915c70 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 12:06:18 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=95=94=ED=98=B8=ED=99=94=20=EB=B0=8F=20=EB=B3=B5=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmailEncryptor 유틸 클래스와 EmailEncryptionConfig, Properties를 통해 암호화 키 설정 - 이메일 암호화 필드 추가(Member, UserSchoolVerification) 및 관련 엔티티 수정 - 이메일 암호화 저장(Signup) 및 복호화 처리(Query) 로직 추가 - 학교 이메일 인증 서비스에 암호화된 이메일 데이터 저장 로직 반영 - 테스트 코드 및 API 문서(MemberDocsTest) 수정 및 보완 --- .env.example | 2 + .../member/config/EmailEncryptionConfig.kt | 8 +++ .../config/EmailEncryptionProperties.kt | 8 +++ .../com/unit/member/dto/MemberResponse.kt | 2 + .../member/entity/UserSchoolVerification.kt | 15 ++++- .../unit/member/service/MemberQueryService.kt | 4 ++ .../member/service/MemberSignupService.kt | 6 +- .../service/SchoolEmailVerificationService.kt | 16 ++--- .../com/unit/member/util/EmailEncryptor.kt | 58 +++++++++++++++++++ src/main/resources/application-local.yml | 2 + src/main/resources/application.yml | 2 + .../member/controller/MemberControllerTest.kt | 8 ++- .../unit/member/controller/MemberDocsTest.kt | 12 ++++ .../member/service/MemberQueryServiceTest.kt | 48 +++++++++++++-- .../member/service/MemberSignupServiceTest.kt | 9 ++- .../SchoolEmailVerificationServiceTest.kt | 8 +++ .../unit/member/util/EmailEncryptorTest.kt | 54 +++++++++++++++++ src/test/resources/application-test.yml | 2 + 18 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/com/unit/member/config/EmailEncryptionConfig.kt create mode 100644 src/main/kotlin/com/unit/member/config/EmailEncryptionProperties.kt create mode 100644 src/main/kotlin/com/unit/member/util/EmailEncryptor.kt create mode 100644 src/test/kotlin/com/unit/member/util/EmailEncryptorTest.kt diff --git a/.env.example b/.env.example index 73e724f..65504ea 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,5 @@ OAUTH_KAKAO_CLIENT_ID= OAUTH_KAKAO_CLIENT_SECRET= OAUTH_NAVER_CLIENT_ID= OAUTH_NAVER_CLIENT_SECRET= + +EMAIL_ENCRYPTION_KEY_BASE64=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= diff --git a/src/main/kotlin/com/unit/member/config/EmailEncryptionConfig.kt b/src/main/kotlin/com/unit/member/config/EmailEncryptionConfig.kt new file mode 100644 index 0000000..896c8ca --- /dev/null +++ b/src/main/kotlin/com/unit/member/config/EmailEncryptionConfig.kt @@ -0,0 +1,8 @@ +package com.unit.member.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(EmailEncryptionProperties::class) +class EmailEncryptionConfig \ No newline at end of file diff --git a/src/main/kotlin/com/unit/member/config/EmailEncryptionProperties.kt b/src/main/kotlin/com/unit/member/config/EmailEncryptionProperties.kt new file mode 100644 index 0000000..130f6c7 --- /dev/null +++ b/src/main/kotlin/com/unit/member/config/EmailEncryptionProperties.kt @@ -0,0 +1,8 @@ +package com.unit.member.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "unit.security.email-encryption") +data class EmailEncryptionProperties( + val keyBase64: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/unit/member/dto/MemberResponse.kt b/src/main/kotlin/com/unit/member/dto/MemberResponse.kt index 70ffddc..a43ff91 100644 --- a/src/main/kotlin/com/unit/member/dto/MemberResponse.kt +++ b/src/main/kotlin/com/unit/member/dto/MemberResponse.kt @@ -5,6 +5,7 @@ import com.unit.member.enums.UserSchoolVerificationStatus data class MemberMeResponse( val memberId: Long, + val email: String?, val nickname: String, val profileImageUrl: String?, val status: MemberStatus, @@ -16,4 +17,5 @@ data class MemberSchoolResponse( val schoolId: Long, val name: String, val verificationStatus: UserSchoolVerificationStatus, + val verifiedEmail: String?, ) \ No newline at end of file diff --git a/src/main/kotlin/com/unit/member/entity/UserSchoolVerification.kt b/src/main/kotlin/com/unit/member/entity/UserSchoolVerification.kt index d329fc5..f1fd070 100644 --- a/src/main/kotlin/com/unit/member/entity/UserSchoolVerification.kt +++ b/src/main/kotlin/com/unit/member/entity/UserSchoolVerification.kt @@ -33,10 +33,10 @@ class UserSchoolVerification( val method: UserSchoolVerificationMethod, @Column(name = "verified_email_hash", columnDefinition = "BINARY(32)") - val verifiedEmailHash: ByteArray? = null, + var verifiedEmailHash: ByteArray? = null, @Column(name = "verified_email_encrypted", columnDefinition = "VARBINARY(512)") - val verifiedEmailEncrypted: ByteArray? = null, + var verifiedEmailEncrypted: ByteArray? = null, @Column(name = "student_number_hash", columnDefinition = "BINARY(32)") val studentNumberHash: ByteArray? = null, @@ -74,4 +74,15 @@ class UserSchoolVerification( fun expire() { this.status = UserSchoolVerificationStatus.EXPIRED } + + fun verifyByEmail( + now: LocalDateTime, + emailHash: ByteArray, + emailEncrypted: ByteArray? = null, + ) { + this.status = UserSchoolVerificationStatus.VERIFIED + this.verifiedAt = now + this.verifiedEmailHash = emailHash + this.verifiedEmailEncrypted = emailEncrypted + } } diff --git a/src/main/kotlin/com/unit/member/service/MemberQueryService.kt b/src/main/kotlin/com/unit/member/service/MemberQueryService.kt index 1b0abc6..d2435e7 100644 --- a/src/main/kotlin/com/unit/member/service/MemberQueryService.kt +++ b/src/main/kotlin/com/unit/member/service/MemberQueryService.kt @@ -7,6 +7,7 @@ import com.unit.member.exception.MemberErrorCode import com.unit.member.repository.MemberRepository import com.unit.member.repository.SchoolRepository import com.unit.member.repository.UserSchoolVerificationRepository +import com.unit.member.util.EmailEncryptor import com.unit.platform.error.BusinessException import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -18,6 +19,7 @@ class MemberQueryService( private val memberRepository: MemberRepository, private val userSchoolVerificationRepository: UserSchoolVerificationRepository, private val schoolRepository: SchoolRepository, + private val emailEncryptor: EmailEncryptor ) : MemberQueryUseCase { override fun getMe(memberId: Long): MemberMeResponse { @@ -35,11 +37,13 @@ class MemberQueryService( schoolId = requireNotNull(school.id), name = school.name, verificationStatus = it.status, + verifiedEmail = it.verifiedEmailEncrypted?.let(emailEncryptor::decrypt), ) } return MemberMeResponse( memberId = requireNotNull(member.id), + email = member.emailEncrypted?.let(emailEncryptor::decrypt), nickname = member.nickname, profileImageUrl = member.profileImageUrl, status = member.status, diff --git a/src/main/kotlin/com/unit/member/service/MemberSignupService.kt b/src/main/kotlin/com/unit/member/service/MemberSignupService.kt index 75a4fc1..f2060ed 100644 --- a/src/main/kotlin/com/unit/member/service/MemberSignupService.kt +++ b/src/main/kotlin/com/unit/member/service/MemberSignupService.kt @@ -15,6 +15,7 @@ import com.unit.member.repository.MemberConsentRepository import com.unit.member.repository.MemberRepository import com.unit.member.repository.SchoolRepository import com.unit.member.repository.UserSchoolVerificationRepository +import com.unit.member.util.EmailEncryptor import com.unit.member.util.EmailHasher import com.unit.platform.error.BusinessException import org.springframework.security.crypto.password.PasswordEncoder @@ -32,10 +33,12 @@ class MemberSignupService( private val emailHasher: EmailHasher, private val memberConsentRepository: MemberConsentRepository, private val memberConsentProperties: MemberConsentProperties, + private val emailEncryptor: EmailEncryptor, ) : MemberSignupUseCase { override fun signup(request: MemberSignupRequest): MemberSignupResponse { - val emailHash = emailHasher.hash(request.email) + val email = request.email.trim().lowercase() + val emailHash = emailHasher.hash(email) val nickname = request.nickname.trim() val schoolId = request.schoolId!! @@ -54,6 +57,7 @@ class MemberSignupService( val member = memberRepository.save( Member( emailHash = emailHash, + emailEncrypted = emailEncryptor.encrypt(email), passwordHash = passwordEncoder.encode(request.password), nickname = nickname, status = MemberStatus.PENDING, diff --git a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt index 44a6e48..d8b3ffd 100644 --- a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt +++ b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt @@ -12,6 +12,7 @@ import com.unit.member.repository.SchoolEmailDomainRepository import com.unit.member.repository.SchoolEmailVerificationCodeRepository import com.unit.member.repository.SchoolRepository import com.unit.member.repository.UserSchoolVerificationRepository +import com.unit.member.util.EmailEncryptor import com.unit.member.util.EmailHasher import com.unit.member.util.SchoolEmailVerificationCodeGenerator import com.unit.member.util.SchoolEmailVerificationFailureRecorder @@ -38,7 +39,7 @@ class SchoolEmailVerificationService( private val failureRecorder: SchoolEmailVerificationFailureRecorder, private val emailSender: EmailSender, private val emailTemplateRenderer: EmailTemplateRenderer, - + private val emailEncryptor: EmailEncryptor, ) : SchoolEmailVerificationUseCase { private val verificationExpiresInSeconds = 300L @@ -140,21 +141,22 @@ class SchoolEmailVerificationService( throw BusinessException(MemberErrorCode.SCHOOL_EMAIL_VERIFICATION_CODE_MISMATCHED) } - verificationCode.verify(now) - val schoolVerification = userSchoolVerificationRepository.findByMemberIdAndSchoolId( memberId = memberId, schoolId = schoolId, ) ?: throw BusinessException(MemberErrorCode.SCHOOL_VERIFICATION_NOT_FOUND) - schoolVerification.status = UserSchoolVerificationStatus.VERIFIED - schoolVerification.verifiedAt = now - val member = memberRepository.findByIdAndStatusAndDeletedAtIsNull( id = memberId, status = MemberStatus.PENDING, ) ?: throw BusinessException(MemberErrorCode.MEMBER_LOGIN_FORBIDDEN) + verificationCode.verify(now) + schoolVerification.verifyByEmail( + now = now, + emailHash = emailHash, + emailEncrypted = emailEncryptor.encrypt(email), + ) member.activate() return SchoolEmailVerificationConfirmResponse( @@ -170,4 +172,4 @@ class SchoolEmailVerificationService( private fun SchoolEmailVerificationCode.memberIdEquals(memberId: Long): Boolean { return this.memberId == memberId } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/unit/member/util/EmailEncryptor.kt b/src/main/kotlin/com/unit/member/util/EmailEncryptor.kt new file mode 100644 index 0000000..735ee18 --- /dev/null +++ b/src/main/kotlin/com/unit/member/util/EmailEncryptor.kt @@ -0,0 +1,58 @@ +package com.unit.member.util + +import com.unit.member.config.EmailEncryptionProperties +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Component +class EmailEncryptor( + properties: EmailEncryptionProperties, +) { + + private val keySpec = SecretKeySpec(decodeKey(properties.keyBase64), "AES") + private val secureRandom = SecureRandom() + + fun encrypt(email: String): ByteArray { + val iv = ByteArray(IV_LENGTH_BYTES) + secureRandom.nextBytes(iv) + + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(TAG_LENGTH_BITS, iv)) + + val cipherText = cipher.doFinal(email.toByteArray(Charsets.UTF_8)) + return iv + cipherText + } + + fun decrypt(encryptedEmail: ByteArray): String { + require(encryptedEmail.size > IV_LENGTH_BYTES) { + "Encrypted email payload is too short." + } + + val iv = encryptedEmail.copyOfRange(0, IV_LENGTH_BYTES) + val cipherText = encryptedEmail.copyOfRange(IV_LENGTH_BYTES, encryptedEmail.size) + + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(TAG_LENGTH_BITS, iv)) + + return cipher.doFinal(cipherText).toString(Charsets.UTF_8) + } + + private fun decodeKey(keyBase64: String): ByteArray { + val key = Base64.getDecoder().decode(keyBase64) + require(key.size == AES_256_KEY_LENGTH_BYTES) { + "Email encryption key must be a 32-byte Base64 encoded value." + } + return key + } + + private companion object { + const val ALGORITHM = "AES/GCM/NoPadding" + const val IV_LENGTH_BYTES = 12 + const val TAG_LENGTH_BITS = 128 + const val AES_256_KEY_LENGTH_BYTES = 32 + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c760e9f..267fa4a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -52,6 +52,8 @@ unit: access-token-expiration-seconds: ${JWT_ACCESS_TOKEN_EXPIRATION_SECONDS:1800} refresh-token: expiration-seconds: ${REFRESH_TOKEN_EXPIRATION_SECONDS:1209600} # 14일 + email-encryption: + key-base64: ${EMAIL_ENCRYPTION_KEY_BASE64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} mail: enabled: ${MAIL_ENABLED:false} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4d57345..d627e0d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,6 +62,8 @@ unit: access-token-expiration-seconds: ${JWT_ACCESS_TOKEN_EXPIRATION_SECONDS:1800} refresh-token: expiration-seconds: ${REFRESH_TOKEN_EXPIRATION_SECONDS:1209600} + email-encryption: + key-base64: ${EMAIL_ENCRYPTION_KEY_BASE64} consent: terms-version: ${TERMS_VERSION:2026-05-14} privacy-policy-version: ${PRIVACY_POLICY_VERSION:2026-05-14} diff --git a/src/test/kotlin/com/unit/member/controller/MemberControllerTest.kt b/src/test/kotlin/com/unit/member/controller/MemberControllerTest.kt index ff251a3..d2cdc88 100644 --- a/src/test/kotlin/com/unit/member/controller/MemberControllerTest.kt +++ b/src/test/kotlin/com/unit/member/controller/MemberControllerTest.kt @@ -226,6 +226,7 @@ class MemberControllerTest @Autowired constructor( given(memberQueryUseCase.getMe(1L)).willReturn( MemberMeResponse( memberId = 1L, + email = "test@unit.com", nickname = "unit_user", profileImageUrl = "profile_image_url", status = MemberStatus.ACTIVE, @@ -234,6 +235,7 @@ class MemberControllerTest @Autowired constructor( schoolId = 1L, name = "Unit_University", verificationStatus = UserSchoolVerificationStatus.VERIFIED, + verifiedEmail = "test@snu.ac.kr", ), ) ) @@ -246,6 +248,7 @@ class MemberControllerTest @Autowired constructor( content { contentTypeCompatibleWith(MediaType.APPLICATION_JSON) } jsonPath("$.code") { value("OK") } jsonPath("$.data.memberId") { value(1) } + jsonPath("$.data.email") { value("test@unit.com") } jsonPath("$.data.nickname") { value("unit_user") } jsonPath("$.data.profileImageUrl") { value("profile_image_url") } jsonPath("$.data.status") { value("ACTIVE") } @@ -253,6 +256,7 @@ class MemberControllerTest @Autowired constructor( jsonPath("$.data.school.schoolId") { value(1) } jsonPath("$.data.school.name") { value("Unit_University") } jsonPath("$.data.school.verificationStatus") { value("VERIFIED") } + jsonPath("$.data.school.verifiedEmail") { value("test@snu.ac.kr") } } then(memberQueryUseCase).should().getMe(1L) @@ -265,6 +269,7 @@ class MemberControllerTest @Autowired constructor( given(memberQueryUseCase.getMe(1L)).willReturn( MemberMeResponse( memberId = 1L, + email = "test@unit.com", nickname = "unit_user", profileImageUrl = null, status = MemberStatus.PENDING, @@ -281,6 +286,7 @@ class MemberControllerTest @Autowired constructor( content { contentTypeCompatibleWith(MediaType.APPLICATION_JSON) } jsonPath("$.code") { value("OK") } jsonPath("$.data.memberId") { value(1) } + jsonPath("$.data.email") { value("test@unit.com") } jsonPath("$.data.nickname") { value("unit_user") } jsonPath("$.data.profileImageUrl") { doesNotExist() } jsonPath("$.data.status") { value("PENDING") } @@ -309,4 +315,4 @@ class MemberControllerTest @Autowired constructor( then(memberWithdrawalUseCase).shouldHaveNoMoreInteractions() } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/unit/member/controller/MemberDocsTest.kt b/src/test/kotlin/com/unit/member/controller/MemberDocsTest.kt index c840f61..6e9b258 100644 --- a/src/test/kotlin/com/unit/member/controller/MemberDocsTest.kt +++ b/src/test/kotlin/com/unit/member/controller/MemberDocsTest.kt @@ -303,6 +303,7 @@ class MemberDocsTest @Autowired constructor( given(memberQueryUseCase.getMe(1L)).willReturn( MemberMeResponse( memberId = 1L, + email = "test@unit.com", nickname = "unit_user", profileImageUrl = "profile_image_url", status = MemberStatus.ACTIVE, @@ -311,6 +312,7 @@ class MemberDocsTest @Autowired constructor( schoolId = 1L, name = "Unit_University", verificationStatus = UserSchoolVerificationStatus.VERIFIED, + verifiedEmail = "test@snu.ac.kr", ), ), ) @@ -323,6 +325,7 @@ class MemberDocsTest @Autowired constructor( content { contentTypeCompatibleWith(MediaType.APPLICATION_JSON) } jsonPath("$.code") { value("OK") } jsonPath("$.data.memberId") { value(1) } + jsonPath("$.data.email") { value("test@unit.com") } jsonPath("$.data.nickname") { value("unit_user") } jsonPath("$.data.profileImageUrl") { value("profile_image_url") } jsonPath("$.data.status") { value("ACTIVE") } @@ -330,6 +333,7 @@ class MemberDocsTest @Autowired constructor( jsonPath("$.data.school.schoolId") { value(1) } jsonPath("$.data.school.name") { value("Unit_University") } jsonPath("$.data.school.verificationStatus") { value("VERIFIED") } + jsonPath("$.data.school.verifiedEmail") { value("test@snu.ac.kr") } }.andDo { handle( document( @@ -361,6 +365,10 @@ class MemberDocsTest @Autowired constructor( fieldWithPath("data.trustScore") .type(JsonFieldType.NUMBER) .description("회원 신뢰 점수"), + fieldWithPath("data.email") + .type(JsonFieldType.STRING) + .optional() + .description("회원 이메일"), fieldWithPath("data.school") .type(JsonFieldType.OBJECT) .optional() @@ -373,6 +381,10 @@ class MemberDocsTest @Autowired constructor( .type(JsonFieldType.STRING) .optional() .description("인증된 학교 이름"), + fieldWithPath("data.school.verifiedEmail") + .type(JsonFieldType.STRING) + .optional() + .description("인증된 학교 이메일"), fieldWithPath("data.school.verificationStatus") .type(JsonFieldType.STRING) .optional() diff --git a/src/test/kotlin/com/unit/member/service/MemberQueryServiceTest.kt b/src/test/kotlin/com/unit/member/service/MemberQueryServiceTest.kt index f2b9bf8..ddc65b8 100644 --- a/src/test/kotlin/com/unit/member/service/MemberQueryServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/MemberQueryServiceTest.kt @@ -11,6 +11,7 @@ import com.unit.member.exception.MemberErrorCode import com.unit.member.repository.MemberRepository import com.unit.member.repository.SchoolRepository import com.unit.member.repository.UserSchoolVerificationRepository +import com.unit.member.util.EmailEncryptor import com.unit.platform.error.BusinessException import io.mockk.every import io.mockk.mockk @@ -28,28 +29,35 @@ class MemberQueryServiceTest { private val memberRepository = mockk() private val userSchoolVerificationRepository = mockk() private val schoolRepository = mockk() + private val emailEncryptor = mockk() private val memberQueryService = MemberQueryService( memberRepository = memberRepository, userSchoolVerificationRepository = userSchoolVerificationRepository, schoolRepository = schoolRepository, + emailEncryptor = emailEncryptor, ) @Test @DisplayName("내 정보 조회 정상 응답") fun getMe() { - val member = createMember() - val schoolVerification = createUserSchoolVerification() + val memberEmailEncrypted = ByteArray(64) { 1 } + val schoolEmailEncrypted = ByteArray(64) { 2 } + val member = createMember(emailEncrypted = memberEmailEncrypted) + val schoolVerification = createUserSchoolVerification(verifiedEmailEncrypted = schoolEmailEncrypted) val school = createSchool() every { memberRepository.findByIdAndStatusInAndDeletedAtIsNull(id = 1L, statuses = listOf(MemberStatus.PENDING, MemberStatus.ACTIVE)) } returns member every { userSchoolVerificationRepository.findByMemberId(1L) } returns schoolVerification every { schoolRepository.findById(1L) } returns Optional.of(school) + every { emailEncryptor.decrypt(memberEmailEncrypted) } returns "test@unit.com" + every { emailEncryptor.decrypt(schoolEmailEncrypted) } returns "test@snu.ac.kr" val response = memberQueryService.getMe(1L) assertThat(response.memberId).isEqualTo(1L) + assertThat(response.email).isEqualTo("test@unit.com") assertThat(response.nickname).isEqualTo("unit_user") assertThat(response.profileImageUrl).isEqualTo("profile_image_url") assertThat(response.status).isEqualTo(MemberStatus.ACTIVE) @@ -57,6 +65,7 @@ class MemberQueryServiceTest { assertThat(response.school!!.schoolId).isEqualTo(1L) assertThat(response.school.name).isEqualTo("Unit_University") assertThat(response.school.verificationStatus).isEqualTo(UserSchoolVerificationStatus.VERIFIED) + assertThat(response.school.verifiedEmail).isEqualTo("test@snu.ac.kr") verify(exactly = 1) { memberRepository.findByIdAndStatusInAndDeletedAtIsNull( @@ -150,7 +159,32 @@ class MemberQueryServiceTest { } @Test - @DisplayName("조회된 회원 ID가 없으면 예외가 발생한다") + @DisplayName("학교 인증 이메일 암호문이 없으면 학교 이메일은 null로 응답한다") + fun getMeWithoutVerifiedSchoolEmail() { + val member = createMember() + val schoolVerification = createUserSchoolVerification() + val school = createSchool() + + every { + memberRepository.findByIdAndStatusInAndDeletedAtIsNull( + id = 1L, + statuses = listOf(MemberStatus.PENDING, MemberStatus.ACTIVE), + ) + } returns member + every { userSchoolVerificationRepository.findByMemberId(1L) } returns schoolVerification + every { schoolRepository.findById(1L) } returns Optional.of(school) + + val response = memberQueryService.getMe(1L) + + assertThat(response.email).isNull() + assertThat(response.school).isNotNull + assertThat(response.school?.verifiedEmail).isNull() + + verify(exactly = 0) { emailEncryptor.decrypt(any()) } + } + + @Test + @DisplayName("조회할 회원 ID가 없으면 예외가 발생한다") fun getMeWithNullMemberId() { val member = createMember(id = null) @@ -213,10 +247,12 @@ class MemberQueryServiceTest { nickname: String = "unit_user", profileImageUrl: String = "profile_image_url", trustScore: Int = 100, - status: MemberStatus = MemberStatus.ACTIVE + status: MemberStatus = MemberStatus.ACTIVE, + emailEncrypted: ByteArray? = null, ): Member { return Member( id = id, + emailEncrypted = emailEncrypted, nickname = nickname, profileImageUrl = profileImageUrl, trustScore = trustScore, @@ -229,12 +265,14 @@ class MemberQueryServiceTest { schoolId: Long = 1L, method: UserSchoolVerificationMethod = UserSchoolVerificationMethod.EMAIL, status: UserSchoolVerificationStatus = UserSchoolVerificationStatus.VERIFIED, + verifiedEmailEncrypted: ByteArray? = null, ): UserSchoolVerification { return UserSchoolVerification( memberId = memberId, schoolId = schoolId, method = method, status = status, + verifiedEmailEncrypted = verifiedEmailEncrypted, ) } @@ -249,4 +287,4 @@ class MemberQueryServiceTest { status = status, ) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/unit/member/service/MemberSignupServiceTest.kt b/src/test/kotlin/com/unit/member/service/MemberSignupServiceTest.kt index 4001923..780ce53 100644 --- a/src/test/kotlin/com/unit/member/service/MemberSignupServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/MemberSignupServiceTest.kt @@ -13,6 +13,7 @@ import com.unit.member.repository.MemberConsentRepository import com.unit.member.repository.MemberRepository import com.unit.member.repository.SchoolRepository import com.unit.member.repository.UserSchoolVerificationRepository +import com.unit.member.util.EmailEncryptor import com.unit.member.util.EmailHasher import com.unit.platform.error.BusinessException import io.mockk.every @@ -33,6 +34,7 @@ class MemberSignupServiceTest { private val userSchoolVerificationRepository = mockk() private val passwordEncoder = mockk() private val emailHasher = mockk() + private val emailEncryptor = mockk() private val memberConsentRepository = mockk() private val memberConsentProperties = MemberConsentProperties( termsVersion = "2026-05-14-test", @@ -45,6 +47,7 @@ class MemberSignupServiceTest { userSchoolVerificationRepository = userSchoolVerificationRepository, passwordEncoder = passwordEncoder, emailHasher = emailHasher, + emailEncryptor = emailEncryptor, memberConsentRepository = memberConsentRepository, memberConsentProperties = memberConsentProperties, ) @@ -54,6 +57,7 @@ class MemberSignupServiceTest { fun signup() { val request = createRequest(nickname = " unit_user ") val emailHash = ByteArray(32) { 1 } + val emailEncrypted = ByteArray(64) { 3 } val encodedPassword = "encoded-password" val memberSlot = slot() @@ -64,10 +68,12 @@ class MemberSignupServiceTest { every { memberRepository.existsByEmailHashAndDeletedAtIsNull(emailHash) } returns false every { memberRepository.existsByNicknameAndDeletedAtIsNull("unit_user") } returns false every { schoolRepository.existsByIdAndStatus(1L) } returns true + every { emailEncryptor.encrypt("test@unit.com") } returns emailEncrypted every { passwordEncoder.encode(request.password) } returns encodedPassword every { memberRepository.save(capture(memberSlot)) } returns Member( id = 1L, emailHash = emailHash, + emailEncrypted = emailEncrypted, passwordHash = encodedPassword, nickname = "unit_user", status = MemberStatus.PENDING, @@ -87,6 +93,7 @@ class MemberSignupServiceTest { assertThat(response.schoolVerificationStatus).isEqualTo(UserSchoolVerificationStatus.PENDING) assertThat(memberSlot.captured.emailHash).isEqualTo(emailHash) + assertThat(memberSlot.captured.emailEncrypted).isEqualTo(emailEncrypted) assertThat(memberSlot.captured.passwordHash).isEqualTo(encodedPassword) assertThat(memberSlot.captured.nickname).isEqualTo("unit_user") assertThat(memberSlot.captured.status).isEqualTo(MemberStatus.PENDING) @@ -214,4 +221,4 @@ class MemberSignupServiceTest { ) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt index 4570ced..53aa16d 100644 --- a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt @@ -9,6 +9,7 @@ import com.unit.member.entity.UserSchoolVerification import com.unit.member.enums.* import com.unit.member.exception.MemberErrorCode import com.unit.member.repository.* +import com.unit.member.util.EmailEncryptor import com.unit.member.util.EmailHasher import com.unit.member.util.SchoolEmailVerificationCodeGenerator import com.unit.member.util.SchoolEmailVerificationFailureRecorder @@ -37,6 +38,7 @@ class SchoolEmailVerificationServiceTest { private val userSchoolVerificationRepository = mockk() private val memberRepository = mockk() private val emailHasher = mockk() + private val emailEncryptor = mockk() private val tokenHasher = mockk() private val codeGenerator = mockk() private val failureRecorder = mockk() @@ -55,6 +57,7 @@ class SchoolEmailVerificationServiceTest { failureRecorder = failureRecorder, emailSender = emailSender, emailTemplateRenderer = emailTemplateRenderer, + emailEncryptor = emailEncryptor, ) @Test @@ -254,6 +257,7 @@ class SchoolEmailVerificationServiceTest { val memberId = 1L val schoolId = 1L val emailHash = ByteArray(32) { 1 } + val emailEncrypted = ByteArray(64) { 3 } val codeHash = ByteArray(32) { 2 } val verificationCode = createVerificationCode(id = 10L, memberId = memberId, schoolId = schoolId, emailHash = emailHash, codeHash = codeHash) val schoolVerification = createUserSchoolVerification(memberId, schoolId) @@ -267,6 +271,7 @@ class SchoolEmailVerificationServiceTest { } returns verificationCode every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true + every { emailEncryptor.encrypt("test@snu.ac.kr") } returns emailEncrypted every { userSchoolVerificationRepository.findByMemberIdAndSchoolId(memberId, schoolId) } returns schoolVerification every { memberRepository.findByIdAndStatusAndDeletedAtIsNull(memberId, MemberStatus.PENDING) } returns member @@ -278,6 +283,9 @@ class SchoolEmailVerificationServiceTest { assertThat(response.status).isEqualTo(UserSchoolVerificationStatus.VERIFIED) assertThat(verificationCode.status).isEqualTo(SchoolEmailVerificationStatus.VERIFIED) assertThat(schoolVerification.status).isEqualTo(UserSchoolVerificationStatus.VERIFIED) + assertThat(schoolVerification.verifiedEmailHash).containsExactly(*emailHash) + assertThat(schoolVerification.verifiedEmailEncrypted).isEqualTo(emailEncrypted) + assertThat(schoolVerification.verifiedAt).isNotNull() assertThat(member.status).isEqualTo(MemberStatus.ACTIVE) verify(exactly = 1) { failureRecorder.increaseAttempt(10L) } diff --git a/src/test/kotlin/com/unit/member/util/EmailEncryptorTest.kt b/src/test/kotlin/com/unit/member/util/EmailEncryptorTest.kt new file mode 100644 index 0000000..b50a4e4 --- /dev/null +++ b/src/test/kotlin/com/unit/member/util/EmailEncryptorTest.kt @@ -0,0 +1,54 @@ +package com.unit.member.util + +import com.unit.member.config.EmailEncryptionProperties +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.DisplayName +import kotlin.test.Test + +@DisplayName("EmailEncryptor 테스트") +class EmailEncryptorTest { + + private val emailEncryptor = EmailEncryptor( + EmailEncryptionProperties( + keyBase64 = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + ), + ) + + @Test + @DisplayName("암호화한 이메일은 복호화하면 원문으로 돌아온다") + fun encryptAndDecrypt() { + val encrypted = emailEncryptor.encrypt("test@unit.com") + + val decrypted = emailEncryptor.decrypt(encrypted) + + assertThat(decrypted).isEqualTo("test@unit.com") + } + + @Test + @DisplayName("같은 이메일을 암호화해도 매번 다른 암호문이 생성된다") + fun encryptUsesRandomIv() { + val first = emailEncryptor.encrypt("test@unit.com") + val second = emailEncryptor.encrypt("test@unit.com") + + assertThat(first).isNotEqualTo(second) + assertThat(emailEncryptor.decrypt(first)).isEqualTo("test@unit.com") + assertThat(emailEncryptor.decrypt(second)).isEqualTo("test@unit.com") + } + + @Test + @DisplayName("32바이트가 아닌 키는 생성에 실패한다") + fun invalidKey() { + assertThatThrownBy { + EmailEncryptor(EmailEncryptionProperties(keyBase64 = "c2hvcnQ=")) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + @DisplayName("IV보다 짧은 암호문은 복호화에 실패한다") + fun decryptWithTooShortPayload() { + assertThatThrownBy { + emailEncryptor.decrypt(ByteArray(12)) + }.isInstanceOf(IllegalArgumentException::class.java) + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ca41326..39db00d 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -24,6 +24,8 @@ unit: access-token-expiration-seconds: 1800 refresh-token: expiration-seconds: 1209600 # 14일 + email-encryption: + key-base64: ${EMAIL_ENCRYPTION_KEY_BASE64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} mail: enabled: false consent: From f79f27a299b5b0eeea3b6abaa0caa7b5d81ac6d3 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 12:24:10 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=ED=95=99=EA=B5=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=20=ED=95=99=EA=B5=90=20=EC=9D=B8=EC=A6=9D=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이메일 인증 요청 시 UserSchoolVerification 상태가 PENDING인 항목 검증 로직 추가 - 상태 검증 실패 시 MemberErrorCode.SCHOOL_VERIFICATION_NOT_FOUND 예외 발생 처리 - SchoolEmailVerificationServiceTest에 관련 단위 테스트 추가 - 잘못된 학교 인증 요청 시 발생하는 예외 및 동작 검증 테스트 보완 --- .../service/SchoolEmailVerificationService.kt | 12 ++++ .../SchoolEmailVerificationServiceTest.kt | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt index d8b3ffd..d33e47a 100644 --- a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt +++ b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt @@ -52,6 +52,18 @@ class SchoolEmailVerificationService( schoolRepository.findByIdAndStatus(schoolId) ?: throw BusinessException(MemberErrorCode.SCHOOL_NOT_FOUND) + val hasPendingSchoolVerification = + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + memberId = memberId, + schoolId = schoolId, + status = UserSchoolVerificationStatus.PENDING, + ) + + if (!hasPendingSchoolVerification) { + throw BusinessException(MemberErrorCode.SCHOOL_VERIFICATION_NOT_FOUND) + } + + val email = request.email.trim().lowercase() val domain = extractDomain(email) diff --git a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt index 53aa16d..88c4c16 100644 --- a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt @@ -71,6 +71,13 @@ class SchoolEmailVerificationServiceTest { val savedSlot = slot() every { schoolRepository.findByIdAndStatus(schoolId) } returns createSchool(schoolId) + every { + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + memberId, + schoolId, + UserSchoolVerificationStatus.PENDING, + ) + } returns true every { schoolEmailDomainRepository.existsBySchoolIdAndDomainAndStatus(schoolId, "snu.ac.kr") } returns true every { emailHasher.hash("test@snu.ac.kr") } returns emailHash @@ -146,6 +153,13 @@ class SchoolEmailVerificationServiceTest { val codeHash = ByteArray(32) { 2 } every { schoolRepository.findByIdAndStatus(schoolId) } returns createSchool(schoolId) + every { + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + memberId, + schoolId, + UserSchoolVerificationStatus.PENDING, + ) + } returns true every { schoolEmailDomainRepository.existsBySchoolIdAndDomainAndStatus(schoolId, "snu.ac.kr") } returns true every { emailHasher.hash("test@snu.ac.kr") } returns emailHash every { @@ -210,6 +224,13 @@ class SchoolEmailVerificationServiceTest { @DisplayName("학교 이메일 도메인이 아니면 인증 요청에 실패한다") fun requestWithNotAllowedDomain() { every { schoolRepository.findByIdAndStatus(1L) } returns createSchool(1L) + every { + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + 1L, + 1L, + UserSchoolVerificationStatus.PENDING, + ) + } returns true every { schoolEmailDomainRepository.existsBySchoolIdAndDomainAndStatus(1L, "gmail.com") } returns false assertThatThrownBy { @@ -230,6 +251,41 @@ class SchoolEmailVerificationServiceTest { verify(exactly = 0) { emailSender.send(any()) } } + @Test + @DisplayName("회원가입 때 생성된 학교 인증 대기 row와 다른 학교로 인증 요청하면 실패한다") + fun requestWithDifferentSchoolVerification() { + val memberId = 1L + val requestedSchoolId = 2L + + every { schoolRepository.findByIdAndStatus(requestedSchoolId) } returns createSchool(requestedSchoolId) + every { + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + memberId, + requestedSchoolId, + UserSchoolVerificationStatus.PENDING, + ) + } returns false + + assertThatThrownBy { + service.request( + memberId = memberId, + request = SchoolEmailVerificationRequest( + schoolId = requestedSchoolId, + email = "test@snu.ac.kr", + ), + ) + } + .isInstanceOf(BusinessException::class.java) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.SCHOOL_VERIFICATION_NOT_FOUND) + + verify(exactly = 0) { schoolEmailDomainRepository.existsBySchoolIdAndDomainAndStatus(any(), any()) } + verify(exactly = 0) { emailHasher.hash(any()) } + verify(exactly = 0) { schoolEmailVerificationCodeRepository.save(any()) } + verify(exactly = 0) { emailTemplateRenderer.render(any(), any()) } + verify(exactly = 0) { emailSender.send(any()) } + } + @Test @DisplayName("인증 요청의 schoolId가 null이면 예외가 발생한다") fun requestWithNullSchoolId() { From 5d99fcb6731b3e8e861abf81e19f69ae170a1682 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 12:27:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 학교 이메일 인증 메일 발송을 트랜잭션 커밋 이후로 지연 처리 - TransactionSynchronizationManager를 활용한 afterCommit 콜백 메서드 registerAfterCommit 추가 - SchoolEmailVerificationServiceTest에 이메일 발송 동작 검증 테스트 추가 - 테스트 정리(TearDown) 단계에서 트랜잭션 동기화 초기화 로직 추가 --- .../service/SchoolEmailVerificationService.kt | 34 ++++-- .../SchoolEmailVerificationServiceTest.kt | 113 +++++++++++++++--- 2 files changed, 125 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt index d33e47a..ebcf226 100644 --- a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt +++ b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt @@ -23,6 +23,8 @@ import com.unit.platform.mail.EmailSender import com.unit.platform.mail.EmailTemplateRenderer import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime @Service @@ -104,15 +106,17 @@ class SchoolEmailVerificationService( ), ) - emailSender.send( - EmailMessage( - to = email, - subject = "[UniT] 학교 이메일 인증 코드", - body = html, - html = true, - ), + val message = EmailMessage( + to = email, + subject = "[UniT] 학교 이메일 인증 코드", + body = html, + html = true, ) + registerAfterCommit { + emailSender.send(message) + } + return SchoolEmailVerificationResponse( schoolId = schoolId, email = email, @@ -184,4 +188,20 @@ class SchoolEmailVerificationService( private fun SchoolEmailVerificationCode.memberIdEquals(memberId: Long): Boolean { return this.memberId == memberId } + + private fun registerAfterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + action() + } + }, + ) + } + } diff --git a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt index 88c4c16..de6b1d1 100644 --- a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt @@ -9,23 +9,16 @@ import com.unit.member.entity.UserSchoolVerification import com.unit.member.enums.* import com.unit.member.exception.MemberErrorCode import com.unit.member.repository.* -import com.unit.member.util.EmailEncryptor -import com.unit.member.util.EmailHasher -import com.unit.member.util.SchoolEmailVerificationCodeGenerator -import com.unit.member.util.SchoolEmailVerificationFailureRecorder -import com.unit.member.util.TokenHasher +import com.unit.member.util.* import com.unit.platform.error.BusinessException import com.unit.platform.mail.EmailSender import com.unit.platform.mail.EmailTemplateRenderer -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify +import io.mockk.* import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.DisplayName +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import kotlin.test.Test @@ -60,6 +53,13 @@ class SchoolEmailVerificationServiceTest { emailEncryptor = emailEncryptor, ) + @AfterEach + fun tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + } + @Test @DisplayName("학교 이메일 인증 요청에 성공하면 기존 대기 코드를 취소하고 새 코드를 저장한다") fun requestSuccess() { @@ -197,6 +197,74 @@ class SchoolEmailVerificationServiceTest { verify(exactly = 1) { emailSender.send(any()) } } + @Test + @DisplayName("학교 이메일 인증 메일은 트랜잭션 커밋 이후 발송한다") + fun requestSendsEmailAfterCommit() { + val memberId = 1L + val schoolId = 1L + val emailHash = ByteArray(32) { 1 } + val codeHash = ByteArray(32) { 2 } + + every { schoolRepository.findByIdAndStatus(schoolId) } returns createSchool(schoolId) + every { + userSchoolVerificationRepository.existsByMemberIdAndSchoolIdAndStatus( + memberId, + schoolId, + UserSchoolVerificationStatus.PENDING, + ) + } returns true + every { schoolEmailDomainRepository.existsBySchoolIdAndDomainAndStatus(schoolId, "snu.ac.kr") } returns true + every { emailHasher.hash("test@snu.ac.kr") } returns emailHash + every { + schoolEmailVerificationCodeRepository.findTopBySchoolIdAndEmailHashAndStatusOrderByCreatedAtDesc( + schoolId, + emailHash, + SchoolEmailVerificationStatus.PENDING, + ) + } returns null + every { codeGenerator.generate() } returns "123456" + every { tokenHasher.hash("123456") } returns codeHash + every { schoolEmailVerificationCodeRepository.save(any()) } answers { firstArg() } + every { + emailTemplateRenderer.render( + templatePath = "mail/school-email-verification.html", + variables = mapOf( + "code" to "123456", + "expiresInMinutes" to "5", + ), + ) + } returns "인증코드 123456" + every { emailSender.send(any()) } just Runs + + TransactionSynchronizationManager.initSynchronization() + + service.request( + memberId = memberId, + request = SchoolEmailVerificationRequest( + schoolId = schoolId, + email = "test@snu.ac.kr", + ), + ) + + verify(exactly = 0) { emailSender.send(any()) } + + val synchronizations = TransactionSynchronizationManager.getSynchronizations() + assertThat(synchronizations).hasSize(1) + + synchronizations.forEach { it.afterCommit() } + + verify(exactly = 1) { + emailSender.send( + match { + it.to == "test@snu.ac.kr" && + it.body == "인증코드 123456" && + it.html + }, + ) + } + } + + @Test @DisplayName("학교가 없으면 인증 요청에 실패한다") fun requestWithNotFoundSchool() { @@ -315,7 +383,13 @@ class SchoolEmailVerificationServiceTest { val emailHash = ByteArray(32) { 1 } val emailEncrypted = ByteArray(64) { 3 } val codeHash = ByteArray(32) { 2 } - val verificationCode = createVerificationCode(id = 10L, memberId = memberId, schoolId = schoolId, emailHash = emailHash, codeHash = codeHash) + val verificationCode = createVerificationCode( + id = 10L, + memberId = memberId, + schoolId = schoolId, + emailHash = emailHash, + codeHash = codeHash + ) val schoolVerification = createUserSchoolVerification(memberId, schoolId) val member = createMember(memberId) @@ -328,7 +402,12 @@ class SchoolEmailVerificationServiceTest { every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true every { emailEncryptor.encrypt("test@snu.ac.kr") } returns emailEncrypted - every { userSchoolVerificationRepository.findByMemberIdAndSchoolId(memberId, schoolId) } returns schoolVerification + every { + userSchoolVerificationRepository.findByMemberIdAndSchoolId( + memberId, + schoolId + ) + } returns schoolVerification every { memberRepository.findByIdAndStatusAndDeletedAtIsNull(memberId, MemberStatus.PENDING) } returns member val response = service.confirm( @@ -465,7 +544,6 @@ class SchoolEmailVerificationServiceTest { } - @Test @DisplayName("인증 코드가 만료되면 EXPIRED 처리 후 실패한다") fun confirmWithExpiredCode() { @@ -697,7 +775,12 @@ class SchoolEmailVerificationServiceTest { } returns verificationCode every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true - every { userSchoolVerificationRepository.findByMemberIdAndSchoolId(memberId, schoolId) } returns schoolVerification + every { + userSchoolVerificationRepository.findByMemberIdAndSchoolId( + memberId, + schoolId + ) + } returns schoolVerification every { memberRepository.findByIdAndStatusAndDeletedAtIsNull(memberId, MemberStatus.PENDING) } returns null assertThatThrownBy { From 704c338361e81119ae0bfb5a7df26cde8e8f6467 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 12:33:12 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A6=9D=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실패 시도 증가(failureRecorder.increaseAttempt) 로직을 검증 조건 내부로 이동 - 조건에 의한 의도치 않은 로직 실행 방지 위해 테스트 코드 수정 - 불필요한 Mock 및 verify 호출 제거로 테스트 단순화 - failureRecorder 및 tokenHasher 동작 검증 테스트 보완 --- .../member/service/SchoolEmailVerificationService.kt | 3 +-- .../service/SchoolEmailVerificationServiceTest.kt | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt index ebcf226..c19374d 100644 --- a/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt +++ b/src/main/kotlin/com/unit/member/service/SchoolEmailVerificationService.kt @@ -151,9 +151,8 @@ class SchoolEmailVerificationService( throw BusinessException(MemberErrorCode.SCHOOL_EMAIL_VERIFICATION_CODE_EXPIRED) } - failureRecorder.increaseAttempt(requireNotNull(verificationCode.id)) - if (!tokenHasher.matches(code, verificationCode.codeHash)) { + failureRecorder.increaseAttempt(requireNotNull(verificationCode.id)) throw BusinessException(MemberErrorCode.SCHOOL_EMAIL_VERIFICATION_CODE_MISMATCHED) } diff --git a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt index de6b1d1..1da3b8b 100644 --- a/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/SchoolEmailVerificationServiceTest.kt @@ -399,7 +399,6 @@ class SchoolEmailVerificationServiceTest { schoolId, emailHash, SchoolEmailVerificationStatus.PENDING, ) } returns verificationCode - every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true every { emailEncryptor.encrypt("test@snu.ac.kr") } returns emailEncrypted every { @@ -423,7 +422,7 @@ class SchoolEmailVerificationServiceTest { assertThat(schoolVerification.verifiedAt).isNotNull() assertThat(member.status).isEqualTo(MemberStatus.ACTIVE) - verify(exactly = 1) { failureRecorder.increaseAttempt(10L) } + verify(exactly = 0) { failureRecorder.increaseAttempt(any()) } verify(exactly = 0) { failureRecorder.expire(any()) } } @@ -685,6 +684,7 @@ class SchoolEmailVerificationServiceTest { SchoolEmailVerificationStatus.PENDING, ) } returns verificationCode + every { tokenHasher.matches("123456", verificationCode.codeHash) } returns false assertThatThrownBy { service.confirm( @@ -699,7 +699,7 @@ class SchoolEmailVerificationServiceTest { verify(exactly = 0) { failureRecorder.expire(any()) } verify(exactly = 0) { failureRecorder.increaseAttempt(any()) } - verify(exactly = 0) { tokenHasher.matches(any(), any()) } + verify(exactly = 1) { tokenHasher.matches("123456", verificationCode.codeHash) } } @Test @@ -726,7 +726,6 @@ class SchoolEmailVerificationServiceTest { SchoolEmailVerificationStatus.PENDING, ) } returns verificationCode - every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true every { userSchoolVerificationRepository.findByMemberIdAndSchoolId(memberId, schoolId) } returns null @@ -744,7 +743,7 @@ class SchoolEmailVerificationServiceTest { .extracting("errorCode") .isEqualTo(MemberErrorCode.SCHOOL_VERIFICATION_NOT_FOUND) - verify(exactly = 1) { failureRecorder.increaseAttempt(10L) } + verify(exactly = 0) { failureRecorder.increaseAttempt(any()) } verify(exactly = 0) { failureRecorder.expire(any()) } } @@ -773,7 +772,6 @@ class SchoolEmailVerificationServiceTest { SchoolEmailVerificationStatus.PENDING, ) } returns verificationCode - every { failureRecorder.increaseAttempt(10L) } just Runs every { tokenHasher.matches("123456", codeHash) } returns true every { userSchoolVerificationRepository.findByMemberIdAndSchoolId( @@ -797,7 +795,7 @@ class SchoolEmailVerificationServiceTest { .extracting("errorCode") .isEqualTo(MemberErrorCode.MEMBER_LOGIN_FORBIDDEN) - verify(exactly = 1) { failureRecorder.increaseAttempt(10L) } + verify(exactly = 0) { failureRecorder.increaseAttempt(any()) } verify(exactly = 0) { failureRecorder.expire(any()) } } From 6f0370fc42fc355afa974fe7a5b4d5b33de22574 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 12:56:33 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=EC=83=9D=EC=84=B1=20=EC=A0=84=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=8F=20=EC=86=8C=EB=AC=B8=EC=9E=90=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 요청 시 이메일 입력값의 공백 제거 및 소문자 변환 처리 추가 - 이메일 해시 생성 전 불필요한 대소문자 및 공백 문제 방지 - MemberRepository 조회 시 일관된 이메일 데이터 사용 보장 --- src/main/kotlin/com/unit/member/service/AuthLoginService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/unit/member/service/AuthLoginService.kt b/src/main/kotlin/com/unit/member/service/AuthLoginService.kt index acb9043..127de00 100644 --- a/src/main/kotlin/com/unit/member/service/AuthLoginService.kt +++ b/src/main/kotlin/com/unit/member/service/AuthLoginService.kt @@ -25,7 +25,8 @@ class AuthLoginService( override fun login(request: AuthLoginRequest): AuthLoginResponse { - val emailHash = emailHasher.hash(request.email) + val email = request.email.trim().lowercase() + val emailHash = emailHasher.hash(email) val member = memberRepository.findByEmailHashAndDeletedAtIsNull(emailHash) ?: memberRepository.findTopByEmailHashAndDeletedAtIsNotNullOrderByDeletedAtDescIdDesc(emailHash) From ab301569399f705984eaa31f695e167ccf1cbb90 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 13:02:43 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=ED=9B=84=EC=B2=98=EB=A6=AC=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WithdrawalPolicy 적용 로직을 applyWithdrawalPoliciesAfterCommit 메서드로 분리 - 후처리 중 예외 발생 시 memberId 및 정책명을 포함한 에러 로그 추가 - 로그 처리를 위해 MemberWithdrawalService에 LoggerFactory 추가 --- .../member/service/MemberWithdrawalService.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt index eaea98d..b564f8d 100644 --- a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt +++ b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt @@ -6,6 +6,7 @@ import com.unit.member.repository.MemberRepository import com.unit.member.withdrawal.MemberWithdrawalContext import com.unit.member.withdrawal.MemberWithdrawalPolicy import com.unit.platform.error.BusinessException +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization @@ -38,7 +39,7 @@ class MemberWithdrawalService( refreshTokenUseCase.revokeAll(context.memberId) registerAfterCommit { - withdrawalPolicies.forEach { it.apply(context) } + applyWithdrawalPoliciesAfterCommit(context) } } @@ -57,4 +58,21 @@ class MemberWithdrawalService( ) } + private fun applyWithdrawalPoliciesAfterCommit(context: MemberWithdrawalContext) { + withdrawalPolicies.forEach { policy -> + try { + policy.apply(context) + } catch (e: Exception) { + log.error( + "Member withdrawal post-processing failed. memberId=${context.memberId}, policy=${policy.javaClass.name}", + e, + ) + } + } + } + + companion object { + private val log = LoggerFactory.getLogger(MemberWithdrawalService::class.java) + } + } \ No newline at end of file From bd78f620d706cfd6efaf1be377c303c4f0f836b9 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Thu, 14 May 2026 13:09:54 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EB=8F=99=EC=9D=98=20=EC=B2=A0=ED=9A=8C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberWithdrawalService에 회원 동의 철회 처리 로직 추가 - MemberConsent 엔티티에 withdraw 메서드 추가 및 withdrawnAt 저장 처리 - MemberConsentRepository에 findAllByMemberId 메서드 추가 - 회원 탈퇴 서비스 테스트(MemberWithdrawalServiceTest)에 동의 철회 검증 로직 추가 - 트랜잭션 동기화 없는 상황에서도 동의 철회 동작 검증 테스트 보완 --- .../com/unit/member/entity/MemberConsent.kt | 6 ++- .../repository/MemberConsentRepository.kt | 5 ++- .../member/service/MemberWithdrawalService.kt | 6 ++- .../service/MemberWithdrawalServiceTest.kt | 38 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/unit/member/entity/MemberConsent.kt b/src/main/kotlin/com/unit/member/entity/MemberConsent.kt index a3567c6..eee17fd 100644 --- a/src/main/kotlin/com/unit/member/entity/MemberConsent.kt +++ b/src/main/kotlin/com/unit/member/entity/MemberConsent.kt @@ -50,4 +50,8 @@ class MemberConsent( @Column(name = "updated_at", nullable = false) var updatedAt: LocalDateTime? = null protected set -} \ No newline at end of file + + fun withdraw(now: LocalDateTime) { + this.withdrawnAt = now + } +} diff --git a/src/main/kotlin/com/unit/member/repository/MemberConsentRepository.kt b/src/main/kotlin/com/unit/member/repository/MemberConsentRepository.kt index bb08e0d..78b4745 100644 --- a/src/main/kotlin/com/unit/member/repository/MemberConsentRepository.kt +++ b/src/main/kotlin/com/unit/member/repository/MemberConsentRepository.kt @@ -3,4 +3,7 @@ package com.unit.member.repository import com.unit.member.entity.MemberConsent import org.springframework.data.jpa.repository.JpaRepository -interface MemberConsentRepository : JpaRepository \ No newline at end of file +interface MemberConsentRepository : JpaRepository { + + fun findAllByMemberId(memberId: Long): List +} diff --git a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt index b564f8d..a90daa6 100644 --- a/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt +++ b/src/main/kotlin/com/unit/member/service/MemberWithdrawalService.kt @@ -2,6 +2,7 @@ package com.unit.member.service import com.unit.member.enums.MemberStatus import com.unit.member.exception.MemberErrorCode +import com.unit.member.repository.MemberConsentRepository import com.unit.member.repository.MemberRepository import com.unit.member.withdrawal.MemberWithdrawalContext import com.unit.member.withdrawal.MemberWithdrawalPolicy @@ -17,6 +18,7 @@ import java.time.LocalDateTime @Transactional class MemberWithdrawalService( private val memberRepository: MemberRepository, + private val memberConsentRepository: MemberConsentRepository, private val refreshTokenUseCase: RefreshTokenUseCase, private val withdrawalPolicies: List, ) : MemberWithdrawalUseCase { @@ -36,6 +38,8 @@ class MemberWithdrawalService( withdrawalPolicies.forEach { it.validate(context) } member.withdraw(now) + memberConsentRepository.findAllByMemberId(context.memberId) + .forEach { it.withdraw(now) } refreshTokenUseCase.revokeAll(context.memberId) registerAfterCommit { @@ -75,4 +79,4 @@ class MemberWithdrawalService( private val log = LoggerFactory.getLogger(MemberWithdrawalService::class.java) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt index b3832db..110f5e8 100644 --- a/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt +++ b/src/test/kotlin/com/unit/member/service/MemberWithdrawalServiceTest.kt @@ -1,8 +1,11 @@ package com.unit.member.service import com.unit.member.entity.Member +import com.unit.member.entity.MemberConsent +import com.unit.member.enums.MemberConsentType import com.unit.member.enums.MemberStatus import com.unit.member.exception.MemberErrorCode +import com.unit.member.repository.MemberConsentRepository import com.unit.member.repository.MemberRepository import com.unit.member.withdrawal.MemberWithdrawalContext import com.unit.member.withdrawal.MemberWithdrawalPolicy @@ -24,11 +27,13 @@ import kotlin.test.Test class MemberWithdrawalServiceTest { private val memberRepository = mockk() + private val memberConsentRepository = mockk() private val refreshTokenUseCase = mockk() private val withdrawalPolicy = mockk() private val memberWithdrawalService = MemberWithdrawalService( memberRepository = memberRepository, + memberConsentRepository = memberConsentRepository, refreshTokenUseCase = refreshTokenUseCase, withdrawalPolicies = listOf(withdrawalPolicy), ) @@ -44,6 +49,7 @@ class MemberWithdrawalServiceTest { @DisplayName("탈퇴 가능 회원이면 정책 검증 후 회원을 탈퇴 처리하고 Refresh Token을 모두 폐기한다") fun withdraw() { val member = createMember() + val consents = createMemberConsents() val validateContext = slot() val applyContext = slot() @@ -54,6 +60,7 @@ class MemberWithdrawalServiceTest { ) } returns member every { withdrawalPolicy.validate(capture(validateContext)) } just Runs + every { memberConsentRepository.findAllByMemberId(1L) } returns consents every { refreshTokenUseCase.revokeAll(1L) } just Runs every { withdrawalPolicy.apply(capture(applyContext)) } just Runs @@ -69,9 +76,13 @@ class MemberWithdrawalServiceTest { assertThat(member.phoneHash).isNull() assertThat(member.emailHash).isNotNull() + assertThat(consents).allSatisfy { + assertThat(it.withdrawnAt).isEqualTo(validateContext.captured.requestedAt) + } assertThat(validateContext.captured.memberId).isEqualTo(1L) verify(exactly = 1) { withdrawalPolicy.validate(any()) } + verify(exactly = 1) { memberConsentRepository.findAllByMemberId(1L) } verify(exactly = 1) { refreshTokenUseCase.revokeAll(1L) } verify(exactly = 0) { withdrawalPolicy.apply(any()) } @@ -90,6 +101,7 @@ class MemberWithdrawalServiceTest { @DisplayName("트랜잭션 동기화가 없으면 탈퇴 후처리를 즉시 실행한다") fun withdrawWithoutTransactionSynchronization() { val member = createMember() + val consents = createMemberConsents() val validateContext = slot() val applyContext = slot() @@ -100,16 +112,21 @@ class MemberWithdrawalServiceTest { ) } returns member every { withdrawalPolicy.validate(capture(validateContext)) } just Runs + every { memberConsentRepository.findAllByMemberId(1L) } returns consents every { refreshTokenUseCase.revokeAll(1L) } just Runs every { withdrawalPolicy.apply(capture(applyContext)) } just Runs memberWithdrawalService.withdraw(1L) assertThat(member.status).isEqualTo(MemberStatus.DELETED) + assertThat(consents).allSatisfy { + assertThat(it.withdrawnAt).isEqualTo(validateContext.captured.requestedAt) + } assertThat(applyContext.captured.memberId).isEqualTo(1L) assertThat(applyContext.captured.requestedAt).isEqualTo(validateContext.captured.requestedAt) verify(exactly = 1) { withdrawalPolicy.validate(any()) } + verify(exactly = 1) { memberConsentRepository.findAllByMemberId(1L) } verify(exactly = 1) { refreshTokenUseCase.revokeAll(1L) } verify(exactly = 1) { withdrawalPolicy.apply(any()) } } @@ -132,6 +149,7 @@ class MemberWithdrawalServiceTest { .isEqualTo(MemberErrorCode.MEMBER_LOGIN_FORBIDDEN) verify(exactly = 0) { withdrawalPolicy.validate(any()) } + verify(exactly = 0) { memberConsentRepository.findAllByMemberId(any()) } verify(exactly = 0) { refreshTokenUseCase.revokeAll(any()) } verify(exactly = 0) { withdrawalPolicy.apply(any()) } } @@ -153,6 +171,7 @@ class MemberWithdrawalServiceTest { }.isInstanceOf(IllegalArgumentException::class.java) verify(exactly = 0) { withdrawalPolicy.validate(any()) } + verify(exactly = 0) { memberConsentRepository.findAllByMemberId(any()) } verify(exactly = 0) { refreshTokenUseCase.revokeAll(any()) } verify(exactly = 0) { withdrawalPolicy.apply(any()) } } @@ -178,4 +197,23 @@ class MemberWithdrawalServiceTest { status = status, ) } + + private fun createMemberConsents(): List { + return listOf( + MemberConsent( + memberId = 1L, + consentType = MemberConsentType.TERMS_OF_SERVICE, + policyVersion = "terms-v1", + agreed = true, + agreedAt = null, + ), + MemberConsent( + memberId = 1L, + consentType = MemberConsentType.PRIVACY_POLICY, + policyVersion = "privacy-v1", + agreed = true, + agreedAt = null, + ), + ) + } }