본문 바로가기
Framework/Spring Boot

[Spring Boot] 파일 업로드와 AWS S3 클라우드 사용하기

게시판에 사용자가 10MB짜리 고화질 사진을 첨부하여 글을 작성합니다. 초보 시절에는 MultipartFile 객체를 받아 스프링 부트(Spring Boot)가 구동되고 있는 로컬 서버의 C:\upload 디렉토리나 리눅스 /var/www/images 폴더에 file.transferTo() 코드로 저장(Save)하는 방식을 썼습니다. 서버가 딱 1대일 때는 이 방식이 기가 막히게 잘 돌아갑니다. 하지만 대박이 터져서 서버를 3대(A, B, C)로 스케일 아웃(Scale-out)하는 순간 지옥문이 열립니다. 사용자가 A서버에 접속해 프로필 사진을 올렸는데, 내일 다시 접속할 땐 로드밸런서가 B서버로 연결해 주면? B서버 디스크에는 사진이 없으므로 화면에 끔찍한 '엑스박스(이미지 깨짐)'가 뜹니다. 게다가 하드디스크 용량이 가득 차면 서버 자체가 터져버립니다. 이러한 무거운 '상태(Stateful)'의 사슬을 끊고, 이미지는 서버가 아닌 클라우드 무한 창고에 던져버리고 백엔드는 오직 그 이미지의 'URL 주소(문자열)'만 DB에 저장하는 현대 글로벌 표준 아키텍처가 등장했습니다.

AWS S3 (Simple Storage Service) 버킷 연동 완벽 가이드에 대하여 다뤄보겠습니다.

1. 왜 AWS S3 인가? (서버 무상태화의 핵심)

아마존 웹 서비스(AWS)의 S3는 무한대의 용량을 제공하는 클라우드 스토리지입니다. S3를 도입하면 스프링 부트 서버는 무거운 이미지 파일을 보관할 필요가 없어지므로 진정한 무상태(Stateless) 어플리케이션으로 거듭납니다. 이제 서버가 100대로 늘어나도 모든 서버는 하나의 S3 창고를 바라보게 되므로 엑스박스 에러가 영원히 사라집니다. 게다가 이미지 제공 트래픽을 S3가 온전히 부담하므로 우리 스프링 서버의 CPU와 네트워크 낭비를 획기적으로 줄일 수 있습니다.

 

2. AWS S3 연동 의존성 및 yml 세팅

스프링에서 S3와 통신하려면 AWS SDK 라이브러리를 추가해야 합니다. 최근에는 spring-cloud-starter-aws를 통해 아주 손쉽게 설정이 가능합니다.

// build.gradle (Spring Cloud AWS 의존성 추가)
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
# application.yml (AWS 자격 증명 설정)
cloud:
  aws:
    credentials:
      access-key: AKIA_여러분의_액세스키_입력  # 절대 Github에 퍼블릭으로 올리면 안 됩니다!! (해킹당해 수천만 원 요금 폭탄)
      secret-key: 여러분의_시크릿키_입력
    s3
      bucket: my-awesome-project-bucket # AWS 콘솔에서 만든 S3 버킷 이름
    region:
      static: ap-northeast-2 # 서울 리전
    stack:
      auto: false # EC2 인스턴스가 아닐 때 발생하는 에러 방지

 

3. S3Uploader 서비스 클래스 구현 (핵심 로직)

이제 프론트엔드로부터 MultipartFile 객체를 넘겨받아 S3로 업로드하고, 저장된 URL 주소를 문자열(String)로 반환받는 핵심 서비스 객체를 만들어 보겠습니다. 실무에서는 파일 이름이 중복되어 덮어씌워지는 것을 막기 위해 UUID(고유 식별자)를 붙여서 파일명을 난수화하는 것이 필수입니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class S3UploadService {

    private final AmazonS3 amazonS3; // 스프링이 제공하는 S3 통신 객체

    @Value("${cloud.aws.s3.bucket}") // yml에 적어둔 버킷 이름을 변수로 가져옴
    private String bucket;

    public String uploadFile(MultipartFile multipartFile) throws IOException {
        // 1. 파일 이름 중복 방지를 위해 UUID 생성 (예: 123e4567-e89b_profile.jpg)
        String originalFileName = multipartFile.getOriginalFilename();
        String uniqueFileName = UUID.randomUUID() + "_" + originalFileName;

        // 2. S3에 전송할 파일의 메타데이터(크기, 확장자 등) 세팅
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(multipartFile.getSize());
        metadata.setContentType(multipartFile.getContentType());

        // 3. S3 버킷에 파일 업로드 실행!
        amazonS3.putObject(bucket, uniqueFileName, multipartFile.getInputStream(), metadata);
        log.info("S3 업로드 성공: {}", uniqueFileName);

        // 4. 업로드 완료 후, 해당 이미지의 브라우저 접근 가능 URL을 뽑아서 리턴!
        // 이 리턴된 URL 주소를 우리 DB의 'profile_image_url' 컬럼에 String으로 쏙 저장하면 됩니다.
        return amazonS3.getUrl(bucket, uniqueFileName).toString();
    }
}

 

