[Kakao Cloud School] 23번째 회고록

lango·2023년 4월 24일
1
post-thumbnail

Intro


일정 관리의 어려움

프로젝트에 필요한 서비스 개발 일정이 조금씩 지연되면서 앞으로의 일정을 계획하고 수행하는 것이 많이 버거웠다.

결국 기획 및 설계 단계에서 욕심을 부렸던 내용들을 가지치기를 하였고, 본 서비스에 없으면 안될 중요한 기능들의 우선순위를 다시 산출하여 남은 개발기간을 더욱 효율적으로 사용하려고 한다.

하고 싶었던 것들을 모두 할 수 없으니 미련도 남고, 아쉽지만 현재 가장 중요한 것은 MVP이기 때문에 프로토타입 MVP를 개발하는데 초점을 두고 프로젝트를 진행할 것이다.




Week 23

카카오 클라우드 스쿨 23주차 106~110일까지의 공부하고 고민했던 흔적들을 기록하였습니다.

AWS S3를 통해 이미지와 같은 첨부파일을 관리하기

프로젝트를 진행하면서 이미지 파일을 업로드하고 출력해야 할 개발사항이 있었다. 본래 이러한 첨부파일들을 직접 서버에서 관리하려 했으나, 시간이나 비용적인 문제로 다른 방법을 찾아보게 되었고 AWS의 S3를 이용하여 이미지 파일을 업로드하고 출력하는 로직을 개발할 수 있었다.


AWS S3에서 버킷을 생성하고 설정하기

생성할 버킷의 이름과 리전을 입력한 후 객체 소유권 설정에서 ACS 활성화 및 퍼블릭 액세스 차단 해제를 선택하여 버킷을 생성했다.

이제 생성한 버킷의 권한 설정을 해주어야 한다.
먼저 버킷 정책을 작성한다.

버킷 정책

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicList",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:List*",
                "s3:Get*",
                "s3:Put*",
                "s3:Delete*"
            ],
            "Resource": [
                "arn:aws:s3:::developers-attach-test",
                "arn:aws:s3:::developers-attach-test/*"
            ]
        }
    ]
}

그리고 CORS 설정도 작성한다.

CORS 설정

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "DELETE"
        ],
        "AllowedOrigins": [
            "http://localhost:3000",
            "https://diveloper.site"
        ],
        "ExposeHeaders": []
    }
]

Spring Boot에서 파일을 S3 버킷에 저장하기

앞서 S3에 이미지와 같은 파일을 저장할 공간을 만들었으니 클라이언트에서 요청으로 보낸 파일을 Spring Boot 서버에서 S3로 업로드해보도록 하자.

이를 위해, Spring Boot 서버에 AWS S3로 접근하기 위한 설정이 필요하다.

application.yml

cloud:
  aws:
    credentials:
      accessKey: [IAM AccessKey]
      secretKey: [IAM SecretKey]
    s3:
      bucket: developers-attach-test
    region:
      static: ap-northeast-2
    stack:
      auto: false

S3로 접근하기 위한 AWS의 IAM 사용자가 있다면 엑세스 권한을 부여받아 엑세스키와 시크릿키를 설정에 추가하고, 버킷명, 지역을 작성한다.

그리고 여기서 작성한 stack.auto 설정은 CloudFormation 스택을 자동으로 만들지 않도록 지정하는 것을 의미한다.

자, 설정을 마쳤으니 클라이언트에 요청에 파일을 담아서 Spring Boot 서버로 전달하여 S3로 업로드하는 로직을 작성하자.

Controller.java

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/attach")
public class AttachController {
    private final AttachService attachService;

    @PostMapping("/profile")
    public ResponseEntity<MemberProfileImgUpdateResponse> uploadProfileImage(
            @RequestParam(value = "memberId") Long memberId,
            @RequestPart(value = "file") MultipartFile multipartFile
    ) {
        MemberProfileImgUpdateResponse response = attachService.uploadProfileImage(memberId, multipartFile);
        return ResponseEntity.status(HttpStatus.OK).body(response);
    }
}

