Theory
고급 2-3시간 이론 + 실습

Spring 20: 클라우드 - AWS 연동

AWS 서비스 통합

AWSS3SQSParameter StoreECS

1. AWS 기초 - 클라우드 개념과 Spring Cloud AWS

1.1 클라우드 컴퓨팅 개념

클라우드 컴퓨팅은 인터넷을 통해 컴퓨팅 리소스(서버, 스토리지, 데이터베이스, 네트워킹, 소프트웨어, 분석, 인텔리전스)를 온디맨드로 제공하는 서비스입니다. 기존의 온프레미스 인프라 대비 비용 효율성, 확장성, 유연성을 제공합니다.

NIST 클라우드 컴퓨팅의 5가지 핵심 특징

온디맨드 셀프 서비스

사용자가 필요에 따라 자동으로 컴퓨팅 리소스를 프로비저닝

광범위한 네트워크 액세스

다양한 클라이언트 플랫폼에서 네트워크를 통해 접근

리소스 풀링

멀티테넌트 모델을 사용하여 여러 소비자에게 리소스 제공

신속한 탄력성

수요에 따라 리소스를 빠르게 확장하거나 축소

측정 가능한 서비스

리소스 사용량을 모니터링하고 제어하며 보고

클라우드 서비스 모델

IaaS

Infrastructure as a Service

가상화된 컴퓨팅 리소스 (EC2, VPC)

PaaS

Platform as a Service

애플리케이션 개발 플랫폼 (Elastic Beanstalk)

SaaS

Software as a Service

완전한 소프트웨어 솔루션 (WorkMail)

1.2 AWS 주요 서비스 개요

컴퓨팅 서비스

  • EC2: 가상 서버 인스턴스 - 확장 가능한 컴퓨팅 용량
  • Lambda: 서버리스 컴퓨팅 - 이벤트 기반 코드 실행
  • ECS: 컨테이너 오케스트레이션 - Docker 컨테이너 관리
  • EKS: 관리형 Kubernetes - 컨테이너 오케스트레이션
  • Fargate: 서버리스 컨테이너 - 인프라 관리 없는 컨테이너

스토리지 서비스

  • S3: 객체 스토리지 - 무제한 확장 가능한 스토리지
  • EBS: 블록 스토리지 - EC2용 고성능 스토리지
  • EFS: 파일 시스템 - 완전 관리형 NFS
  • Glacier: 아카이브 스토리지 - 장기 보관용 저비용
  • Storage Gateway: 하이브리드 스토리지 연결

데이터베이스 서비스

  • RDS: 관계형 데이터베이스 - MySQL, PostgreSQL 등
  • DynamoDB: NoSQL 데이터베이스 - 고성능 키-값 저장소
  • ElastiCache: 인메모리 캐시 - Redis, Memcached
  • Redshift: 데이터 웨어하우스 - 페타바이트급 분석
  • DocumentDB: MongoDB 호환 문서 데이터베이스

네트워킹 서비스

  • VPC: 가상 프라이빗 클라우드 - 격리된 네트워크
  • CloudFront: CDN 서비스 - 글로벌 콘텐츠 배포
  • Route 53: DNS 서비스 - 도메인 이름 시스템
  • API Gateway: API 관리 - RESTful API 생성/관리
  • Load Balancer: 로드 밸런싱 - 트래픽 분산

AWS 글로벌 인프라

리전 (Regions)

전 세계 25개 이상의 지리적 영역

데이터 주권과 지연 시간 최적화

가용 영역 (AZ)

각 리전 내 독립적인 데이터센터

고가용성과 내결함성 제공

엣지 로케이션

CloudFront CDN 캐시 서버

전 세계 400개 이상 위치

1.3 Spring Cloud AWS 소개

Spring Cloud AWS는 Spring Framework와 AWS 서비스를 쉽게 통합할 수 있도록 도와주는 라이브러리입니다. AWS의 다양한 서비스를 Spring의 프로그래밍 모델과 추상화를 통해 사용할 수 있습니다.

주요 기능

  • • EC2 인스턴스 메타데이터 접근
  • • S3 리소스 추상화
  • • SQS 메시징 지원
  • • SNS 알림 서비스
  • • RDS 데이터베이스 연동
  • • ElastiCache 캐시 연동
  • • CloudFormation 스택 관리
  • • Parameter Store 설정 관리

Maven 의존성 설정

<dependencies>
    <!-- Spring Cloud AWS Core -->
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter</artifactId>
        <version>3.0.3</version>
    </dependency>
    
    <!-- Spring Cloud AWS S3 -->
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter-s3</artifactId>
        <version>3.0.3</version>
    </dependency>
    
    <!-- Spring Cloud AWS SQS -->
    <dependency>
        <groupId>io.awspring.cloud</groupId>
        <artifactId>spring-cloud-aws-starter-sqs</artifactId>
        <version>3.0.3</version>
    </dependency>
    
    <!-- AWS SDK v2 -->
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>bom</artifactId>
        <version>2.21.29</version>
        <type>pom</type>
        <scope>import</scope>
    </dependency>
