Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8f7308921
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| List<Long> requestMemberIds = request.members().stream() | ||
| .map(CreateProjectGroupMemberRequest::userId) | ||
| .sorted() |
There was a problem hiding this comment.
동일 matchId 재요청 검증에서 구성원을 userId만으로 비교하고 있어, 같은 사용자 목록이지만 MemberRole이 다른 요청도 동일 요청으로 간주되어 성공 응답으로 반환됩니다. 커밋 설명의 의도(페이로드 불일치 시 예외)와 실제 동작이 달라져 클라이언트는 역할 변경이 반영된 것으로 오해할 수 있고, 재시도/멱등 처리의 정확성이 깨집니다. userId만이 아니라 (userId, role) 기준으로 양쪽 멤버 구성을 비교하도록 검증을 보강하는 것이 안전합니다.
Useful? React with 👍 / 👎.
이슈랑 PR 템플릿 있는거 사용해주세요! |
flowchart TD
A["클라이언트 API 호출"] --> B["POST /api/project-groups"]
%% 인증 사용자 해석
B --> C["@LoginUser resolver"]
C --> C1{"인증 정보 존재 여부"}
C1 -->|"No"| X1["401 NO_AUTHENTICATED_USER"]
C1 -->|"Yes"| C2{"principal이 UserPrincipal인가"}
C2 -->|"No"| X2["500 INVALID_SECURITY_CONTEXT"]
C2 -->|"Yes"| C3["findByIdAndDeletedAtIsNull(userId)"]
C3 --> C4{"활성 사용자 존재 여부"}
C4 -->|"No"| X3["401 UNEXISTED_USER"]
C4 -->|"Yes"| D["요청 본문 검증"]
%% 컨트롤러 입력 검증
D --> D1{"Errors.hasErrors()"}
D1 -->|"Yes"| X4["400 INVALID_INPUT_FIELD"]
D1 -->|"No"| E["createProjectGroup(requester.id, request)"]
%% 요청자 권한 검증
E --> E1{"requesterUserId == hostUserId"}
E1 -->|"No"| X5["403 PROJECT_GROUP_PERMISSION_DENIED"]
E1 -->|"Yes"| E2["findByMatchId(matchId)"]
%% 동일 matchId 중복 요청 분기
E2 --> E3{"기존 팀스페이스 존재 여부"}
E3 -->|"Yes"| F["handleDuplicateMatchRequest"]
E3 -->|"No"| G["members -> userIds / uniqueUserIds 추출"]
%% 멤버 목록 검증
G --> G1{"팀 인원 4명인가"}
G1 -->|"No"| X6["400 INVALID_PROJECT_GROUP_REQUEST"]
G1 -->|"Yes"| G2{"중복 userId 없는가"}
G2 -->|"No"| X7["400 INVALID_PROJECT_GROUP_REQUEST"]
G2 -->|"Yes"| G3{"hostUserId가 멤버 목록에 포함되는가"}
G3 -->|"No"| X8["400 INVALID_PROJECT_GROUP_REQUEST"]
G3 -->|"Yes"| H["findAllByIdInAndDeletedAtIsNull(uniqueUserIds)"]
%% 사용자 존재 / 이미 소속 여부 검증
H --> H1{"요청한 모든 사용자가 존재하는가"}
H1 -->|"No"| X9["404 PROJECT_GROUP_MEMBER_NOT_FOUND"]
H1 -->|"Yes"| I{"이미 팀에 속한 사용자 존재 여부"}
I -->|"Yes"| X10["400 INVALID_PROJECT_GROUP_REQUEST\n이미 팀에 속한 사용자가 포함"]
I -->|"No"| J["ProjectGroup 저장\nstatus = request.status ?? ACTIVE"]
%% 멤버 저장
J --> K["ProjectGroupMember 생성\nhost -> HOST\nothers -> MEMBER"]
K --> L["saveAllAndFlush(members)"]
L -->|"성공"| R1["201 CREATED + CreateProjectGroupResponse"]
L -->|"DataIntegrityViolationException"| M["중복/경합 재확인"]
%% 동시성 충돌 처리
M --> M1{"matchId로 이미 생성된 그룹 존재 여부"}
M1 -->|"Yes"| F
M1 -->|"No"| M2{"이미 팀에 속한 사용자 생겼는가"}
M2 -->|"Yes"| X11["400 INVALID_PROJECT_GROUP_REQUEST\n이미 팀에 속한 사용자가 포함"]
M2 -->|"No"| X12["400 INVALID_PROJECT_GROUP_REQUEST\n팀 스페이스 저장 중 데이터 충돌"]
%% 기존 그룹 반환 로직
F --> F1["기존 그룹 멤버 조회"]
F1 --> F2{"HOST 멤버 존재 여부"}
F2 -->|"No"| X13["400 INVALID_PROJECT_GROUP_REQUEST"]
F2 -->|"Yes"| F3{"기존 hostUserId == 요청 hostUserId"}
F3 -->|"No"| X14["403 PROJECT_GROUP_PERMISSION_DENIED"]
F3 -->|"Yes"| F4["idempotent payload 검증"]
F4 --> F5{"프로젝트 정보 동일 여부"}
F5 -->|"No"| X15["400 INVALID_PROJECT_GROUP_REQUEST\n같은 matchId에 다른 프로젝트 정보 불가"]
F5 -->|"Yes"| F6{"멤버 ID 목록 동일 여부"}
F6 -->|"No"| X16["400 INVALID_PROJECT_GROUP_REQUEST\n같은 matchId에 다른 구성원 목록 불가"]
F6 -->|"Yes"| R2["201 CREATED + 기존 팀스페이스 반환"]
중복 요청/동시성 처리 요약 flowchart LR
A["같은 matchId로 재요청"] --> B{"기존 팀스페이스 존재?"}
B -->|"Yes"| C["기존 그룹 + payload 동일성 검증"]
C -->|"동일"| D["기존 팀스페이스 반환"]
C -->|"다름"| E["400 INVALID_PROJECT_GROUP_REQUEST"]
B -->|"No"| F["새 팀스페이스 생성 시도"]
F -->|"저장 성공"| G["새 팀스페이스 반환"]
F -->|"저장 중 경합 발생"| H["DataIntegrityViolationException"]
H --> I["matchId / 사용자 소속 상태 재확인"]
I -->|"기존 그룹 생김"| D
I -->|"사용자 중복 소속"| E
I -->|"기타 충돌"| E
참고
|
| private final UserRepository userRepository; | ||
|
|
||
| @Transactional | ||
| public CreateProjectGroupResponse createProjectGroup(Long requesterUserId, CreateProjectGroupRequest request) { |
There was a problem hiding this comment.
제가 여기서 예외 처리 책임 축소 얘기를 했었는데요,
caller에게 데이터 정합성을 모두 위임하는게 다시 생각해보니 썩 좋은 구조는 아닌거같아요. 추후 또다른 caller가 생겼는데, 거기서 데이터 정합성 검증이 약하면 문제가 될 것 같아요
그래서 작업해주신 검증들은 유지하되 requesterUserId == hostUserId부분만 제거하면 되겠다 싶네요
| package team.po.feature.projectgroup.domain; | ||
|
|
||
| public enum ProjectGroupStatus { | ||
| ACTIVE |
| @@ -0,0 +1,7 @@ | |||
| package team.po.feature.projectgroup.domain; | |||
|
|
|||
| public enum MemberRole { | |||
There was a problem hiding this comment.
match 쪽은 DESIGN, BE, FE로 했는데
나중에 제가 여기에 맞출게요
| this.matchId = matchId; | ||
| this.projectDescription = projectDescription; | ||
| this.projectMvp = projectMvp; | ||
| this.status = status; |
There was a problem hiding this comment.
초기 status는 ACTIVE로 고정해도 될 것 같습니다
| String projectTitle, | ||
| String projectDescription, | ||
| String projectMvp, | ||
| ProjectGroupStatus status |
There was a problem hiding this comment.
status는 내부에서 초기값으로 설정하는 게 좋지 않을까요?
- is_admin(관리자권한) 필드 추가 - matchId -> groupId 로 수정 - uniqueUserIds 제거 - 중복 생성 충돌 시 payload 동일성(idempotent) 검증 반영
hwangjokim
left a comment
There was a problem hiding this comment.
생각보다 규모가 커서 고민도 많이했을텐데 고생하셨습니다. 코멘트 확인해주세요~
| created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), | ||
| CONSTRAINT fk_project_group_member_user FOREIGN KEY (user_id) REFERENCES users(id), | ||
| CONSTRAINT fk_project_group_member_group FOREIGN KEY (project_group_id) REFERENCES project_group(id), | ||
| CONSTRAINT uq_project_group_member_user UNIQUE (user_id), |
There was a problem hiding this comment.
[blocker] userId 왜 유니크 걸려있어요??
There was a problem hiding this comment.
해당 유저가 두개의 팀에 들어가는 경우를 db에서도 검증하고싶어서 unique처리를 했습니다
There was a problem hiding this comment.
finished라는 상태를 둬서 플젝 끝나도 레코드가 남아있을텐데, 유니크를 걸면 안될것같아요
There was a problem hiding this comment.
그렇겠네요,, CONSTRAINT uq_project_group_member UNIQUE (project_group_id, user_id)에만 unique제약 걸어놓고 말씀하신 부분은 제거하겠습니다
| @Column(name = "created_at", nullable = false, insertable = false, updatable = false) | ||
| private Instant createdAt; | ||
|
|
||
| public ProjectGroupMember( |
| @Transactional | ||
| public void grantAdminPermission(Long projectGroupId, Long requesterUserId, Long targetUserId) { | ||
| this.changeAdminPermission(projectGroupId, requesterUserId, targetUserId, true); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void revokeAdminPermission(Long projectGroupId, Long requesterUserId, Long targetUserId) { | ||
| this.changeAdminPermission(projectGroupId, requesterUserId, targetUserId, false); | ||
| } |
There was a problem hiding this comment.
너 정말 핵심을 짚었어!
감사합니다
| @Column(name = "group_id", nullable = false, unique = true) | ||
| private Long groupId; |
There was a problem hiding this comment.
이 groupId는 의도가 뭔지 궁금해요. PK있는데 별도로 Unique한 값을 둬야하는 이유랑, 어떤 의도로 쓰이고 있는건지 잘 모르겠어요..
There was a problem hiding this comment.
groupId는 중복검사 로직에 사용하고있습니다.
매칭이후 groupId를 제쪽으로 넘겨주면 중복검사(재요청 등에 대비)를 하려고 생각중입니다.
pk를 사용할 경우 insert시점에서 발급받기때문에 재요청에 대응할 수 없다고 생각해 groupId를 별도로 두었습니다
There was a problem hiding this comment.
저는 groupId를 중복검사용으로 따로 넘기는 것보다, 생성하려는 사용자들이 이미 ACTIVE 상태의 그룹 멤버인지 확인하는 방식이 더 맞다고 생각해요.
실제로 막고 싶은 건 같은 groupId 중복이 아니라, 같은 유저들이 이미 활성 그룹에 들어갔는데 또 다른 그룹이 생기는 상황이니까요.
그리고 groupId를 중복검사용으로 쓰더라도, 같은 구성의 멤버에 대해 서로 다른 groupId가 들어올 수 있지 않을까요?
예를 들어 A, B, C, D가 묶이는 상황에서 각 유저의 polling 요청이 비슷한 시점에 createProjectGroup을 실행하면, 요청마다 다른 groupId가 생성될 가능성이 있어 보여요.
그래서 중복 기준은 groupId보다 userId와 ACTIVE GroupMember 여부로 잡는 게 더 의도에 맞고, 매칭 확정부터 그룹 생성까지는 하나의 트랜잭션으로 묶는 게 좋을 것 같습니다.
There was a problem hiding this comment.
좋은 생각인 것 같습니다 ! 해당방식으로 한 멤버가 두개의 팀에 들어가는 상황에 예외처리를 하면 될 것 같습니다. 그럼 groupId를 제거하고 projectgroup의 pk 를 써도 될 것 같은데 이렇게 했을 때 재시도(두번클릭)등의 상황에서 팀이 두번 생기는 걸 방지할 수 있는지도 같이 고민해보고 수정해서 올리도록 하겠습니다.
| if (memberRequest.groupRole() == GroupRole.MEMBER && Boolean.TRUE.equals(memberRequest.admin())) { | ||
| member.grantAdmin(); | ||
| } |
There was a problem hiding this comment.
여기를 보니까 헷갈리던데
- grouprole == HOST면 방장
- grouprole == MEMBER면 팀원
- admin은 별개의 관리자 권한
이라서, 동작 자체는 이해가 잘 가요. 다만 가독성 부분에서 이해가 바로 되진 않았어요.
- HOST 는 항상 관리자라고 규칙을 고정하고, 요청 DTO에서는 admin 을 멤버에게만 허용하거나
- 아니면 아예 admin 을 없애고 HOST, ADMIN, MEMBER 같은 권한으로 통합하면
어떨까요
There was a problem hiding this comment.
동의합니다. 1번에 맞춰서 host는 항상 admin을 true로 하고 요청dto에서는 admin을 제거했습니다!
| projectGroup.getId(), groupId, members.size()); | ||
|
|
||
| return new CreateProjectGroupResponse( | ||
| projectGroup.getId(), |
There was a problem hiding this comment.
groupId 코멘트의 연장선인데
Request의 groupId는 DB에 별도로 있는 칼럼의 groupId고
Response의 groupId는 PK네용
There was a problem hiding this comment.
response에서도 groupId로 통일하겠습니다 !
| } | ||
| } | ||
|
|
||
| private void changeAdminPermission( |
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record CreateProjectGroupRequest( |
There was a problem hiding this comment.
여기 달린 Sprng validation용 어노테이션은 컨트롤러가 없으니 동작하지 않습니다
There was a problem hiding this comment.
조회용 controller를 만드는게 나을 것 같아서 반영해서 수정하겠습니다 !
| lenient().when(projectGroupRepository.findByGroupId(anyLong())).thenReturn(Optional.empty()); | ||
| lenient().when(projectGroupMemberRepository.existsByUser_IdIn(anyList())).thenReturn(false); |
There was a problem hiding this comment.
AI한테 시켜서 lenient 쓰지 말라고 해주세용 테스트코드 검증력 약해집니다
| @Transactional | ||
| public CreateProjectGroupResponse createProjectGroup(CreateProjectGroupRequest request) { |
There was a problem hiding this comment.
다은이가 작업하기 전까진 쓰이는 곳 없으니 주석 달아둡시다!
There was a problem hiding this comment.
고민인게 다은이가 작업하기 전에 안쓰이는 부분이 너무 많아서 해당 부분 완료 전까지 merge를 안하는 방향으로 가는게 어떨까...생각...중입니다..
📌 Related Issue
Closes #4
🚀 Description
📢 Review Point
📚 Etc (선택)