위 컨트롤러의 /api/attach/profile API는 사용자의 프로필 이미지를 등록하는 기능이다. 여기서는 attachService로 MultipartFile 형식의 파일이 담긴 요청 파라미터를 전달한다.

다음으로 이 요청 파라미터를 통해 S3로의 이미지 업로드를 수행할 비즈니스 로직이 작성된 서비스 코드를 살펴보자.

Service.java

@Log4j2
@RequiredArgsConstructor
@Service
public class AttachService {
    private final MemberRepository memberRepository;
    private AmazonS3 amazonS3Client;
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;
    @Value("${cloud.aws.s3.bucket}")
    private String bucketName;
    @Value("${cloud.aws.region.static}")
    private String region;

    // 생성자 호출 후 수행할 작업 - 초기화
    // Spring은 Bean에 Proxy 패턴을 적용함.
    // Proxy 패턴은 작성한 클래스 또는 서비스를 상속받아서 새로운 클래스의 객체를 만들어서 사용하는 패턴임.
    @PostConstruct
    public void setS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(this.accessKey,
                this.secretKey);
        amazonS3Client = AmazonS3ClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(this.region)
                .build();
    }

    // 파일 업로드를 처리할 메서드
    @Transactional
    public MemberProfileImgUpdateResponse uploadProfileImage(Long memberId, MultipartFile file) {
        boolean result = validateFileExists(file);
        // 업로드 할 파일이 없으면 종료
        if(result == false){
            log.info("[AttachService] 업로드할 파일이 없습니다.");
            return null;
        }
        
        // 파일 경로 생성
        String fileName = CommonUtils.buildFileName(memberId, file.getOriginalFilename());
        log.info("[AttachService] 업로드할 파일명: {}", fileName);

        // 파일 형식 설정
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentType(file.getContentType());

        // 파일의 내용을 읽어서 S3에 전송
        try (InputStream inputStream = file.getInputStream()) {
            log.info("[AttachService] S3에 파일 업로드를 시도합니다.");
            amazonS3Client.putObject(new PutObjectRequest(bucketName, fileName, inputStream, objectMetadata).withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (IOException e) {
            log.info("[AttachService] S3에 파일 업로드 중 문제가 발생했습니다.");
            e.printStackTrace();
            return null;
        }
        // 동일한 버킷에 모든 파일을 업로드하는 경우는 fileName 만 데이터베이스에 저장
        // 버킷 여러 개를 사용하는 경우는 버킷 이름도 데이터베이스에 저장
        // 업로드 된 파일의 실제 URL을 리턴
        String imagePath = amazonS3Client.getUrl(bucketName, fileName).toString();
        log.info("[AttachService] S3 파일 업로드 성공, {}", imagePath);
        Optional<Member> member = memberRepository.findById(memberId);
        
        if (member.isEmpty()) {
            log.info("[AttachService] S3이미지등록: 존재하지 않는 사용자입니다.");
            return MemberProfileImgUpdateResponse.builder()
                    .code(HttpStatus.NOT_FOUND.toString())
                    .msg("존재하지 않는 사용자입니다.")
                    .build();
        }
        member.get().updateProfileImageUrl(imagePath);
        return MemberProfileImgUpdateResponse.builder()
                .code(HttpStatus.OK.toString())
                .msg("정상적으로 프로필 이미지를 등록했습니다.")
                .build();
    }

    // 업로드할 파일의 존재 여부를 리턴하는 메서드
    private boolean validateFileExists(MultipartFile multipartFile) {
        boolean result = true;
        if (multipartFile.isEmpty()) {
            result = false;
        }
        return result;
    }
}

파일이 없을 경우 로직을 진행하지 않고 종료하도록 하였으며, 파일의 경로와 파일의 형식을 설정하여 amazonS3Client.putObject(new PutObjectRequest(bucketName, fileName, inputStream, objectMetadata).withCannedAcl(CannedAccessControlList.PublicRead)); 구문을 통해 사전에 지정한 S3 버킷으로 파일 업로드를 수행하게 된다.

private AmazonS3 amazonS3Client;
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
@Value("${cloud.aws.region.static}")
private String region;

앞서 application.yml 에서 설정한 설정들을 불러와서 사용해야 한다.

만약 실습 수준의 코드였다면 S3로 파일을 업로드하는 것으로 끝냈을텐데, 필자의 프로젝트 서비스에서 사용할 프로필 이미지 변경 기능을 구현하기 위해서 사용자 테이블에 S3에 저장한 이미지 파일 경로를 저장하는 로직도 추가되었다.

String imagePath = amazonS3Client.getUrl(bucketName, fileName).toString(); 구문을 통해 저장한 파일의 실제 경로를 받아와서 사용자 테이블의 컬럼을 수정한다.

파일 업로드 기능 테스트하기

React 클라이언트 애플리케이션에서 마이페이지에 프로필 이미지 기능을 테스트해보자.

회원가입후 최초 프로필 이미지 변경 전까지는 시스템에서 정의한 기본 이미지를 보여주고 있다.

영역을 선택하면 원하는 이미지 파일을 선택할 수 있다.

원하는 이미지를 선택하면 프로필 사진 변경 영역에 해당 이미지를 출력한다. 그리고 저장 버튼을 클릭하는 순간 서버로 해당 이미지 파일을 전송하여 S3로 업로드하고, 사용자 테이블의 프로필 이미지 속성을 변경후 응답을 주게 된다.

서비스 로그 확인하기

앞서 서비스 코드에서 로그를 출력하도록 설정하였으니 로그 내역을 확인해보자.

Server Log

2023-04-24T14:06:21.464Z  INFO 1 --- [nio-9000-exec-7] c.d.member.attach.service.AttachService  : [AttachService] 업로드할 파일명: 546/KakaoTalk_Photo_2022-11-10-18-19-32_1682345181464.jpeg
2023-04-24T14:06:21.464Z  INFO 1 --- [nio-9000-exec-7] c.d.member.attach.service.AttachService  : [AttachService] S3에 파일 업로드를 시도합니다.
2023-04-24T14:06:21.587Z  INFO 1 --- [nio-9000-exec-7] c.d.member.attach.service.AttachService  : [AttachService] S3 파일 업로드 성공, https://developers-attach-test.s3.ap-northeast-2.amazonaws.com/546/KakaoTalk_Photo_2022-11-10-18-19-32_1682345181464.jpeg

로그에서도 정상적으로 S3로 파일을 업로드하는 것을 볼 수 있었다.




Final..

카카오 클라우드 스쿨에서의 23주차가 끝났다. 정말 한 주, 한 주가 쏜살같이 지나가고 있음을 느낀다.

현재 시점에서 프로젝트의 상황을 현실적으로 바라보자면 오버 엔지니어링이 맞는 것 같다. 너무 많은 것들을 욕심내어 기획 및 설계했지만, 개발 단게에서 모두 구현하지 못함을 느끼고 팀원들과의 스프린트 과정에서 하고 싶었던 것들을 많이 덜어내고 있다.

지금 당장 구현하지 못하더라도 이번 교육과정에서 끝낼 것이 아닌 프로젝트이기에 먼저는 MVP 구현을 목표로 달려가기로 하였다.


이번 글에서 실습했던 소스 코드는 진행하고 있는 Developers 프로젝트의 Github에서 확인하실 수 있습니다.

혹여 잘못된 내용이 있다면 지적해주시면 정정하도록 하겠습니다.

참고자료 출처

profile
찍어 먹기보단 부어 먹기를 좋아하는 개발자

0개의 댓글