</dependencies>

기본 AWS 설정 클래스

@Configuration
@EnableConfigurationProperties
public class AwsConfig {

    @Bean
    @Primary
    public AwsCredentialsProvider awsCredentialsProvider() {
        return DefaultCredentialsProvider.create();
    }

    @Bean
    public Region awsRegion() {
        return Region.AP_NORTHEAST_2; // 서울 리전
    }

    @Bean
    public S3Client s3Client(AwsCredentialsProvider credentialsProvider, Region region) {
        return S3Client.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }

    @Bean
    public SqsClient sqsClient(AwsCredentialsProvider credentialsProvider, Region region) {
        return SqsClient.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }

    @Bean
    public SnsClient snsClient(AwsCredentialsProvider credentialsProvider, Region region) {
        return SnsClient.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }
}

AWS 자격 증명 설정 방법

1. AWS CLI 설정
aws configure
AWS Access Key ID: YOUR_ACCESS_KEY
AWS Secret Access Key: YOUR_SECRET_KEY
Default region name: ap-northeast-2
Default output format: json
2. 환경 변수 설정
export AWS_ACCESS_KEY_ID=your_access_key
export AWS_SECRET_ACCESS_KEY=your_secret_key
export AWS_DEFAULT_REGION=ap-northeast-2
3. IAM 역할 (EC2에서 실행 시)

EC2 인스턴스에 IAM 역할을 연결하여 자동으로 자격 증명 획득

1.4 AWS 보안 모델

공동 책임 모델 (Shared Responsibility Model)

AWS 책임 영역
  • • 물리적 인프라 보안
  • • 하드웨어 및 소프트웨어 패치
  • • 네트워크 인프라 보안
  • • 서비스 가용성 보장
  • • 데이터센터 물리적 보안
고객 책임 영역
  • • 데이터 암호화
  • • 네트워크 트래픽 보호
  • • 운영체제 패치
  • • 애플리케이션 보안
  • • IAM 사용자 관리

IAM 정책 예제

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::my-bucket/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "sqs:SendMessage",
                "sqs:ReceiveMessage",
                "sqs:DeleteMessage"
            ],
            "Resource": "arn:aws:sqs:ap-northeast-2:123456789012:my-queue"
        }
    ]
}

2. S3 연동 - 파일 업로드/다운로드와 보안

2.1 S3 기본 설정 및 연동

Amazon S3(Simple Storage Service)는 업계 최고의 확장성, 데이터 가용성, 보안 및 성능을 제공하는 객체 스토리지 서비스입니다. Spring Boot 애플리케이션에서 S3를 연동하여 파일 업로드, 다운로드, 관리 기능을 구현할 수 있습니다.

S3 의존성 및 설정

<!-- Maven 의존성 -->
<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-s3</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
</dependency>

<!-- application.yml 설정 -->
spring:
  cloud:
    aws:
      credentials:
        access-key: ${AWS_ACCESS_KEY_ID}
        secret-key: ${AWS_SECRET_ACCESS_KEY}
      region:
        static: ap-northeast-2
      s3:
        bucket: my-spring-bucket
        path-style-access-enabled: true

S3 설정 클래스

@Configuration
@EnableConfigurationProperties(S3Properties.class)
public class S3Config {

    @Bean
    public S3Client s3Client(AwsCredentialsProvider credentialsProvider, Region region) {
        return S3Client.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }

    @Bean
    public S3Template s3Template(S3Client s3Client) {
        return new S3Template(s3Client);
    }
}

@ConfigurationProperties(prefix = "spring.cloud.aws.s3")
@Data
public class S3Properties {
    private String bucket;
    private String region = "ap-northeast-2";
    private boolean pathStyleAccessEnabled = true;
}

2.2 파일 업로드 구현

S3 서비스 클래스

@Service
@RequiredArgsConstructor
@Slf4j
public class S3FileService {
    
    private final S3Client s3Client;
    private final S3Properties s3Properties;
    
