Skip to content

feat: [alt-267] 업장 업종 관리 시스템 재설계#97

Open
ysw789 wants to merge 7 commits into
devfrom
feat/alt-267
Open

feat: [alt-267] 업장 업종 관리 시스템 재설계#97
ysw789 wants to merge 7 commits into
devfrom
feat/alt-267

Conversation

@ysw789

@ysw789 ysw789 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

변경사항

[BE] 업장 업종 관리 시스템 재설계

업종(業種)은 업장(Workspace) 등록 시 확정되는 속성인데, 기존에는 free-text로 저장되고 사실상 PostingKeyword(공고 키워드)가 그 역할을 대행하고 있었습니다. 업종을 업장의 마스터 FK로 재설계하고 PostingKeyword를 폐기합니다.

  • 업종(BusinessType) 마스터 테이블·엔티티 신설 및 기본 시드 추가 (Flyway V4)
  • 업장/업장 신청의 업종을 free-text에서 마스터 FK + 상세입력(businessTypeDetail)으로 전환, 기존 데이터 백필 (Flyway V5)
  • '기타' 업종 선택 시에만 상세 입력을 요구·저장 (관리자가 마스터 확충 필요성을 인지하도록)
  • 업장 등록 폼용 업종 목록 조회 API 추가 (app / manager)
  • 관리자 업종 관리 CRUD API 추가 (/admin/business-types, '기타'·사용중 업종 삭제 방지)
  • PostingKeyword 폐기 — 관련 코드 및 테이블(posting_keywords, posting_keyword_map) 제거 (Flyway V6)
  • 공고 응답에 업장 업종 노출 + 업종 기반 목록 필터(businessTypeIds) 및 filter-options facet 추가
  • fix: 공고 지원 조건 반전 버그 수정 (모집중(OPEN) 공고 지원이 차단되던 문제)

Breaking (프론트 협의 필요)

  • 업장 등록 요청 typebusinessTypeId + businessTypeDetail
  • 공고 등록/수정 요청 및 모든 공고 응답에서 keywords 제거
  • GET /manager/postings/available-keywords 제거

Summary by CodeRabbit

  • 새 기능

    • 관리자가 업종을 조회·생성·수정·삭제할 수 있습니다.
    • 업종 목록을 workspace 신청 및 공고 필터에서 확인할 수 있습니다.
    • 업종이 “기타”인 경우 상세 정보를 입력할 수 있습니다.
    • 공고 목록과 workspace 정보에 업종 및 상세 정보가 표시됩니다.
  • 변경 사항

    • 공고 생성·수정 및 조회 화면에서 키워드 정보가 제거되었습니다.
    • 공고 검색 시 여러 업종을 선택해 필터링할 수 있습니다.
  • 버그 수정

    • 모집 종료 상태의 공고에는 지원할 수 없도록 검증이 개선되었습니다.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

업종을 독립 엔티티로 도입하고 관리자 CRUD API와 워크스페이스 등록·조회 흐름을 업종 ID 및 상세 정보 기반으로 변경했습니다. 공고 필터에 업종 선택을 추가했으며, 기존 공고 키워드 모델·조회·응답 경로를 제거했습니다.

Changes

Business type management