4. 마무리 및 주요 발전 방향

지금까지 파일을 로컬 디스크에 쑤셔 넣던 원시적인 방식을 타파하고, 무한한 스케일 아웃(Scale-out)이 가능한 무상태(Stateless) 서버 아키텍처를 완성하기 위한 'AWS S3 클라우드 스토리지' 연동 완벽 실무 가이드에 대해 다루어 보았습니다. 이제 여러분의 DB는 무거운 바이너리 덩어리 없이 깨끗한 URL 텍스트만 보관하게 되어 깃털처럼 가벼워졌습니다.

회원가입, 로그인, 데이터베이스 설계, 비동기 이메일 발송, S3 이미지 업로드까지... 지금까지 구현한 이 거대하고 훌륭한 백엔드 애플리케이션 코드가 "정말로 버그 없이 100% 정상 작동하는가?"를 어떻게 증명할 수 있을까요? 매번 코드를 수정할 때마다 포스트맨(Postman)을 열어 버튼을 수백 번 클릭하는 수동 테스트(QA)를 하실 건가요? 이런 야만적인 시대는 끝났습니다. 본 설명에서는 "코드가 코드를 검증한다! 내 코드가 살아 숨 쉼을 보증하는 백엔드의 생명줄!" JUnit5와 Mockito를 활용한 완벽한 단위 테스트(Unit Test) 및 통합 테스트 작성 비법에 대해 아주 심도 있게 살펴보겠습니다.


초보 개발자와 실무 프로 개발자를 가르는 가장 거대하고 뚜렷한 경계선은 바로 '테스트 코드(Test Code)'의 존재 여부입니다. 학원 프로젝트 시절에는 기능을 구현하고 서버를 켠 뒤, 포스트맨(Postman)으로 API를 한 번 호출해 보고 성공이 뜨면 "우와! 다 됐다!" 하고 환호하며 코딩을 끝냈습니다. 하지만 6개월 뒤, A 기능을 살짝 수정했는데 전혀 상관없어 보이던 B 기능과 C 기능이 연쇄적으로 펑펑 터지기 시작합니다. 어디서부터 손대야 할지 몰라 밤을 새우는 '레거시 코드의 늪'에 빠지게 되죠. 만약 수백 개의 기능이 "나는 지금 정상적으로 작동하고 있어!"라고 매번 자동 검증해 주는 시스템이 있었다면 어땠을까요? 코드가 코드를 검증하고, 내 로직이 100% 옳다는 것을 수학적으로 증명해 내는 마법이 바로 테스트 코드입니다.

JUnit 5와 Mockito를 활용한 단위 테스트(Unit Test) 작성 비법에 대하여 다뤄보겠습니다.

1. Given-When-Then 패턴: 명확한 테스트의 뼈대

테스트 코드를 처음 짤 때 가장 막막한 것은 "도대체 어떻게 시작해야 하지?"입니다. 전 세계 개발자들이 사랑하는 가장 명확하고 아름다운 BDD(Behavior-Driven Development) 테스트 작성 템플릿이 있습니다. 무조건 이 주석을 깔아두고 시작하세요.

  • Given (준비): 테스트를 위한 상황(데이터, 가짜 객체)을 세팅합니다. "어떤 데이터가 주어졌을 때"
  • When (실행): 우리가 실제로 테스트하고 검증하고 싶은 그 핵심 '메서드'를 실행합니다. "무언가를 하면"
  • Then (검증): 실행 결과가 내가 예상한 값(Expected)과 일치하는지 단언(Assert)합니다. "이런 결과가 나와야 한다!"

2. Mockito의 마법: 외부 통신을 싹 다 끊고 고립시켜라!

