-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 기능 구현 #34
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: pable91
Are you sure you want to change the base?
Changes from all commits
c9e1af3
40037d8
0768937
fcfe9df
7b5ee41
01dfaf4
1d431d4
4534d90
bb55305
d5f5679
afce54c
ddb16e2
28f3ab8
4bcad0d
f33afac
02792ab
1380b79
fe97a43
a94a2dd
4a1e035
00efb46
f31a7bb
402838e
b8d02c6
8609eaf
4320bc1
5b45d32
1679ffe
4b0b620
bc89541
f6f3e3a
84b19be
3aa3349
c122240
cd56a8a
bd6908b
61c0372
d9f704c
3048849
1c89ed2
3a52ab6
39dc6b8
0067927
f84a492
079a149
7e379b6
5b50c60
89930d5
fbb0b85
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,23 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| /** | ||
| * 인증된 사용자의 식별 정보. 컨트롤러에서 "현재 사용자"를 식별하는 용도로 사용한다. | ||
| */ | ||
| @Getter | ||
| @AllArgsConstructor | ||
| public class AuthUserPrincipal { | ||
|
|
||
| private final Long id; | ||
| private final String loginId; | ||
|
|
||
| public static AuthUserPrincipal from(UserModel user) { | ||
| return new AuthUserPrincipal( | ||
| user.getId(), | ||
| user.getLoginId() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import java.time.LocalDate; | ||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public class SignUpCommand { | ||
|
|
||
| private final String loginId; | ||
| private final String loginPw; | ||
| private final LocalDate birthDate; | ||
| private final String name; | ||
| private final String email; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserService; | ||
| import com.loopers.interfaces.user.ChangePasswordRequest; | ||
| import lombok.AllArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @AllArgsConstructor | ||
| public class UserFacade { | ||
|
|
||
| private final UserService userService; | ||
|
|
||
| public UserInfo signUp(SignUpCommand command) { | ||
| UserModel userModel = userService.createUser( | ||
| command.getLoginId(), | ||
| command.getLoginPw(), | ||
| command.getBirthDate(), | ||
| command.getName(), | ||
| command.getEmail() | ||
| ); | ||
|
|
||
| return UserInfo.from(userModel); | ||
| } | ||
|
|
||
| public UserInfo getMyInfo(Long userId) { | ||
| UserModel user = userService.findById(userId); | ||
| return UserInfo.from(user); | ||
| } | ||
|
|
||
| public void changePassword(Long userId, ChangePasswordRequest request) { | ||
| userService.changePassword(userId, request.currentPassword(), request.newPassword()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import java.time.LocalDate; | ||
|
|
||
| public record UserInfo(Long id, String loginId, String name, String email, LocalDate birthDate) { | ||
|
|
||
| public static UserInfo from(UserModel userModel) { | ||
| return new UserInfo( | ||
| userModel.getId(), | ||
| userModel.getLoginId(), | ||
| userModel.getName(), | ||
| userModel.getEmail(), | ||
| userModel.getBirthDate() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
|
|
||
| @Configuration | ||
| public class PasswordEncoderConfig { | ||
|
|
||
| @Bean | ||
| public PasswordEncoder passwordEncoder() { | ||
| return new BCryptPasswordEncoder(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import com.loopers.interfaces.api.AuthUserArgumentResolver; | ||
| import com.loopers.interfaces.api.CredentialsHeadersArgumentResolver; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
|
||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class WebMvcConfig implements WebMvcConfigurer { | ||
|
|
||
| private final CredentialsHeadersArgumentResolver credentialsHeadersArgumentResolver; | ||
| private final AuthUserArgumentResolver authUserArgumentResolver; | ||
|
|
||
| @Override | ||
| public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
| resolvers.add(credentialsHeadersArgumentResolver); | ||
| resolvers.add(authUserArgumentResolver); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| 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.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import java.time.LocalDate; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import org.hibernate.annotations.Comment; | ||
|
|
||
| @Entity | ||
| @Table(name = "users") | ||
| @NoArgsConstructor | ||
| @Getter | ||
| public class UserModel extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue | ||
| private Long id; | ||
|
|
||
| @Comment("아이디") | ||
| @Column(name = "login_id", nullable = false) | ||
| private String loginId; | ||
|
|
||
| @Comment("비밀번호") | ||
| @Column(name = "password", nullable = false) | ||
| private String password; | ||
|
|
||
| @Comment("생년월일") | ||
| @Column(name = "birth", nullable = false) | ||
| private LocalDate birthDate; | ||
|
|
||
| @Comment("이름") | ||
| @Column(name = "name", nullable = false) | ||
| private String name; | ||
|
|
||
| @Comment("이메일") | ||
| @Column(name = "email", nullable = false) | ||
| private String email; | ||
|
Comment on lines
+26
to
+44
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. 🧩 Analysis chain🏁 Script executed: find apps/commerce-api -name "UserModel.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 155 🏁 Script executed: fd "UserModel" --type f apps/commerce-apiRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 230 🏁 Script executed: git ls-files "apps/commerce-api" | grep -i user | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1672 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3051 🏁 Script executed: cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3097 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 464 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1209 🏁 Script executed: find apps/commerce-api -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 513 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3136 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2318 🏁 Script executed: cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6343 🏁 Script executed: find apps/commerce-api -path "*/resources/*" -name "*.sql" -o -path "*/resources/*" -name "*.xml" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat -n apps/commerce-api/src/test/java/com/loopers/application/UserFacadeTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3545 🏁 Script executed: find apps/commerce-api/src/test -name "*Integration*" -o -name "*Repository*Test*"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 178 🏁 Script executed: cat -n apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2006 🏁 Script executed: rg "DataIntegrityViolation|ConstraintViolation|duplicate" apps/commerce-api/src/test --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5775 🏁 Script executed: rg "existsByLoginId|findByLoginId" apps/commerce-api/src --type java -A 2 -B 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2362 login_id/email에 데이터베이스 유니크 제약이 필수다
🔧 수정 예시 import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
@@
-@Table(name = "users")
+@Table(
+ name = "users",
+ uniqueConstraints = {
+ `@UniqueConstraint`(name = "uk_users_login_id", columnNames = "login_id"),
+ `@UniqueConstraint`(name = "uk_users_email", columnNames = "email")
+ }
+)🤖 Prompt for AI Agents |
||
|
|
||
| private UserModel(String loginId, String password, LocalDate birthDate, String name, String email) { | ||
| this.loginId = loginId; | ||
| this.password = password; | ||
| this.birthDate = birthDate; | ||
| this.name = name; | ||
| this.email = email; | ||
| } | ||
|
|
||
| @Override | ||
| protected void guard() { | ||
| if (loginId == null || loginId.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); | ||
| } | ||
| if (password == null || password.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); | ||
| } | ||
| if (birthDate == null) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); | ||
| } | ||
| if (name == null || name.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); | ||
| } | ||
| if (email == null || email.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); | ||
| } | ||
| } | ||
|
|
||
| public static UserModel create(String loginId, String encodedPassword, LocalDate birthDate, String name, String email) { | ||
| return new UserModel(loginId, encodedPassword, birthDate, name, email); | ||
| } | ||
|
|
||
| public void changePassword(String newEncodedPassword) { | ||
| this.password = newEncodedPassword; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface UserRepository { | ||
|
|
||
| UserModel save(UserModel userModel); | ||
|
|
||
| Optional<UserModel> findById(Long id); | ||
|
|
||
| Optional<UserModel> findByLoginId(String loginId); | ||
|
|
||
| Boolean existsByEmail(String email); | ||
|
|
||
| Boolean existsByLoginId(String loginId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import jakarta.transaction.Transactional; | ||
| import java.time.LocalDate; | ||
| import lombok.AllArgsConstructor; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
| @AllArgsConstructor | ||
| public class UserService { | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final PasswordEncoder passwordEncoder; | ||
|
|
||
| @Transactional | ||
| public UserModel createUser(String loginId, String rawPassword, LocalDate birthDate, String name, String email) { | ||
| if (userRepository.existsByLoginId(loginId)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용 중인 아이디입니다."); | ||
| } | ||
| if (userRepository.existsByEmail(email)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 이메일입니다."); | ||
| } | ||
| validatePasswordNotContainsBirthDate(rawPassword, birthDate); | ||
|
|
||
| String encodedPassword = passwordEncoder.encode(rawPassword); | ||
| UserModel user = UserModel.create(loginId, encodedPassword, birthDate, name, email); | ||
| return userRepository.save(user); | ||
| } | ||
|
|
||
| public UserModel authenticate(String loginId, String rawPassword) { | ||
| UserModel user = userRepository.findByLoginId(loginId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다.")); | ||
| if (!passwordEncoder.matches(rawPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다."); | ||
| } | ||
| return user; | ||
| } | ||
|
|
||
| public Boolean existsByEmail(String email) { | ||
| return userRepository.existsByEmail(email); | ||
| } | ||
|
|
||
| public UserModel findById(Long id) { | ||
| return userRepository.findById(id) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void changePassword(Long userId, String currentPassword, String newPassword) { | ||
| UserModel user = findById(userId); | ||
|
|
||
| if (!passwordEncoder.matches(currentPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."); | ||
| } | ||
|
|
||
| if (passwordEncoder.matches(newPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); | ||
| } | ||
|
|
||
| validatePasswordNotContainsBirthDate(newPassword, user.getBirthDate()); | ||
|
|
||
| String newEncodedPassword = passwordEncoder.encode(newPassword); | ||
| user.changePassword(newEncodedPassword); | ||
pable91 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private void validatePasswordNotContainsBirthDate(String password, LocalDate birthDate) { | ||
| String birthStr = birthDate.toString().replace("-", ""); | ||
| if (password.contains(birthStr)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.loopers.infrastructure; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import java.util.Optional; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface UserJpaRepository extends JpaRepository<UserModel, Long> { | ||
|
|
||
| Boolean existsByEmail(String email); | ||
|
|
||
| Boolean existsByLoginId(String loginId); | ||
|
|
||
| Optional<UserModel> findByLoginId(String loginId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package com.loopers.infrastructure; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserRepositoryImpl implements UserRepository { | ||
|
|
||
| private final UserJpaRepository userJpaRepository; | ||
|
|
||
| @Override | ||
| public UserModel save(UserModel userModel) { | ||
| return userJpaRepository.save(userModel); | ||
| } | ||
|
|
||
| @Override | ||
| public java.util.Optional<UserModel> findById(Long id) { | ||
| return userJpaRepository.findById(id); | ||
| } | ||
|
|
||
| @Override | ||
| public java.util.Optional<UserModel> findByLoginId(String loginId) { | ||
| return userJpaRepository.findByLoginId(loginId); | ||
| } | ||
|
|
||
| @Override | ||
| public Boolean existsByEmail(String email) { | ||
| return userJpaRepository.existsByEmail(email); | ||
| } | ||
|
|
||
| @Override | ||
| public Boolean existsByLoginId(String loginId) { | ||
| return userJpaRepository.existsByLoginId(loginId); | ||
| } | ||
| } |
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.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 470
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 238
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2529
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 233
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3051
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 513
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3136
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2318
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
이메일 중복 확인만으로는 레이스 컨디션을 막기 어렵다
동시 가입 요청이 existsByEmail을 통과한 후 createUser가 호출되면 중복 사용자가 생성되거나 DB 무결성 위반으로 500 에러가 발생한다. 현재 UserModel의 email 필드는 nullable 제약만 있고 unique 제약이 없으며(line 43), UserService.createUser에서 중복키 예외를 처리하지 않는다(line 19-22).
수정안: DB 레벨에 email 컬럼에 unique 제약을 추가하고, createUser에서 DataIntegrityViolationException을 CoreException(BAD_REQUEST)으로 변환하여 처리하거나 트랜잭션 격리 수준을 상향 조정하라. 추가로 동일 이메일 동시 요청 시 하나만 성공하고 나머지는 적절한 예외로 반환되는지 동시성 테스트로 검증하라.
참고로 password 검증 정책(생년월일 포함 여부)은 signUp과 changePassword에서 일관되게 적용되고 있다(UserService line 55, UserFacade line 43-53).
🤖 Prompt for AI Agents