-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-1] TDD 기반 1주차 과제 (회원, 포인트) 기능 구현 #40
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: main
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,44 @@ | ||
| package com.loopers.application.point; | ||
|
|
||
| import com.loopers.domain.point.PointModel; | ||
| import com.loopers.domain.point.PointService; | ||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserService; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class PointFacade { | ||
| private final PointService pointService; | ||
| private final UserService userService; | ||
|
|
||
| public PointInfo getPoint(String userId) { | ||
| UserModel user = userService.getUser(userId); | ||
| if (user == null) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); | ||
| } | ||
| PointModel pointModel = new PointModel(user, 0); | ||
| PointModel point = pointService.findPoint(pointModel); | ||
|
|
||
| if (point == null) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다."); | ||
| } | ||
|
|
||
| return PointInfo.from(point); | ||
| } | ||
|
|
||
| public PointInfo chargePoint(String userId, int amount) { | ||
| UserModel user = userService.getUser(userId); | ||
| if (user == null) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); | ||
| } | ||
| PointModel pointModel = new PointModel(user, amount); | ||
| pointService.charge(pointModel); | ||
|
|
||
| PointModel charged = pointService.findPoint(new PointModel(user, 0)); | ||
| return PointInfo.from(charged); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.application.point; | ||
|
|
||
| import com.loopers.domain.point.PointModel; | ||
| import com.loopers.domain.user.UserModel; | ||
|
|
||
| public record PointInfo(Long id, UserModel user, int point) { | ||
| public static PointInfo from(PointModel model) { | ||
| return new PointInfo(model.getId(), model.getUser(), model.getPoint()); | ||
| } | ||
| public int getPoint() { | ||
| return point; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserService; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserFacade { | ||
| private final UserService userService; | ||
|
|
||
| public UserInfo signup(String userId, String email, String birthDate) { | ||
| UserModel userModel = new UserModel(userId, email, birthDate); | ||
| UserModel savedUser = userService.signUp(userModel); | ||
| return UserInfo.from(savedUser); | ||
| } | ||
|
|
||
| public UserInfo getUser(String userId) { | ||
| UserModel user = userService.getUser(userId); | ||
| if (user == null) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); | ||
| } | ||
| return UserInfo.from(user); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
|
|
||
| public record UserInfo(String userId, String email, String birthDate) { | ||
| public static UserInfo from(UserModel model) { | ||
| return new UserInfo( | ||
| model.getUserId(), | ||
| model.getEmail(), | ||
| model.getBirthDate() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.loopers.domain.point; | ||
|
|
||
| import com.loopers.domain.BaseEntity; | ||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
|
|
||
| @Entity | ||
| @Table(name = "point") | ||
| public class PointModel extends BaseEntity { | ||
|
|
||
| @ManyToOne | ||
| @JoinColumn(name = "user_model_id") | ||
| private UserModel user; | ||
| private int point = 0; | ||
|
|
||
| public PointModel() {} | ||
|
|
||
| public PointModel(UserModel user, int point) { | ||
|
|
||
| if( point < 0 ){ | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); | ||
| } | ||
| this.user = user; | ||
| this.point = point; | ||
| } | ||
|
|
||
| public UserModel getUser() { | ||
| return user; | ||
| } | ||
|
|
||
| public int getPoint() { | ||
| return point; | ||
| } | ||
|
|
||
| public void charge(int amount) { | ||
| if (amount < 0) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); | ||
| } | ||
| this.point += amount; | ||
| } | ||
|
|
||
| public void use(int amount) { | ||
|
|
||
| if (amount < 0) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0보다 커야 합니다."); | ||
| } | ||
| if (point < 0) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); | ||
| } | ||
| this.point -= amount; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.domain.point; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface PointRepository { | ||
| Optional<PointModel> findPoint(UserModel user); | ||
| PointModel save(PointModel pointModel); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.loopers.domain.point; | ||
|
|
||
| import com.loopers.domain.user.UserRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class PointService { | ||
|
|
||
| private final PointRepository pointRepository; | ||
| private final UserRepository userRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public PointModel findPoint(PointModel point) { | ||
| UserModel requestUser = point.getUser(); | ||
| var foundUser = userRepository.find(requestUser.getUserId()); | ||
| if (foundUser.isEmpty()) { | ||
| return null; | ||
| } | ||
| return pointRepository.findPoint(foundUser.get()).orElse(null); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void charge(PointModel point) { | ||
| UserModel user = point.getUser(); | ||
| var foundUser = userRepository.find(user.getUserId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); | ||
|
|
||
| var existing = pointRepository.findPoint(foundUser); | ||
| if (existing.isPresent()) { | ||
| existing.get().charge(point.getPoint()); | ||
| pointRepository.save(existing.get()); | ||
| return; | ||
| } | ||
| pointRepository.save(new PointModel(foundUser, point.getPoint())); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,59 @@ | ||||||||||
| 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 = "user") | ||||||||||
|
Comment on lines
+9
to
+10
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. 예약어 테이블명으로 인한 SQL 오류 가능성
-@Table(name = "user")
+@Table(name = "users")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| public class UserModel extends BaseEntity { | ||||||||||
|
|
||||||||||
| private String userId; | ||||||||||
| private String email; | ||||||||||
| private String birthDate; | ||||||||||
|
|
||||||||||
| protected UserModel() {} | ||||||||||
|
|
||||||||||
| public UserModel(String userId, String email, String birthDate) { | ||||||||||
|
|
||||||||||
| if ( userId == null || userId.isBlank() ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); | ||||||||||
| } | ||||||||||
| if ( !userId.matches("^[a-zA-Z0-9_-]{1,10}$") ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if ( email == null || email.isBlank() ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); | ||||||||||
| } | ||||||||||
| if ( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$") ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if ( birthDate == null || birthDate.isBlank() ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); | ||||||||||
| } | ||||||||||
| if ( !birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$") ) { | ||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| this.userId = userId; | ||||||||||
| this.email = email; | ||||||||||
| this.birthDate = birthDate; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| public String getUserId() { | ||||||||||
| return userId; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| public String getEmail() { | ||||||||||
| return email; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| public String getBirthDate() { | ||||||||||
| return birthDate; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface UserRepository { | ||
| Optional<UserModel> find(String userId); | ||
| Optional<UserModel> findById(Long id); | ||
|
|
||
| UserModel save(UserModel userModel); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserService { | ||
|
|
||
| private final UserRepository userRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public UserModel getUser(String userId) { | ||
| return userRepository.find(userId).orElse(null); | ||
| } | ||
|
|
||
| @Transactional | ||
| public UserModel signUp(UserModel userModel) { | ||
| Optional<UserModel> user = userRepository.find(userModel.getUserId()); | ||
|
|
||
| if (user.isPresent()) { | ||
| throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId() + "] 아이디가 중복되었습니다."); | ||
| } | ||
| return userRepository.save(userModel); | ||
| } | ||
|
Comment on lines
+23
to
+30
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. 동시 가입 시 중복 체크-저장 사이 경쟁 상태가 발생합니다. +import org.springframework.dao.DataIntegrityViolationException;
...
public UserModel signUp(UserModel userModel) {
Optional<UserModel> user = userRepository.find(userModel.getUserId());
if (user.isPresent()) {
throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId() + "] 아이디가 중복되었습니다.");
}
- return userRepository.save(userModel);
+ try {
+ return userRepository.save(userModel);
+ } catch (DataIntegrityViolationException e) {
+ throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId() + "] 아이디가 중복되었습니다.", e);
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.infrastructure.point; | ||
|
|
||
| import com.loopers.domain.point.PointModel; | ||
| import com.loopers.domain.user.UserModel; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface PointJpaRepository extends JpaRepository<PointModel, Long> { | ||
| Optional<PointModel> findByUser(UserModel user); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.infrastructure.point; | ||
|
|
||
| import com.loopers.domain.point.PointModel; | ||
| import com.loopers.domain.point.PointRepository; | ||
| import com.loopers.domain.user.UserModel; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class PointRepositoryImpl implements PointRepository { | ||
| private final PointJpaRepository pointJpaRepository; | ||
|
|
||
| @Override | ||
| public Optional<PointModel> findPoint(UserModel user) { | ||
| return pointJpaRepository.findByUser(user); | ||
| } | ||
|
|
||
| @Override | ||
| public PointModel save(PointModel pointModel) { | ||
| return pointJpaRepository.save(pointModel); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.infrastructure.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface UserJpaRepository extends JpaRepository<UserModel, Long> { | ||
| Optional<UserModel> findByUserId(String userId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.loopers.infrastructure.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserRepositoryImpl implements UserRepository { | ||
| private final UserJpaRepository userJpaRepository; | ||
|
|
||
| @Override | ||
| public Optional<UserModel> find(String userId) { | ||
| return userJpaRepository.findByUserId(userId); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<UserModel> findById(Long id) { | ||
| return Optional.empty(); | ||
| } | ||
|
Comment on lines
+20
to
+23
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 🧩 Analysis chainfindById 구현이 플레이스홀더입니다.
🏁 Script executed: #!/bin/bash
# Description: findById 메서드가 실제로 호출되는지 확인
# UserRepository.findById 호출을 검색
rg -nP --type=java '\bfindById\s*\(' -A 2 -B 2Length of output: 1726 Let me search the codebase more specifically for how UserRepository is being used. Let me search for where UserRepository is injected and used in the codebase: This will help identify which classes are using UserRepository and what methods they're calling. 🏁 Script executed: #!/bin/bash
# Search for UserRepository bean injection and usage
rg -nP --type=java '(UserRepository|@Autowired.*User|userRepository\s*\.)' -A 2 -B 2 | head -100Length of output: 10177 🏁 Script executed: #!/bin/bash
# Search for UserRepository injection points and method calls
rg -nP --type=java 'private.*UserRepository|@Autowired' -A 3 | head -80Length of output: 7245 🏁 Script executed: #!/bin/bash
# Search for all Java files in the codebase
fd -e java --type f | head -20Length of output: 1593 🏁 Script executed: #!/bin/bash
# Search for Service or Controller classes that might use UserRepository
rg -nP --type=java '@Service|@Component|@Controller' -l | head -20Length of output: 974 UserRepository.findById는 사용되지 않는 플레이스홀더입니다. 인터페이스에서 제거하거나, 구현하거나, TODO 주석을 추가하세요. 코드베이스를 검색한 결과,
🤖 Prompt for AI Agents |
||
|
|
||
| @Override | ||
| public UserModel save(UserModel userModel) { | ||
| return userJpaRepository.save(userModel); | ||
| } | ||
|
|
||
|
|
||
|
|
||
| } | ||
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.
use 메서드에 잔액 부족 검증이 누락되었습니다.
현재 구현의 문제점:
this.point -= amount를 실행하기 전에point >= amount검증이 없습니다. 잔액이 부족해도 차감되어 음수가 될 수 있습니다.amount < 0으로 0 이상을 허용합니다. 메시지를 "사용 금액은 0 이상이어야 합니다."로 수정하세요.point < 0검증은 불필요합니다. 생성자와 charge에서 이미 음수를 차단하므로 point는 항상 0 이상입니다.다음 diff를 적용하세요:
public void use(int amount) { if (amount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0보다 커야 합니다."); + throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0 이상이어야 합니다."); } - if (point < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + if (this.point < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트 잔액이 부족합니다."); } this.point -= amount; }📝 Committable suggestion
🤖 Prompt for AI Agents