Layer / File(s) Summary
업종 도메인 및 관리자 관리
src/main/java/com/dreamteam/alter/domain/workspace/**, src/main/java/com/dreamteam/alter/application/workspace/usecase/Admin*BusinessType.java
BusinessType 엔티티, 생성·수정 커맨드, 저장소 포트와 관리자용 목록·생성·수정·삭제 유스케이스를 추가했습니다.
업종 영속화 및 관리자 API
src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/**, src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/**
JPA/QueryDSL 저장소와 관리자 CRUD REST 엔드포인트, 요청·응답 DTO, OpenAPI 계약을 추가했습니다.
업종 마이그레이션 및 유스케이스 검증
src/main/resources/db/migration/V4__create_business_types.sql, src/test/java/com/dreamteam/alter/application/workspace/usecase/Admin*BusinessTypeTests.java
업종 테이블·시드 데이터를 추가하고, 이름 중복·사용 중인 업종·삭제 및 수정 동작을 테스트했습니다.

Workspace business type propagation

Layer / File(s) Summary
Workspace 도메인 및 등록 처리
src/main/java/com/dreamteam/alter/domain/workspace/entity/*, src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java
Workspace와 WorkspaceRequest가 BusinessType 연관 및 businessTypeDetail을 저장하도록 변경했으며, 등록 시 업종 존재 여부와 상세 입력을 검증합니다.
승인·조회 응답 및 목록 API
src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java, src/main/java/com/dreamteam/alter/adapter/outbound/workspace/**, src/main/java/com/dreamteam/alter/adapter/inbound/**/workspace/**
승인 시 업종 상세를 Workspace로 복사하고, 관리자·매니저·일반 응답에 업종과 상세 정보를 매핑했습니다. 각 등록 화면용 업종 목록 API도 추가했습니다.
스키마 변경 및 테스트
src/main/resources/db/migration/V5__migrate_workspace_business_type.sql, src/test/java/com/dreamteam/alter/application/workspace/usecase/*
기존 문자열 업종을 FK와 상세 컬럼으로 이전하고, 업종 조회·상세 정규화·승인 복사 동작을 검증했습니다.

Posting filter and keyword removal

Layer / File(s) Summary
공고 업종 필터
src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java, src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java, src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java
공고 목록 필터에 businessTypeIds를 추가하고, 필터 옵션 응답에 업종 목록을 포함했으며, QueryDSL 조회에 업종 조건과 Workspace fetch join을 적용했습니다.
공고 응답 및 생성·수정 흐름 정리
src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java, src/main/java/com/dreamteam/alter/application/posting/usecase/{CreatePosting,ManagerUpdatePosting}.java, src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/**
공고 생성·수정과 읽기 응답에서 키워드 입력·매핑을 제거하고, 응답 팩토리를 공고와 스크랩 정보 중심으로 단순화했습니다.
키워드 API·모델·테이블 제거
src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/**, src/main/java/com/dreamteam/alter/domain/posting/**, src/main/resources/db/migration/V6__drop_posting_keyword_tables.sql
키워드 목록 API, DTO, 유스케이스, 저장소 포트·구현, 매핑 엔티티와 관련 테이블을 제거했습니다.
필터 검증 테스트
src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptionsTests.java
필터 옵션 결과에 업종 목록과 지역 목록이 포함되는지 검증했습니다.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: FEAT

Suggested reviewers: hodoon, juny0955

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 업장 업종을 BusinessType FK와 businessTypeDetail 기반으로 재설계하는 변경을 잘 요약한 제목입니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/alt-267

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 333c799 and d070d70.

📒 Files selected for processing (81)
  • src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/controller/AdminBusinessTypeController.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/controller/AdminBusinessTypeControllerSpec.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminBusinessTypeRequestDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminBusinessTypeResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/admin/workspace/dto/AdminWorkspaceRequestResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/BusinessTypeResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/ManagerPostingListWorkspaceResponseDto.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/PostingFilterOptionsResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingKeywordListResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListWorkspaceResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingMapListResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceRequestController.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceRequestControllerSpec.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/PostingDetailWorkspaceResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/WorkspaceRequestResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.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/ManagerPostingListResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceRequestController.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceRequestControllerSpec.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceListResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeJpaRepository.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeQueryRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessTypeRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestQueryRepositoryImpl.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/ManagerWorkspaceListResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/ManagerWorkspaceResponse.java
  • src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/readonly/WorkspaceRequestResponse.java
  • src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java
  • src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java
  • src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java
  • src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingKeywordList.java
  • src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessType.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessType.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminGetBusinessTypeList.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/AdminUpdateBusinessType.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/GetBusinessTypeList.java
  • src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java
  • src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java
  • src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeywordMap.java
  • src/main/java/com/dreamteam/alter/domain/posting/port/inbound/GetPostingKeywordListUseCase.java
  • src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java
  • src/main/java/com/dreamteam/alter/domain/workspace/command/AdminCreateBusinessTypeCommand.java
  • src/main/java/com/dreamteam/alter/domain/workspace/command/AdminUpdateBusinessTypeCommand.java
  • src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessType.java
  • src/main/java/com/dreamteam/alter/domain/workspace/entity/Workspace.java
  • src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequest.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminCreateBusinessTypeUseCase.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminDeleteBusinessTypeUseCase.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminGetBusinessTypeListUseCase.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AdminUpdateBusinessTypeUseCase.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetBusinessTypeListUseCase.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessTypeQueryRepository.java
  • src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessTypeRepository.java
  • src/main/resources/db/migration/V4__create_business_types.sql
  • src/main/resources/db/migration/V5__migrate_workspace_business_type.sql
  • src/main/resources/db/migration/V6__drop_posting_keyword_tables.sql
  • src/test/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptionsTests.java
  • src/test/java/com/dreamteam/alter/application/workspace/usecase/AdminCreateBusinessTypeTests.java
  • src/test/java/com/dreamteam/alter/application/workspace/usecase/AdminDeleteBusinessTypeTests.java
  • src/test/java/com/dreamteam/alter/application/workspace/usecase/AdminUpdateBusinessTypeTests.java
  • src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java
  • src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java
  • src/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

Comment on lines +22 to +30
@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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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/usecase

Repository: 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/entity

Repository: 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.java

Repository: 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.

Comment on lines 40 to +51
@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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

업종 상세값 검증 로직을 도메인 계층으로 이동 고려

resolveBusinessTypeDetail이 검증하는 "'기타' 업종이면 상세 입력 필수" 규칙은 BusinessType.requiresDetail이라는 도메인 개념 자체의 불변식입니다. 지금은 application 계층의 private 헬퍼에 있어, 향후 다른 use case(예: 관리자 워크스페이스 승인/수정 흐름)에서 동일 규칙이 필요해지면 로직이 중복될 소지가 있습니다. BusinessTyperesolveDetail(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.

Comment on lines +13 to +34
@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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +60 to +65
@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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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

Comment on lines +35 to +47
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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

Comment on lines +25 to +115
@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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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.

Suggested change
@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

Comment on lines +166 to +241

@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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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.

Suggested change
@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant