← 개발 로그 목록

website: 프로젝트 이미지·참여자 수정을 "통째 교체"에서 하위 리소스 개별 추가·삭제로 재설계

/ 13분 분량 / 개발 로그

프로젝트 쇼케이스 기능에서 이미지와 참여자를 PATCH 한 방에 통째로 갈아끼우던 방식을, POST/DELETE로 하나씩 추가·삭제하는 방식으로 다시 짰다. 리뷰 중 나온 "부분 추가·삭제가 개별적으로 가능해야 한다"는 요구를 반영한 것이고, 그 과정에서 죽어있던 스토리지 삭제 코드도 같이 연결했다.

요약

2026년 7월 16일, feat/119-project-images 브랜치에서 작업한 내용이다. 커밋 메시지 제목 그대로 "이미지·참여자 부분 추가/삭제 API로 재설계 + 스토리지 정리 연결"이 이번 변경의 핵심이다.

  • POST/DELETE /api/projects/{id}/images{,/{imageId}}
  • POST/DELETE /api/projects/{id}/participants{,/{participantId}}

이 4개 엔드포인트를 새로 만들고, 기존 ProjectUpdateRequest에서 images/participants 필드를 아예 걷어냈다. 프론트엔드가 아직 이 API에 붙지 않은 상태라 흔적 없이 갈아엎을 수 있었다. 총 15개 파일이 바뀌었고 618줄 추가, 120줄 삭제로 이번 커밋치고는 꽤 큰 규모다.

배경 및 목적

원래 설계는 Member.roles가 쓰던 관례를 그대로 따와서, 이미지·참여자 목록을 PATCH 본문에 넘기면 전체를 지우고 다시 저장하는 방식이었다. 커밋 메시지에 적힌 이유를 그대로 옮기면:

리뷰 중 "부분 추가·삭제가 개별적으로 가능해야 한다"는 요구로 이미지·참여자 수정 방식을 "PATCH로 통째 교체"에서 하위 리소스 개별 추가·삭제로 다시 설계한다.

docs/project-showcase-module.md에 남긴 기록을 보면, 이 요구는 PM 역할을 맡은 팀원이 명시적으로 낸 것이었다. 통째 교체 방식은 구현은 간단하지만, 클라이언트 입장에서 이미지 하나 지우려고 전체 목록을 다시 조립해서 보내야 하는 게 번거롭다. 특히 참여자가 여러 명인 프로젝트에서 한 명만 빼고 싶을 때, 나머지 목록을 전부 다시 구성해서 보내야 하는 건 API 설계로서 불친절하다.

구현 내용

변경된 파일

백엔드 쪽 컨트롤러·서비스·엔티티·리포지토리·DTO 전반과 테스트, 문서까지 포함해서 15개 파일이 손을 탔다.

  • ProjectController.java, ProjectService.java — 새 엔드포인트/메서드 4개
  • ProjectImage.java, ProjectImageRepository.java, ProjectParticipantRepository.java — 조회·삭제용 메서드 추가
  • ProjectImageResponse.java, ProjectParticipantResponse.java — 행 id 노출
  • ProjectUpdateRequest.java — images/participants 필드 제거
  • OciStorageService.java — deleteByUrl() 신설
  • ProjectControllerTest.java, OciStorageServiceTest.java — 테스트 대거 추가
  • STATE_SPACE_QA.md, project-showcase-module.md — 설계 결정 문서 갱신
  • shared/types/project.ts, backend/build.gradle

새 엔드포인트와 서비스 메서드

컨트롤러에는 4개 메서드가 추가됐다. 전부 @PreAuthorize("hasRole('MEMBER')")로 막혀 있고, 이미지 추가/참여자 추가는 성공 시 201 Created를 응답한다.

