Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Comment on lines +47 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

use 메서드에 잔액 부족 검증이 누락되었습니다.

현재 구현의 문제점:

  1. Critical: Line 55에서 this.point -= amount를 실행하기 전에 point >= amount 검증이 없습니다. 잔액이 부족해도 차감되어 음수가 될 수 있습니다.
  2. Minor: Line 49-51의 에러 메시지가 "사용 금액은 0보다 커야 합니다."인데, 실제 검증은 amount < 0으로 0 이상을 허용합니다. 메시지를 "사용 금액은 0 이상이어야 합니다."로 수정하세요.
  3. Optional: Line 52-54의 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
public void use(int amount) {
if (amount < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0 이상이어야 합니다.");
}
if (this.point < amount) {
throw new CoreException(ErrorType.BAD_REQUEST, "포인트 잔액이 부족합니다.");
}
this.point -= amount;
}
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java
around lines 47 to 56, add a balance-check before subtracting so the method
validates that amount is non-negative and that point >= amount (throw
CoreException with an appropriate ErrorType and message on failure), change the
error message for the amount check from "사용 금액은 0보다 커야 합니다." to "사용 금액은 0 이상이어야
합니다.", and remove the redundant point < 0 check (since constructor/charge
already prevent negative balances); then perform 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

예약어 테이블명으로 인한 SQL 오류 가능성

user는 PostgreSQL, MySQL, H2 등에서 예약어라서 Hibernate가 select ... from user 같은 쿼리를 생성할 때 즉시 SQLSyntaxError 가 발생합니다. 테스트나 운영 DB 어느 쪽이든 영향을 받으니 테이블명을 users 같이 예약어가 아닌 이름으로 바꾸거나, 반드시 해당 이름을 써야 한다면 @Table(name = "\"user\"")처럼 ANSI 인용을 강제해야 합니다.

-@Table(name = "user")
+@Table(name = "users")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Entity
@Table(name = "user")
@Entity
@Table(name = "users")
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java around
lines 9-10, the entity is mapped to the reserved table name "user" which can
cause SQLSyntaxError on PostgreSQL/MySQL/H2; change the mapping to a
non-reserved name (e.g., @Table(name = "users")) or, if you must keep "user",
force ANSI quoting (e.g., @Table(name = "\"user\"")), and then update any
related JPQL/native queries, migrations, and tests to use the new table name.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

동시 가입 시 중복 체크-저장 사이 경쟁 상태가 발생합니다.
현재는 userRepository.find(...)로 중복을 확인한 뒤 save를 호출하지만, 두 요청이 동시에 들어오면 둘 다 Optional이 비어 있다고 판단하고 각각 save를 시도할 수 있습니다. DB에 userId 유니크 제약이 없다면 중복 레코드가 쌓이고, 제약이 있더라도 DataIntegrityViolationException이 발생해 500으로 튈 위험이 큽니다. 저장 구간을 예외 처리로 감싸 CONFLICT로 변환하고, userId에 유니크 인덱스를 두는 식으로 방어 로직을 추가해 주세요.

+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
In apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
around lines 23 to 30, there is a race between the find() duplicate check and
save() that can produce duplicate users or a 500 on constraint violation; wrap
the save call in a try/catch that catches the persistence exception thrown on
unique-constraint violations (e.g., DataIntegrityViolationException /
PersistenceException) and translate it into a CoreException with
ErrorType.CONFLICT and a descriptive message including the userId, and also
ensure the userId has a unique index at the DB/schema level (add a unique
constraint in the JPA entity or DB migration) so the service is protected even
under concurrent requests.

}
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

findById 구현이 플레이스홀더입니다.

findById 메서드가 항상 Optional.empty()를 반환합니다. 이 메서드가 실제로 사용되지 않는다면 문제없지만, 향후 사용 시 버그가 될 수 있습니다. 현재 과제 범위에서 필요하지 않다면 TODO 주석을 추가하거나, 사용할 계획이라면 올바른 구현을 제공하세요.


🏁 Script executed:

#!/bin/bash
# Description: findById 메서드가 실제로 호출되는지 확인

# UserRepository.findById 호출을 검색
rg -nP --type=java '\bfindById\s*\(' -A 2 -B 2

Length 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 -100

Length 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 -80

Length of output: 7245


🏁 Script executed:

#!/bin/bash
# Search for all Java files in the codebase
fd -e java --type f | head -20

Length 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 -20

Length of output: 974


UserRepository.findById는 사용되지 않는 플레이스홀더입니다. 인터페이스에서 제거하거나, 구현하거나, TODO 주석을 추가하세요.

코드베이스를 검색한 결과, UserRepository.findById() 메서드는 어디에도 호출되지 않습니다. UserService에서는 find(userId)save() 메서드만 사용되며, 테스트 파일에서도 findById를 호출하는 곳이 없습니다. 현재 이 메서드는 항상 Optional.empty()를 반환하는 플레이스홀더 상태이므로, 다음 중 하나를 수행해야 합니다:

  • 인터페이스와 구현에서 제거
  • 필요한 경우 실제 구현 제공
  • 향후 사용 계획이 있으면 TODO 주석 추가
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
around lines 20-23, the findById(Long) method is a placeholder that always
returns Optional.empty() and is not used anywhere; either remove it from the
UserRepository interface and this implementation (also remove unused imports and
update any references), or if you intend to keep it, replace the body with a
proper implementation that queries the underlying data source and returns
Optional.ofNullable(result), or at minimum add a TODO comment explaining why
it’s a stub and when it will be implemented; pick one of these actions and apply
it consistently to both the interface and implementation so the codebase remains
clean and compiles.


@Override
public UserModel save(UserModel userModel) {
return userJpaRepository.save(userModel);
}



}
Loading