-
Notifications
You must be signed in to change notification settings - Fork 14
[volume-1] 회원 가입, 내 정보 조회, 포인트 조회, 포인트 충전 구현 #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: choco3193
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.loopers.application.user | ||
|
|
||
| import com.loopers.domain.user.Gender | ||
| import com.loopers.domain.user.UserService | ||
| import com.loopers.support.error.CoreException | ||
| import com.loopers.support.error.ErrorType | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| @Component | ||
| class UserFacade( | ||
| private val service: UserService, | ||
| ) { | ||
| fun registerUser(userId: String, email: String, birth: String, gender: Gender): UserInfo { | ||
| return service.registerUser(userId, email, birth, gender) | ||
| .let { UserInfo.from(it) } | ||
| } | ||
|
|
||
| fun getUser(userId: String): UserInfo { | ||
| return service.getUserByUserId(userId) | ||
| ?.let { UserInfo.from(it) } | ||
| ?: throw CoreException(ErrorType.NOT_FOUND, "User not found") | ||
| } | ||
|
|
||
| fun getPoint(userId: String): Int { | ||
| return service.getPointByUserId(userId) | ||
| ?: throw CoreException(ErrorType.NOT_FOUND, "User not found") | ||
| } | ||
|
|
||
| fun chargePoint(userId: String, amount: Int): Int { | ||
| return service.chargePointByUserId(userId, amount) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.loopers.application.user | ||
|
|
||
| import com.loopers.domain.user.Gender | ||
| import com.loopers.domain.user.User | ||
|
|
||
| data class UserInfo( | ||
| val userId: String, | ||
| val email: String, | ||
| val birth: String, | ||
| val gender: Gender, | ||
| ) { | ||
| companion object { | ||
| fun from(user: User): UserInfo { | ||
| return UserInfo( | ||
| userId = user.userId, | ||
| email = user.email, | ||
| birth = user.birth, | ||
| gender = user.gender, | ||
| ) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package com.loopers.domain.user | ||
|
|
||
| import com.loopers.domain.BaseEntity | ||
| import com.loopers.support.error.CoreException | ||
| import com.loopers.support.error.ErrorType | ||
| import jakarta.persistence.Entity | ||
| import jakarta.persistence.Table | ||
|
|
||
| @Entity | ||
| @Table(name = "users") | ||
| class User( | ||
| userId: String, | ||
| email: String, | ||
| birth: String, | ||
| gender: Gender, | ||
| ) : BaseEntity() { | ||
| var userId: String = userId | ||
| protected set | ||
|
|
||
| var email: String = email | ||
| protected set | ||
|
|
||
| var birth: String = birth | ||
| protected set | ||
|
|
||
| var gender: Gender = gender | ||
| protected set | ||
|
|
||
| var point: Int = 0 | ||
|
|
||
| init { | ||
| if (!validateUserId(userId)) throw CoreException(ErrorType.BAD_REQUEST, "invalid user id") | ||
|
|
||
| if (!validateEmail(email)) throw CoreException(ErrorType.BAD_REQUEST, "invalid email") | ||
|
|
||
| if (!validateBirth(birth)) throw CoreException(ErrorType.BAD_REQUEST, "invalid birth") | ||
| } | ||
|
|
||
| fun chargePoint(point: Int): Int { | ||
| if (point <= 0) throw CoreException(ErrorType.BAD_REQUEST, "invalid point") | ||
|
|
||
| this.point += point | ||
| return this.point | ||
| } | ||
|
|
||
| companion object { | ||
| private fun validateUserId(userId: String): Boolean { | ||
| val userIdValidator = Regex("^[A-Za-z0-9]{1,10}$") | ||
|
|
||
| return userIdValidator.matches(userId) | ||
| } | ||
|
|
||
| private fun validateEmail(email: String): Boolean { | ||
| val emailValidator = Regex("^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\\.[a-zA-Z]{2,3}$") | ||
|
|
||
| return emailValidator.matches(email) | ||
| } | ||
|
|
||
| private fun validateBirth(birth: String): Boolean { | ||
| val birthValidator = Regex("^\\d{4}-\\d{2}-\\d{2}$") | ||
|
|
||
| return birthValidator.matches(birth) | ||
| } | ||
| } | ||
|
Comment on lines
+46
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 데이터베이스 제약조건 및 인덱스 추가 권장 검증 로직은 잘 구현되어 있지만, 데이터베이스 레벨의 보장이 필요합니다.
@Entity
-@Table(name = "users")
+@Table(
+ name = "users",
+ indexes = [Index(name = "idx_user_id", columnList = "userId")],
+ uniqueConstraints = [UniqueConstraint(name = "uk_user_id", columnNames = ["userId"])]
+)
class User(그리고 userId 필드에: + @Column(unique = true, nullable = false, length = 10)
var userId: String = userId
protected set이유:
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| enum class Gender { | ||
| NONE, | ||
| MALE, | ||
| FEMALE, | ||
| OTHER, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.loopers.domain.user | ||
|
|
||
| interface UserRepository { | ||
| fun save(user: User): User | ||
| fun existsByUserId(userId: String): Boolean | ||
| fun findByUserId(userId: String): User? | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,39 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.loopers.domain.user | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.loopers.support.error.CoreException | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.loopers.support.error.ErrorType | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.transaction.annotation.Transactional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Component | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class UserService( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val userRepository: UserRepository, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Transactional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun registerUser( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| birth: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gender: Gender, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): User { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (userRepository.existsByUserId(userId)) throw CoreException(ErrorType.CONFLICT, "User already exists") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return userRepository.save(User(userId, email, birth, gender)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun getUserByUserId(userId: String): User? { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return userRepository.findByUserId(userId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun getPointByUserId(userId: String): Int? { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return userRepository.findByUserId(userId)?.point | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun chargePointByUserId(userId: String, point: Int): Int { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val user = userRepository.findByUserId(userId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ?: throw CoreException(ErrorType.NOT_FOUND, "user not found") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user.chargePoint(point) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return user.point | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 트랜잭션 경계가 누락되어 포인트 변경사항이 저장되지 않습니다.
작성자 질문에 대한 답변:
해결 방법 1 (권장): + @Transactional
fun chargePointByUserId(userId: String, point: Int): Int {
val user = userRepository.findByUserId(userId)
?: throw CoreException(ErrorType.NOT_FOUND, "user not found")
user.chargePoint(point)
return user.point
}해결 방법 2: 명시적 저장 fun chargePointByUserId(userId: String, point: Int): Int {
val user = userRepository.findByUserId(userId)
?: throw CoreException(ErrorType.NOT_FOUND, "user not found")
user.chargePoint(point)
+ userRepository.save(user)
return user.point
}📝 Committable suggestion
Suggested change
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.infrastructure.user | ||
|
|
||
| import com.loopers.domain.user.User | ||
| import org.springframework.data.jpa.repository.JpaRepository | ||
|
|
||
| interface UserJpaRepository : JpaRepository<User, Long> { | ||
| fun existsByUserId(userId: String): Boolean | ||
| fun findByUserId(userId: String): User? | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.loopers.infrastructure.user | ||
|
|
||
| import com.loopers.domain.user.User | ||
| import com.loopers.domain.user.UserRepository | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| @Component | ||
| class UserRepositoryImpl( | ||
| private val userJpaRepository: UserJpaRepository, | ||
| ) : UserRepository { | ||
| override fun save(user: User): User { | ||
| return userJpaRepository.save(user) | ||
| } | ||
|
|
||
| override fun existsByUserId(userId: String): Boolean { | ||
| return userJpaRepository.existsByUserId(userId) | ||
| } | ||
|
|
||
| override fun findByUserId(userId: String): User? { | ||
| return userJpaRepository.findByUserId(userId) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,39 @@ | ||||||||||||||||||||||||||||||||||
| package com.loopers.interfaces.api.user | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import com.loopers.interfaces.api.ApiResponse | ||||||||||||||||||||||||||||||||||
| import io.swagger.v3.oas.annotations.Operation | ||||||||||||||||||||||||||||||||||
| import io.swagger.v3.oas.annotations.media.Schema | ||||||||||||||||||||||||||||||||||
| import io.swagger.v3.oas.annotations.tags.Tag | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Tag(name = "User V1 API", description = "회원 API") | ||||||||||||||||||||||||||||||||||
| interface UserV1ApiSpec { | ||||||||||||||||||||||||||||||||||
| @Operation( | ||||||||||||||||||||||||||||||||||
| summary = "회원 가입", | ||||||||||||||||||||||||||||||||||
| description = "ID, email, birth, gender 정보로 회원 가입 시도", | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| fun registerUser(req: UserV1Dto.RegisterUserRequest): ApiResponse<UserV1Dto.UserResponse> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Operation( | ||||||||||||||||||||||||||||||||||
| summary = "회원 조회", | ||||||||||||||||||||||||||||||||||
| description = "ID로 회원을 조회합니다.", | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| fun getUser( | ||||||||||||||||||||||||||||||||||
| @Schema(name = "회원 ID", description = "조회할 회원의 ID") | ||||||||||||||||||||||||||||||||||
| userId: String, | ||||||||||||||||||||||||||||||||||
| ): ApiResponse<UserV1Dto.UserResponse> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Operation( | ||||||||||||||||||||||||||||||||||
| summary = "회원 포인트 조회", | ||||||||||||||||||||||||||||||||||
| description = "ID로 회원 포인트를 조회합니다.", | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| fun getPoint( | ||||||||||||||||||||||||||||||||||
| @Schema(name = "회원 ID", description = "조회할 회원의 ID") | ||||||||||||||||||||||||||||||||||
| userId: String?, | ||||||||||||||||||||||||||||||||||
| ): ApiResponse<Int> | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 파라미터 nullable 불일치 확인 필요
만약 인증 컨텍스트에서 userId를 가져오는 등의 이유로 의도적으로 optional이라면, 이를 주석이나 문서에 명시하는 것이 좋습니다. 그렇지 않다면 non-nullable로 변경해야 합니다. 일관성을 위해 다음과 같이 수정하는 것을 권장합니다: fun getPoint(
@Schema(name = "회원 ID", description = "조회할 회원의 ID")
- userId: String?,
+ userId: String,
): ApiResponse<Int>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Operation( | ||||||||||||||||||||||||||||||||||
| summary = "회원 포인트 충전", | ||||||||||||||||||||||||||||||||||
| description = "ID와 포인트로 회원 포인트를 충전합니다.", | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| fun chargePoint(req: UserV1Dto.ChargePointRequest): ApiResponse<Int> | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package com.loopers.interfaces.api.user | ||
|
|
||
| import com.loopers.application.user.UserFacade | ||
| import com.loopers.domain.user.Gender | ||
| import com.loopers.interfaces.api.ApiResponse | ||
| import com.loopers.support.error.CoreException | ||
| import com.loopers.support.error.ErrorType | ||
| import org.springframework.web.bind.annotation.GetMapping | ||
| import org.springframework.web.bind.annotation.PathVariable | ||
| import org.springframework.web.bind.annotation.PostMapping | ||
| import org.springframework.web.bind.annotation.RequestBody | ||
| import org.springframework.web.bind.annotation.RequestHeader | ||
| import org.springframework.web.bind.annotation.RequestMapping | ||
| import org.springframework.web.bind.annotation.RestController | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/users") | ||
| class UserV1Controller( | ||
| private val userFacade: UserFacade, | ||
| ) : UserV1ApiSpec { | ||
| @PostMapping("/register") | ||
| override fun registerUser(@RequestBody req: UserV1Dto.RegisterUserRequest): ApiResponse<UserV1Dto.UserResponse> { | ||
| if (req.gender == Gender.NONE) throw CoreException(ErrorType.BAD_REQUEST) | ||
|
|
||
| return userFacade.registerUser(req.userId, req.email, req.birth, req.gender) | ||
| .let { UserV1Dto.UserResponse.from(it) } | ||
| .let { ApiResponse.success(it) } | ||
| } | ||
|
|
||
| override fun getUser( | ||
| @PathVariable userId: String, | ||
| ): ApiResponse<UserV1Dto.UserResponse> { | ||
| return userFacade.getUser(userId) | ||
| .let { UserV1Dto.UserResponse.from(it) } | ||
| .let { ApiResponse.success(it) } | ||
| } | ||
|
|
||
| @GetMapping("/point") | ||
| override fun getPoint( | ||
| @RequestHeader("X-USER-ID", required = false) userId: String?, | ||
| ): ApiResponse<Int> { | ||
| if (userId.isNullOrBlank()) throw CoreException(ErrorType.BAD_REQUEST) | ||
|
|
||
| return userFacade.getPoint(userId) | ||
| .let { ApiResponse.success(it) } | ||
| } | ||
|
|
||
| @PostMapping("/chargePoint") | ||
| override fun chargePoint(@RequestBody req: UserV1Dto.ChargePointRequest): ApiResponse<Int> { | ||
| return userFacade.chargePoint(req.userId, req.amount) | ||
| .let { ApiResponse.success(it) } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.loopers.interfaces.api.user | ||
|
|
||
| import com.loopers.application.user.UserInfo | ||
| import com.loopers.domain.user.Gender | ||
|
|
||
| class UserV1Dto { | ||
| data class RegisterUserRequest(val userId: String, val email: String, val birth: String, val gender: Gender) | ||
| data class UserResponse(val userId: String, val email: String, val birth: String, val gender: Gender) { | ||
| companion object { | ||
| fun from(user: UserInfo): UserResponse { | ||
| return UserResponse( | ||
| userId = user.userId, | ||
| email = user.email, | ||
| birth = user.birth, | ||
| gender = user.gender, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| data class ChargePointRequest(val userId: String, val amount: Int) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
동시성 제어 필요 (Critical) 및 재저장 질문에 대한 답변
1. 동시성 문제 (Race Condition):
현재 구현은 동시에 여러 포인트 충전 요청이 들어올 때 업데이트 손실이 발생할 수 있습니다.
예시:
2. 재저장 관련 질문 답변:
JPA의 dirty checking 메커니즘이
@Transactional내에서 엔티티 변경을 자동으로 감지하여 저장하므로, 명시적인save()호출은 불필요합니다. 다만, 반드시 서비스 레이어에서@Transactional안에서 호출되어야 합니다.해결 방법:
낙관적 잠금을 위한
@Version필드 추가를 권장합니다:@Entity @Table(name = "users") class User( userId: String, email: String, birth: String, gender: Gender, ) : BaseEntity() { + @Version + var version: Long = 0 + protected set + var userId: String = userId protected set이렇게 하면 동시 수정 시
OptimisticLockException이 발생하여 데이터 무결성을 보호할 수 있습니다.🤖 Prompt for AI Agents