@PostMapping("/api/projects/{id}/images")
public ResponseEntity<ProjectDetailResponse> addImage(
        @PathVariable Long id,
        @Valid @RequestBody ProjectImageRequest request,
        Authentication authentication) {
    AdminPrincipal member = (AdminPrincipal) authentication.getPrincipal();
    return ResponseEntity.status(HttpStatus.CREATED).body(projectService.addImage(id, member.getId(), request));
}

서비스 레이어에서 눈에 띄는 부분은 대표 이미지 처리 로직이다. 새 이미지를 representative=true로 추가하면 기존 대표를 자동으로 해제한다.

@Transactional
public ProjectDetailResponse addImage(Long projectId, Long memberId, ProjectImageRequest request) {
    Project project = findProjectOrThrow(projectId);
    requireParticipant(projectId, memberId);

    if (request.isRepresentative()) {
        projectImageRepository.findAllByProjectIdOrderByIdAsc(projectId)
                .forEach(image -> image.setRepresentative(false));
    }
    projectImageRepository.save(ProjectImage.create(project, request.getUrl(), request.isRepresentative()));

    return toDetailResponse(project);
}

반대로 대표 이미지를 지우는 경우엔 막지 않는다. "대표 없음"을 허용된 상태로 두고, 자동 승격도 하지 않는다. 다음 대표는 멤버가 다시 명시적으로 지정해야 한다. 이 결정은 프론트가 "먼저 기존 대표 해제 → 새로 추가" 2단계를 안 밟아도 되게 하려는 의도와 짝을 이룬다.

참여자 쪽은 삭제 권한이 특징적이다. 참여자면 누구든(본인 포함) 다른 참여자를 뺄 수 있고, 다만 최소 1명은 항상 남아야 한다.

@Transactional
public ProjectDetailResponse removeParticipant(Long projectId, Long memberId, Long participantId) {
    Project project = findProjectOrThrow(projectId);
    requireParticipant(projectId, memberId);

    ProjectParticipant participant = projectParticipantRepository.findByIdAndProjectId(participantId, projectId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "참여자를 찾을 수 없어요."));
    if (projectParticipantRepository.countByProjectId(projectId) <= 1) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "참여 멤버는 최소 1명 있어야 해요.");
    }
    projectParticipantRepository.delete(participant);

    return toDetailResponse(project);
}

구현하다가 발견한 것들

커밋 메시지에 "구현 중 발견해서 같이 고친 것"이라고 따로 적어둔 세 가지가 있다.

1. 응답 DTO에 행 id가 없었다. ProjectImageResponse/ProjectParticipantResponse에 id 필드가 없어서 DELETE 대상을 특정할 방법이 없었다. 4개 엔드포인트를 다 만들고 나서 테스트 헬퍼를 짜다가 "클라이언트가 이 id를 어디서 받지?"라는 질문에 부딪혀 뒤늦게 깨달았다고 한다. 두 응답에 id를 추가해서 해결했다.

2. OCI 버킷 파일이 실제로 안 지워지고 있었다. OciStorageService.delete()는 이미 존재했지만 어디서도 호출되지 않는 죽은 코드였다. 이미지를 지워도 DB 행만 사라지고 실제 파일은 버킷에 영구히 남는 상태였던 것이다. deleteByUrl()을 새로 추가해 removeImage와 프로젝트 delete에 연결했다.

ProjectImage image = projectImageRepository.findByIdAndProjectId(imageId, projectId)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없어요."));
projectImageRepository.delete(image);
storageService.deleteByUrl(image.getUrl());

스토리지 삭제가 실패해도(네트워크 오류 등) 예외를 삼키고 로그만 남기도록 했다. 부가 작업 실패가 원래 하려던 DB 삭제까지 막아버리면 안 된다는 판단에서다.

3. jacoco 플러그인을 추가했다. 이 프로젝트는 STATE_SPACE_QA.md라는 문서로 "상태공간트리"를 그려가며 테스트 커버리지를 사람이 직접 점검하는 방식을 쓰고 있는데, 이번에 코드 커버리지 도구(JaCoCo)로 교차검증을 붙였다. 문서에 이런 설명이 있다.

