Infra/Aws

[AWS] S3 연동 후 Spring Boot 파일 업로드 구현 일지

kth990303 2023. 4. 9. 22:24
반응형

해당 글에서는 AWS S3 연동 및 @MockBean을 이용한 테스트 코드에 대해 다룹니다.

S3 버킷 정책, 퍼블릭 액세스에 대한 추측 및 의문이 존재합니다.


사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다.


 

해당 프로젝트 github: https://github.com/mocacong/Mocacong-Backend

 

GitHub - mocacong/Mocacong-Backend: 모카콩 백엔드

모카콩 백엔드. Contribute to mocacong/Mocacong-Backend development by creating an account on GitHub.

github.com


들어가며

모카콩에서는 프로필 이미지를 업로드할 수 있습니다. 마이페이지에서 프로필 이미지 수정을 클릭해서, 언제든지 프로필 이미지를 손쉽게 변경할 수 있습니다. (단, 10MB를 넘는 사진은 불가능하다는 점 참고해주세요!) 이러한 이미지는 Amazon 클라우드 서비스의 S3에 저장됩니다.

 

하지만 S3를 연동하는 과정 자체는 쉽지 않았는데요, 이번 위키에서는 S3 연동 삽질기와 함께 프로덕션 코드와 테스트 코드를 어떻게, 그리고 왜 이렇게 작성했는지 적어보는 시간을 가져보려 합니다.

 

AWS S3 버킷 생성 및 권한 설정

Amazon S3에 접속해서 버킷 생성을 클릭합니다.

 

적절한 버킷 이름과 리전을 설정해줍니다. 이 때, 버킷 이름은 전역에서 고유해야 합니다. 고유하지 않으면 생성이 되지 않을 뿐 아니라, 실제로 이후에 애플리케이션 yml에서 설정할 때 버킷 이름을 써주어야 하는 때가 오기 때문에 기억해주도록 합시다. 버킷명은 s3 연동 시에도 중요한 식별자 역할을 한다는 점을 기억해주시면 좋을 듯합니다.

객체 소유권은 ACL 활성화됨을 선택했습니다.

이를 선택하지 않으면 아래처럼 에러가 발생하면서 파일 업로드에 실패합니다.

