feat: [alt-267] 업장 업종 관리 시스템 재설계#97
Conversation
모집중(OPEN) 공고 지원이 오히려 차단되던 조건 반전 버그 수정. if (OPEN) throw -> if (!OPEN) throw.
📝 WalkthroughWalkthrough업종을 독립 엔티티로 도입하고 관리자 CRUD API와 워크스페이스 등록·조회 흐름을 업종 ID 및 상세 정보 기반으로 변경했습니다. 공고 필터에 업종 선택을 추가했으며, 기존 공고 키워드 모델·조회·응답 경로를 제거했습니다. ChangesBusiness type management
Workspace business type propagation
Posting filter and keyword removal
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessType.java`:
- Around line 22-30: Update AdminCreateBusinessType.execute to catch the
DataIntegrityViolationException raised by saving a duplicate BusinessType and
translate it to CustomException with ErrorCode.CONFLICT, preserving the existing
pre-check while ensuring concurrent duplicate inserts return 409 instead of 500.
In
`@src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java`:
- Around line 40-51: Move the “기타” 업종의 상세 입력 필수 검증 from the application-layer
resolveBusinessTypeDetail helper into the BusinessType domain model, preferably
through a method such as resolveDetail. Update CreateWorkspaceRequest.execute
and any related WorkspaceRequest.create flow to use the domain behavior,
preserving the existing exception behavior for missing required details and
allowing reuse by other use cases.
In `@src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.java`:
- Around line 13-34: BusinessType retains JPA and Spring infrastructure
annotations, violating the domain layer’s dependency boundary. Keep this PR
unchanged for this existing cross-cutting pattern; track separation of
BusinessType into a pure domain model and dedicated persistence entity as a
broader refactor rather than modifying this class now.
In
`@src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequest.java`:
- Around line 60-65: WorkspaceRequest.create(...) 내부에서도 BusinessType이 “기타”인 경우
businessTypeDetail이 필수라는 도메인 불변식을 검증하도록 수정하세요. CreateWorkspaceRequest에만 의존하지 말고,
BusinessType의 “기타” 판별과 상세값 검증을 팩토리 메서드에 포함해 다른 생성 경로에서도 동일한 규칙이 적용되게 하며, 기존의 유효한
생성 흐름은 유지하세요.
In `@src/main/resources/db/migration/V5__migrate_workspace_business_type.sql`:
- Around line 35-47: 분리 마이그레이션을 적용해 workspace_requests와 workspaces의 FK를 먼저 NOT
VALID로 추가하고, 별도 단계에서 VALIDATE CONSTRAINT 및 데이터 정합성 확인 후 NOT NULL을 설정하세요. 기존
business_type 컬럼 삭제는 구버전 애플리케이션이 참조하지 않도록 배포가 완료된 뒤 별도 contract 마이그레이션으로 이동하고,
현재 마이그레이션에서는 DROP COLUMN을 제거하세요.
In
`@src/test/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessTypeTests.java`:
- Around line 25-115: Organize the tests in AdminDeleteBusinessTypeTests into
`@Nested` classes with `@DisplayName`, separating the four exception scenarios from
the successful deletion scenario. Keep each existing test’s setup, assertions,
and behavior unchanged while placing failure cases under one clearly named group
and execute_성공 under a success group.
In
`@src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java`:
- Around line 166-241: Group the four BusinessType-related
tests—execute_존재하지않는업종_예외, execute_기타_상세없음_예외, execute_기타_상세있음_저장, and
execute_비기타_상세무시—inside a dedicated `@Nested` test class with a descriptive
`@DisplayName`. Preserve each test’s existing setup, assertions, and behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ecd7bbce-b337-4283-95ec-a31840b0de94
📒 Files selected for processing (81)
src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/controller/AdminBusinessTypeController.javasrc/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/controller/AdminBusinessTypeControllerSpec.javasrc/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminBusinessTypeRequestDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminBusinessTypeResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminWorkspaceRequestResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/common/dto/BusinessTypeResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/ManagerPostingListWorkspaceResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingFilterOptionsResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingKeywordListResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListWorkspaceResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceRequestController.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceRequestControllerSpec.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/PostingDetailWorkspaceResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/WorkspaceRequestResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceRequestController.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceRequestControllerSpec.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceListResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeJpaRepository.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeQueryRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestQueryRepositoryImpl.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/ManagerWorkspaceListResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/ManagerWorkspaceResponse.javasrc/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/WorkspaceRequestResponse.javasrc/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.javasrc/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.javasrc/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.javasrc/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingKeywordList.javasrc/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessType.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessType.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/AdminGetBusinessTypeList.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/AdminUpdateBusinessType.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/GetBusinessTypeList.javasrc/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.javasrc/main/java/com/dreamteam/alter/domain/posting/entity/Posting.javasrc/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeywordMap.javasrc/main/java/com/dreamteam/alter/domain/posting/port/inbound/GetPostingKeywordListUseCase.javasrc/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.javasrc/main/java/com/dreamteam/alter/domain/workspace/command/AdminCreateBusinessTypeCommand.javasrc/main/java/com/dreamteam/alter/domain/workspace/command/AdminUpdateBusinessTypeCommand.javasrc/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.javasrc/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.javasrc/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequest.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminCreateBusinessTypeUseCase.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminDeleteBusinessTypeUseCase.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminGetBusinessTypeListUseCase.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminUpdateBusinessTypeUseCase.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetBusinessTypeListUseCase.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessTypeQueryRepository.javasrc/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessTypeRepository.javasrc/main/resources/db/migration/V4__create_business_types.sqlsrc/main/resources/db/migration/V5__migrate_workspace_business_type.sqlsrc/main/resources/db/migration/V6__drop_posting_keyword_tables.sqlsrc/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptionsTests.javasrc/test/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessTypeTests.javasrc/test/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessTypeTests.javasrc/test/java/com/dreamteam/alter/application/workspace/usecase/AdminUpdateBusinessTypeTests.javasrc/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.javasrc/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.javasrc/test/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequestTests.java
💤 Files with no reviewable changes (17)
- src/main/java/com/dreamteam/alter/domain/posting/port/inbound/GetPostingKeywordListUseCase.java
- src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeywordMap.java
- src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java
- src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java
- src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingKeywordListResponseDto.java
- src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingKeywordList.java
- src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java
- src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.java
- src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java
- src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java
- src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.java
- src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java
- src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java
- src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListResponseDto.java
- src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.java
- src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java
- src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java
| @Override | ||
| public Long execute(AdminCreateBusinessTypeCommand command) { | ||
| if (businessTypeRepository.existsByName(command.name())) { | ||
| throw new CustomException(ErrorCode.CONFLICT, "이미 존재하는 업종입니다."); | ||
| } | ||
|
|
||
| BusinessType businessType = BusinessType.create(command.name(), command.description()); | ||
| return businessTypeRepository.save(businessType).getId(); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# V4 마이그레이션에서 UNIQUE 제약 조건 확인
cat src/main/resources/db/migration/V4__create_business_types.sql
# BusinessType.create() 메서드 검증
ast-grep outline src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.java --items all --match 'create'
# posting_keywords 테이블 참조 잔존 확인
rg -n "posting_keyword" --type java -g '!**/db/migration/**'Repository: alter-app/alter-backend
Length of output: 1430
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== AdminCreateBusinessType ==\n'
cat -n src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessType.java
printf '\n== BusinessType entity ==\n'
cat -n src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.java
printf '\n== DataIntegrityViolationException handling ==\n'
rg -n "DataIntegrityViolationException|IntegrityViolation|`@RestControllerAdvice`|`@ExceptionHandler`|CustomException|ErrorCode.CONFLICT" src/main/java
printf '\n== Transaction annotations in application workspace usecases ==\n'
rg -n "`@Transactional`|`@Slf4j`|`@Service`\\(" src/main/java/com/dreamteam/alter/application/workspace/usecaseRepository: alter-app/alter-backend
Length of output: 50380
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== Global exception handling ==\n'
rg -n "DataIntegrityViolationException|IntegrityViolation|ExceptionHandler|RestControllerAdvice|ControllerAdvice|CustomException" src/main/java/com/dreamteam/alter/common src/main/java/com/dreamteam/alter -g '!**/target/**' | head -n 200
printf '\n== Common exception package ==\n'
fd -a . src/main/java/com/dreamteam/alter/common/exception
cat -n src/main/java/com/dreamteam/alter/common/exception/CustomException.java
printf '\n---\n'
cat -n src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java
printf '\n== Search for UNIQUE handling around business_types ==\n'
rg -n "business_types|unique = true|existsByName|findByName" src/main/java src/main/resources/db/migration
printf '\n== BusinessType-related use case references ==\n'
rg -n "AdminCreateBusinessType|AdminUpdateBusinessType|AdminDeleteBusinessType|BusinessType.create\\(" src/main/java/com/dreamteam/alter/application/workspace/usecase src/main/java/com/dreamteam/alter/domain/workspace/entityRepository: alter-app/alter-backend
Length of output: 46069
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== GlobalExceptionHandler ==\n'
cat -n src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java
printf '\n== AdminCreateBusinessTypeCommand ==\n'
cat -n src/main/java/com/dreamteam/alter/domain/workspace/command/AdminCreateBusinessTypeCommand.java
printf '\n== Workspace business-type usecases ==\n'
sed -n '1,120p' src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminUpdateBusinessType.java
printf '\n---\n'
sed -n '1,120p' src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessType.javaRepository: alter-app/alter-backend
Length of output: 9105
existsByName는 여전히 경쟁 조건이지만, 중복 저장은 DB 제약으로 막고 예외를 409로 바꿔야 함
business_types.name에 UNIQUE 제약이 이미 있어서 중복 데이터가 실제로 저장되진 않습니다. 다만 existsByName() 이후 동시 insert가 들어오면 한쪽은 제약 위반으로 실패하고, 현재는 그 예외가 CONFLICT로 매핑되지 않아 500으로 노출될 수 있습니다. DataIntegrityViolationException을 409로 처리하거나, 중복 체크를 DB 제약 기준으로 통일하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessType.java`
around lines 22 - 30, Update AdminCreateBusinessType.execute to catch the
DataIntegrityViolationException raised by saving a duplicate BusinessType and
translate it to CustomException with ErrorCode.CONFLICT, preserving the existing
pre-check while ensuring concurrent duplicate inserts return 409 instead of 500.
| @Override | ||
| public void execute(User user, CreateWorkspaceRequestDto request) { | ||
| BusinessType businessType = businessTypeRepository.findById(request.getBusinessTypeId()) | ||
| .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "존재하지 않는 업종입니다.")); | ||
| String businessTypeDetail = resolveBusinessTypeDetail(businessType, request.getBusinessTypeDetail()); | ||
|
|
||
| WorkspaceRequest workspaceRequest = WorkspaceRequest.create( | ||
| user, | ||
| request.getBrn(), | ||
| request.getBizName(), | ||
| request.getType(), | ||
| businessType, | ||
| businessTypeDetail, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
업종 상세값 검증 로직을 도메인 계층으로 이동 고려
resolveBusinessTypeDetail이 검증하는 "'기타' 업종이면 상세 입력 필수" 규칙은 BusinessType.requiresDetail이라는 도메인 개념 자체의 불변식입니다. 지금은 application 계층의 private 헬퍼에 있어, 향후 다른 use case(예: 관리자 워크스페이스 승인/수정 흐름)에서 동일 규칙이 필요해지면 로직이 중복될 소지가 있습니다. BusinessType에 resolveDetail(String raw) 같은 도메인 메서드를 두거나 WorkspaceRequest.create/BusinessType 내부에서 검증하도록 옮기는 것을 고려해보세요.
♻️ 예시 리팩터 방향
- private String resolveBusinessTypeDetail(BusinessType businessType, String rawDetail) {
- if (!businessType.isRequiresDetail()) {
- return null;
- }
- if (StringUtils.isBlank(rawDetail)) {
- throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "'기타' 업종은 상세 입력이 필요합니다.");
- }
- return StringUtils.trim(rawDetail);
- }
+ // BusinessType.resolveDetail(rawDetail) 형태로 도메인에 위임Also applies to: 90-100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java`
around lines 40 - 51, Move the “기타” 업종의 상세 입력 필수 검증 from the application-layer
resolveBusinessTypeDetail helper into the BusinessType domain model, preferably
through a method such as resolveDetail. Update CreateWorkspaceRequest.execute
and any related WorkspaceRequest.create flow to use the domain behavior,
preserving the existing exception behavior for missing required details and
allowing reuse by other use cases.
| @Table(name = "business_types") | ||
| @Builder(access = AccessLevel.PRIVATE) | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| @EntityListeners(AuditingEntityListener.class) | ||
| public class PostingKeyword { | ||
| public class BusinessType { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(name = "id", nullable = false) | ||
| private Long id; | ||
|
|
||
| @Column(name = "name", length = 128, nullable = false, unique = true) | ||
| @Column(name = "name", length = 64, nullable = false, unique = true) | ||
| private String name; | ||
|
|
||
| @Column(name = "description", length = 255, nullable = true) | ||
| private String description; | ||
|
|
||
| // '기타' 등 상세 입력을 요구하는 업종 식별 플래그. 시드로만 생성되며 관리자 CRUD로는 항상 false. | ||
| @Column(name = "requires_detail", nullable = false) | ||
| private boolean requiresDetail; | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff
도메인 엔티티에 JPA/Spring 어노테이션이 그대로 포함됨
@Entity, @Table, @Column, @GeneratedValue, @EntityListeners(AuditingEntityListener.class) 등은 인프라 의존성으로, 도메인 계층은 "ZERO infrastructure dependencies (no Spring, no JPA annotations, no external libs)"를 요구합니다. 다만 이는 이 PR에서 새로 도입된 문제가 아니라 기존 PostingKeyword 및 다른 도메인 엔티티들에서 이미 사용되던 패턴을 그대로 이전한 것으로 보입니다. 별도 영속성 엔티티(JPA)와 순수 도메인 모델을 분리하는 것은 코드베이스 전체에 걸친 대규모 리팩터가 필요해 이번 PR 범위에서 다루기는 어려워 보이나, 참고 차원에서 기록합니다.
As per path instructions, domain layer should have "ZERO infrastructure dependencies (no Spring, no JPA annotations, no external libs)".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.java`
around lines 13 - 34, BusinessType retains JPA and Spring infrastructure
annotations, violating the domain layer’s dependency boundary. Keep this PR
unchanged for this existing cross-cutting pattern; track separation of
BusinessType into a pure domain model and dedicated persistence entity as a
broader refactor rather than modifying this class now.
Source: Path instructions
| @JoinColumn(name = "business_type_id", nullable = false) | ||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| private BusinessType businessType; | ||
|
|
||
| @Column(name = "business_type_detail", length = 128, nullable = true) | ||
| private String businessTypeDetail; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
'기타' 업종 상세 필수 불변식을 엔티티 레벨에서도 보호하는 것을 고려해보세요.
현재 "기타 업종 선택 시 상세 입력 필수" 검증은 CreateWorkspaceRequest 유스케이스에서만 수행되고, WorkspaceRequest.create(...)는 이미 검증된 값을 단순히 저장합니다. BusinessType이 "기타"인지 여부는 인프라 의존 없이 판단 가능한 순수 도메인 규칙이므로, 팩토리 메서드 내부에서 함께 검증하면 향후 다른 생성 경로가 이 불변식을 우회하는 것을 방지할 수 있습니다.
As per path instructions, domain 계층은 "Entities encapsulate business rules and invariants"를 요구합니다.
Also applies to: 96-125
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequest.java`
around lines 60 - 65, WorkspaceRequest.create(...) 내부에서도 BusinessType이 “기타”인 경우
businessTypeDetail이 필수라는 도메인 불변식을 검증하도록 수정하세요. CreateWorkspaceRequest에만 의존하지 말고,
BusinessType의 “기타” 판별과 상세값 검증을 팩토리 메서드에 포함해 다른 생성 경로에서도 동일한 규칙이 적용되게 하며, 기존의 유효한
생성 흐름은 유지하세요.
Source: Path instructions
| ALTER TABLE workspace_requests | ||
| ALTER COLUMN business_type_id SET NOT NULL, | ||
| ADD CONSTRAINT fk_workspace_requests_business_type | ||
| FOREIGN KEY (business_type_id) REFERENCES business_types (id); | ||
|
|
||
| ALTER TABLE workspaces | ||
| ALTER COLUMN business_type_id SET NOT NULL, | ||
| ADD CONSTRAINT fk_workspaces_business_type | ||
| FOREIGN KEY (business_type_id) REFERENCES business_types (id); | ||
|
|
||
| -- 구 free-text 컬럼 제거 (미일치분은 '기타' 상세로 보존됨) | ||
| ALTER TABLE workspace_requests DROP COLUMN business_type; | ||
| ALTER TABLE workspaces DROP COLUMN business_type; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift
단일 마이그레이션에서 NOT NULL/FK/DROP COLUMN을 한꺼번에 수행 — 운영 환경 락 위험
Squawk 정적분석 결과 기준으로, SET NOT NULL(36/41행)과 FK 제약 추가(37-38/42-43행)는 각각 테이블 풀스캔과 SHARE ROW EXCLUSIVE 락을 유발하고, DROP COLUMN(46-47행)은 무중단 배포 중 구버전 앱 인스턴스가 여전히 business_type 컬럼을 참조할 경우 장애를 유발할 수 있습니다. 테이블 크기가 크다면 이 모든 작업이 하나의 마이그레이션에서 실행되어 서비스 다운타임으로 이어질 수 있습니다.
Postgres는 ADD COLUMN, SET NOT NULL, DROP COLUMN 모두 ACCESS EXCLUSIVE 락을 획득하며, FK/제약 추가는 기본적으로 테이블 스캔을 동반합니다. 안전한 expand/contract 패턴으로 분리하는 것을 권장합니다:
- FK는
NOT VALID로 추가 후 별도 트랜잭션에서VALIDATE CONSTRAINT실행 DROP COLUMN은 애플리케이션이 새 컬럼만 사용하도록 배포를 완전히 마친 이후의 별도 마이그레이션으로 분리
🛡️ 제안: FK를 NOT VALID로 분리
ALTER TABLE workspace_requests
ALTER COLUMN business_type_id SET NOT NULL,
- ADD CONSTRAINT fk_workspace_requests_business_type
- FOREIGN KEY (business_type_id) REFERENCES business_types (id);
+ ADD CONSTRAINT fk_workspace_requests_business_type
+ FOREIGN KEY (business_type_id) REFERENCES business_types (id) NOT VALID;
+ALTER TABLE workspace_requests VALIDATE CONSTRAINT fk_workspace_requests_business_type;테이블 규모와 트래픽 패턴에 따라 리스크 수준이 달라지므로, 실제 운영 배포 전 테이블 크기와 배포 방식(무중단 여부)을 재확인해보시길 권장합니다.
🧰 Tools
🪛 Squawk (2.59.0)
[warning] 36-36: Setting a column NOT NULL blocks reads while the table is scanned. Make the field nullable and use a CHECK constraint instead.
(adding-not-nullable-field)
[warning] 37-38: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.
(constraint-missing-not-valid)
[warning] 37-38: Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes to each table. Add NOT VALID to the constraint in one transaction and then VALIDATE the constraint in a separate transaction.
(adding-foreign-key-constraint)
[warning] 41-41: Setting a column NOT NULL blocks reads while the table is scanned. Make the field nullable and use a CHECK constraint instead.
(adding-not-nullable-field)
[warning] 42-43: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.
(constraint-missing-not-valid)
[warning] 42-43: Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes to each table. Add NOT VALID to the constraint in one transaction and then VALIDATE the constraint in a separate transaction.
(adding-foreign-key-constraint)
[warning] 46-46: Dropping a column may break existing clients.
(ban-drop-column)
[warning] 47-47: Dropping a column may break existing clients.
(ban-drop-column)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/resources/db/migration/V5__migrate_workspace_business_type.sql`
around lines 35 - 47, 분리 마이그레이션을 적용해 workspace_requests와 workspaces의 FK를 먼저 NOT
VALID로 추가하고, 별도 단계에서 VALIDATE CONSTRAINT 및 데이터 정합성 확인 후 NOT NULL을 설정하세요. 기존
business_type 컬럼 삭제는 구버전 애플리케이션이 참조하지 않도록 배포가 완료된 뒤 별도 contract 마이그레이션으로 이동하고,
현재 마이그레이션에서는 DROP COLUMN을 제거하세요.
Source: Linters/SAST tools
| @ExtendWith(MockitoExtension.class) | ||
| @DisplayName("AdminDeleteBusinessType 테스트") | ||
| class AdminDeleteBusinessTypeTests { | ||
|
|
||
| @Mock | ||
| private BusinessTypeRepository businessTypeRepository; | ||
|
|
||
| @Mock | ||
| private BusinessTypeQueryRepository businessTypeQueryRepository; | ||
|
|
||
| @InjectMocks | ||
| private AdminDeleteBusinessType adminDeleteBusinessType; | ||
|
|
||
| @Test | ||
| @DisplayName("존재하지 않는 업종이면 NOT_FOUND 예외가 발생한다") | ||
| void execute_미존재_예외() { | ||
| // given | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.empty()); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); | ||
| then(businessTypeRepository).should(never()).delete(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("'기타'(requiresDetail) 업종은 삭제할 수 없어 CONFLICT 예외가 발생한다") | ||
| void execute_기타_예외() { | ||
| // given | ||
| BusinessType etc = mock(BusinessType.class); | ||
| given(etc.isRequiresDetail()).willReturn(true); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | ||
| then(businessTypeRepository).should(never()).delete(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("업장에서 사용 중이면 CONFLICT 예외가 발생한다") | ||
| void execute_업장사용중_예외() { | ||
| // given | ||
| BusinessType businessType = mock(BusinessType.class); | ||
| given(businessType.isRequiresDetail()).willReturn(false); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | ||
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(true); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | ||
| then(businessTypeRepository).should(never()).delete(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("업장 신청에서 사용 중이면 CONFLICT 예외가 발생한다") | ||
| void execute_신청사용중_예외() { | ||
| // given | ||
| BusinessType businessType = mock(BusinessType.class); | ||
| given(businessType.isRequiresDetail()).willReturn(false); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | ||
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | ||
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(true); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | ||
| then(businessTypeRepository).should(never()).delete(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("사용 중이 아닌 일반 업종은 삭제된다") | ||
| void execute_성공() { | ||
| // given | ||
| BusinessType businessType = mock(BusinessType.class); | ||
| given(businessType.isRequiresDetail()).willReturn(false); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | ||
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | ||
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(false); | ||
|
|
||
| // when | ||
| adminDeleteBusinessType.execute(1L); | ||
|
|
||
| // then | ||
| then(businessTypeRepository).should().delete(businessType); | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
@Nested 클래스로 성공/실패 케이스 그룹화를 권장합니다.
테스트 품질 가이드라인에 따라 @Nested + @DisplayName으로 관련 테스트를 그룹화하는 것을 검토해 주세요. 현재 4개의 실패 케이스와 1개의 성공 케이스를 @Nested로 분리하면 가독성이 향상됩니다. As per path instructions, "@Nested classes group related tests with @DisplayName."
♻️ 제안하는 구조
`@ExtendWith`(MockitoExtension.class)
`@DisplayName`("AdminDeleteBusinessType 테스트")
class AdminDeleteBusinessTypeTests {
`@Mock`
private BusinessTypeRepository businessTypeRepository;
`@Mock`
private BusinessTypeQueryRepository businessTypeQueryRepository;
`@InjectMocks`
private AdminDeleteBusinessType adminDeleteBusinessType;
+ `@Nested`
+ `@DisplayName`("실패 케이스")
+ class FailureCases {
+
`@Test`
`@DisplayName`("존재하지 않는 업종이면 NOT_FOUND 예외가 발생한다")
void execute_미존재_예외() {
// ... existing test body
}
+
`@Test`
`@DisplayName`("'기타'(requiresDetail) 업종은 삭제할 수 없어 CONFLICT 예외가 발생한다")
void execute_기타_예외() {
// ... existing test body
}
+
`@Test`
`@DisplayName`("업장에서 사용 중이면 CONFLICT 예외가 발생한다")
void execute_업장사용중_예외() {
// ... existing test body
}
+
`@Test`
`@DisplayName`("업장 신청에서 사용 중이면 CONFLICT 예외가 발생한다")
void execute_신청사용중_예외() {
// ... existing test body
}
+ }
+
+ `@Nested`
+ `@DisplayName`("성공 케이스")
+ class SuccessCases {
+
`@Test`
`@DisplayName`("사용 중이 아닌 일반 업종은 삭제된다")
void execute_성공() {
// ... existing test body
}
+ }
}📝 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.
| @ExtendWith(MockitoExtension.class) | |
| @DisplayName("AdminDeleteBusinessType 테스트") | |
| class AdminDeleteBusinessTypeTests { | |
| @Mock | |
| private BusinessTypeRepository businessTypeRepository; | |
| @Mock | |
| private BusinessTypeQueryRepository businessTypeQueryRepository; | |
| @InjectMocks | |
| private AdminDeleteBusinessType adminDeleteBusinessType; | |
| @Test | |
| @DisplayName("존재하지 않는 업종이면 NOT_FOUND 예외가 발생한다") | |
| void execute_미존재_예외() { | |
| // given | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.empty()); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| @Test | |
| @DisplayName("'기타'(requiresDetail) 업종은 삭제할 수 없어 CONFLICT 예외가 발생한다") | |
| void execute_기타_예외() { | |
| // given | |
| BusinessType etc = mock(BusinessType.class); | |
| given(etc.isRequiresDetail()).willReturn(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| @Test | |
| @DisplayName("업장에서 사용 중이면 CONFLICT 예외가 발생한다") | |
| void execute_업장사용중_예외() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(true); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| @Test | |
| @DisplayName("업장 신청에서 사용 중이면 CONFLICT 예외가 발생한다") | |
| void execute_신청사용중_예외() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | |
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(true); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| @Test | |
| @DisplayName("사용 중이 아닌 일반 업종은 삭제된다") | |
| void execute_성공() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | |
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(false); | |
| // when | |
| adminDeleteBusinessType.execute(1L); | |
| // then | |
| then(businessTypeRepository).should().delete(businessType); | |
| } | |
| } | |
| `@ExtendWith`(MockitoExtension.class) | |
| `@DisplayName`("AdminDeleteBusinessType 테스트") | |
| class AdminDeleteBusinessTypeTests { | |
| `@Mock` | |
| private BusinessTypeRepository businessTypeRepository; | |
| `@Mock` | |
| private BusinessTypeQueryRepository businessTypeQueryRepository; | |
| `@InjectMocks` | |
| private AdminDeleteBusinessType adminDeleteBusinessType; | |
| `@Nested` | |
| `@DisplayName`("실패 케이스") | |
| class FailureCases { | |
| `@Test` | |
| `@DisplayName`("존재하지 않는 업종이면 NOT_FOUND 예외가 발생한다") | |
| void execute_미존재_예외() { | |
| // given | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.empty()); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| `@Test` | |
| `@DisplayName`("'기타'(requiresDetail) 업종은 삭제할 수 없어 CONFLICT 예외가 발생한다") | |
| void execute_기타_예외() { | |
| // given | |
| BusinessType etc = mock(BusinessType.class); | |
| given(etc.isRequiresDetail()).willReturn(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| `@Test` | |
| `@DisplayName`("업장에서 사용 중이면 CONFLICT 예외가 발생한다") | |
| void execute_업장사용중_예외() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(true); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| `@Test` | |
| `@DisplayName`("업장 신청에서 사용 중이면 CONFLICT 예외가 발생한다") | |
| void execute_신청사용중_예외() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | |
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(true); | |
| // when & then | |
| assertThatThrownBy(() -> adminDeleteBusinessType.execute(1L)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); | |
| then(businessTypeRepository).should(never()).delete(any()); | |
| } | |
| } | |
| `@Nested` | |
| `@DisplayName`("성공 케이스") | |
| class SuccessCases { | |
| `@Test` | |
| `@DisplayName`("사용 중이 아닌 일반 업종은 삭제된다") | |
| void execute_성공() { | |
| // given | |
| BusinessType businessType = mock(BusinessType.class); | |
| given(businessType.isRequiresDetail()).willReturn(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(businessTypeQueryRepository.existsWorkspaceUsingBusinessType(1L)).willReturn(false); | |
| given(businessTypeQueryRepository.existsWorkspaceRequestUsingBusinessType(1L)).willReturn(false); | |
| // when | |
| adminDeleteBusinessType.execute(1L); | |
| // then | |
| then(businessTypeRepository).should().delete(businessType); | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/test/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessTypeTests.java`
around lines 25 - 115, Organize the tests in AdminDeleteBusinessTypeTests into
`@Nested` classes with `@DisplayName`, separating the four exception scenarios from
the successful deletion scenario. Keep each existing test’s setup, assertions,
and behavior unchanged while placing failure cases under one clearly named group
and execute_성공 under a success group.
Source: Path instructions
|
|
||
| @Test | ||
| @DisplayName("존재하지 않는 업종 ID 면 ILLEGAL_ARGUMENT 예외가 발생하고 신청을 저장하지 않는다") | ||
| void execute_존재하지않는업종_예외() { | ||
| // given | ||
| User user = mock(User.class); | ||
| CreateWorkspaceRequestDto dto = baseRequest(); | ||
| dto.setBusinessTypeId(99L); | ||
| given(businessTypeRepository.findById(99L)).willReturn(Optional.empty()); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | ||
| then(workspaceRequestRepository).should(never()).save(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("'기타' 업종인데 상세 입력이 없으면 ILLEGAL_ARGUMENT 예외가 발생한다") | ||
| void execute_기타_상세없음_예외() { | ||
| // given | ||
| User user = mock(User.class); | ||
| CreateWorkspaceRequestDto dto = baseRequest(); | ||
| dto.setBusinessTypeDetail(" "); | ||
| BusinessType businessType = businessType(true); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | ||
|
|
||
| // when & then | ||
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | ||
| .isInstanceOf(CustomException.class) | ||
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | ||
| then(workspaceRequestRepository).should(never()).save(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("'기타' 업종이면 상세 입력을 trim 하여 신청에 저장한다") | ||
| void execute_기타_상세있음_저장() { | ||
| // given | ||
| User user = mock(User.class); | ||
| given(user.getId()).willReturn(100L); | ||
| BusinessType etc = businessType(true); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | ||
| given(workspaceRequestRepository.save(any())).willReturn(1L); | ||
|
|
||
| CreateWorkspaceRequestDto dto = baseRequest(); | ||
| dto.setBusinessTypeDetail(" 떡볶이 전문점 "); | ||
|
|
||
| // when | ||
| createWorkspaceRequest.execute(user, dto); | ||
|
|
||
| // then | ||
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | ||
| assertThat(workspaceRequestCaptor.getValue().getBusinessType()).isEqualTo(etc); | ||
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isEqualTo("떡볶이 전문점"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("'기타'가 아닌 업종이면 상세 입력이 들어와도 무시(null)한다") | ||
| void execute_비기타_상세무시() { | ||
| // given | ||
| User user = mock(User.class); | ||
| given(user.getId()).willReturn(100L); | ||
| BusinessType businessType = businessType(false); | ||
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | ||
| given(workspaceRequestRepository.save(any())).willReturn(1L); | ||
|
|
||
| CreateWorkspaceRequestDto dto = baseRequest(); | ||
| dto.setBusinessTypeDetail("무시되어야 함"); | ||
|
|
||
| // when | ||
| createWorkspaceRequest.execute(user, dto); | ||
|
|
||
| // then | ||
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | ||
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isNull(); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
신규 업종 관련 테스트를 @Nested + @DisplayName으로 그룹화 권장.
execute_존재하지않는업종_예외, execute_기타_상세없음_예외, execute_기타_상세있음_저장, execute_비기타_상세무시 4개 테스트가 모두 "업종(BusinessType) 검증"이라는 공통 관심사를 다루지만, 최상위 레벨에 나열되어 있습니다. @Nested 클래스로 묶으면 테스트 의도가 더 명확해집니다.
Given-When-Then, BDD 스타일 Mockito, AssertJ 사용 등 나머지 규칙은 잘 준수되었습니다.
As per path instructions, src/test/**: "@Nested 클래스로 관련 테스트를 그룹화하고 @DisplayName을 사용해야 합니다."
♻️ 그룹화 예시
+ `@Nested`
+ `@DisplayName`("업종(BusinessType) 검증")
+ class BusinessTypeValidation {
+
- `@Test`
+ `@Test`
`@DisplayName`("존재하지 않는 업종 ID 면 ILLEGAL_ARGUMENT 예외가 발생하고 신청을 저장하지 않는다")
- void execute_존재하지않는업종_예외() {
+ void execute_존재하지않는업종_예외() {
...
- }
+ }
// ... 나머지 3개 테스트도 동일하게 이동
+ }📝 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.
| @Test | |
| @DisplayName("존재하지 않는 업종 ID 면 ILLEGAL_ARGUMENT 예외가 발생하고 신청을 저장하지 않는다") | |
| void execute_존재하지않는업종_예외() { | |
| // given | |
| User user = mock(User.class); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeId(99L); | |
| given(businessTypeRepository.findById(99L)).willReturn(Optional.empty()); | |
| // when & then | |
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | |
| then(workspaceRequestRepository).should(never()).save(any()); | |
| } | |
| @Test | |
| @DisplayName("'기타' 업종인데 상세 입력이 없으면 ILLEGAL_ARGUMENT 예외가 발생한다") | |
| void execute_기타_상세없음_예외() { | |
| // given | |
| User user = mock(User.class); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail(" "); | |
| BusinessType businessType = businessType(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| // when & then | |
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | |
| then(workspaceRequestRepository).should(never()).save(any()); | |
| } | |
| @Test | |
| @DisplayName("'기타' 업종이면 상세 입력을 trim 하여 신청에 저장한다") | |
| void execute_기타_상세있음_저장() { | |
| // given | |
| User user = mock(User.class); | |
| given(user.getId()).willReturn(100L); | |
| BusinessType etc = businessType(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | |
| given(workspaceRequestRepository.save(any())).willReturn(1L); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail(" 떡볶이 전문점 "); | |
| // when | |
| createWorkspaceRequest.execute(user, dto); | |
| // then | |
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessType()).isEqualTo(etc); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isEqualTo("떡볶이 전문점"); | |
| } | |
| @Test | |
| @DisplayName("'기타'가 아닌 업종이면 상세 입력이 들어와도 무시(null)한다") | |
| void execute_비기타_상세무시() { | |
| // given | |
| User user = mock(User.class); | |
| given(user.getId()).willReturn(100L); | |
| BusinessType businessType = businessType(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(workspaceRequestRepository.save(any())).willReturn(1L); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail("무시되어야 함"); | |
| // when | |
| createWorkspaceRequest.execute(user, dto); | |
| // then | |
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isNull(); | |
| } | |
| `@Nested` | |
| `@DisplayName`("업종(BusinessType) 검증") | |
| class BusinessTypeValidation { | |
| `@Test` | |
| `@DisplayName`("존재하지 않는 업종 ID 면 ILLEGAL_ARGUMENT 예외가 발생하고 신청을 저장하지 않는다") | |
| void execute_존재하지않는업종_예외() { | |
| // given | |
| User user = mock(User.class); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeId(99L); | |
| given(businessTypeRepository.findById(99L)).willReturn(Optional.empty()); | |
| // when & then | |
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | |
| then(workspaceRequestRepository).should(never()).save(any()); | |
| } | |
| `@Test` | |
| `@DisplayName`("'기타' 업종인데 상세 입력이 없으면 ILLEGAL_ARGUMENT 예외가 발생한다") | |
| void execute_기타_상세없음_예외() { | |
| // given | |
| User user = mock(User.class); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail(" "); | |
| BusinessType businessType = businessType(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| // when & then | |
| assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) | |
| .isInstanceOf(CustomException.class) | |
| .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); | |
| then(workspaceRequestRepository).should(never()).save(any()); | |
| } | |
| `@Test` | |
| `@DisplayName`("'기타' 업종이면 상세 입력을 trim 하여 신청에 저장한다") | |
| void execute_기타_상세있음_저장() { | |
| // given | |
| User user = mock(User.class); | |
| given(user.getId()).willReturn(100L); | |
| BusinessType etc = businessType(true); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(etc)); | |
| given(workspaceRequestRepository.save(any())).willReturn(1L); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail(" 떡볶이 전문점 "); | |
| // when | |
| createWorkspaceRequest.execute(user, dto); | |
| // then | |
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessType()).isEqualTo(etc); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isEqualTo("떡볶이 전문점"); | |
| } | |
| `@Test` | |
| `@DisplayName`("'기타'가 아닌 업종이면 상세 입력이 들어와도 무시(null)한다") | |
| void execute_비기타_상세무시() { | |
| // given | |
| User user = mock(User.class); | |
| given(user.getId()).willReturn(100L); | |
| BusinessType businessType = businessType(false); | |
| given(businessTypeRepository.findById(1L)).willReturn(Optional.of(businessType)); | |
| given(workspaceRequestRepository.save(any())).willReturn(1L); | |
| CreateWorkspaceRequestDto dto = baseRequest(); | |
| dto.setBusinessTypeDetail("무시되어야 함"); | |
| // when | |
| createWorkspaceRequest.execute(user, dto); | |
| // then | |
| then(workspaceRequestRepository).should().save(workspaceRequestCaptor.capture()); | |
| assertThat(workspaceRequestCaptor.getValue().getBusinessTypeDetail()).isNull(); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java`
around lines 166 - 241, Group the four BusinessType-related
tests—execute_존재하지않는업종_예외, execute_기타_상세없음_예외, execute_기타_상세있음_저장, and
execute_비기타_상세무시—inside a dedicated `@Nested` test class with a descriptive
`@DisplayName`. Preserve each test’s existing setup, assertions, and behavior.
Source: Path instructions
변경사항
[BE] 업장 업종 관리 시스템 재설계
업종(業種)은 업장(Workspace) 등록 시 확정되는 속성인데, 기존에는 free-text로 저장되고 사실상 PostingKeyword(공고 키워드)가 그 역할을 대행하고 있었습니다. 업종을 업장의 마스터 FK로 재설계하고 PostingKeyword를 폐기합니다.
/admin/business-types, '기타'·사용중 업종 삭제 방지)businessTypeIds) 및 filter-options facet 추가Breaking (프론트 협의 필요)
type→businessTypeId+businessTypeDetailkeywords제거GET /manager/postings/available-keywords제거Summary by CodeRabbit
새 기능
변경 사항
버그 수정