이 트리는 코드를 읽고 축·가지치기를 뽑은 것이라, 코드를 잘못 읽었으면 트리도 같이 틀린다... 그래서 트리만으론 부족하고, jacocoTestReport로 별도 검증한다.

실제로 이 라운드에서 JaCoCo가 트리가 놓친 리프를 하나 잡아냈다고 기록돼 있다 — ProjectService가 95%(19/20 분기) 커버리지였고, 놓친 1건이 update()의 이미지 교체 성공 경로였다. "실패 케이스만 테스트하고 성공 케이스를 빠뜨리는" 패턴이었던 셈이다.

기술적 의사결정

왜 통째 교체를 버렸나

Member.roles는 여전히 "넘긴 Set으로 전체 교체" 방식을 쓴다. 이번에 이미지·참여자만 따로 하위 리소스 엔드포인트로 뺀 이유는, 컬렉션 항목 하나를 추가/삭제하는 데 전체 목록을 재구성해서 보내는 비용이 참여자·이미지처럼 개수가 늘어날 수 있는 리소스에서는 부담이 크다는 판단 때문이다. 특히 참여자 삭제 시 "빈 배열을 보내면 400"이라는 예외 규칙까지 따로 둬야 했던 옛 설계보다, "이 항목 하나를 지운다"는 의도를 그대로 표현하는 DELETE 엔드포인트가 API 사용자 입장에서 더 명확하다.

대표 이미지 자동 해제 vs 수동 관리

대표 이미지 "정확히 1장"을 강제하는 방법은 두 가지가 있었을 것이다. 하나는 매번 클라이언트가 대표 해제와 신규 대표 지정을 나눠 요청하게 하는 것, 다른 하나는 서버가 representative=true 요청을 받으면 알아서 기존 대표를 내리는 것. 여기선 후자를 택했다 — 클라이언트 쪽 왕복 횟수를 줄이는 대신, 서버가 "대표는 항상 최대 1장"이라는 불변식을 스스로 책임지는 구조다. 반면 삭제 시 자동 승격은 일부러 안 만들었다. 대표가 없어지는 걸 "고장난 상태"가 아니라 "정상적으로 있을 수 있는 상태"로 인정한 것이다.

배운 점 및 개선점

이번 작업에서 제일 인상 깊었던 부분은 응답 DTO에 id가 빠져있던 실수다. API 설계를 먼저 확정하고 구현에 들어갔는데도, "삭제할 대상을 어떻게 특정하지?"라는 질문이 뒤늦게 튀어나왔다. 설계 문서 단계에서 "클라이언트가 이 요청을 보내려면 무슨 정보를 갖고 있어야 하나"를 거꾸로 따져봤어야 했는데, 놓쳤던 것 같다.

또 하나는 죽은 코드(OciStorageService.delete)가 꽤 오래 방치돼 있었다는 점이다. 존재는 하지만 아무도 호출하지 않는 메서드는 테스트 커버리지 관점에서도, 실제 동작 관점에서도 위험 신호다. Member.photoUrl 교체 시 이전 파일을 안 지우는 문제가 이전부터 있었다는 것도 이번에 같이 확인했는데, 이번 PR 범위 밖이라 손대지 않고 다음 미션 후보로 남겨뒀다.

STATE_SPACE_QA.md 방식(사람이 상태공간트리를 그려서 커버리지를 점검)과 JaCoCo(기계적 분기 커버리지 측정)를 같이 쓰는 게 이번엔 확실히 도움이 됐다. 트리만 믿었으면 놓쳤을 리프를 도구가 잡아준 셈이다. 다음부터는 새 엔드포인트를 만들 때 실패 케이스만 먼저 넣고 성공 케이스를 빠뜨리는 습관을 경계해야겠다고 적어뒀다.

참고 자료

  • backend/src/test/STATE_SPACE_QA.md (트리 C — 프로젝트 쇼케이스)
  • backend/docs/project-showcase-module.md