website: 프로젝트 쇼케이스 — 목록·상세 API + 계약 (#119)
부원이 참여한 프로젝트를 소개하는 프로젝트 쇼케이스 페이지의 백엔드를 만든 PR로, 목록·상세 조회부터 등록·수정·삭제, 관리자 숨김 기능까지 구현하고 상태공간트리 기반 QA로 여러 개의 숨은 버그를 잡아낸 뒤 병합됐습니다.
요약
이 PR은 멋사 경희대 사이트에서 "부원들이 실제로 뭘 만들었는지" 보여주는 프로젝트 쇼케이스 페이지의 백엔드를 처음부터 구현한 작업입니다. 목록·상세 API, 등록·수정·삭제 API, 관리자 숨김 API, 그리고 프론트와 합의할 shared/types/project.ts 계약까지 한 번에 만들었습니다. 2026-07-16에 시작해서 다음날인 2026-07-17에 feat/117-member-auth 브랜치를 베이스로 병합됐습니다.
원래는 백엔드 팀의 일이었지만, 담당자들이 다른 미션으로 여유가 없어서 인프라를 맡고 있던 작성자(찬욱)가 대신 맡아 진행했습니다.
배경 및 목적
이슈 #119를 닫는 PR입니다. 프로젝트 쇼케이스는 "멋사가 진짜 뭔가 만드는 동아리"라는 걸 보여주는 페이지라, 이 부분이 채워져야 사이트가 완성돼 보인다는 문제의식에서 출발했습니다. 지금까지는 스펙만 있고 실제 구현은 하나도 없는 상태였습니다.
한 가지 중요한 전제 조건이 있었는데, 프로젝트 등록·수정·삭제가 전부 "로그인한 멤버"를 전제로 한다는 점입니다. 그런데 #117(멤버 학번 로그인)이 머지되기 전에는 멤버가 로그인할 방법 자체가 없었기 때문에, 이 브랜치는 feat/117-member-auth 위에서 작업해야 했고, 머지 순서도 #117 → #119 순으로 고정됐습니다. 또한 미션 발주 시점부터 신선우님 또는 안시현님의 리뷰 승인 없이는 머지하지 않는다는 조건이 걸려 있었습니다.
구현 내용
API 구성
- 공개 목록·상세:
GET /api/projects(대표 이미지·제목·한줄소개·기수·스택),GET /api/projects/{id}(이미지 전부·참여 멤버·기수·개발기간·기술스택·GitHub 링크). 숨김 처리된 프로젝트는 둘 다에서 제외됩니다. - 등록:
POST /api/projects. 로그인한 멤버(hasRole('MEMBER'))만 가능하고, 요청 본문의 참여자 목록에 본인이 반드시 포함돼야 합니다(안 그러면 400). - 수정·삭제:
PATCH/DELETE /api/projects/{id}. 참여한 프로젝트만 건드릴 수 있고(비참여자는 403), 만든 사람만이 아니라 참여자면 누구든 가능합니다. - 관리자 숨김:
PATCH /api/admin/projects/{id}/hidden(hasAnyRole('ADMIN','SUPER_ADMIN')). 숨김/복원 양방향 지원. shared/types/project.ts: 프론트가 바로 화면을 만들 수 있게 요청·응답 타입을feed.ts·member.ts와 같은 스타일로 정리했습니다.
ProjectService를 보면 이 규칙들이 코드로 어떻게 표현되는지 잘 드러납니다.
@Transactional
public ProjectDetailResponse create(ProjectCreateRequest request, Long creatorMemberId) {
requireSelfAmongParticipants(request.getParticipants(), creatorMemberId);
requireNoDuplicateParticipants(request.getParticipants());
requireAtMostOneRepresentative(request.getImages());
...
}
private void requireParticipant(Long projectId, Long memberId) {
if (!projectParticipantRepository.existsByProjectIdAndMemberId(projectId, memberId)) {
throw new NotProjectParticipantException();
}
}
Project 엔티티에는 "만든 사람(creator)" 같은 필드가 아예 없습니다. 오직 참여자 집합만 존재하고, 수정·삭제 권한은 이 집합에 속해 있는지만으로 판단합니다.
코드리뷰 중 발견해서 함께 고친 문제들
원본 구현엔 없던 문제들인데, #117의 MemberPasswordGuardFilter와 이 PR의 상호작용을 상태공간트리 기반 QA로 검토하다가 발견해서 같이 반영했습니다.
비밀번호 미변경 멤버가 쓰기 API를 통과할 수 있었던 문제 —
MemberPasswordGuardFilter가/api/member/네임스페이스만 검사하는 구조였는데,/api/projects는 그 밖에 있어서 전혀 걸리지 않았습니다. 앞으로 멤버 전용 API가 늘어날수록 같은 구멍이 반복될 걸 예상해서, 가드 기준을 "경로 목록"이 아니라 "쓰기 메서드(POST/PUT/PATCH/DELETE)인가"로 일반화했습니다.update()에서 참여자 목록을 교체하며 본인을 뺄 수 있었던 문제 —create()에는 본인 포함 검증이 있는데update()에는 없었습니다. 자기가 만든 프로젝트라도 본인을 목록에서 빼버리면 다음부터 스스로 못 고치게 되는 상황이 생길 수 있었습니다. 같은 검증을update()에도 추가했습니다.
if (request.getParticipants() != null) {
if (request.getParticipants().isEmpty()) {
throw new EmptyParticipantsException();
}
// 참여자 목록을 통째로 교체할 때 요청자 본인을 빼면, 자기가 수정한 프로젝트인데도
// 다음부턴 requireParticipant()에 걸려 스스로 못 고치는 상태가 된다
requireSelfAmongParticipants(request.getParticipants(), memberId);
requireNoDuplicateParticipants(request.getParticipants());
...
}
참여자 중복 등록을 막는 로직이 없었던 문제 — 같은 memberId를 두 번 넣으면
ProjectParticipant행이 그대로 중복 저장됐습니다. 요청 단계에서 검증을 추가했습니다.공동 소유 상태가 테스트에 한 번도 없었던 문제 — 모든 테스트 픽스처가 참여자 1명(만든 사람 본인)짜리 프로젝트만 써서, "참여자 집합에 있는가" 체크가 실수로 "첫 참여자인가"였어도 안 드러나는 상태였습니다. 실제 버그는 아니었지만, 이 축 자체가 테스트에서 빠져 있었다는 걸 발견해서 채웠습니다.
데이터 격리 검증 — 프로젝트 A를 삭제·수정해도 B의 이미지·참여자가 안 새는지 별도로 확인하는 테스트를 추가했습니다.
변경 파일
핵심 도메인 코드(Project, ProjectController, ProjectService, 이미지·참여자 엔티티/리포지토리, DTO들, 예외 클래스 7종)와 SecurityConfig, GlobalExceptionHandler, shared/types/project.ts, 그리고 테스트(ProjectControllerTest, STATE_SPACE_QA.md)까지 총 32개 파일이 바뀌었습니다. 새로 짜인 코드가 대부분이라 추가 1715줄, 삭제 1줄로 규모가 컸습니다.
기술적 의사결정
소유권 판단 기준: "만든 사람"이 아니라 "참여자 집합"
수정·삭제 권한을 설계할 때 "만든 사람 vs 나중에 추가된 참여자"로 나눌 수도 있었지만, 실제 코드(requireParticipant() = existsByProjectIdAndMemberId)에는 "만든 사람"이라는 개념 자체가 없습니다. Project 엔티티에 creator 필드를 두지 않고 참여자 집합만으로 판단하게 만들어서, 참여자라면 누구든 동등하게 프로젝트를 관리할 수 있도록 했습니다. 다만 QA 트리를 짤 때는 sole(단독 소유)과 shared(공동 소유)을 구분해서, "집합 멤버십 체크를 실수로 첫 참여자 여부로 잘못 짰다면 sole에서는 우연히 통과하고 shared에서만 드러난다"는 점을 놓치지 않으려 했습니다.
에러 응답 형식 통일
pm/missions/119-project-showcase-be/qa-blackbox-e2e.md에 기록된 블랙박스 QA(구현 코드를 미리 읽지 않고 스펙과 계약 파일만으로 시나리오를 짜서 실제 서버에 HTTP 요청을 날려보는 방식)에서, 프로젝트 도메인의 비즈니스 규칙 위반(자기제외·중복참여자·대표이미지 개수 등)이 ResponseStatusException을 그대로 던져 code 필드 없는 Spring 기본 에러 바디로 나가는 걸 발견했습니다. admin.ts·member-auth.ts가 정의한 {success:false, message, code} 형태와 어긋나는 상태였습니다. 전용 예외 7종(SelfNotIncludedException, DuplicateParticipantException 등)을 추가하고 GlobalExceptionHandler가 기존 도메인과 같은 형태로 응답하도록 맞췄습니다.
이 QA 과정에서 "참여자 memberId가 존재하지 않으면 400이 아니라 404를 반환한다"는 부분이 갭처럼 보였는데, 확인해보니 이미 ProjectControllerTest#create_NonExistentParticipantMemberId_Returns404로 의도적으로 박아둔 팀의 결정이었습니다. 그래서 상태코드는 그대로 유지하고 code:"PARTICIPANT_NOT_FOUND"만 추가해 응답 형태만 통일하는 선에서 정리했습니다.
이미지 업로드는 별도 API로 만들지 않음
이미지는 이 PR에서 새 업로드 코드를 만들지 않고, 기존 POST /api/feed/images(OCI 업로드)로 먼저 올려서 받은 URL을 그대로 저장하는 방식을 택했습니다. Member.photoUrl과 같은 패턴입니다.
배운 점 및 개선점
커밋 흐름을 보면 초기 구현 이후 두 번의 후속 수정이 있었습니다. 먼저 참여자 중복·자기제외 검증과 공동소유·격리 불변식 테스트를 보강했고, 그다음 블랙박스 QA로 에러 응답 형태를 다른 도메인과 통일했습니다. 처음엔 기능이 동작하는 것까지만 확인하기 쉬운데, "테스트가 커버하지 않는 축이 있다는 것 자체가 문제"라는 관점으로 한 번 더 들여다보니 공동 소유 케이스처럼 실제 버그는 아니어도 검증되지 않은 영역이 드러났습니다.
또한 MemberPasswordGuardFilter처럼 한 기능(#117)을 위해 만든 규칙이 다음 기능(#119)에서 뚫리는 패턴이 반복됐습니다. STATE_SPACE_QA.md에도 "트리 A→B→C가 전부 한 기능만 보고 설계한 규칙이 다음 기능에서 뚫렸다는 패턴을 반복했다"는 메모를 남겨서, 앞으로 멤버 전용 API가 추가될 때마다 이 지점을 우선적으로 확인하도록 해뒀습니다.
이후 다룰 것으로는 이미지·참여자를 등록 이후에 개별 추가·삭제하는 API(feat/119-project-images 브랜치로 분리), 상세 설명·발표자료 필드, Member.photoUrl 교체 시 이전 파일이 OCI에서 안 지워지는 기존 문제(#87부터 있던 것)가 남아 있습니다.