    public String uploadFile(MultipartFile file) {
        try {
            String fileName = generateFileName(file.getOriginalFilename());
            String contentType = file.getContentType();
            
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .contentType(contentType)
                    .contentLength(file.getSize())
                    .build();
            
            s3Client.putObject(putObjectRequest, 
                RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
            
            log.info("파일 업로드 완료: {}", fileName);
            return fileName;
            
        } catch (Exception e) {
            log.error("파일 업로드 실패", e);
            throw new RuntimeException("파일 업로드에 실패했습니다.", e);
        }
    }
    
    public String uploadFileWithMetadata(MultipartFile file, Map<String, String> metadata) {
        try {
            String fileName = generateFileName(file.getOriginalFilename());
            
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .contentType(file.getContentType())
                    .contentLength(file.getSize())
                    .metadata(metadata)
                    .build();
            
            s3Client.putObject(putObjectRequest, 
                RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
            
            return fileName;
            
        } catch (Exception e) {
            throw new RuntimeException("메타데이터와 함께 파일 업로드 실패", e);
        }
    }
    
    private String generateFileName(String originalFilename) {
        String extension = StringUtils.getFilenameExtension(originalFilename);
        String baseName = StringUtils.stripFilenameExtension(originalFilename);
        return String.format("%s_%d.%s", 
            baseName, System.currentTimeMillis(), extension);
    }
}

파일 업로드 컨트롤러

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
@Validated
public class FileUploadController {
    
    private final S3FileService s3FileService;
    
    @PostMapping("/upload")
    public ResponseEntity<FileUploadResponse> uploadFile(
            @RequestParam("file") @Valid @NotNull MultipartFile file) {
        
        // 파일 검증
        validateFile(file);
        
        try {
            String fileName = s3FileService.uploadFile(file);
            String fileUrl = generateFileUrl(fileName);
            
            FileUploadResponse response = FileUploadResponse.builder()
                    .fileName(fileName)
                    .originalFileName(file.getOriginalFilename())
                    .fileUrl(fileUrl)
                    .fileSize(file.getSize())
                    .contentType(file.getContentType())
                    .uploadTime(LocalDateTime.now())
                    .build();
            
            return ResponseEntity.ok(response);
            
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(FileUploadResponse.builder()
                            .error("파일 업로드 실패: " + e.getMessage())
                            .build());
        }
    }
    
    @PostMapping("/upload/multiple")
    public ResponseEntity<List<FileUploadResponse>> uploadMultipleFiles(
            @RequestParam("files") List<MultipartFile> files) {
        
        List<FileUploadResponse> responses = files.stream()
                .map(file -> {
                    try {
                        validateFile(file);
                        String fileName = s3FileService.uploadFile(file);
                        return FileUploadResponse.builder()
                                .fileName(fileName)
                                .originalFileName(file.getOriginalFilename())
                                .fileUrl(generateFileUrl(fileName))
                                .fileSize(file.getSize())
                                .contentType(file.getContentType())
                                .uploadTime(LocalDateTime.now())
                                .build();
                    } catch (Exception e) {
                        return FileUploadResponse.builder()
                                .originalFileName(file.getOriginalFilename())
                                .error("업로드 실패: " + e.getMessage())
                                .build();
                    }
                })
                .collect(Collectors.toList());
        
        return ResponseEntity.ok(responses);
    }
    
    private void validateFile(MultipartFile file) {
        if (file.isEmpty()) {
            throw new IllegalArgumentException("파일이 비어있습니다.");
        }
        
        // 파일 크기 제한 (10MB)
        if (file.getSize() > 10 * 1024 * 1024) {
            throw new IllegalArgumentException("파일 크기는 10MB를 초과할 수 없습니다.");
        }
        
        // 허용된 파일 타입 검증
        String contentType = file.getContentType();
        List<String> allowedTypes = Arrays.asList(
            "image/jpeg", "image/png", "image/gif", 
            "application/pdf", "text/plain"
        );
        
        if (!allowedTypes.contains(contentType)) {
            throw new IllegalArgumentException("지원하지 않는 파일 형식입니다.");
        }
    }
    
    private String generateFileUrl(String fileName) {
        return String.format("https://%s.s3.%s.amazonaws.com/%s", 
            s3Properties.getBucket(), s3Properties.getRegion(), fileName);
    }
}

2.3 파일 다운로드 구현

파일 다운로드 서비스

@Service
@RequiredArgsConstructor
@Slf4j
public class S3DownloadService {
    
    private final S3Client s3Client;
    private final S3Properties s3Properties;
    
    public ResponseEntity<Resource> downloadFile(String fileName) {
        try {
            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .build();
            
            ResponseInputStream<GetObjectResponse> s3Object = 
                s3Client.getObject(getObjectRequest);
            
            byte[] content = s3Object.readAllBytes();
            ByteArrayResource resource = new ByteArrayResource(content);
            
            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType(
                        s3Object.response().contentType()))
                    .contentLength(content.length)
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                        "attachment; filename="" + fileName + """)
                    .body(resource);
                    
        } catch (NoSuchKeyException e) {
            log.error("파일을 찾을 수 없습니다: {}", fileName);
            throw new FileNotFoundException("파일을 찾을 수 없습니다: " + fileName);
        } catch (Exception e) {
            log.error("파일 다운로드 실패: {}", fileName, e);
            throw new RuntimeException("파일 다운로드에 실패했습니다.", e);
        }
    }
    
