이 글은 GDSC Ewha 서버 파트의 2024 솔루션 챌린지를 위해 진행한 Springboot 미니 프로젝트를 설명하는 글입니다. GDSC Ewha의 활동을 더 보고 싶다면 여기를 참고해주세요.
프로젝트의 깃허브 레포지토리 링크
이번 시간에는 다음 API를 만들어보려고 합니다.
[👩💻프로필]
- [GET] 내 프로필 조회: Profile 객체 반환
- [POST] 내 프로필 사진 업로드
[🖼️포스트]
- [GET] 모든 포스트 조회: List<Post> 객체 반환
- [DELETE] 포스트 삭제: 기존 Post 객체 삭제
하나씩 만들어보겠습니다.
[GET] 내 프로필 조회(feat: Signed URL)
사진과 같은 미디어는 bucket으로 업로드되고, 해당 사진의 엔드포인트(url)만 RDB에 저장됩니다. 사진 파일은 굉장히 많은 트래픽을 소모하므로, 프론트엔드→백엔드→ s3로 전달하는 것보다 프론트엔드→s3로 전달하느 것이 필수적입니다.
하지만 bucket에 업로드를 하기 위해서는 권한이 필요한데, 이 키를 프론트엔드가 직접 사용할 수는 없습니다. 프론트엔드는 사용자가 접근 가능한 영역이므로, 키가 유출되어 보안에 굉장한 문제가 생길 수 있습니다.
이 문제를 해결하기 위해서 Signed URL을 사용합니다. Signed URL은 서버에서 가지고 있는 bucket 권한을 사용해, 프론트엔드에게 임시적인 권한을 발급해주는 것을 의미합니다. 프론트엔드와 백엔드가 Signed URL을 사용해 특정 객체를 조회하는 과정은 다음과 같습니다.
- 클라이언트는 API Call을 통해서 서버에게 이미지를 조회하고 싶다는 요청을 합니다.
- 서버는 DB에서 조회할 이미지 이름을 가져옵니다.
- 서버는 조회할 이미지 이름과, bucket 이름, 프로젝트 ID를 통해 GCP에서 Signed URL을 받아 클라이언트에게 전송합니다.
- 클라이언트는 발급 받은 임시 권한(Signed URL)으로 이미지를 조회합니다.
조회할 사진 Cloud Storage에 업로드
먼저 API를 만들기 전에, Cloud Storage에 지금 아무것도 없으므로 조회할 사진을 하나 업로드해보겠습니다. GCP 콘솔에서 Cloud Storage의 버킷으로 이동한 후, “파일 업로드”를 선택합니다.
(프로필 사진은 아니지만) 저는 다음과 같은 gdsc.png 파일을 업로드했습니다.
객체 세부 정보를 보면 다음과 같이, 모든 사용자가 인증 없이 링크로만 이미지를 볼 수 있는 “공개 URL”은 존재하지 않고, 인증된 사용자만 접근 가능한 “인증된 URL”은 존재하는 것을 볼 수 있습니다.
해당 이미지를 업로드했던 같은 컴퓨터의 같은 브라우저에서 인증된 URL로 링크를 들어가면, 이미지를 볼 수 있습니다.
그러나 다른 조건에서는 해당 이미지를 볼 수 없습니다. GCP를 사용하는 구글 계정이 아닌 다른 구글 계정으로 로그인해 똑같은 URL로 접근하면 이번에는 403 Forbidden이 뜹니다.
이를 통해 인증된 사용자만 해당 이미지에 접근 가능하다는 것을 알 수 있습니다.
StorageService.java
이제 코드를 통해서 프론트가 조회하고 싶은 이미지 파일 이름(예를 들어, gdsc.png)를 넘겨주면, 백엔드가 Signed URL를 발급해주는 서비스 로직을 만들어보겠습니다. (참고로 이 이미지 파일 이름은 당연히 백엔드에서 DB에서 가져와야 하지만, 지금은 Signed URL만 테스트할 것이므로 편의상 클라이언트가 준다고 가정하겠습니다.)
@Service
@Transactional(readOnly = true)
public class StorageService {
@Value(value = "${storage.bucket}")
private String bucket;
@Value(value = "${storage.project-id}")
private String projectId;
/* 이미지를 조회하기 위해서 필요한 URL 발급 프로세스 */
@Transactional
public String generateGetObjectSignedUrl (GetUrlDto getUrlDto) throws StorageException {
Storage storage = StorageOptions
.newBuilder()
.setProjectId(projectId)
.build()
.getService();
String objectName = getUrlDto.getObjectName();
BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucket, objectName)).build();
URL url = storage.signUrl(
blobInfo,
15,
TimeUnit.MINUTES,
Storage.SignUrlOption.withV4Signature());
return url.toString();
}
}
참고: Blob이란?
Google Cloud Storage는 파일이나 객체를 Blob라고 부릅니다. BlobInfo 객체를 통해 Blob 관련 정보를 받아볼 수 있습니다. BlobInfo는 내부적으로 이런 구조를 가집니다.
BlobInfo{
bucket='gdsc-storage-access',
name='gdsc.png',
generation=null,
size=null,
contentType=null,
metadata=null
}
StorageController.java
이제 위에서 만든 서비스 로직을 컨트롤러를 통해서 접근해주겠습니다. 여기서 주의할 점은, 이 API는 조회를 위해 만든 것이 맞지만, 지금은 GCP에게 POST 요청을 통해 URL을 발급받는 기능만 있으므로 @PostMapping을 사용하고 있습니다.
@RestController
@RequiredArgsConstructor
@Tag(name = "GCP Storage API", description = "signed-url 발급, 객체 URL 업로드 및 조회 API.")
public class StorageController {
private final StorageService storageService;
@PostMapping("/profile")
@Operation(summary = "조회용 signed-url 발급 API", description = "클라이언트에게 조회용 signed-url 발급하는 API 입니다.")
public ResponseEntity<String> getSignedUrl(@RequestBody GetUrlDto getUrlDto) throws IOException {
String url = storageService.generateGetObjectSignedUrl(getUrlDto);
return ResponseEntity.ok(url);
}
}
GetUrlDto.java
우리는 조회할 이미지 이름을 담은 Dto를 만들어주겠습니다. 이렇게 객체를 만들면, JSON의 키-값 쌍이 바로 해당 객체의 필드에 맵핑되어 편리합니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GetUrlDto {
@Schema(description = "조회할 이미지 이름", example = "gdsc.png")
private String objectName;
}
여기까지 코드를 짰으면 서버를 실행시킵니다.
Postman으로 API 테스트
이제 Postman으로 이 API를 테스트해보겠습니다. objectName으로 아까 업로드했었던 gdsc.png를 명시하고, Send를 누르면 다음과 같이 URL이 반환됩니다.
{
"objectName": "gdsc.png"
}
다른 구글 계정에서 해당 URL로 접근해보면, 이미지가 아주 잘 보이는 것을 확인할 수 있습니다.
웹 프론트엔드에서는 이 URL을 받아 다음과 같이 src 속성을 통해서 해당 이미지 경로를 명시해주면 됩니다.
<img src="발급받은Signed URL">
[POST] 내 프로필 사진 업로드
- 클라이언트는 서버에게 저장할 이미지 이름을 전달하면서, POST를 위한 Signed URL을 발급받고 싶다는 API Call을 합니다.
- 서버는 저장할 이미지 이름을 DB에 저장합니다. (여기서는 Profile 엔티티의 String picture 필드에 저장해야 합니다.)
- 그 후 서버는 저장할 이미지 이름과, bucket 이름, 프로젝트 ID를 통해 GCP에서 Signed URL을 받아 클라이언트에게 전송합니다.
- 클라이언트는 발급 받은 임시 권한(Signed URL)으로 bucket에 파일을 직접 POST합니다.
- 서버는 객체 URL을 받아서 클라이언트에게 전송합니다.
클라이언트에서는 다음과 같은 코드로, 서버에서 준 Signed URL을 가지고 Storage에 직접 저장할 수 있습니다.
// 예시를 위한 프론트엔드 코드
// Signed URL발급 요청하기
var signedRequest = {
url: "http://[서버의 API 경로]",
method: "POST",
headers: {
Authorization: localStorage.getItem("accessToken"),
"Content-Type": "application/json",
},
data: JSON.stringify({
imageName: document.getElementById("profile-file").files[0].name,
}),
};
$.ajax(signedRequest).done(function (response) {
var signedUrl = response.url;
// Signed URL로 Cloud Storage에 파일 저장하기
var file = document.getElementById("profile-file").files[0];
var uploadRequest = {
url: signedUrl,
method: "PUT",
processData: false,
contentType: "image/png",
data: file,
};
$.ajax(uploadRequest).done(function (uploadResponse) {
console.log("업로드 성공: " + uploadResponse);
});
});
StorageService.java
위에서 만들었던 Service에 다음 메서드를 추가해주겠습니다. 코드를 잘 보면, extensionHeaders.put("Content-Type", contentType)로 Content-Type를 명시해 주고 있는 것을 알 수 있습니다.
이 Signed URL은 정확하게 같은 Content-Type과 이미지 파일 이름 내에서만 유효합니다. 즉, 같은 이미지여도 Content-Type: image/png 형식으로 발급받은 서명된 URL을 Content-Type: multipart/form-data에 적용할 수 없습니다. 따라서 반드시 클라이언트와 서버 사이에서 이미지 파일 이름과 Content-Type을 일치시키도록 합니다.
/* 이미지를 저장하기 위해서 위해서 필요한 URL 발급 프로세스 */
@Transactional
public String generatePutObjectSignedUrl(PutUrlDto putUrlDto) throws StorageException {
Storage storage = StorageOptions
.newBuilder()
.setProjectId(projectId)
.build()
.getService();
String objectName = putUrlDto.getObjectName();
String contentType = putUrlDto.getContentType();
BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucket, objectName)).build();
Map<String, String> extensionHeaders = new HashMap<>();
extensionHeaders.put("Content-Type", contentType);
URL url = storage.signUrl(
blobInfo,
15,
TimeUnit.MINUTES,
Storage.SignUrlOption.httpMethod(HttpMethod.PUT),
Storage.SignUrlOption.withExtHeaders(extensionHeaders),
Storage.SignUrlOption.withV4Signature());
return url.toString();
}
StorageController.java
위에서 만들었던 컨트롤러에 업로드용 API를 추가해주겠습니다. 다시 한번 주의하자면, PUT 로직은 클라이언트가 진행할 것이고, 여기서는 GCP에게 POST 요청을 통해 URL을 발급받는 기능만 있으므로 @PostMapping을 사용해야 합니다.
@PostMapping("/profile/upload")
@Operation(summary = "업로드용 signed-url 발급 API", description = "클라이언트에게 업로드용 signed-url 발급하는 API 입니다.")
public ResponseEntity<String> putSignedUrl(@RequestBody PutUrlDto putUrlDto){
String url = storageService.generatePutObjectSignedUrl(putUrlDto);
return ResponseEntity.ok(url);
}
GetUrlDto.java
이제 업로드할 이미지 이름과 컨텐츠 타입을 받은 Dto를 만들어주겠습니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PutUrlDto {
@Schema(description = "업로드할 이미지 이름", example = "gdsc.png")
private String objectName;
@Schema(description = "업로드할 이미지 content type", example = "image/png")
private String contentType;
}
Postman으로 API 테스트
이제 Postman으로 이 API를 테스트해보겠습니다. objectName으로는 test.png를 써주고, contentType은 “image/png”로 지정합니다. 이미지 파일 아무거나 이름을 test.png로 설정한 후, Send를 누르면 다음과 같이 URL이 반환됩니다.
{
"objectName": "gdsc.png",
"contentType": "image/png"
}
아까와는 다르게 이 링크로 들어가 본다고 해서 특정한 이미지가 보이진 않습니다. 그 이유는 이렇게 브라우저로 특정 URL을 접근하는 것은 기본적으로 GET 요청이고, 이 서명된 URL은 업로드 요청(PUT)에 대해서만 서명되어 있기 때문입니다.
적절한 요청을 만들기 위해서 Postman에서 해당 URL을 클릭합니다. 그 후 PUT 형식의 요청을 지정해줍니다. 이 URL은 지금 image/png 타입의 Content-Type으로 서명되어 있으므로, 이 컨텐츠 타입을 반드시 맞추어 주어야 합니다. 따라서 Body에서 binary를 클릭한 후, test.png를 업로드합니다. 그 후 Send를 보내면 다음과 같이 200 OK가 뜨는 것을 확인할 수 있습니다.
버킷에서 확인해 봐도 정상적으로 보이는 것을 알 수 있습니다.
이제 이 업로드된 이미지 파일 이름을 백엔드에서 Profile 객체에 저장해두어야 합니다. 하지만 지금 여기서는 Signed URL만 테스트할 것이므로 여기까지만 구현하겠습니다.
중요! CORS 에러 방지하기
이제 GCS에 대해서 CORS를 허용해줘야 합니다. CORS란 교차 출처 리소스 공유(Cross-origin resource sharing)의 약어입니다. CORS는 한 Origin의 웹 페이지가 다른 Origin을 가진 리소스에 액세스 할 수 있게하는 보안 메커니즘입니다. 원칙적으로, 클라이언트는 동일 출처 정책을 따릅니다. 이는 동일한 출처(동일한 프로토콜, 호스트명, 포트)의 리소스에만 접근하도록 제한하는 것입니다.
동일 출처 정책을 따르는 이유는 보안 때문입니다. 예를 들어, 한 사용자가 A 사이트에 접속해 인증정보(Access Token)를 받아 쿠키에 저장했다고 가정합니다. 이때 B 사이트에 대한 API Call을 하면, 이 인증정보가 자동으로 헤더에 포함되어 전송됩니다. 이 경우 B 사이트가 해당 인증정보를 탈취할 수도 있습니다.
그러나 클라이언트에서 다양한 API Call을 하는 경우가 점점 많아지면서, CORS를 허용해 줘야 하는 경우도 많아졌습니다. 지금 이 상황에서는 백엔드 서버 뿐만 아니라 프론트엔드 서버도 GCS 리소스를 사용할 수 있게 해야 합니다. 따라서 GCS의 CORS를 허용해주겠습니다.
우측 상단의 Cloud Shell을 클릭합니다.
아래 메세지를 그대로 모두 터미널에 복사 붙여넣기하여 CORS 관련 JSON 파일을 만들어줍니다.
cat > cors.json <<EOF
[{
"origin": ["*"],
"responseHeader": [
"Content-Type",
"Access-Control-Allow-Origin",
"x-goog-resumable"
],
"method": ["GET","PATCH","PUT","DELETE","OPTIONS"],
"maxAgeSeconds": 3600
}]
EOF
그리고 Google Cloud CLI를 통해서 CORS를 설정해줍니다.
gsutil cors set cors.json gs://[버킷 이름]
#예시
gsutil cors set cors.json gs://gdsc-storage-prac
다음과 같이 뜨면 성공입니다.
[GET] 모든 포스트 조회
이번에는 모든 포스트를 조회해보겠습니다.
중요! JpaRepository로 레포지토리 기본 메서드들 구현
JpaRepository는 Spring Data JPA 프레임워크에서 제공하는 인터페이스 중 하나입니다. 이 인터페이스는 JPA(Java Persistence API)를 기반으로 한 데이터 액세스 계층의 구현을 단순화하고 일반적인 CRUD (Create, Read, Update, Delete) 작업을 제공합니다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
//findAll, save와 같은 함수는 기본 내장되어 있습니다.
Post findPostById(Long postId);
}
물론 복잡한 몇몇 쿼리들은 이러한 방식으로 짤 수 없습니다. 하지만 대부분의 단순한 쿼리는 위와 같이 인터페이스만으로만 개발을 할 수 있습니다.
중요! @Builder로 생성자들 구현
@Builder 어노테이션은 Lombok 프로젝트에서 제공하는 어노테이션 중 하나로, 빌더 패턴을 자동으로 생성해주는 기능을 제공합니다. 프로젝트를 진행하다 보면 다양한 생성자가 필요한 경우가 있습니다. @NoArgsConstructor와 @AllArgsConstructor로 아무 파라미터도 없는 생성자와, 모든 파라미터가 필요한 생성자는 쉽게 구현할 수 있지만, 다른 다양한 생성자들을 직접 하나하나 코딩하기는 어렵습니다.
이때 @Builder를 사용하면 이러한 문제를 해결할 수 있습니다. 이는 다양한 생성자 조합을 자동으로 만들어주는 역할을 합니다.
@Builder
public class PostDto {
...(생략)...
}
생성자를 사용할 때도, 다음과 같은 방식으로 만들어주면 되기 때문에 기존의 파라미터 형식보다 훨씬 시각적으로보기 좋습니다.
PostDto.PostDtoBuilder()
.postDate(post.getPostDate())
.title(post.getTitle())
.body(post.getBody())
.likes(post.getLikes())
.thumbnail(post.getThumbnail())
.build()
DB에 임시 데이터 저장
API가 잘 동작하는 지를 확인해보기 위해 다음과 같은 SQL문을 실행해, DB에 임시 데이터를 저장해줍니다.
show databases;
use test;
show tables;
-- Member 1
INSERT INTO member (age, city, country, email, name, password)
VALUES (25, 'Seoul', 'South Korea', 'email_1@example.com', 'John Doe', 'password_1');
-- Member 2
INSERT INTO member (age, city, country, email, name, password)
VALUES (30, 'New York', 'USA', 'email_2@example.com', 'Jane Smith', 'password_2');
-- Post 1
INSERT INTO post (post_date, title, thumbnail, body, likes, member_id)
VALUES ('2022-01-01 12:00:00', 'Title 1', 'thumbnail_url_1', 'Body 1', 10, 1);
-- Post 2
INSERT INTO post (post_date, title, thumbnail, body, likes, member_id)
VALUES ('2022-01-02 13:30:00', 'Title 2', 'thumbnail_url_2', 'Body 2', 15, 1);
-- Post 3
INSERT INTO post (post_date, title, thumbnail, body, likes, member_id)
VALUES ('2022-01-03 14:45:00', 'Title 3', 'thumbnail_url_3', 'Body 3', 20, 1);
-- Post 4
INSERT INTO post (post_date, title, thumbnail, body, likes, member_id)
VALUES ('2022-01-04 16:15:00', 'Title 4', 'thumbnail_url_4', 'Body 4', 25, 2);
-- Post 5
INSERT INTO post (post_date, title, thumbnail, body, likes, member_id)
VALUES ('2022-01-05 18:00:00', 'Title 5', 'thumbnail_url_5', 'Body 5', 30, 2);
Postman에서 API Call하기
그 후에 Postman에서 리퀘스트를 보내보면, 이런 알 수 없는 에러가 뜨는 것을 볼 수 있습니다.
좀 더 명확하게 이유를 알기 위해서 로그를 확인해보니, 다음과 같습니다. 찾아보니 java.lang.StackOverflowError는 메서드 호출이 무한히 재귀되어 스택이 넘치는 경우에 발생한다고 합니다. 즉, 지금 상황은 무한 참조 상황입니다.
중요! 지연로딩(FetchType.EAGER)vs즉시로딩(fetch = FetchType.LAZY)
JPQL을 사용하면 바로 SQL문으로 번역되어서 쿼리가 날라갑니다. 예를 들어, select m From Post m; 는 바로 SQL문으로 select * from Post로 번역되어 쿼리가 날라갑니다. 그런데 이럴 경우 만약 Post가 100개 있고, Post에서 참조 객체인 Member가 EAGER로 되어 있으면, member를 가져오기 위해서 쿼리가 100개 날라가게 됩니다. 이를 n+1개 문제라고 합니다. (처음 MemberCircle을 위한 쿼리 1개, Member를 위한 쿼리 n개) 따라서 이때는 참고 객체의 정보가 아닌, 해당 엔티티의 정보만 가져오는 지연 로딩을 써야 합니다.
참고 객체의 정보까지 다 가져오는 즉시로딩(EAGER)은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵습니다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생합니다. 따라서 연관된 엔티티를 함께 DB에서 조회해야 할 필요가 없다면 지연로딩(LAZY)으로 설정해야 합니다.
특히, @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 합니다.
@XToONe //기본으로 fetch = FetchType.EAGER, 즉 즉시로딩
@XToONe(fetch = FetchType.LAZY) //지연로딩으로 직접 설정하기
해결법1: DTO를 통해서 해결하기
List<PostDto>를 담고 있는 PostListResponseDto를 만들어서 반환합니다. DB에서 원하는 값만 가져와서 Dto에 넣어줄 수 있으므로, JPA 무한 순환 문제가 발생하지 않습니다. PostController를 수정해서 다음과 같이 바꾸어줍니다.
@GetMapping("")
@Operation(summary = "포스트 조회 API", description = "모든 포스트를 조회하는 API 입니다.")
public ResponseEntity<?> getAllPosts() {
PostListResponseDto allPosts = postService.findAllPosts();
return new ResponseEntity<>(allPosts, HttpStatusCode.valueOf(200));
}
그 후에, PostService에서 DB 데이터를 DTO에 넣어주는 메서드를 만들겠습니다.
public PostListResponseDto findAllPosts(){
List<Post> postList = postRepository.findAll();
// PostListResponseDTO에 매핑
List<PostDto> postDtos = postList.stream()
.map(post -> new PostDto.PostDtoBuilder()
.postDate(post.getPostDate())
.title(post.getTitle())
.body(post.getBody())
.likes(post.getLikes())
.thumbnail(post.getThumbnail())
.build())
.toList();
return new PostListResponseDto(postDtos);
}
다시 Postman에서 리퀘스트를 보내면, 이제는 정상적으로 모든 포스트의 리스트가 반환되는 것을 확인할 수 있습니다.
해결법2: @JsonIgnore 또는 fetch = FetchType.LAZY 사용하기
또한, 지난 시간에 배웠던 것처럼 @JsonIgnore 또는 fetch = FetchType.LAZY를 사용해서 해결할 수도 있습니다.
@OneToMany(mappedBy = "entity2", fetch = FetchType.LAZY)
@JsonIgnore
private List<Entity3> entity3List = new ArrayList<>();
- fetch = FetchType.LAZY: 특정 엔티티를 조회할 때 연관된 엔티티들을 즉시 가져오지 않고, 해당 연관 엔티티의 실제로 접근(로딩)이 필요한 시점에 가져오는 옵션입니다. 예를 들어, Entity1을 조회할 때는 Entity2를 즉시 가져오지 않습니다. 대신, Entity2에 실제로 접근(로딩)이 필요한 시점, 예를 들어 Entity1.getEntity2List()를 호출할 때 해당 Entity2들을 가져오게 됩니다.
- @JsonIgnore: JSON 직렬화 시에 해당 필드를 무시합니다. 즉 엔티티가 JSON으로 변경될 때, 특정 방향의 참조를 무시하도록 설정합니다.
[DELETE] 포스트 삭제
이번 시간에는 특정 Post를 삭제해주겠습니다. JpaRepository에서 제공해주는 delete 메서드를 쓰면 간단하게 개발할 수 있습니다.
public void deletePost(Long postId){
Post post = postRepository.findPostById(postId);
postRepository.delete(post);
}
Postman에서 API Call하기
Postman에서 API를 호출합니다. 이때 @RequestParam 어노테이션을 사용했으므로, URL은 localhost:8080/post?postId=2 와 같이 명시하고, 반드시 DELETE로 리퀘스트 타입을 설정해줍니다.
DB에서 확인해보면, 아까 저장해주었던 포스트가 사라진 것을 확인할 수 있습니다.
'🗄️Backend > [GDSC] SpringBoot 프로젝트' 카테고리의 다른 글
Spring Boot 단기 프로젝트: 6. Make API(Auth, Circle) (0) | 2024.02.08 |
---|---|
Spring Boot 단기 프로젝트: 4. Connect to GCP Cloud Storage & make Dto (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 3. RestController & Swagger-UI & Postman (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 2. Build Entity & Connect to GCP Cloud SQL (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 1. Build RDB & Class Diagram (1) | 2024.02.08 |