The bucket does not allow ACLs (Service: Amazon S3; Status Code: 400; 
Error Code: AccessControlListNotSupported;

혹시나 해서 모든 퍼블릭 액세스를 허용하고 파일 업로드를 시도해보았음에도 불구하고, ACL 비활성화됨(권장)을 선택하고 버킷 정책을 부여했을 때 위 에러가 발생했습니다.

따라서 우선은 ACL을 활성화시키고, ACL로 권한을 설정하는 방법을 선택했습니다. 이후에 해당 부분 공부가 진행되거나 Aws CloudFront 또는 presigned url이 도입될 경우 달라질 수 있습니다.

퍼블릭 엑세스 차단 설정에 대해서는 조금 더 공부할 필요가 있어보입니다.

퍼블릭 액세스는 2023년 4월 현재, 위와 같이 설정했습니다.

 

모든 퍼블릭 액세스를 차단할 경우, 별도의 설정을 하지 않는 이상 파일 업로드나 조회 시 403 access denied 에러를 만나게 됩니다. 이는 버킷 정책이나 ACL을 따로 설정해준다 해도 마찬가지입니다. 

 

퍼블릭 액세스 차단 옵션 중 위의 2개를 체크(차단 활성화)했더니 사용자 파일 업로드 및 조회가 불가능한 현상을 확인할 수 있었습니다. 위의 2개를 체크(차단 활성화)한다는 것은 버킷 및 객체의 ACL에 대해 퍼블릭 액세스를 완전히 차단한다는 것입니다. 아래 ACL을 사용하지 않고 퍼블릭 액세스를 차단해버린다면, 애플리케이션 yml에서 access-key나 secret-key를 설정해준다 하더라도 읽기 및 쓰기 권한이 존재하지 않아 파일 업로드가 불가능한 것이 아닐까 추측합니다.

위처럼 버킷 내의 객체에는 각각의 권한 설정을 다루는 ACL이 존재합니다. 모카콩에서는 해당 객체의 소유자가 아니면 권한을 부여하지 않도록 설정하였으며, 객체 소유자일 경우 아래와 같이 읽기 (ACL에 한해서는 쓰기도 가능) 권한이 있도록 설정했습니다. 이는 디폴트 옵션이기도 합니다.

 

맨 마지막 옵션만을 체크(차단 활성화)하면 이미지 파일 업로드 및 url 조회는 문제가 없습니다. 하지만 객체의 url을 클릭하여 직접 접근으로 조회 시 403 access denied 에러를 만나게 됩니다. 해당 옵션을 선택하면 버킷 정책을 모두 무시하기 때문에 그런 것이라 생각합니다. 모카콩에는 아래의 버킷 정책이 존재합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
    "Version""2012-10-17",
    "Id""PolicyImgBucket",
    "Statement": [
        {
            "Sid""AllowGetAndPutObjectPolicyForAll",
            "Effect""Allow",
            "Principal""*",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource""arn:aws:s3:::{bucket_name}/*"
        }
    ]
}
cs

GetObject 버킷 정책 허용 옵션이 사라지므로 위와 같은 에러를 만난 것이 아닐까 추측합니다. 따라서 교차 계정을 쓸 일이 없어 교차 계정에 대한 퍼블릭 액세스 차단이 활성화되지 않는 것은 아쉽지만, 위 옵션 적용을 해제했습니다.

마찬가지로 이후에 해당 부분 공부가 진행되거나 Aws CloudFront 또는 presigned url이 도입될 경우 달라질 수 있습니다.

 

한 가지 의문인 것은, 퍼블릭 액세스 차단 옵션 중 위의 2개만을 체크(차단 활성화)하고, 객체 소유권에서 ACL 비활성화 옵션을 선택하면 버킷 정책이 유효한 것으로 알고 있습니다. 해당 버킷에는 위처럼 GetObject, PutObject 액션이 *(모두)에게 허용되게 정책이 추가돼있습니다. 이렇게 버킷 정책에서 허용해주었음에도 불구하고 파일 업로드가 막히는 이유를 잘 모르겠습니다. 이 부분은 추가적인 공부가 필요해보입니다.

 

버킷 버전 관리를 활성화할 경우, 특정 시점마다 버킷 상태를 저장하므로 롤백 및 백업에 유용하리라 생각했습니다. 하지만 그만큼 용량을 차지하지 않을까 싶어 비용적인 측면을 고려해 비활성화로 선택했습니다.

 

버킷 암호화에는 별도의 비용이 들어가지 않으므로 기본 옵션인 SSE-S3 암호화 옵션을 선택했습니다. 참고로 이 옵션을 선택한다고 해서 해당 버킷에 저장되는 객체 url이 암호화되거나 그런 건 아닙니다. 객체의 실제 데이터에 대해 암호화가 진행되는 것입니다.

 

SSE-S3은 Amazon S3에서 관리하는 키를 사용하여 객체 데이터를 암호화하므로, 저장된 객체가 암호화되어 S3 내부에서 보호됩니다. 이는 S3에서 데이터를 읽고 쓰는 모든 작업에 대해 적용됩니다.

하지만, 객체의 URL은 암호화되지 않습니다. URL은 AWS에서 자원에 대한 위치 정보를 나타내며, 객체 데이터의 실제 내용이 아닙니다. 따라서 객체 URL에 액세스하려면 인증 및 권한 부여 등의 보안 기능이 필요하며, 이러한 기능들은 SSE-S3와는 별개의 기능입니다.
- chatgpt-

 

chatgpt를 100% 신뢰할 수는 없지만, 기본적인 윤곽을 잡는 데에는 탁월하다고 생각합니다. 해당 옵션에 대해서는 chatgpt의 답변을 참고하셔도 좋을 듯합니다.

 

고급 설정의 객체 잠금은 비활성화로 선택했습니다. 해당 옵션을 선택하면 버킷 내의 객체를 수정 및 삭제할 수 없습니다. 모카콩에서는 객체를 수정하고 삭제할 수 있게 하도록 선택했습니다.


애플리케이션 프로덕션 코드

먼저 AWS S3와의 연동을 위해 의존성을 아래와 같이 추가해주었습니다.

 

build.gradle

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cloud:
  aws:
    s3:
      bucket: ${S3_BUCKET_NAME}
    region:
      static: ${S3_REGION}
    stack:
      auto: false
    credentials:
      access-key: ${S3_ACCESS_KEY}
      secret-key: ${S3_SECRET_KEY}
      
spring:
  servlet:
    multipart:
      max-file-size: 10MB # 파일 하나 당 최대 사이즈
      max-request-size: 20MB # 요청 당 최대 사이즈
cs

리전이나 버킷명이 올바르지 않으면 해당 관련 에러를 띄워주므로 정확하게 입력해주도록 합시다. application-prod yml도 위와 동일하게 설정했습니다.

 

참고로 모카콩은 아직 개발 서버와 배포 서버가 분리되지 않았습니다. 즉, 모카콩에는 로컬과 개발서버만으로 이루어져 있습니다. 그렇기 때문에 로컬 환경에서 swagger로 테스트할 때 실제로 s3와 연동해서 테스트를 해야겠다는 생각이 들어 yml을 위와 같이 실제 환경변수를 주입받도록 했습니다.

 

테스트에서는 해당 환경변수를 실제로 주입받지 않도록 테스트 전용 yml도 생성했습니다. 테스트 전용 yml은 이후 아래에 후술하겠습니다.

 

AwsS3Uploader.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Slf4j
@Component
@RequiredArgsConstructor
public class AwsS3Uploader {
 
    private static final String S3_BUCKET_DIRECTORY_NAME = "static";
 
    private final AmazonS3Client amazonS3Client;
 
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;
 
    public String uploadImage(MultipartFile multipartFile) {
       // 메타데이터 설정
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentType(multipartFile.getContentType());
        objectMetadata.setContentLength(multipartFile.getSize());
 
       // 실제 S3 bucket 디렉토리명 설정
       // 파일명 중복을 방지하기 위한 UUID 추가
        String fileName = S3_BUCKET_DIRECTORY_NAME + "/" + UUID.randomUUID() + "." + multipartFile.getOriginalFilename();
 
        try (InputStream inputStream = multipartFile.getInputStream()) {
            amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (IOException e) {
            log.error("S3 파일 업로드에 실패했습니다. {}", e.getMessage());
            throw new IllegalStateException("S3 파일 업로드에 실패했습니다.");
        }
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }
}
cs

Aws S3 연동을 위해 AwsS3Uploader 클래스를 support 라는 패키지에 생성했습니다.

 

스프링 부트에서는 yml에 작성한 access-key, secret-key, region 정보를 바탕으로 AmazonS3Client 빈을 자동으로 생성해줍니다. 따라서 해당 값들은 @Value로 받을 필요가 없습니다. 버킷명만 @Value를 통해 주입받아 줍시다.

 

예전 버전에서는 AWS S3에 MultiPartFile이 올라가지 않았지만, 현재는 문제 없이 잘 올라가는 것을 확인했습니다. 그렇기 때문에 MultiPartFile → File로 convert하는 작업 없이 바로 S3에 업로드하도록 구현했습니다.

 

S3 버킷에 올라갈 이미지 객체의 contentType이나 사이즈를 metadata로 지정해주는 것이 좋을 듯하여 ObjectMetaData를 설정해주었습니다. 버킷에 올라갈 이미지 객체명(fileName)으로는 S3 버킷에 존재하는 디렉토리 명에 UUID.randomUUID() 값을 추가하고 실제 파일명을 덧붙여주도록 설정했습니다. 프로필 이미지를 위한 버킷 디렉토리이므로 정적 파일들만 존재합니다. 따라서 해당 디렉토리명은 static으로 결정했습니다. 그리고 파일명이 중복되는 경우 업로드되지 않거나 덮어씌워질 위험이 있다고 판단하여 UUID.randomUUID() 값을 추가해주었습니다.

 

S3에 putObject를 할 때에는 withCannedAcl(CannedAccessControlList.PublicRead)옵션으로 설정하여 퍼블릭 액세스를 허용하도록 업로드했습니다. 이 부분 역시 마찬가지로 이후에 해당 부분 공부가 진행되거나 Aws CloudFront 또는 presigned url이 도입될 경우 달라질 수 있습니다.

 

업로드가 성공적으로 진행되면 해당 파일의 url을 반환하도록 했습니다.

문제 없이 S3 서버에 잘 올라가는 모습 확인

 

이제 해당 객체를 사용할 MemberService 클래스의 코드를 살펴보시죠.

 

MemberService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@RequiredArgsConstructor
public class MemberService {
    private static final Pattern PASSWORD_REGEX = Pattern.compile("^(?=.*[a-z])(?=.*\\d)[a-z\\d]{8,20}$");
 
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final AwsS3Uploader awsS3Uploader;  // AwsS3Uploader 의존 추가됨
 
    ...
 
    // 프로필 이미지 변경
    @Transactional
    public void updateProfileImage(String email, MultipartFile profileImg) {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(NotFoundMemberException::new);
                
        // 프로필 이미지 업로드를 위한 awsS3Uploader 메서드 호출
        String profileImgUrl = profileImg == null ? null : awsS3Uploader.uploadImage(profileImg);
        member.updateProfileImgUrl(profileImgUrl);
    }
}
 
cs

보시다시피 MemberService 클래스가 AwsS3Uploader 객체를 주입받고 있습니다. 이 부분으로 인해 MemberService 관련 테스트가 깨지는 현상이 발생했는데요. 아래에 테스트 코드 부분에서 후술하겠습니다.

 

프로필 이미지 변경 시에는 @Transactional 을 통해 커밋 또는 롤백이 올바르게 이루어져 ACID가 보장되도록 하였습니다. profileImg가 null이 아닐 경우는 새로운 프로필 사진으로 등록하는 것이기 때문에 AwsS3Uploader 객체의 메서드를 호출합니다. profileImg가 null일 경우는 프로필 이미지를 기본이미지로 되돌려놓는 경우이므로 awsS3Uploader의 메서드를 호출하지 않습니다.

 

그럼 기존의 프로필 이미지는 S3에서 지워주지 않는걸까요? 예, 맞습니다. 현재로써는 지워주지 않고 있습니다. 그 이유로는 두 가지가 존재합니다.

첫째로 모카콩의 사용자 수가 S3 버킷을 청소해야 할 만큼 많지 않다는 겁니다. 아직 배포를 하지 않은 상황이어서 더더욱 그렇겠지만, AWS S3의 프리티어 허용 범위 용량은 5GB입니다. 위에서 설정했듯이 한 번에 올릴 수 있는 사진의 최대 용량은 10MB입니다. 최소 500장 정도는 거뜬히 버틸 수 있는 것이죠. 하지만 500장 정도는 사용자 수가 많지 않더라도 몇 번 프로필 이미지를 변경한다면 금방 채워질 양입니다.

둘째로는 사용하지 않는 프로필 이미지 객체를 S3 버킷 내에서 지워주는 작업은 실시간성이 요구되지 않는 작업이기 때문입니다. S3에 접근하여 삭제 요청하는 것은 실제로 꽤 오래 걸리는 작업입니다. 그렇기 때문에 실시간으로 해당 객체를 지울 필요 없이, 배치로 사용자가 적은 시각에 해당 객체를 지우도록 이후 스프린트에 추가 구현할 예정입니다.


애플리케이션 테스트 코드

위에서 봤듯이 MemberService는 AWS S3와 연동하는 책임을 가지고 있는 AwsS3Uploader 빈을 주입받고 있습니다. 그리고 로컬에서는 AwsS3Uploader 에게 실제 버킷 관련 환경변수를 주입해주고 있습니다. 그 이유는 위에서 서술한대로입니다.

 

참고로 모카콩은 아직 개발 서버와 배포 서버가 분리되지 않았습니다. 즉, 모카콩에는 로컬과 개발서버만으로 이루어져 있습니다. 그렇기 때문에 로컬 환경에서 swagger로 테스트할 때 실제로 s3와 연동해서 테스트를 해야겠다는 생각이 들어 yml을 위와 같이 실제 환경변수를 주입받도록 했습니다.

 

실제 로컬에서 돌릴 때에는 Edit Configurations 를 클릭하여 환경변수를 주입해줄 수 있습니다만, 테스트 코드에서는 불가능합니다. 그렇기 때문에 테스트 전용 yml을 만들어주었습니다.

yml 파일명에 별도로 test 키워드가 없어도 됩니다. 즉, application-test.yml 로 작성할 필요가 없습니다.

test 디렉토리 내에 application.yml이 존재하면 테스트 환경에서는 main의 application.yml보다 test의 application.yml이 우선됩니다.

테스트 코드에서는 실제 환경변수를 주입받을 필요가 없습니다. 오히려 실제 환경변수를 주입받는 것이 더 위험할 수 있습니다. 보안 상으로도 그렇고, 테스트용일 뿐인데 파일을 실제로 업로드해버리는 불상사가 발생합니다. 따라서 위와 같이 버킷명을 임의로 지어주었습니다.

 

단, region.static은 실제 존재하는 region 명으로 작성했습니다. 그렇지 않으면 존재하지 않는 region명이라고 에러가 발생합니다.

 

자, 그런데 실제로 존재하지도 않는 bucket name을 적어주었으니 @Autowired 로 주입받는 AwsS3Uploader가 제대로 동작할까요? 당연히 제대로 돌아가지 않습니다. AwsS3Uploader 빈을 생성하는 것조차 불가능하므로 Failed to load Application Context 에러가 발생합니다. 어떻게 해주면 좋을까요?

 

정답은 바로 해당 객체를 모킹해주는 것입니다.

 

MemberServiceTest.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...
 
  @MockBean
  private AwsS3Uploader awsS3Uploader;
 
// ...
 
  @Test
  @DisplayName("회원의 프로필 이미지를 변경하면 s3 서버와 연동하여 이미지를 업로드한다")
  void updateProfileImg() throws IOException {
      String expected = "test_img.jpg";
      Member member = memberRepository.save(new Member("kth990303@naver.com""a1b2c3d4""메리""010-1234-5678"));
      FileInputStream fileInputStream = new FileInputStream("src/test/resources/images/" + expected);
      MockMultipartFile mockMultipartFile = new MockMultipartFile("test_img", expected, "jpg", fileInputStream);
  
      when(awsS3Uploader.uploadImage(mockMultipartFile)).thenReturn("test_img.jpg");
      memberService.updateProfileImage(member.getEmail(), mockMultipartFile);
  
      Member actual = memberRepository.findByEmail(member.getEmail())
              .orElseThrow();
      assertThat(actual.getImgUrl()).isEqualTo(expected);
  }
cs

잘 보시면 AwsS3Uploader를 @MockBean 으로 모킹해준 것을 확인할 수 있습니다. 이렇게 @MockBean 을 이용해 등록하면 해당 환경에서는 실제 애플리케이션에 등록된 AwsS3Uploader 대신 우리가 지정해준 역할을 해주는 가짜 AwsS3Uploader 빈을 이용할 수 있습니다.

 

테스트 코드의 13~14번째 줄을 잘 보시면 MultiPartFile 자체도 mocking하여 만든 것을 확인할 수 있습니다. 굳이 실제 MultiPartFile로 번거롭게 만들 필요 없이, 해당 이미지 자체를 MultiPartFile로 인식하라고 임의로 우리 마음대로 지정해버린 것이죠.

 

16~17번째 줄을 보면 @MockBean으로 등록한 awsS3Uploader의 uploadImage 메서드 결과를 stub해주고 있는 걸 확인할 수 있습니다. 테스트 코드에서 실제로 해당 이미지를 업로드한다면 성능상으로도, 보안상으로도 문제가 발생합니다. 그렇기 때문에 Mockito.when()thenReturn()을 활용하여 우리가 임의로 행동 결과를 stub해주는 것입니다.

위와 같이 작성하면 테스트가 성공적으로 통과합니다.


트러블슈팅

퍼블릭 액세스 설정 (버킷 설정), 버킷 정책, ACL 관련하여 트러블 슈팅을 정말 많이 겪었습니다. 일단 퍼블릭 액세스를 모두 차단하고 S3 파일 업로드를 이용할 수 있게 변경하는 작업은 이후 스프린트에서 preSignedUrl 또는 AWS CloudFront를 이용하는 방법으로 진행해보려 합니다.

 

이러한 보안적 트러블슈팅 외에도 다른 문제를 만났었습니다.

바로 아래 에러입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
com.amazonaws.SdkClientException: Failed to connect to service endpoint: 
    at com.amazonaws.internal.EC2ResourceFetcher.doReadResource(EC2ResourceFetcher.java:100) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.internal.EC2ResourceFetcher.doReadResource(EC2ResourceFetcher.java:70) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.internal.InstanceMetadataServiceResourceFetcher.readResource(InstanceMetadataServiceResourceFetcher.java:75) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.internal.EC2ResourceFetcher.readResource(EC2ResourceFetcher.java:66) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.util.EC2MetadataUtils.getItems(EC2MetadataUtils.java:402) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.util.EC2MetadataUtils.getData(EC2MetadataUtils.java:371) ~[aws-java-sdk-core-1.11.792.jar:na]
    at org.springframework.cloud.aws.context.support.env.AwsCloudEnvironmentCheckUtils.isRunningOnCloudEnvironment(AwsCloudEnvironmentCheckUtils.java:38) ~[spring-cloud-aws-context-2.2.6.RELEASE.jar:2.2.6.RELEASE]
    at org.springframework.cloud.aws.context.annotation.OnAwsCloudEnvironmentCondition.matches(OnAwsCloudEnvironmentCondition.java:38) ~[spring-cloud-aws-context-2.2.6.RELEASE.jar:2.2.6.RELEASE]
    at org.springframework.context.annotation.ConditionEvaluator.shouldSkip(ConditionEvaluator.java:108) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader$TrackedConditionEvaluator.shouldSkip(ConfigurationClassBeanDefinitionReader.java:489) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForConfigurationClass(ConfigurationClassBeanDefinitionReader.java:140) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitions(ConfigurationClassBeanDefinitionReader.java:129) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:343) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:247) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:311) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:112) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:746) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:564) ~[spring-context-5.3.25.jar:5.3.25]
    at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:147) ~[spring-boot-2.7.9.jar:2.7.9]
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:731) ~[spring-boot-2.7.9.jar:2.7.9]
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:408) ~[spring-boot-2.7.9.jar:2.7.9]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:307) ~[spring-boot-2.7.9.jar:2.7.9]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1303) ~[spring-boot-2.7.9.jar:2.7.9]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1292) ~[spring-boot-2.7.9.jar:2.7.9]
    at mocacong.server.ServerApplication.main(ServerApplication.java:10) ~[main/:na]
Caused by: java.net.SocketException: Host is down
    at java.base/sun.nio.ch.Net.connect0(Native Method) ~[na:na]
    at java.base/sun.nio.ch.Net.connect(Net.java:574) ~[na:na]
    at java.base/sun.nio.ch.Net.connect(Net.java:563) ~[na:na]
    at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:588) ~[na:na]
    at java.base/java.net.Socket.connect(Socket.java:648) ~[na:na]
    at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:177) ~[na:na]
    at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:474) ~[na:na]
    at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:569) ~[na:na]
    at java.base/sun.net.www.http.HttpClient.<init>(HttpClient.java:242) ~[na:na]
    at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:341) ~[na:na]
    at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:362) ~[na:na]
    at java.base/sun.net.www.protocol.http.HttpURLConnection.getNewHttpClient(HttpURLConnection.java:1261) ~[na:na]
    at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1239) ~[na:na]
    at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1082) ~[na:na]
    at java.base/sun.net.www.protocol.http.HttpURLConnection.connect(HttpURLConnection.java:1016) ~[na:na]
    at com.amazonaws.internal.ConnectionUtils.connectToEndpoint(ConnectionUtils.java:52) ~[aws-java-sdk-core-1.11.792.jar:na]
    at com.amazonaws.internal.EC2ResourceFetcher.doReadResource(EC2ResourceFetcher.java:80) ~[aws-java-sdk-core-1.11.792.jar:na]
    ... 24 common frames omitted
cs

로컬은 aws ec2 메타데이터가 실제로 존재하지 않는 환경이므로 실행에는 상관없는 일부 에러가 발생합니다.

(버킷 객체의 ObjectMetaData 때문에 발생하는 에러가 아닙니다!)

EC2의 메타데이터를 읽다가 발생하는 에러로써, EC2 인스턴스가 아닌 곳에서는 의미가 없는 에러라고 하네요. 따라서 위 에러가 발생해도 문제는 없지만, 메타데이터를 읽는 동안 꽤 오랜 시간(약 5~10초)의 지연이 발생합니다. 따라서 아래 코드를 추가해주었습니다.

-Dcom.amazonaws.sdk.disableEc2Metadata=true

 

위와 같이 작성하더라도 com.amazonaws.AmazonClientException: EC2 Instance Metadata Service is disabled 는 여전히 발생합니다. 하지만 아까 발생했던 메타데이터 조회 지연은 사라지게 됩니다. 따라서 모카콩 서버에서는 로컬과 개발 서버에 해당 옵션을 추가한 방식으로 운영하기로 결정했습니다.


마치며

AWS S3 연동을 진행하면서 모카콩 내에 처음으로 테스트 코드에 Mockito를 작성해보았습니다. (그 이후로 기능들이 많아지면서 꽤 많이 등장하고 있다는 슬픈 사실… 😂) 앞으로 외부 api 호출이나 외부 서비스 연동이 잦아진다면 해당 작업은 더더욱 많아지게 되겠죠. 첫 단추를 잘 끼운 것 같아 만족스럽네요. 이왕이면 모카콩 규모가 매우 커져서 멀티모듈이나 MSA 환경으로 가면 진짜 좋을 거 같네요.

 

또, S3 연동하면서 인프라 및 배치성 작업에 대해 고민해보게 됐습니다. 나중에 사용자 수가 많아진다면 S3 버킷을 어떻게 관리하고 모니터링할지, 그리고 보안 관련해서 어떠한 인프라를 추가로 도입할지 계속해서 고민하게 된 것 같습니다. 그리고 사실 이후에 작업할 배포 자동화 CD 작업을 S3 서버를 이용할 가능성도 고려했기 때문에 S3 공부를 꽤 많이 하게 된 것도 있습니다. (결론을 말씀드리자면 CD는 S3 없이 진행될 것 같습니다. 이후에 CI/CD 위키에 해당 이유를 서술하겠습니다.) 그럼에도 불구하고 아직 버킷 정책이나 보안 관련은 취약하네요. 공부가 더 필요해보입니다. 모카콩이 성장할 때마다 저도 성장하고, 앞으로의 성장 기회도 많이 생기는 것 같습니다.

반응형