Skip to content
Closed
214 changes: 214 additions & 0 deletions Gathering_be/docs/1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# **'관심 프로젝트' 기능 백엔드 구현**

## 📚 목차

1. [초기 문제 상황](#1_기능_개요)
2. [데이터 모델-entity](#2_데이터_모델_entity)
3. [데이터 접근 계층-repository](#3_데이터_접근_계층-repository)
4. [서비스 계층-business logic](#4_서비스_계층-business_logic)
5. [api 계층-controller](#5_api_계층-controller)
6. [다른 서비스와의 연동 및 활용](#6_다른_서비스와의_연동_및_활용)

---

## **1. 기능 개요**

'관심 프로젝트'는 사용자가 흥미로운 프로젝트를 저장(찜하기)하고, 나중에 쉽게 찾아볼 수 있도록 하는 기능이다. 핵심 기능은 다음과 같다.

- **관심 등록/해제 (Toggle):** 사용자는 버튼 클릭 한 번으로 특정 프로젝트를 관심 목록에 추가하거나 제거할 수 있다.
- **관심 목록 조회:** 사용자는 자신이 관심 등록한 모든 프로젝트의 목록을 조회할 수 있다.
- **상태 표시:** 프로젝트 목록을 볼 때, 각 프로젝트에 대해 현재 사용자의 관심 등록 여부(`isInterested`)가 함께 표시된다.

---

## **2. 데이터 모델 (Entity)**

사용자(`Profile`)와 프로젝트(`Project`) 간의 **다대다(Many-to-Many)** 관계를 표현하기 위해, `InterestProject`라는 별도의 조인 테이블 엔티티를 설계했다.

- **핵심 역할:** 어떤 `Profile`이 어떤 `Project`에 관심을 가졌는지 기록한다.
- **구조:** `profile_id`, `project_id`를 외래 키로 가지며, 두 ID의 조합이 하나의 관심 관계를 나타낸다.

```java
// InterestProject.java
package com.Gathering_be.domain;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor
@Getter
public class InterestProject {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private Profile profile;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;

@Builder
public InterestProject(Long id, Profile profile, Project project) {
this.id = id;
this.profile = profile;
this.project = project;
}
}
```

---

## **3. 데이터 접근 계층 (Repository)**

Spring Data JPA를 사용하여 데이터베이스와의 상호작용을 처리한다. `profileId`와 `projectId`를 조합하여 효율적으로 데이터를 조회, 확인, 삭제하는 쿼리 메서드를 정의했다.

- **주요 메서드:**
- `existsByProfileIdAndProjectId(...)`: 특정 사용자가 특정 프로젝트에 이미 관심을 등록했는지 `boolean` 값으로 빠르게 확인한다. (토글 로직의 핵심)
- `deleteByProfileIdAndProjectId(...)`: 특정 관심 등록 기록을 효율적으로 삭제한다.
- `findAllByProfileId(...)`: 특정 사용자의 모든 관심 프로젝트 ID를 조회하여, 다른 서비스에서 `isInterested` 상태를 확인하는 데 사용된다.

```java
// InterestProjectRepository.java
package com.Gathering_be.repository;

import com.Gathering_be.domain.InterestProject;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface InterestProjectRepository extends JpaRepository<InterestProject, Long> {
List<InterestProject> findByProfileNickname(String nickname);
boolean existsByProfileIdAndProjectId(Long profileId, Long projectId);
void deleteByProfileIdAndProjectId(Long profileId, Long projectId);
List<InterestProject> findAllByProfileId(Long profileId);
}
```

---

## **4. 서비스 계층 (Business Logic)**

핵심 비즈니스 로직은 `InterestProjectService`에서 처리한다. 모든 메서드는 요청을 보낸 사용자가 해당 리소스에 접근할 권한이 있는지 `validateMemberAccess`를 통해 철저히 검증한다.

- **`toggleInterestProject` (관심 등록/해제):**
1. `existsByProfileIdAndProjectId`를 통해 현재 관심 등록 상태를 확인한다.
2. **만약 이미 존재한다면,** 새로운 `InterestProject` 엔티티를 생성하여 저장하고 `true`를 반환한다.
3. 이 '토글(Toggle)' 방식을 통해 하나의 API로 두 가지 기능을 모두 처리하여 효율성을 높였다.
- **`getInterestProjects` (관심 목록 조회):**

1. `findByProfileNickname`을 통해 특정 사용자의 모든 관심 프로젝트 목록을 조회하여 DTO로 변환 후 반환한다.

```java
// InterestProjectService.java
package com.Gathering_be.service;

import com.Gathering_be.domain.InterestProject;
// ... (기타 import)

@Service
@RequiredArgsConstructor
public class InterestProjectService {
private final ProfileRepository profileRepository;
private final ProjectRepository projectRepository;
private final InterestProjectRepository interestProjectRepository;

@Transactional
public boolean toggleInterestProject(InterestProjectRequest request) {
// ... (로그인한 사용자 정보 및 권한 확인) ...

boolean exists = interestProjectRepository
.existsByProfileIdAndProjectId(profileId, projectId);

if (exists) {
interestProjectRepository
.deleteByProfileIdAndProjectId(profileId, projectId);
return false; // 관심 해제됨
} else {
InterestProject interestProject = InterestProject.builder()
.profile(profile)
.project(getProjectById(projectId))
.build();
interestProjectRepository.save(interestProject);
return true; // 관심 등록됨
}
}
// ... (getInterestProjects 및 private 헬퍼 메서드) ...
}


```

---

## **5. API 계층 (Controller)**

`InterestProjectController`는 외부 요청을 받아 `InterestProjectService`의 로직을 호출하는 창구 역할을 한다.

- **`POST /api/project/interest`:** `projectId`를 받아 `toggleInterestProject`서비스를 호출하고, 변경된 관심 상태(`true`/`false`)를 반환한다.
- **`GET /api/project/interest/{nickname}`:** `nickname`을 받아 `getInterestProjects` 서비스를 호출하고, 해당 사용자의 관심 프로젝트 목록을 반환한다.

```java
// InterestProjectController.java
package com.Gathering_be.controller;

import com.Gathering_be.dto.request.InterestProjectRequest;
// ... (기타 import)

@RestController
@RequestMapping("/api/project/interest")
@RequiredArgsConstructor
public class InterestProjectController {
private final InterestProjectService interestProjectService;

@PostMapping
public ResultResponse toggleInterestProject(@RequestBody InterestProjectRequest request) {
boolean isInterest = interestProjectService.toggleInterestProject(request);
return ResultResponse.of(ResultCode.INTEREST_PROJECT_TOGGLE_SUCCESS, isInterest);
}

@GetMapping("/{nickname}")
public ResultResponse getInterestProjects(@PathVariable String nickname) {
List<InterestProjectResponse> projects = interestProjectService.getInterestProjects(nickname);
return ResultResponse.of(ResultCode.INTEREST_PROJECT_GET_SUCCESS, projects);
}
}
```

---

## **6. 다른 서비스와의 연동 및 활용**

`ProjectService`나 `ApplicationService`와 같이, 프로젝트 목록을 사용자에게 보여줘야 하는 모든 곳에서는 `isInterested` 필드를 채워주기 위해 `InterestProjectRepository`를 직접 활용한다.

- **설계 결정:** 비즈니스 로직이 아닌 단순 조회를 위해 `InterestProjectService`를 거치지 않고, `InterestProjectRepository`를 직접 주입받아 사용함으로써 서비스 간의 불필요한 결합도를 낮추고 성능을 최적화했다.
- **구현 패턴:**

1. 현재 로그인한 사용자의 `Profile ID`를 가져온다.
2. `interestProjectRepository.findAllByProfileId()`를 호출하여 해당 사용자가 관심 등록한 모든 프로젝트의 ID를 Set<Long> 형태로 미리 준비한다.
3. 사용자에게 보여줄 프로젝틑 목록을 순회하면서, 각 프로젝트의 ID가 위에서 만든 `Set`에 포함되어 있는지 확인하여 `isInterested` 값을 `true` 또는 `false`로 설정한다.

```java
// ProjectService.java (활용 예시)
public Page<ProjectSimpleResponse> searchProjectsWithFilters(...) {
// ...
// 1 & 2. 현재 사용자의 모든 관심 프로젝트 ID를 Set으로 미리 준비
Set<Long> interestedProjectIds = interestProjectRepository.findAllByProfileId(...)
.stream()
.map(interest -> interest.getProject().getId())
.collect(Collectors.toSet());

// 3. 프로젝트 목록을 DTO로 변환할 때, Set에 포함되어 있는지 확인하여 isInterested 값 설정
return projectPage
.map(project -> ProjectSimpleResponse.from(project, interestedProjectIds.contains(project.getId()), null));
}


```

---
152 changes: 152 additions & 0 deletions Gathering_be/docs/2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# **포트폴리오 업로드 기능: S3 권한 문제 해결 과정**

## 📚 목차

1. [초기 문제 상황: 403 Forbidden 에러 발생](#1_초기_문제_상황_403_Forbidden_에러_발생)
2. [원인 분석 및 해결 전략 선택](#2_원인_분석_및_해결_전략_선택)
3. [1차 해결 시도 (Pre-Signed URL 구현)](<#3_1차_해결_시도_(Pre-Signed_URL_구현)>)
4. [새로운 문제 발생: NoSuchKey 에러](#4_새로운_문제_발생_NoSuchKey_에러)
5. [NoSuchKey 최종 원인 분석 및 해결 (실제 코드 기반)](<#5_NoSuchKey_최종_원인_분석_및_해결_(실제_코드_기반)>)

---

## **1. 초기 문제 상황: `403 Forbidden` 에러 발생**

**구현 내용:**

- 사용자의 프로필(`Profile`)에는 포트폴리오 정보를 담는 `Portfolio` 객체가 필드로 존재한다.
- 사용자가 포트폴리오 파일(PDF, 이미지 등)을 업로드하면, 파일은 AWS S3 버킷에 저장된다.
- S3에 저장된 파일의 URL과 원본 파일 이름은 `Portfolio` 객체에 정상적으로 저장된다.

**문제점:**

- 데이터베이스에는 URL이 올바르게 저장되었으나, `ProfileResponse`를 통해 전달받은 이 URL을 웹 브라우저에서 직접 클릭하여 접근하면, 파일이 보이는 대신 **`403 Forbidden` (접근 거부)** 에러가 발생했다.

---

## **2. 원인 분석 및 해결 전략 선택**

**원인 진단:**

- S3 버킷과 그 안의 객체(파일)는 기본적으로 **비공개(Private)**로 설정된다. 따라서 외부에서 URL을 통해 직접 접근하려는 모든 시도는 보안 정책에 의해 차단된다. 이것이 `403 Forbidden` 에러의 직접적인 원인이다.

**해결 전략:**

- **'최소 권한 원칙'**에 따라, 보안성이 월등히 뛰어난 **'Pre-Signed URL 사용'** 방식을 채택하기로 결정했다. 이는 개인 정보가 포함될 수 있는 포트폴리오 파일을 안전하게 보호하기 위한 최선의 선택이다.

---

## **3. 1차 해결 시도 (Pre-Signed URL 구현)**

선택한 전략에 따라 아래와 같이 코드를 수정했다.

1. **`S3Service` 수정:** 파일 키(key)를 받아 Pre-Signed URL을 생성하는 `getPresignedUrl(String fileKey)` 메서드를 새로 추가했다.
2. **`ProfileResponse` 수정:** `Portfolio` 객체의 정보를 DTO로 변환할 때, DB에 저장된 원본 S3 URL을 그대로 반환하는 대신, `S3Service.getPresignedUrl()`을 호출하여 **매번 새로운 Pre-Signed URL을 생성**하여 반환하도록 로직을 변경했다.

---

## **4. 새로운 문제 발생: `NoSuchKey` 에러**

1차 해결책을 적용하고 다시 테스트한 결과, `403 Forbidden` 에러는 성공적으로 해결되었다. 하지만 이제 Pre-Signed URL을 클릭하면, 브라우저에 아래와 같은 새로운 에러가 XML 형태로 표시되었다.

```xml
<Error>
<Code>NoSuchKey</Code>
<Message>The specified key does not exist.</Message>
<Key>tmp.pdf</Key>
<RequestId>...</RequestId>
<HostId>...</HostId>
</Error>
```

**새로운 문제:** S3가 "요청한 URL은 유효하지만, 그 URL이 가리키는 파일(Key)이 버킷 안에 존재하지 않습니다" 라고 응답하고 있다.

---

## **5. `NoSuchKey` 최종 원인 분석 및 해결 (실제 코드 기반)**

이 문제의 진짜 원인을 파악하기 위해, `ProfileResponse` DTO가 생성되는 과정과 `S3Service`의 관련 메서드를 함께 분석했다.

### **원인 진단: "잘못된 재료로 파일 키 요청"**

`NoSuchKey` 에러의 근본적인 원인은, S3에 파일이 유니크한 이름으로 저장된다는 점을 간과하고, **원본 파일 이름**으로 Pre-Signed URL 생성을 시도했기 때문임이 밝혀졌다.

1. **S3 저장 방식:** `S3Service`의 `uploadFile` 메서드는 `createUniqueFileName`을 통해 S3에 파일을 저장할 때 **`portfolio/uuid-원본파일이름.pdf`** 와 같이 유니크한 이름으로 저장한다.
2. **DB 저장 정보:**
- `portfolio.url`: 유니크한 이름이 포함된 **전체 URL**이 저장된다.
- `portfolio.fileName`: **원본 파일 이름**이 저장된다.
3. **초기 구현 실수:** `getPresignedUrl` 메서드는 초기에 아래와 같이 파일 키(`fileName`)를 직접 받아 URL을 생성하도록 구현되었다.

```java
// 초기 S3Service의 getPresignedUrl 메서드
public String getPresignedUrl(String fileName) {
// Presigned URL의 만료 시간 설정 (예: 1시간)
Date expiration = new Date(System.currentTimeMillis() + 1000 * 60 * 60);

// Presigned URL 생성
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucketName, fileName) // 파일 키를 직접 사용
.withExpiration(expiration);

URL url = s3Client.generatePresignedUrl(generatePresignedUrlRequest);
return url.toString();
}
```

4. **문제 발생 코드:** `ProfileResponse` DTO를 생성하는 과정에서, 위 메서드에 **`portfolio.getFileName()`**, 즉 **'원본 파일 이름'**을 전달했다.

```java
// 수정 전 ProfileResponse 생성 로직
String presignedUrl = s3Service.getPresignedUrl(portfolio.getFileName()); // 잘못된 값을 전달!
```

5. **결과:** `S3Service`는 `portfolio/원본파일이름.pdf` 라는 키로 Pre-Signed URL 생성을 시도했고, S3에는 해당 키를 가진 파일이 존재하지 않았기 때문에 `NoSuchKey` 에러가 발생한 것이다.

### **최종 해결책: "올바른 재료로 파일 키 요청하기"**

이 문제를 해결하기 위해, `ProfileResponse` DTO 생성 로직에서 `getPresignedUrl` 메서드에 **'원본 파일 이름'** 대신, **'전체 URL'**을 전달하도록 수정했다. 동시에, `S3Service`의 `getPresignedUrl` 메서드도 전체 URL을 받아 내부에서 파일 키를 추출하도록 변경했다.

**✅ 최종 `ProfileResponse` 생성 로직:**

```java
// 수정 후 ProfileResponse 생성 로직

if (isMyProfile) {
Portfolio portfolio = profile.getPortfolio();
if (portfolio != null) {
// [핵심 해결책 1]
// S3Service가 전체 URL에서 올바른 파일 키를 추출할 수 있도록,
// portfolio.getFileName() 대신 portfolio.getUrl()을 전달한다.
String presignedUrl = s3Service.getPresignedUrl(portfolio.getUrl());
builder.portfolio(new Portfolio(presignedUrl, portfolio.getFileName()));
}
}
```

**✅ 최종 `S3Service`의 `getPresignedUrl` 메서드:**

```java
// S3Service.java

public String getPresignedUrl(String fileUrl) {
// [핵심 해결책 2]
// "https://.../portfolio/..." 형태의 전체 URL에서,
// "portfolio/" 부터 시작하는 실제 파일 키(File Key)를 추출한다.
String fileKey = fileUrl.substring(fileUrl.indexOf("portfolio/"));

// 1시간 동안 유효한 만료 시간을 설정한다.
Date expiration = new Date(System.currentTimeMillis() + 1000 * 60 * 60);

// 추출된 fileKey를 사용하여 Pre-Signed URL 생성 요청을 만든다.
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucket, fileKey)
.withExpiration(expiration);

URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
return url.toString();
}
```

이 수정을 통해, `S3Service`는 항상 올바른 전체 URL을 받아 정확한 파일 키를 추출할 수 있게 되었고, `NoSuchKey` 문제가 최종적으로 해결되었다.

---
Loading