가장 중요한 단위 테스트(Unit Test)의 철학은 "오직 내 코드(Service 비즈니스 로직)만 순수하게 검증한다"는 것입니다. 회원가입 로직을 테스트하는데 실제 MySQL 데이터베이스까지 가서 쿼리가 잘 박히는지 확인하면, 그건 통합 테스트(Integration Test)지 단위 테스트가 아닙니다. DB가 꺼져있으면 테스트가 실패해버리는, 외부 환경에 의존적인 쓰레기(?) 테스트가 됩니다.

DB 연동, S3 업로드 등 나머지 외부 의존성들을 전부 '가짜 장난감(Mock)'으로 흉내 내어버리고 순수 로직만 검증하게 해주는 미친 라이브러리가 바로 Mockito입니다.


3. 완벽한 비즈니스 로직 테스트 실전 코드 (Service Test)

UserService의 '회원가입(Signup)' 로직을 테스트해 봅시다. 여기에는 "이메일 중복이면 예외(Exception)가 터져야 한다!"라는 핵심 비즈니스 룰이 있습니다.

// Spring Boot 껍데기(톰캣)를 안 띄우고 순수 Java 코드로만 번개처럼 빠르게 돕니다!
@ExtendWith(MockitoExtension.class) 
class UserServiceTest {

    @InjectMocks // 진짜로 테스트할 대상 (얘한테 가짜들을 쑤셔 넣음)
    private UserService userService;

    @Mock // 가짜(Mock) 객체 만들기 (진짜 DB 안 찌름!)
    private UserRepository userRepository;

    @Test
    @DisplayName("회원가입 실패: 중복된 이메일이면 예외가 터져야 한다")
    void signup_fail_duplicate_email() {
        // [1] Given (준비)
        String duplicateEmail = "test@gmail.com";
        UserSignupDto dto = new UserSignupDto(duplicateEmail, "1234");
        
        // Mockito의 마법: "만약 가짜 Repository한테 저 이메일로 중복검사 물어보면, 무조건 true(중복)라고 대답해 줘!" 라고 미리 세팅(조작)함
        given(userRepository.existsByEmail(duplicateEmail)).willReturn(true);

        // [2] When & Then (실행 및 검증)
        // 저 DTO를 넣고 가입 메서드를 돌리면(When), IllegalArgumentException 에러가 터져야만 한다!(Then)고 단언(Assert)
        assertThrows(IllegalArgumentException.class, () -> {
            userService.registerUser(dto);
        });
        
        // 부가 검증: 가입 로직이 터졌으니, DB 저장(save) 메서드는 단 한 번도 호출되지 않았음을 맹세해라!
        verify(userRepository, never()).save(any());
    }
}

이 테스트 코드는 서버나 DB를 켜지 않아도 마우스 클릭 0.05초 만에 초록색 [PASSED] 불을 띄우며 "네 비즈니스 로직은 예외 처리를 완벽하게 해내고 있어!"라고 증명해 줍니다. 이런 테스트 코드가 500개 쌓여있다면? 여러분은 두 다리 쭉 뻗고 숙면을 취하며, 거침없이 코드 리팩토링을 감행할 수 있는 불사신의 심장을 가지게 됩니다.


4. 마무리 및 주요 발전 방향

지금까지 코드 변경에 대한 공포심을 지워버리고 완벽한 TDD(테스트 주도 개발)의 초석을 다지기 위한, Given-When-Then 구조와 Mockito 가짜 객체를 활용한 초고속 Service 단위 테스트 작성 가이드에 대해 다루어 보았습니다. 이제 여러분의 프로젝트는 '버그 제로(Bug-Zero)'를 향한 든든한 방어선을 구축했습니다.

 

테스트도 다 통과했고, 이제 이 완벽한 .jar 파일을 서버(AWS EC2)에 던져놓고 라이브(Live) 운영을 시작했습니다. 그런데 며칠 뒤 서버가 갑자기 느려지더니 메모리가 터질 것 같습니다! "대체 서버 안에서 무슨 일이 벌어지고 있는 거지? DB 통신은 잘 되나? 메모리는 얼마나 남았지?" 이런 궁금증을 해소하기 위해 서버 컴퓨터에 들어가서 top 명령어를 치는 원시적인 행위는 그만두어야 합니다.

 

다음 설명에서는 "서버의 심장 박동과 디스크 상태, 그리고 수많은 지표를 실시간 API로 뱉어낸다!"
스프링 부트 운영의 절대적 필수품, Spring Boot Actuator 모니터링과 헬스 체크(Health Check) 세팅법
에 대해 살펴보겠습니다.