Conversation
QA 통합 테스트(C-04, D-01, C-01)에서 확인된 결함 3건.
- 만료된 PENDING 초대가 목록에 남아 수락·거절이 모두 409 나고 같은 번호 재초대가 막히던 문제.
만료 스케줄러가 없어 DB status가 PENDING으로 남으므로, 목록·건수 조회와
"이미 초대 대기 중" 판정을 expiresAt 기준 쿼리 조건으로 처리한다.
이미 답한(ACCEPTED/DECLINED) 카드도 같은 증상이라 응답에 status를 추가한다.
- 사장님 근무자 목록이 status 미입력 시 퇴사자까지 노출되던 문제. 기본값을 ACTIVATED로 고정한다.
퇴사자 조회는 status=RESIGNED를 명시해야 한다.
- 초대 실패 응답이 미가입·재직 중·초대 대기 중을 구분하지 않던 문제.
data를 [{phoneNumber, reason}] 형태로 바꾼다(Breaking, ErrorCode는 B001 유지).
@DataJpaTest 리포지토리 테스트 2개 신설, SendWorkspaceInvitation 테스트에 사유·우선순위 케이스 추가.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Repository: alter-app/alter-backend/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
초대 발송이 조회 후 저장 구조라 동시 요청 시 PENDING 초대가 중복 생성되던 경쟁 상황을 V15 부분 유니크 인덱스(status = PENDING)로 DB 차원에서 막는다. 만료된 PENDING 은 인덱스에 걸리므로 재초대 전에 EXPIRED 로 바꿔 flush 하고, 충돌 시 ALREADY_INVITED 사유로 응답한다.
|
|
||
| private BooleanExpression eqWorkerStatus(QWorkspaceWorker qWorkspaceWorker, WorkspaceWorkerStatus status) { | ||
| return status != null ? qWorkspaceWorker.status.eq(status) : null; | ||
| return qWorkspaceWorker.status.eq(status != null ? status : WorkspaceWorkerStatus.ACTIVATED); |
There was a problem hiding this comment.
[결함] status를 비우면 ACTIVATED가 강제로 걸려 기존 조회가 깨집니다
status가 null이면 무조건 ACTIVATED를 걸어서, 아래 요청이 오류 없이 빈 결과를 냅니다.
- 근무자 목록에
resignedAtFrom만 넣고 status를 비움 →status = ACTIVATED AND resigned_at >= …→ 항상 0건 - 재직자와 퇴사자를 한 번에 보던 기존 호출도 이제 재직자만 받습니다.
퇴사일 필터(resignedAtFrom/resignedAtTo)가 있으면 기본값을 적용하지 않는 방법을 제안합니다.
이 줄을 그대로 두더라도 삼항 연산자 대신 ObjectUtils.defaultIfNull(status, WorkspaceWorkerStatus.ACTIVATED)를 써 주세요. null 처리는 org.apache.commons.lang3 유틸을 쓰는 게 컨벤션이고, 같은 파일에서도 이미 ObjectUtils를 쓰고 있습니다.
| || pendingInvitedUserIds.contains(invitedUser.getId())) { | ||
| unavailablePhoneNumbers.add(phoneNumber); | ||
| if (invitedUser == null) { | ||
| details.add(new InvitationUnavailableDetail(phoneNumber, InvitationUnavailableReason.NOT_REGISTERED)); |
There was a problem hiding this comment.
[결함] 정지(SUSPENDED)된 가입자가 '미가입'으로 안내됩니다
userQueryRepository.findByContactIn은 status = ACTIVE인 사용자만 돌려줍니다(UserQueryRepositoryImpl.java:137). 그래서 가입했지만 정지된 번호도 contactToUser에서 빠지고, 여기서 NOT_REGISTERED가 됩니다.
조회 조건은 이 PR 전부터 있던 것이지만, 실패 사유를 구분하자는 C-01의 목적과 어긋나서 같이 남깁니다. 정지 계정용 사유를 따로 두거나, 최소한 API 설명에 "미가입에는 이용할 수 없는 계정도 포함된다"고 적어 두면 좋겠습니다.
|
|
||
| private BooleanExpression notExpiredPendingCondition(QBusinessInvitation q) { | ||
| return q.status.ne(BusinessInvitationStatus.PENDING) | ||
| .or(q.expiresAt.gt(LocalDateTime.now())); |
There was a problem hiding this comment.
[결함] 만료 기준 시각이 쿼리마다 다르고, 엔티티와도 다릅니다
countByUser와findByUserWithCursor가 각자LocalDateTime.now()를 부릅니다. 두 호출 사이에 초대 하나가 만료되면totalCount는 3인데 목록은 2건처럼 어긋날 수 있습니다.- 경계값도 다릅니다. 쿼리는
expiresAt > now만 유효로 보는데, 엔티티isExpired()는now.isAfter(expiresAt)라서expiresAt == now이면 유효로 봅니다. 목록에서는 숨긴 초대를 수락은 허용하는 셈입니다.
UseCase에서 now를 한 번만 구해 넘기고, 경계 기준(gt/goe)을 엔티티와 하나로 맞추는 방법을 제안합니다.
| qBusinessInvitation.status.eq(BusinessInvitationStatus.PENDING), | ||
| qBusinessInvitation.invitedUser.id.in(userIds) | ||
| qBusinessInvitation.invitedUser.id.in(userIds), | ||
| qBusinessInvitation.expiresAt.gt(LocalDateTime.now()) |
There was a problem hiding this comment.
[설계] 'PENDING이면서 만료 전' 판정이 네 곳에 흩어져 있습니다
- 이 줄:
expiresAt.gt(now) - 64줄
findExpiredPendingByWorkspaceAndUserIds:expiresAt.loe(now) - 129~131줄
notExpiredPendingCondition BusinessInvitation.isExpired()
만료된 초대가 DB에는 PENDING으로 남아 있어서, 앞으로 PENDING을 읽는 쿼리를 추가하다가 만료 조건을 하나라도 빠뜨리면 C-04가 다시 생깁니다. "PENDING이면서 만료 전"과 "PENDING이면서 만료됨" 조건을 헬퍼 한 쌍으로 모아 세 쿼리가 함께 쓰면 좋겠습니다.
| public class MyInvitationListFilterDto { | ||
|
|
||
| @Parameter(description = "상태 필터 (PENDING | ACCEPTED | DECLINED | EXPIRED), 미입력 시 전체 조회") | ||
| @Parameter(description = "상태 필터 (PENDING | ACCEPTED | DECLINED | EXPIRED — EXPIRED는 현재 미사용), 미입력 시 전체 조회, 만료된 PENDING 초대는 항상 제외") |
There was a problem hiding this comment.
[결함] d222741 이후 'EXPIRED는 현재 미사용' 설명이 틀리고, 만료된 초대가 행마다 다르게 보입니다
d2227416부터 EXPIRED가 실제로 저장됩니다.
- V15 마이그레이션이 배포 시점에 이미 만료된 PENDING을 모두 EXPIRED로 바꿉니다.
- 재초대할 때도 이전 초대를 EXPIRED로 바꿉니다.
그런데 목록 조회는 여전히 "만료된 PENDING만 숨김" 기준이라 이렇게 됩니다.
- 필터 없이 조회하면 EXPIRED로 바뀐 초대는
status: EXPIRED로 보이고, 배포 뒤에 만료된 PENDING 초대는 숨겨집니다. 똑같이 만료된 초대인데 마이그레이션이나 재초대를 거쳤는지에 따라 보이거나 숨겨집니다. status=EXPIRED필터는 그중 일부만 돌려줍니다.
만료된 초대를 목록에 보여줄지 숨길지 먼저 정하고, EXPIRED 초대와 만료된 PENDING 초대를 같은 규칙으로 다뤄 주세요. 예를 들어 status=EXPIRED를 "EXPIRED이거나 만료된 PENDING"으로 해석하는 방법이 있습니다. UserWorkspaceInvitationControllerSpec.java:46의 같은 문구도 함께 고쳐야 합니다.
| assertThat(result).extracting(ManagerWorkspaceWorkerListResponse::getId) | ||
| .containsExactly(resigned.getId()); | ||
| assertThat(result).extracting(r -> r.getStatus()) | ||
| .containsExactly(WorkspaceWorkerStatus.RESIGNED); |
There was a problem hiding this comment.
[테스트] RESIGNED 케이스에 건수 검증이 없습니다
바로 위 '미지정' 케이스는 getWorkspaceWorkerCount까지 확인하는데, 이 케이스는 목록만 봅니다. count 쿼리의 status 조건이 틀려도 이 테스트는 통과합니다. assertThat(count).isEqualTo(1)을 추가해 주세요.
WorkspaceQueryRepositoryImpl 677줄 코멘트의 상황(퇴사일 필터만 넣고 status를 비운 요청)을 확인하는 케이스도 있으면 좋겠습니다.
|
|
||
| private User saveUser() { | ||
| User user = User.create( | ||
| "010" + String.valueOf(System.nanoTime()).substring(0, 8), "encoded", "김알바", |
There was a problem hiding this comment.
[테스트] nanoTime 앞 8자리를 잘라 써서 번호가 겹칠 수 있습니다
String.valueOf(System.nanoTime()).substring(0, 8)은 값의 앞자리를 씁니다. nanoTime은 보통 시스템 가동 시간 기준이라 자릿수가 크면 앞 8자리가 몇 밀리초 넘게 그대로입니다. 그러면 한 테스트 안에서 만든 사용자들이 같은 번호를 받습니다.
지금은 contact에 유니크 제약이 없어서 통과합니다. 하지만 이 헬퍼로 findByContactIn이나 SendWorkspaceInvitation 통합 테스트를 만들면 Collectors.toMap이 중복 키로 IllegalStateException을 던집니다. 뒷자리를 쓰거나 AtomicInteger 카운터로 바꿔 주세요. WorkspaceQueryRepositoryImplWorkerListTests.java:61도 같습니다.
| private String businessName; | ||
|
|
||
| @Schema(description = "초대 상태", example = "PENDING") | ||
| private BusinessInvitationStatus status; |
There was a problem hiding this comment.
[컨벤션] 응답 enum은 DescribedEnumDto로 내려 주세요
컨벤션상 응답에 나가는 enum은 static Map<EnumType, String> describe()를 구현하고, DTO에서는 DescribedEnumDto.of(enumValue, EnumClass.describe())로 씁니다(예: AdminWorkspaceRequestResponseDto). 지금은 BusinessInvitationStatus를 그대로 내보내고, 이 enum에는 describe()도 없습니다. 이대로면 클라이언트가 상태 표시 문구를 따로 만들어야 합니다.
| @@ -0,0 +1,5 @@ | |||
| package com.dreamteam.alter.domain.workspace.type; | |||
|
|
|||
| public enum InvitationUnavailableReason { | |||
There was a problem hiding this comment.
[컨벤션] 실패 사유 enum에도 describe()가 필요합니다
초대 실패 응답(data[].reason)으로 나가는 enum이라 위와 같은 규칙이 적용됩니다. 사유별 안내 문구를 보여주는 것이 C-01의 목적이니, describe()로 문구를 함께 내려 주면 클라이언트가 문구를 따로 관리하지 않아도 됩니다.
| import com.dreamteam.alter.domain.workspace.type.InvitationUnavailableReason; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "초대 발송 불가 번호와 사유") |
There was a problem hiding this comment.
[컨벤션] 응답용 record가 domain 패키지에 있고, 붙인 @Schema는 문서에 나오지 않습니다
- 컨벤션상
@Schema가 붙은 응답 DTO는adapter/inbound/.../dto/에 둡니다. 지금 domain 패키지에서io.swagger를 import하는 클래스는 이 파일 하나뿐입니다. - 게다가
ManagerWorkspaceInvitationControllerSpec의 400 응답에 content/schema 지정이 없어서 springdoc이 이 record를 문서에 넣지 않습니다. ca91af34에서 붙인@Schema는 실제로 효과가 없습니다.
예외에는 domain 값(번호, 사유)만 담고, 응답 변환과 @Schema는 adapter DTO에서 맡는 방법을 제안합니다. 문서에 보이게 하려면 400 응답에 content = @Content(schema = @Schema(implementation = ...))를 지정해 주세요.
…TO 정리 리뷰(ysw789) 10건을 반영한다. - 근무자 목록 status 기본값: resignedAtFrom/To가 있으면 ACTIVATED 기본을 적용하지 않는다. 퇴사일 필터만 넣은 요청이 빈 결과가 되던 문제. null 처리는 ObjectUtils.defaultIfNull. - 만료 기준 시각을 UseCase가 한 번 구해 리포지토리 4메서드와 응답 DTO에 넘긴다. count와 list가 각자 now()를 불러 어긋날 수 있었고, 엔티티 isExpired() 경계도 쿼리와 달랐다. "PENDING이면서 유효/만료" 조건을 pendingAndValid/pendingAndExpired 헬퍼 한 쌍으로 모은다. - V15 이후 EXPIRED 행과 만료된 PENDING 행이 공존하므로 목록 규칙을 통일한다. 필터 없음 → 둘 다 제외, status=EXPIRED → 둘 다, status=PENDING → 유효한 것만. 응답 status는 getEffectiveStatus(now)로 만료된 PENDING을 EXPIRED로 표시한다. - 정지 계정이 미가입으로 안내되던 문제: findByContactIn의 ACTIVE 필터를 제거하고 ACCOUNT_UNAVAILABLE 사유를 추가한다(호출처는 SendWorkspaceInvitation 하나). - 응답 enum은 describe() + DescribedEnumDto로 내린다. 실패 상세 응답 DTO를 adapter로 옮기고 도메인 record에서 @Schema를 걷어낸다. Swagger 400 응답에 @content 예시를 지정한다. - 테스트: AtomicInteger 픽스처, RESIGNED count 단언, 퇴사일 필터 케이스, PENDING 필터 케이스, 엔티티 만료 경계 테스트 신설.
| @@ -57,36 +63,71 @@ public void execute(ManagerActor actor, Long workspaceId, SendWorkspaceInvitatio | |||
| .stream().collect(Collectors.toMap(User::getContact, Function.identity())); | |||
There was a problem hiding this comment.
[결함] 같은 번호의 계정이 둘이면 여기서 IllegalStateException이 나고 500으로 끝납니다
1c966322에서 findByContactIn의 ACTIVE 조건을 빼면서 한 번호에 계정이 둘 이상 조회될 수 있게 됐습니다. 그러면 이 Collectors.toMap이 중복 키로 IllegalStateException을 던집니다.
실제로 생길 수 있는 경로입니다.
- 사용자 A가 정지(SUSPENDED)됩니다(
AdminUpdateUserStatus). - 같은 번호로 새로 가입합니다. 가입 중복 검사가 쓰는
findByContact는 ACTIVE 계정만 보므로 통과하고, 사용자 B(ACTIVE)가 생깁니다.users.contact에는 유니크 제약도 없습니다. - 사장님이 이 번호로 초대하면 A와 B가 함께 조회돼 여기서 예외가 나고 500이 됩니다.
B는 정상 계정이라 초대가 돼야 하는데, 번호별 실패 응답도 아닌 서버 오류가 납니다. 이전에는 ACTIVE만 조회해서 없던 문제입니다.
번호별로 ACTIVE 계정을 먼저 고르도록 toMap에 중복 처리 함수를 넣어 주세요. 예:
.collect(Collectors.toMap(User::getContact, Function.identity(),
(a, b) -> a.getStatus() == UserStatus.ACTIVE ? a : b));지금 정지 계정 테스트는 조회 결과를 계정 하나로만 가정하니, 같은 번호에 SUSPENDED·ACTIVE 계정이 함께 오는 케이스도 추가해 주세요.
| try { | ||
| businessInvitationRepository.saveAll(invitationsToSave); | ||
| } catch (DataIntegrityViolationException e) { | ||
| // ponytail: 부분 유니크 인덱스 충돌은 같은 요청의 동시 제출이 대부분이라 저장 대상 전부를 ALREADY_INVITED 로 본다. |
There was a problem hiding this comment.
[사소] 주석 앞의 ponytail:은 실수로 들어간 단어로 보입니다. 지워 주세요.
| } | ||
|
|
||
| @Test | ||
| void isExpired_expiresAt이_now와_같으면_만료() { |
There was a problem hiding this comment.
[사소] 테스트 이름과 검증 대상이 다릅니다
이름은 isExpired_...인데 실제로는 getEffectiveStatus(now)를 검증합니다. 두 메서드가 같은 내부 판정(isExpiredAt)을 써서 동작은 맞습니다. isExpired()는 안에서 now()를 직접 불러 경계값을 테스트하기 어려우니, 이름을 getEffectiveStatus_expiresAt이_now와_같으면_EXPIRED처럼 검증 대상에 맞춰 주세요.
요약
QA 2026-08-26 통합 테스트 1주차에서 확인된 업장 근무자·초대 도메인 결함 3건(C-04, D-01, C-01)을 수정했습니다. 리뷰 과정에서 초대 발송 경쟁 조건(부분 유니크 인덱스 V15)과 리뷰 지적 10건을 함께 반영했습니다. 초대 실패 응답
data·내 초대 응답status형태가 바뀌는 Breaking change가 있습니다.변경 내용
P0 — 만료된 초대가 목록에 남아 수락·거절이 모두 실패하고 재초대까지 막힘 (C-04)
expiresAt이 지나도 DBstatus는 PENDING 그대로였고, 목록 조회·"이미 초대 대기 중" 판정이status = PENDING만 봤습니다. 만료 카드가 계속 보이고, 누르면 수락·거절 모두 409, 사장님은 같은 번호를 재초대할 수 없었습니다.BusinessInvitationQueryRepositoryImpl에 "PENDING·유효"/"PENDING·만료" 헬퍼 한 쌍(pendingAndValid/pendingAndExpired)을 두고 목록·건수·재초대 판정이 모두 이걸 씁니다. 만료 기준 시각now는 UseCase가 한 번 구해 리포지토리에 넘기므로 count와 list가 어긋나지 않고, 엔티티isExpired()도 같은 경계(expiresAt <= now)를 씁니다.status=PENDING→ 유효한 것만.status=EXPIRED→ EXPIRED와 만료된 PENDING 둘 다.MyInvitationResponseDto에status({value, description})를 추가했습니다. 만료된 PENDING은EXPIRED로 표시됩니다.P0-2 — 초대 발송 경쟁 조건 (리뷰에서 추가)
SendWorkspaceInvitation이 조회 후 저장 구조이고 유니크 제약이 없어 동시 요청 시 같은 사용자에게 PENDING 초대가 중복 생성될 수 있었습니다.business_invitations (workspace_id, user_id) WHERE status = 'PENDING'부분 유니크 인덱스. 마이그레이션이 먼저 만료된 PENDING과 중복 PENDING(최신 id 외)을 EXPIRED로 정리합니다.expire()로 EXPIRED 처리하고 flush한 뒤 insert합니다(지연 만료, 스케줄러는 여전히 없음). 저장 시 인덱스 충돌이 나면 저장 대상을ALREADY_INVITED로 응답합니다.P1 — 퇴사한 근무자가 사장님 근무자 목록에 노출 (D-01)
GET /manager/workspaces/{id}/workers가status미입력 시 상태 조건을 걸지 않아 RESIGNED 근무자가 근무 배정 화면에 그대로 보였습니다.status미입력 시 ACTIVATED를 기본 적용하되,resignedAtFrom/To가 있으면 기본값을 적용하지 않습니다(퇴사자 조회 요청이 빈 결과가 되지 않도록). count·list가 같은 조건 메서드를 씁니다.P2 — 초대 실패 응답이 원인을 구분하지 않음 (C-01)
InvitationUnavailableDetail(phoneNumber, reason)만 담고,GlobalExceptionHandler가 adapter DTOInvitationUnavailableResponseDto로 바꿔data로 내립니다.reason은{value, description}이며NOT_REGISTERED / ACCOUNT_UNAVAILABLE / ALREADY_WORKING / ALREADY_INVITED(판정 우선순위 그 순서). 정지 계정을 미가입으로 안내하던 문제는findByContactIn의 ACTIVE 필터를 걷어내고ACCOUNT_UNAVAILABLE로 구분합니다. ErrorCode는B001(ILLEGAL_ARGUMENT)그대로이고 Swagger 400 응답에 예시를 넣었습니다.프론트 영향 (Breaking 주의)
POST /manager/workspaces/{id}/invitations실패 응답data:["01012345678", ...]→[{"phoneNumber": "01012345678", "reason": {"value": "NOT_REGISTERED", "description": "가입되지 않은 번호입니다."}}, ...]. 에러 코드B001·메시지는 동일합니다.GET /app/users/me/invitations: 만료된 초대가 기본 목록에서 빠지고(status=EXPIRED로 조회 가능), 응답에status: {value, description}가 추가됩니다. PENDING이 아닌 카드는 수락·거절 버튼을 숨겨 주세요.GET /manager/workspaces/{id}/workers:status미입력 시 재직자만 반환. 퇴사자는status=RESIGNED또는resignedAtFrom/To로 조회합니다.테스트
BusinessInvitationQueryRepositoryImplTests(@DataJpaTest) — 만료 PENDING 제외, 목록 규칙(필터 없음 / PENDING / EXPIRED), 만료 PENDING 조회WorkspaceQueryRepositoryImplWorkerListTests(@DataJpaTest) — status 미지정 시 RESIGNED 제외, RESIGNED 지정, 퇴사일 필터만 지정BusinessInvitationTests—getEffectiveStatus·만료 경계(expiresAt == now)SendWorkspaceInvitationTests— 사유 4종·혼합·우선순위·정지 계정·만료 PENDING 지연 처리 순서·유니크 충돌 → ALREADY_INVITED./gradlew test433건 통과 (skip 2건은 기존 STOMP Redis 통합 테스트)남긴 것
WorkspaceScheduleServiceImpl패턴으로 추가 예정.ddl-auto=create, Flyway off)에서 검증되지 않습니다 — V10/V14와 동일한 기존 공백이며 백로그의 Flyway smoke test 항목이 덮습니다.GetMyInvitationList가pageSize + 1/hasNext처리를 하지 않아 마지막 페이지에도 커서를 내리는 선재 결함이 있습니다(이 PR 범위 밖, 백로그 등록).User.java의@SQLRestriction이 필드에 붙어 있어 사실상 no-op으로 보입니다(선재, 백로그 등록). 탈퇴 사용자는withdraw()의 contact 익명화로 걸러집니다.