상황설명
기존 로컬 컴퓨터에 사진을 저장하도록 구성할수도 있지만 실제 프론트엔드와 협업을 진행하게 되고, 배포를 할 상황이오면 큰 기업이 아닌 이상 직접 컴퓨팅자원을 자체적으로 보유한 물리적인 서버에 직접 설치해 운영할수 없다. 이런 운영방식을 On-Premise
라고 한다.
On-Demand
일개 개발자가 사용할 수 있는 방식은 On-Demand라고 한다. 흔히 클라우드 컴퓨팅이라고 불리는 방식이다.
외부 서비스 공급자가 데이터를 관리하는 방식인데, 여러가지 서비스를 이용할수 있겠지만 가장 많이 쓰고, 거대한 서비스 공급자인 AWS를 사용해볼것이다.
S3
AWS에서 제공하는 Simple Storage Service 의 약자이다. 주로 미디어 파일을 Cloud에 쉽게 저장하기 위해 사용한다.
사용하기 전에 주의해야할 사항들이 있다.
- 특정 VPC에 할당되지 않는다 -> 배포할 서버(EC2)를 public으로 설정해줘야한다.
물론 private로 설정해줘도 VPC EndPoint를 명시적으로 주어 퍼블릭 서브넷과 연결할수 있지만... 사이드 프로젝트를 진행할 경우 돈💰이 너무많이 든다. 그래서 나는 public으로 설정해줬다.
우선 AWS에 루트 계정으로 접속 후 버킷 만들기를 누른다.
** 버킷이란? S3에서 관리하는 데이터(객체)의 묶음이다.
들어가보면 VPC 선택항목이 없는 것을 확인할수 있다. 그 뒤, 내가 원하는 버킷이름을 입력해준다.
이 부분 설정이 가장 중요하다. 위에서 언급했듯이, 퍼블릭으로 설정을 꼭해줘야해서 모든퍼블릭 액세스 차단을 취소하고, 밑에 퍼블릭상태 주의 체크박스를 클릭해준다.
이렇게 버킷 생성이 끝났다..? 🙅♂️
위 상태에서 버킷에 들어가서 내가 올리고 싶은 사진을 추가하면 객체 URL이 생긴다. URL로 들어가서 사진을 확인해보면 AccessDenied라는 상태문구가 뜰것이다. 퍼블릭으로 전부 설정 다 해줬는데 왜?
우리가 하려는 것은 정적호스팅 방식으로 할것이다. 이유는 간단하게 MySQL에서도 blob이라는 타입을 지원해줘서 사용할순 있지만, 바이너리 데이터 변환하는 병목현상이 생기기 때문에 정적호스팅을 할것이다.
아무튼! 정적호스팅에 대한 설정을 추가로 해줘야한다.
버킷에 들어와서 속성을 클릭한다.
밑으로 쭉 스크롤을 내리면 위와같이 정적 웹 사이트 호스팅이라는 것이 보인다.
정적 웹 사이트 호스팅을 활성화 시켜주고, 인덱스 문서, 오류 문서는 위 화면과 똑같이 직접 쳐줘야한다.
다음으로는 권한 설정이다. 권한 설정을 클릭하고, 스크롤을 내려서 버킷 정책 편집을 누른다.
{
"Version": "2012-10-17",
"Id": "Policy1689489055171",
"Statement": [
{
"Sid": "Stmt1689489029299",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "S3 버킷 arn/*"
}
]
}
위와 같이 편집에 수정한다.
- Effect : 허용? 거부?
- Principal : 대상은 어느것으로? (*는 모든 대상)
- Action : 어떤 행위에 대해서? (나는 사진조회, 업로드를 위한것을 설정)
- Resource : 어떤 버킷의 어느 폴더? 여기는 다음의 버킷 ARN을 복붙 하면된다.
이렇게 하면 AWS S3에서 직접 사진을 업로드 하고, 조회하는 것은 잘 작동한다!!
Spring 프로젝트에서 사진업로드 API 구현
구현하기 앞서 AWS의 API를 사용하기 위해 다음과 같은 의존성 설정이 필요하다. Gradle에 추가하자.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
이렇게만 하면 사용할수 있냐? ❌
AWS의 IAM에 들어가서 AWS S3에 권한이 있는 사용자를 만들고, 이를 인증할수 있는 Access Key와 Secret Key를 발급받아야한다!
사용자 -> 사용자 생성으로 들어간다.
원하는 이름을 짓고, 권한설정 단계에서 직접 정책 연결을 클릭한다.
위 정책을 연결하고, 사용자를 생성한다.
만든 사용자를 클릭한다.
보안 자격 증명에서 액세스 키 만들기를 클릭한다.
나는 스프링에서 사용할것이므로 AWS 외부에서 실행되는 애플리케이션으로 설정해준다.
다음으로 원하는 설명 태그를 적어주고, 키가 발급된다.
** 이때 주의점은 비밀 액세스 키의 경우 다시 조회할수 없으므로, 메모장과 같이 꼭 따로 기록해야한다!!
그리고 발급받은 두개의 키값을 설정 파일에 넣어주자.
AmazonConfig.java
@Configuration
@Getter
public class AmazonConfig {
private AWSCredentials awsCredentials;
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Value("${cloud.aws.s3.path.review}")
private String reviewPath;
@PostConstruct
public void init() {
this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
}
@Bean
public AmazonS3 amazonS3() {
AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
@Bean
public AWSCredentialsProvider awsCredentialsProvider() {
return new AWSStaticCredentialsProvider(awsCredentials);
}
}
- @PostConstruct
- 의존성 주입이 완료된 후에 실행되어야 하는 method에 사용
- 해당 어노테이션은 다른 리소스에서 호출되지 않아도 수행
- 생성자 보다 늦게 호출된다.
당연히 .yml 파일에 환경변수 처리를 해줘야 정보누출을 방지할수 있다! 편집기 마다 환경변수 설정 방식이 다를건데 이 부분에 대해서는 직접 찾아보길 바란다.
아무튼 위 코드는 우리가 생성한 사용자 액세스 키와, 리전에 대해서 설정해주는 코드이다. AWS접속에서 그대로 정보를 가져와 환경변수 처리해준다.
Java 코드구현
우선 AWS에서 사진 각각을 구분하기 위해 유일한 식별값이 필요하다. 그렇다면 Java 자체적으로 변수를 설정해두면 되지 않나? ❌
이 방법은 서버가 종료될때 초기화가 되기 때문에 사용할수없다. 그렇다면 어떻게 구분할수 있을까?
UUID
Java에서 지원해주는 일련번호이자 식별자이다.
이것을 사용해서 사진 각각을 구분해주도록 하겠다.
Uuid.java
@Entity
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Uuid extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String uuid;
}
이제 사진 업로드하는 메서드를 만들어야한다.
위의 설정 파일가지고 어떻게 메서드를 구성할수 있을까?
필요한 의존성 부터 살펴보자.
- AmazonS3 : 아까 추가한 gradle 의존성에서 AmazonS3 자체 제공 메서드 이용
- AmazonConfig : 이건 우리가 만든거에서 이용(인증 용도)
- UuidRepository : 식별자 영속돼야해서 씀!
AmazonS3Manager.java
@Slf4j
@Component
@RequiredArgsConstructor
public class AmazonS3Manager {
private final AmazonS3 amazonS3;
private final AmazonConfig amazonConfig;
private final UuidRepository uuidRepository;
public String uploadFile(String keyName, MultipartFile file) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
try {
amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), keyName, file.getInputStream(), metadata));
}catch (IOException e){
log.error("error at AmazonS3Manager uploadFile : {}", (Object) e.getStackTrace());
}
return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString();
}
public String generateReviewKeyName(Uuid uuid) {
return amazonConfig.getReviewPath() + '/' + uuid.getUuid();
}
}
- ObjectMetaData : 필수는 아니지만, 파일의 크기 설정등 추가정보를 전달할때 쓴다.
- putObject : 위에서 설정한 버킷 권한설정이다. PutObjectRequest를 파라미터로 받고, 객체 파라미터 순서대로 버킷이름, 식별이름(UUID), MultiPartFile(나의 경우 png or jpeg), 메타데이터를 넘겨준다.
이렇게 구현했으면 작업이 다끝난걸까? 아니다 아직 해야할 작업이 남아있다.
우리가 쓰는 PC에서도 마구잡이로 파일들을 저장하진 않을것이다.(난 그렇다) 물론 바탕화면에 오만게 다 쌓여서 정리 하나도 안하는 사람도 있긴함 아무튼 이런 이유로 관리하기 편하게 하기위해 AWS에서 생성한 버킷에 들어가보면 폴더 만들기가 있다! 폴더를 만들어주고,
- generateReviewKeyName() 함수를 통해서 폴더까지 포함한 파일의 경로를 가져온다. -> 이게 최종적인 UUID가 된다.
만약 사진을 하나의 리뷰당 여러개를 올릴수 있게 한다면 따로 테이블을 두는것이 좋다.
ReviewImage.java
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ReviewImage extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "review_id")
private Review review;
}
StoreCommandServiceImpl.java
@Service
@Transactional
@RequiredArgsConstructor
public class StoreCommandServiceImpl implements StoreCommandService {
private final ReviewRepository reviewRepository;
private final MemberRepository memberRepository;
private final StoreRepository storeRepository;
private final UuidRepository uuidRepository;
private final ReviewImageRepository reviewImageRepository;
private final AmazonS3Manager s3Manager;
@Override
public Review createReview(Long memberId, Long storeId, StoreRequestDTO.ReviewDTO request) {
Review review = StoreConverter.toReview(request);
String uuid = UUID.randomUUID().toString();
Uuid savedUuid = uuidRepository.save(Uuid.builder()
.uuid(uuid).build());
String pictureUrl = s3Manager.uploadFile(s3Manager.generateReviewKeyName(savedUuid), request.getReviewPicture());
review.setMember(memberRepository.findById(memberId).get());
review.setStore(storeRepository.findById(storeId).get());
reviewImageRepository.save(ReviewImageConverter.toReviewImage(pictureUrl, review));
return reviewRepository.save(review);
}
}
각각 Review, Uuid, ReviewImage에 생성된 미디어 파일정보를 Repository로 통해 저장한다.
이제 비즈니스 로직은 완성되었고, 클라이언트가 요청할 DTO를 만든다.
StoreRequestDTO.java
@Getter
public static class ReviewDTO {
@NotBlank
String title;
@NotNull
Float score;
@NotBlank
String body;
MultipartFile reviewPicture;
}
ReviewImageConverter.java
public class ReviewImageConverter {
public static ReviewImage toReviewImage(String imageUrl, Review review){
return ReviewImage.builder()
.imageUrl(imageUrl)
.review(review)
.build();
}
}
마지막으로 Controller이다.
StoreRestController.java
@RestController
@Validated
@RequiredArgsConstructor
@RequestMapping("/stores")
public class StoreRestController {
private final StoreCommandService storeCommandService;
@PostMapping(value = "/{storeId}/reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<StoreResponseDTO.CreateReviewResultDTO> createReview(@RequestBody @Valid StoreRequestDTO.ReviewDTO request,
@ExistStore @PathVariable(name = "storeId") Long storeId,
@ExistMember @RequestParam(name = "memberId") Long memberId){
Review review = storeCommandService.createReview(memberId, storeId, request);
return ApiResponse.onSuccess(StoreConverter.toCreateReviewResultDTO(review));
}
}
- consumes : 요청의 데이터타입을 물어보는것
- produces : 응답의 데이터타입을 물어보는것
이렇게 요청 데이터 형식까지 설정을 마쳤으면 드디어 끝..!
오류 발생
문제해결을 위해 구글링을 많이 해봤다.
이렇게 알아보면서 내가 정말 공부를 더 많이해야겠다는 계기(?)가 되기도 했다.
우선 아무 생각없이 쓰던 @RequestBody는 데이터 형식을 JSON 형태로 전달받기 때문에 위에서처럼 파일(이미지)을 Body로 받게 된다면 원하는 결과를 얻을 수 없다. RequestBody의 DTO는 setter 없이 getter만 있으면 getter를 이용해 값을 바인딩할 수 있는데, HttpMessageConverter라는 친구가 역직렬화(바이트 스트림 or 문자열 -> 객체)를 해준다.
우리는 일반적으로 지정된 Content-type이 Json일 경우를 예상하고 이 어노테이션을 사용하곤 한다.
근데 내가 요청을 받고 싶은 타입은 MultipartType이다(정확히 하면 multipart/form-data). 근데 @RequestBody는 이것을 바꿔주는 Converter가 없다고 한다. 다른 타입은 대체 어떻게 받아올까?
- @RequestPart
- @ModelAttribute
두 가지가 있는데, 나는 방법 2를 선택했다.
** @ModelAttribute의 특징은 클라이언트가 전송한 데이터를 Java 객체와 바인딩하기 위해서 Setter 를 호출하기에 Setter 가 있어야 한다.
수정된 StoereRestController.java
@RestController
@Validated
@RequiredArgsConstructor
@RequestMapping("/stores")
public class StoreRestController {
private final StoreCommandService storeCommandService;
@PostMapping(value = "/{storeId}/reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<StoreResponseDTO.CreateReviewResultDTO> createReview(@ModelAttribute @Valid StoreRequestDTO.ReviewDTO request,
@ExistStore @PathVariable(name = "storeId") Long storeId,
@ExistMember @RequestParam(name = "memberId") Long memberId){
Review review = storeCommandService.createReview(memberId, storeId, request);
return ApiResponse.onSuccess(StoreConverter.toCreateReviewResultDTO(review));
}
}
수정된 StoreRequestDTO.java
public class StoreRequestDTO {
@Getter
@Setter
public static class ReviewDTO {
@NotBlank
String title;
@NotNull
Float score;
@NotBlank
String body;
MultipartFile reviewPicture;
}
}
최종실행 결과
굿!!
'DevOps > AWS' 카테고리의 다른 글
[AWS] Amazon EC2 원격 로그인 (0) | 2024.08.16 |
---|---|
[AWS] Amazon EC2 인스턴스 만들기 (3) | 2024.08.15 |
[AWS] Amazon 네트워크 운영하기 (0) | 2024.08.15 |
[AWS] Amazon 클라우드 시작하기 (0) | 2024.08.15 |
백엔드 아키텍처 간단히 알아보기 (DDD, MSA, 멀티모듈) (0) | 2024.08.06 |