😀 GCS 이용하게 된 배경
이번 프로젝트에서 메뉴를 추가할 때 메뉴의 이미지 파일도 업로드 하는 기능 구현 파트를 맡았다.
이미지 업로드 방식에는
- 이미지 , 동영상 등 파일을의 미가공 데이터를 나타내며 2진비트로 저장되는 BLOB 방식
- WAS나 GCS와 같은 저장소에 이미지 파일을 저장하고 해당 객체의 URI를 DB에 저장하는 방식
이렇게 2가지가 있다. 하지만 BLOB 방식 같은 경우에는 데이터베이스의 크기가 빠르게 증가할 우려가 있을 뿐 더러 백업 및 복구작업이 복잡해진다는 단점이있기 때문에 2번째 방법으로 정하였고, 처음에는 유명한? WAS를 이용하기로 했었다.근데 WAS 같은 경우 신규 가입 후 1년동안은 무료로 이용할 수 있지만 이미 가입되어 있던 계정이 1년이 지난 계정이라 다른것을 알아보던중 GCS를 알게 되었다. GCS는 Google Cloud Storage의 약자이고 Google Cloud에 객체를 저장하는 서비스이다. Goole Colud는 무료 체험판으로 90일을 제공하고 일반계정을 활성화하지않는 이상 돈이 나갈일은 없어보여서 GCS로 선택하였다.
📗 Cloud Storage란 ?
google cloud에 어떤 형식의 파일이든 저장하게 해주는 서비스입니다.
자세한 개념은 아래 링크를 참고해주세요.
📤 GCS 이용하여 이미지 업로드까지 해보기
1. Cloud Storage Bucket 생성
1 - 1. 콘솔로 이동
1 - 2. 버킷으로 이동
1 - 3 버킷 만들기
1 - 4 공개 엑세스 적용
현재는 엑세스가 공개아님으로 되어있을텐데 승인된 사용자만 객체에 접근할 수 있음을 알 수 있다. 공개 엑세스 상태를 공개로 바꾼다면 모든 사용자가 URL을 이용해서 이 객체에 접근할 수 있다.생성된 버킷을 클릭하여 권한 탭으로 넘어가 엑세스 권한부여를 클릭하고 아래 사진처럼 추가해주면 된다
이후 버킷 새로고침 후 확인해보면 엑세스가 인터넷에 공개로 바뀌어있을 것 이다.
이제 마지막으로 Springboot에서 내 Cloud storage에 접근 권한을 가질 수 있도록, Access key를 받아 등록해주면된다.먼저 탐색창을 열어서 IAM 및 관리자 > 서비스 계정 탭으로 들어가서 서비스 계정 추가를 누른다.세부정보는 원하는대로 쓰셔도되지만 아래 엑세스 권한부여에서 역할은 아래 두개를 주셔야합니다.
이후 만들어진 서비스 계정을 클릭하고, 키 탭으로 이동해 키 추가를 클릭합니다.
만든 json 키 파일은 나중에 프로젝트의 resources 폴더 안으로 옮기기 위해 컴퓨터에 저장해둡니다.
📖 2. Spring project 세팅
build.gradle에 의존성 추가
implementation group: 'org.springframework.cloud', name: 'spring-cloud-gcp-starter', version: '1.2.5.RELEASE'
implementation group: 'org.springframework.cloud', name: 'spring-cloud-gcp-storage', version: '1.2.5.RELEASE'
application.yml
spring:
cloud:
gcp:
storage:
credentials:
location: classpath:{위에서 만든 key 파일 이름}.json
project-id: {key 파일 안에 있는 project_id 값}
bucket: {버킷 이름}
application.properties
spring.cloud.gcp.storage.credentials.location=classpath:{위에서 만든 key 파일 이름}.json
spring.cloud.gcp.storage.project-id={key 파일 안에 있는 project_id 값}
spring.cloud.gcp.storage.bucket={버킷 이름}
json key 파일은 아래 사진처럼 resources폴더 안에 넣어주시면 됩니다.
🔎 3. 이미지 업로드 해보기
3 - 1 메뉴 Entity
@Entity
@Getter
@Table(name = "menu")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Comment(value = "메뉴 고유번호")
private Long id;
@Column(nullable = false)
@Comment(value = "메뉴명")
private String menuName;
@Setter
@Column(nullable = false)
private int price;
@Column(nullable = false)
private String content;
@Column
private String imageUri;
@Setter
@Column(name = "status", nullable = false)
@Enumerated(value = EnumType.STRING)
private Status status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Store store;
@Builder
public Menu(String menuName, int price, String content, Status status, Store store) {
this.menuName = menuName;
this.price = price;
this.content = content;
this.status = status;
this.store = store;
}
3 - 2 Controller
@PostMapping("/{storeId}/menus")
public ResponseEntity<MenuResponseDto> createMenu(
@PathVariable Long storeId,
@Valid @RequestPart CreateMenuRequestDto createMenuRequestDto,
@RequestPart(required = false) MultipartFile file,
@Auth AuthMember authMember) {
return ResponseEntity.status(HttpStatus.CREATED).body(menuService.createMenu(storeId,
createMenuRequestDto,
file,
authMember.getId()));
}
3 - 3 Service
public MenuResponseDto createMenu(
Long storeId,
CreateMenuRequestDto createMenuRequestDto,
MultipartFile file,
Long memberId) {
Store store = getStore(storeId);
validateStoreOwner(memberId, store);
menuRepository.findByMenuNameAndStoreId(createMenuRequestDto.getMenuName(), storeId).ifPresent(po->{
throw new InvalidRequestException("이미 가게에 추가되어있는 메뉴입니다.");
});
String imageUri = imageUtil.uploadImage(file);
Menu menu = Menu.builder()
.menuName(createMenuRequestDto.getMenuName())
.price(createMenuRequestDto.getPrice())
.content(createMenuRequestDto.getContent())
.status(Status.ACTIVE)
.store(store)
.build();
menu.uploadImage(imageUri);
return new MenuResponseDto(menuRepository.save(menu));
}
3 - 4 StorageConfig
@Configuration
public class StorageConfig {
@Value("${spring.cloud.gcp.storage.credentials.location}")
private String location;
@Bean
public Storage storage() throws IOException {
InputStream keyFile = ResourceUtils.getURL(location).openStream();
return StorageOptions.newBuilder()
.setCredentials(GoogleCredentials.fromStream(keyFile))
.build()
.getService();
}
}
3 - 5 ImageUtil
@Slf4j(topic = "ImageUtil")
@Component
@RequiredArgsConstructor
public class ImageUtil {
@Value("${spring.cloud.gcp.storage.bucket}")
private String bucket;
private final static String IMAGE_URI_PREFIX = "https://storage.googleapis.com/";
private final Storage storage;
public String uploadImage(MultipartFile file) {
if (file == null || file.isEmpty()) {
return null;
}
String uuid = UUID.randomUUID().toString();
String fileType = file.getContentType();
if (!(fileType.endsWith("jpg") || fileType.endsWith("png") || fileType.endsWith("jpeg") || fileType.startsWith("image"))) {
throw new InvalidRequestException("허용하지 않는 파일 형식입니다.");
}
BlobInfo blobInfo = BlobInfo.newBuilder(bucket,
uuid)
.setContentType(fileType)
.build();
try {
storage.create(blobInfo, file.getBytes());
} catch (IOException e) {
throw new InvalidRequestException("ImageUtil storage.create 메서드 에러");
}
return IMAGE_URI_PREFIX + bucket + "/" + uuid;
}
}
3 - 6 포스트맨으로 테스트
컨트롤러에서 MultipartFile로 받기 때문에 form-data로 보내주어야하는데 이때 requestDto의 Content-Type을application/json으로 설정해주지 않으면 에러가 발생한다.
이후 다시 버킷에 가보면 이미지 파일이 잘 업로드 되어있는 것을 볼 수 있다.
DB에는 해당 객체의 URI가 저장되게 되고 그 URI를 복사해서 인터넷에 붙여넣으면 사진이 잘 나오는것도 확인할수 있다!
이렇게 GCS를 이용한 이미지 업로드를 해보았다. 비록 다른 분들이 정리해주신 글들을 바탕으로 구현해본것이지만
이미지 업로드를 처음 해보는 것이다보니 굉장히 재미있는 시간이였던 것 같다.
'TIL' 카테고리의 다른 글
JPA - 복합키와 식별 관계 매핑 (0) | 2024.11.12 |
---|---|
미리 서명된 URL(Pre-signed url) 사용해보기 (0) | 2024.11.11 |
AOP (0) | 2024.10.31 |
기능 개선 과제 (0) | 2024.10.31 |
Todo 프로젝트 리팩토링 하기 (0) | 2024.10.30 |