    public InputStream getFileStream(String fileName) {
        try {
            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .build();
            
            return s3Client.getObject(getObjectRequest);
            
        } catch (Exception e) {
            throw new RuntimeException("파일 스트림 획득 실패", e);
        }
    }
    
    public boolean fileExists(String fileName) {
        try {
            HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .build();
            
            s3Client.headObject(headObjectRequest);
            return true;
            
        } catch (NoSuchKeyException e) {
            return false;
        } catch (Exception e) {
            log.error("파일 존재 확인 실패: {}", fileName, e);
            return false;
        }
    }
}

다운로드 컨트롤러

@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileDownloadController {
    
    private final S3DownloadService s3DownloadService;
    
    @GetMapping("/download/{fileName}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) {
        
        if (!s3DownloadService.fileExists(fileName)) {
            return ResponseEntity.notFound().build();
        }
        
        return s3DownloadService.downloadFile(fileName);
    }
    
    @GetMapping("/stream/{fileName}")
    public ResponseEntity<StreamingResponseBody> streamFile(@PathVariable String fileName) {
        
        if (!s3DownloadService.fileExists(fileName)) {
            return ResponseEntity.notFound().build();
        }
        
        StreamingResponseBody stream = outputStream -> {
            try (InputStream inputStream = s3DownloadService.getFileStream(fileName)) {
                IOUtils.copy(inputStream, outputStream);
            } catch (Exception e) {
                throw new RuntimeException("파일 스트리밍 실패", e);
            }
        };
        
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename="" + fileName + """)
                .body(stream);
    }
}

2.4 Presigned URL 구현

Presigned URL이란?

Presigned URL은 임시로 S3 객체에 대한 액세스 권한을 부여하는 URL입니다. 클라이언트가 직접 S3에 파일을 업로드하거나 다운로드할 수 있어 서버 부하를 줄일 수 있습니다.

Presigned URL 서비스

@Service
@RequiredArgsConstructor
@Slf4j
public class S3PresignedUrlService {
    
    private final S3Presigner s3Presigner;
    private final S3Properties s3Properties;
    
    public String generatePresignedUploadUrl(String fileName, Duration expiration) {
        try {
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .build();
            
            PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                    .signatureDuration(expiration)
                    .putObjectRequest(putObjectRequest)
                    .build();
            
            PresignedPutObjectRequest presignedRequest = 
                s3Presigner.presignPutObject(presignRequest);
            
            log.info("업로드용 Presigned URL 생성: {}", fileName);
            return presignedRequest.url().toString();
            
        } catch (Exception e) {
            log.error("Presigned URL 생성 실패", e);
            throw new RuntimeException("Presigned URL 생성에 실패했습니다.", e);
        }
    }
    
    public String generatePresignedDownloadUrl(String fileName, Duration expiration) {
        try {
            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .build();
            
            GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
                    .signatureDuration(expiration)
                    .getObjectRequest(getObjectRequest)
                    .build();
            
            PresignedGetObjectRequest presignedRequest = 
                s3Presigner.presignGetObject(presignRequest);
            
            return presignedRequest.url().toString();
            
        } catch (Exception e) {
            throw new RuntimeException("다운로드용 Presigned URL 생성 실패", e);
        }
    }
    
    public Map<String, String> generatePresignedPostUrl(String fileName, 
            Duration expiration, Map<String, String> conditions) {
        try {
            PostSignatureV4 postSignature = PostSignatureV4.builder()
                    .bucket(s3Properties.getBucket())
                    .key(fileName)
                    .expiration(Instant.now().plus(expiration))
                    .conditions(conditions)
                    .build();
            
            return postSignature.getFormData();
            
        } catch (Exception e) {
            throw new RuntimeException("POST용 Presigned URL 생성 실패", e);
        }
    }
}

Presigned URL 컨트롤러

@RestController
@RequestMapping("/api/presigned")
@RequiredArgsConstructor
public class PresignedUrlController {
    
    private final S3PresignedUrlService presignedUrlService;
    
    @PostMapping("/upload-url")
    public ResponseEntity<PresignedUrlResponse> generateUploadUrl(
            @RequestBody PresignedUrlRequest request) {
        
        String fileName = generateFileName(request.getOriginalFileName());
        Duration expiration = Duration.ofMinutes(request.getExpirationMinutes());
        
        String presignedUrl = presignedUrlService
            .generatePresignedUploadUrl(fileName, expiration);
        
        PresignedUrlResponse response = PresignedUrlResponse.builder()
                .presignedUrl(presignedUrl)
                .fileName(fileName)
                .expiresAt(LocalDateTime.now().plus(expiration))
                .build();
        
        return ResponseEntity.ok(response);
    }
    
    @GetMapping("/download-url/{fileName}")
    public ResponseEntity<PresignedUrlResponse> generateDownloadUrl(
            @PathVariable String fileName,
            @RequestParam(defaultValue = "60") int expirationMinutes) {
        
        Duration expiration = Duration.ofMinutes(expirationMinutes);
        
        String presignedUrl = presignedUrlService
            .generatePresignedDownloadUrl(fileName, expiration);
        
        PresignedUrlResponse response = PresignedUrlResponse.builder()
                .presignedUrl(presignedUrl)
                .fileName(fileName)
                .expiresAt(LocalDateTime.now().plus(expiration))
                .build();
        
        return ResponseEntity.ok(response);
    }
    
    private String generateFileName(String originalFilename) {
        String extension = StringUtils.getFilenameExtension(originalFilename);
        String baseName = StringUtils.stripFilenameExtension(originalFilename);
        return String.format("%s_%d.%s", 
            baseName, System.currentTimeMillis(), extension);
    }
}

2.5 S3 권한 설정 및 보안

S3 버킷 정책 예제

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::my-spring-bucket/public/*"
        },
        {
            "Sid": "RestrictedUpload",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::123456789012:role/SpringAppRole"
            },
            "Action": [
                "s3:PutObject",
                "s3:PutObjectAcl"
            ],
            "Resource": "arn:aws:s3:::my-spring-bucket/uploads/*",
            "Condition": {
                "StringEquals": {
                    "s3:x-amz-server-side-encryption": "AES256"
                }
            }
        }
    ]
}

CORS 설정

@Configuration
public class S3CorsConfig {
    
    @Bean
    public CorsRule s3CorsRule() {
        return CorsRule.builder()
                .allowedHeaders("*")
                .allowedMethods("GET", "PUT", "POST", "DELETE", "HEAD")
                .allowedOrigins("https://myapp.com", "http://localhost:3000")
                .exposeHeaders("ETag", "x-amz-meta-custom-header")
                .maxAgeSeconds(3000)
                .build();
    }
    
    @PostConstruct
    public void configureCors() {
        PutBucketCorsRequest corsRequest = PutBucketCorsRequest.builder()
                .bucket(s3Properties.getBucket())
                .corsConfiguration(CorsConfiguration.builder()
                        .corsRules(s3CorsRule())
                        .build())
                .build();
        
        s3Client.putBucketCors(corsRequest);
    }
}

보안 모범 사례

암호화

서버 측 암호화(SSE) 활성화

액세스 제어

IAM 정책과 버킷 정책 조합 사용

로깅

CloudTrail과 S3 액세스 로깅 활성화

버전 관리

중요 데이터의 버전 관리 활성화

3. RDS 연동 - 데이터베이스 설정과 관리

3.1 RDS 기본 설정 및 연동

Amazon RDS(Relational Database Service)는 클라우드에서 관계형 데이터베이스를 쉽게 설정, 운영 및 확장할 수 있게 해주는 관리형 서비스입니다. Spring Boot 애플리케이션과 RDS를 연동하여 안정적이고 확장 가능한 데이터베이스 솔루션을 구축할 수 있습니다.

RDS 의존성 설정

<!-- Maven 의존성 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
</dependency>
<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-rds</artifactId>
    <version>3.0.3</version>
</dependency>

application.yml 데이터베이스 설정

spring:
  datasource:
    url: jdbc:mysql://mydb.cluster-xyz.ap-northeast-2.rds.amazonaws.com:3306/springdb
    username: ${DB_USERNAME:admin}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
    
    # HikariCP 커넥션 풀 설정
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 60000
      
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        format_sql: true
        use_sql_comments: true
        jdbc:
          batch_size: 20
        order_inserts: true
        order_updates: true
        
  # AWS RDS 설정
  cloud:
    aws:
      rds:
        instances:
          - db-instance-identifier: mydb-instance
            password: ${DB_PASSWORD}
            username: ${DB_USERNAME:admin}
            
# 로깅 설정
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    com.zaxxer.hikari: DEBUG

데이터베이스 설정 클래스

@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@EnableTransactionManagement
public class DatabaseConfig {

    @Value("${spring.datasource.url}")
    private String dbUrl;
    
    @Value("${spring.datasource.username}")
    private String dbUsername;
    
    @Value("${spring.datasource.password}")
    private String dbPassword;

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.hikari")
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(dbUrl);
        config.setUsername(dbUsername);
        config.setPassword(dbPassword);
        config.setDriverClassName("com.mysql.cj.jdbc.Driver");
        
        // 커넥션 풀 최적화
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        config.setLeakDetectionThreshold(60000);
        
        // 커넥션 검증
        config.setConnectionTestQuery("SELECT 1");
        config.setValidationTimeout(5000);
        
        return new HikariDataSource(config);
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em = 
            new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.entity");
        
        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        em.setJpaVendorAdapter(vendorAdapter);
        
        Properties properties = new Properties();
        properties.setProperty("hibernate.dialect", 
            "org.hibernate.dialect.MySQL8Dialect");
        properties.setProperty("hibernate.show_sql", "false");
        properties.setProperty("hibernate.format_sql", "true");
        properties.setProperty("hibernate.jdbc.batch_size", "20");
        properties.setProperty("hibernate.order_inserts", "true");
        properties.setProperty("hibernate.order_updates", "true");
        
        em.setJpaProperties(properties);
        return em;
    }

    @Bean
    public PlatformTransactionManager transactionManager(
            EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);
        return transactionManager;
    }
}

3.2 커넥션 풀 최적화

커넥션 풀이란?

커넥션 풀은 데이터베이스 연결을 미리 생성하여 풀에 저장해두고, 필요할 때마다 재사용하는 기술입니다. 연결 생성/해제 비용을 줄여 성능을 향상시킵니다.

HikariCP 고급 설정

@Configuration
public class HikariConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public HikariDataSource hikariDataSource() {
        HikariConfig config = new HikariConfig();
        
        // 기본 연결 설정
        config.setJdbcUrl(dbUrl);
        config.setUsername(dbUsername);
        config.setPassword(dbPassword);
        config.setDriverClassName("com.mysql.cj.jdbc.Driver");
        
        // 풀 크기 설정
        config.setMaximumPoolSize(getOptimalPoolSize());
        config.setMinimumIdle(5);
        
        // 타임아웃 설정
        config.setConnectionTimeout(30000);      // 30초
        config.setIdleTimeout(600000);           // 10분
        config.setMaxLifetime(1800000);          // 30분
        config.setLeakDetectionThreshold(60000); // 1분
        
        // 커넥션 검증
        config.setConnectionTestQuery("SELECT 1");
        config.setValidationTimeout(5000);
        
        // 성능 최적화
        config.addDataSourceProperty("cachePrepStmts", "true");
        config.addDataSourceProperty("prepStmtCacheSize", "250");
        config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
        config.addDataSourceProperty("useServerPrepStmts", "true");
        config.addDataSourceProperty("useLocalSessionState", "true");
        config.addDataSourceProperty("rewriteBatchedStatements", "true");
        config.addDataSourceProperty("cacheResultSetMetadata", "true");
        config.addDataSourceProperty("cacheServerConfiguration", "true");
        config.addDataSourceProperty("elideSetAutoCommits", "true");
        config.addDataSourceProperty("maintainTimeStats", "false");
        
        return new HikariDataSource(config);
    }
    
    private int getOptimalPoolSize() {
        // CPU 코어 수 기반 최적 풀 크기 계산
        int cores = Runtime.getRuntime().availableProcessors();
        return cores * 2 + 1; // 일반적인 공식
    }
    
    @EventListener
    public void handleContextRefresh(ContextRefreshedEvent event) {
        HikariDataSource dataSource = event.getApplicationContext()
            .getBean(HikariDataSource.class);
        
        log.info("HikariCP 설정 정보:");
        log.info("최대 풀 크기: {}", dataSource.getMaximumPoolSize());
        log.info("최소 유휴 연결: {}", dataSource.getMinimumIdle());
        log.info("연결 타임아웃: {}ms", dataSource.getConnectionTimeout());
    }
}

커넥션 풀 모니터링

@Component
@Slf4j
public class ConnectionPoolMonitor {
    
    private final HikariDataSource dataSource;
    private final MeterRegistry meterRegistry;
    
    public ConnectionPoolMonitor(HikariDataSource dataSource, 
                               MeterRegistry meterRegistry) {
        this.dataSource = dataSource;
        this.meterRegistry = meterRegistry;
        registerMetrics();
    }
    
    private void registerMetrics() {
        Gauge.builder("hikari.connections.active")
            .description("Active connections")
            .register(meterRegistry, dataSource, ds -> ds.getHikariPoolMXBean().getActiveConnections());
            
        Gauge.builder("hikari.connections.idle")
            .description("Idle connections")
            .register(meterRegistry, dataSource, ds -> ds.getHikariPoolMXBean().getIdleConnections());
            
        Gauge.builder("hikari.connections.pending")
            .description("Pending connections")
            .register(meterRegistry, dataSource, ds -> ds.getHikariPoolMXBean().getThreadsAwaitingConnection());
            
        Gauge.builder("hikari.connections.total")
            .description("Total connections")
            .register(meterRegistry, dataSource, ds -> ds.getHikariPoolMXBean().getTotalConnections());
    }
    
    @Scheduled(fixedRate = 30000) // 30초마다 실행
    public void logConnectionPoolStats() {
        HikariPoolMXBean poolBean = dataSource.getHikariPoolMXBean();
        
        log.info("커넥션 풀 상태 - 활성: {}, 유휴: {}, 대기: {}, 전체: {}", 
            poolBean.getActiveConnections(),
            poolBean.getIdleConnections(), 
            poolBean.getThreadsAwaitingConnection(),
            poolBean.getTotalConnections());
    }
    
    @EventListener
    public void handleConnectionPoolAlert(ConnectionPoolAlertEvent event) {
        if (event.getActiveConnections() > dataSource.getMaximumPoolSize() * 0.8) {
            log.warn("커넥션 풀 사용률이 80%를 초과했습니다: {}/{}", 
                event.getActiveConnections(), dataSource.getMaximumPoolSize());
        }
    }
}

3.3 AWS Secrets Manager 연동

AWS Secrets Manager란?

데이터베이스 자격 증명, API 키 및 기타 보안 정보를 안전하게 저장하고 관리하는 서비스입니다. 자동 로테이션과 세밀한 액세스 제어를 제공합니다.

Secrets Manager 의존성

<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-secrets-manager</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>secretsmanager</artifactId>
</dependency>

Secrets Manager 설정

@Configuration
@EnableConfigurationProperties(SecretsManagerProperties.class)
public class SecretsManagerConfig {

    @Bean
    public SecretsManagerClient secretsManagerClient(
            AwsCredentialsProvider credentialsProvider, Region region) {
        return SecretsManagerClient.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }
}

@ConfigurationProperties(prefix = "aws.secretsmanager")
@Data
public class SecretsManagerProperties {
    private String dbSecretName = "prod/myapp/db";
    private String region = "ap-northeast-2";
    private boolean enabled = true;
    private int refreshInterval = 3600; // 1시간
}

Secrets Manager 서비스

@Service
@RequiredArgsConstructor
@Slf4j
public class SecretsManagerService {
    
    private final SecretsManagerClient secretsManagerClient;
    private final SecretsManagerProperties properties;
    private final ObjectMapper objectMapper;
    
    @Cacheable(value = "secrets", key = "#secretName")
    public DatabaseCredentials getDatabaseCredentials(String secretName) {
        try {
            GetSecretValueRequest request = GetSecretValueRequest.builder()
                    .secretId(secretName)
                    .build();
            
            GetSecretValueResponse response = secretsManagerClient.getSecretValue(request);
            String secretString = response.secretString();
            
            return objectMapper.readValue(secretString, DatabaseCredentials.class);
            
        } catch (Exception e) {
            log.error("시크릿 조회 실패: {}", secretName, e);
            throw new RuntimeException("데이터베이스 자격 증명 조회 실패", e);
        }
    }
    
    public void updateSecret(String secretName, DatabaseCredentials credentials) {
        try {
            String secretValue = objectMapper.writeValueAsString(credentials);
            
            UpdateSecretRequest request = UpdateSecretRequest.builder()
                    .secretId(secretName)
                    .secretString(secretValue)
                    .build();
            
            secretsManagerClient.updateSecret(request);
            
            // 캐시 무효화
            evictSecretCache(secretName);
            
            log.info("시크릿 업데이트 완료: {}", secretName);
            
        } catch (Exception e) {
            log.error("시크릿 업데이트 실패: {}", secretName, e);
            throw new RuntimeException("시크릿 업데이트 실패", e);
        }
    }
    
    @CacheEvict(value = "secrets", key = "#secretName")
    public void evictSecretCache(String secretName) {
        log.info("시크릿 캐시 무효화: {}", secretName);
    }
    
    @Scheduled(fixedRateString = "${aws.secretsmanager.refresh-interval:3600}000")
    public void refreshSecrets() {
        log.info("시크릿 캐시 새로고침 시작");
        // 모든 시크릿 캐시 무효화
        CacheManager cacheManager = getCacheManager();
        Cache secretsCache = cacheManager.getCache("secrets");
        if (secretsCache != null) {
            secretsCache.clear();
        }
    }
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DatabaseCredentials {
    private String username;
    private String password;
    private String engine;
    private String host;
    private int port;
    private String dbname;
    private String dbInstanceIdentifier;
}

동적 데이터소스 설정

@Configuration
public class DynamicDataSourceConfig {
    
    private final SecretsManagerService secretsManagerService;
    
    @Bean
    @Primary
    public DataSource dataSource() {
        return new DynamicDataSource();
    }
    
    public class DynamicDataSource implements DataSource {
        
        private volatile HikariDataSource actualDataSource;
        private final Object lock = new Object();
        
        @Override
        public Connection getConnection() throws SQLException {
            return getActualDataSource().getConnection();
        }
        
        @Override
        public Connection getConnection(String username, String password) 
                throws SQLException {
            return getActualDataSource().getConnection(username, password);
        }
        
        private DataSource getActualDataSource() {
            if (actualDataSource == null) {
                synchronized (lock) {
                    if (actualDataSource == null) {
                        actualDataSource = createDataSource();
                    }
                }
            }
            return actualDataSource;
        }
        
        private HikariDataSource createDataSource() {
            DatabaseCredentials credentials = secretsManagerService
                .getDatabaseCredentials(properties.getDbSecretName());
            
            HikariConfig config = new HikariConfig();
            config.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", 
                credentials.getHost(), credentials.getPort(), credentials.getDbname()));
            config.setUsername(credentials.getUsername());
            config.setPassword(credentials.getPassword());
            config.setDriverClassName("com.mysql.cj.jdbc.Driver");
            
            // 커넥션 풀 설정
            config.setMaximumPoolSize(20);
            config.setMinimumIdle(5);
            config.setConnectionTimeout(30000);
            config.setIdleTimeout(600000);
            config.setMaxLifetime(1800000);
            
            return new HikariDataSource(config);
        }
        
        @EventListener
        public void handleSecretRotation(SecretRotationEvent event) {
            synchronized (lock) {
                if (actualDataSource != null) {
                    actualDataSource.close();
                    actualDataSource = null;
                }
                log.info("데이터소스 재생성 완료 - 시크릿 로테이션");
            }
        }
    }
}

4. SQS/SNS - 메시지 큐와 알림 서비스

4.1 SQS (Simple Queue Service) 기본 설정

Amazon SQS는 완전 관리형 메시지 큐 서비스로, 마이크로서비스, 분산 시스템 및 서버리스 애플리케이션을 분리하고 확장할 수 있게 해줍니다. Spring Boot와 SQS를 연동하여 비동기 메시지 처리를 구현할 수 있습니다.

SQS 의존성 설정

<!-- Maven 의존성 -->
<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-sqs</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>sqs</artifactId>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

SQS 설정 클래스

@Configuration
@EnableSqs
@EnableConfigurationProperties(SqsProperties.class)
public class SqsConfig {

    @Bean
    public SqsClient sqsClient(AwsCredentialsProvider credentialsProvider, Region region) {
        return SqsClient.builder()
                .credentialsProvider(credentialsProvider)
                .region(region)
                .build();
    }

    @Bean
    public SqsTemplate sqsTemplate(SqsClient sqsClient) {
        return SqsTemplate.builder()
                .sqsClient(sqsClient)
                .build();
    }

    @Bean
    public SqsMessageListenerContainerFactory sqsListenerContainerFactory(
            SqsClient sqsClient) {
        return SqsMessageListenerContainerFactory.builder()
                .sqsClient(sqsClient)
                .maxConcurrentMessages(10)
                .maxMessagesPerPoll(10)
                .pollTimeout(Duration.ofSeconds(10))
                .build();
    }
}

@ConfigurationProperties(prefix = "aws.sqs")
@Data
public class SqsProperties {
    private String orderQueue = "order-processing-queue";
    private String notificationQueue = "notification-queue";
    private String deadLetterQueue = "dead-letter-queue";
    private String region = "ap-northeast-2";
    private int maxReceiveCount = 3;
    private int visibilityTimeoutSeconds = 30;
    private int messageRetentionPeriod = 1209600; // 14일
}

5. SQS 메시징

5.1 AWS SQS 연동

AWS SQS를 사용하여 비동기 메시지 처리를 구현합니다.

@Service
public class SqsService {
    
    @Autowired
    private AmazonSQS amazonSQS;
    
    private final String queueUrl = "https://sqs.ap-northeast-2.amazonaws.com/123456789/my-queue";
    
    public void sendMessage(String message) {
        SendMessageRequest request = new SendMessageRequest()
            .withQueueUrl(queueUrl)
            .withMessageBody(message);
            
        amazonSQS.sendMessage(request);
    }
    
    @SqsListener("my-queue")
    public void receiveMessage(String message) {
        log.info("메시지 수신: {}", message);
        // 메시지 처리 로직
        processMessage(message);
    }
    
    private void processMessage(String message) {
        // 비즈니스 로직 처리
        try {
            // 메시지 처리
            Thread.sleep(1000);
            log.info("메시지 처리 완료: {}", message);
        } catch (Exception e) {
            log.error("메시지 처리 실패", e);
            throw new RuntimeException("메시지 처리 실패", e);
        }
    }
}