🔌 SPARTA/Assignments

[클라우드 과제] 클라우드_아키텍처 설계 & 배포

eunjiom 2026. 3. 13. 10:05

Lv 0 AWS Budget 설정

 

1. AWS Budgets에서 월 예산을 $100 로 설정

 

2. 예산의 80% 도달 시 이메일 알림이 오도록 설정

 

3. 설정 완료

 

LV 1 네트워크 구축 및 핵심 기능 배포

 

1. 인프라 구축 (VPC & EC2)

  • VPC을 설정하여 Public/Private Subnet 분리

 

  • Public Subnet에 EC2를 생성

 

 

2. 팀원 정보 저장 및 조회 API 개발

  • Mbti enum: Mbti
더보기
더보기
public enum Mbti {
    INTJ, INTP, ENTJ, ENTP,
    INFJ, INFP, ENFJ, ENFP,
    ISTJ, ISFJ, ESTJ, ESFJ,
    ISTP, ISFP, ESTP, ESFP
}
  • MBTI를 문자열로 받으면 아무 문자열이나 입력받기 때문에 enum을 사용하여 정해진 MBTI 값만 받게 설정
  • Entity: Member
더보기
더보기
@Entity
@Getter
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    private String name;

    @NotNull
    private Integer age;

    @NotNull
    @Enumerated(EnumType.STRING)
    private Mbti mbti;

    protected Member(){
    } // JPA가 DB 조회할 때

    public Member(String name, Integer age, Mbti mbti) {
        this.name = name;
        this.age = age;
        this.mbti = mbti;
    }
}
  • @GeneratedValue(strategy = GenerationType.IDENTITY)
    : DB가 1, 2, 3처럼 자동으로 ID 값 생성
  • @Enumerated(EnumType.STRING)
    : enum 값을 DB에 숫자가 아니라 문자열로 저장
  • protected Member() {}
    : JPA는 DB에서 데이터를 꺼내와 객체로 만들 때 기본 생성자가 필요 > JPA를 위해 기본 생성자를 하나 설정
  • Repository: MemberRepository
더보기
더보기
public interface MemberRepository extends JpaRepository<Member, Long> {
}
  • JpaRepository<Member, Long> : 저장, 조회, 전체조회, 삭제 기능
  • Service: MemberService
더보기
더보기
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    // 팀원 생성
    public Member create(String name, Integer age, Mbti mbti){
        Member member = new Member(name, age, mbti);
        return memberRepository.save(member);
    }

    // 팀원 조회
    public Member getById(Long id){
        return memberRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("해당 팀원을 찾을 수 없습니다. id="+id));
    }
}
  • Controller: MemberController
더보기
더보기
@RestController
@RequestMapping("/api/members")
public class MemberController {

    private final MemberService memberService;
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public Member createMember(@Valid @RequestBody Member request){
        return memberService.create(
                request.getName(),
                request.getAge(),
                request.getMbti()
        );
    }
    
    @GetMapping("/{id}")
    public Member getMember(@PathVariable Long id){
        return memberService.getById(id);
    }
}

 

3. Profile 분리 : 로컬 개발 환경 + 서버 운영 환경을 분리하는 것

 

1) 설정파일 분리

 

2) application.yml

spring:
  application:
    name: member
    
   profiles:
    active: local

    server:
      port: 8080

      management:
        endpoint:
          web:
            exposure:
              include: health
  • 모든 환경에서 공통으로 쓰는 설정
  • 서버설정: localhost:8080
  • health라는 기능을 밖에서 볼 수 있게 함: 서버 상태 확인

2) application-local.yml

// local 프로필일 때 사용
spring:
  config:
    activate:
      on-profile: local

// 데이터베이스 연결 정보
datasource:
  url: jdbc:h2:mem:testdb
  driver-class-name: org.h2.Driver
  username: sa
  password:

// H2 콘솔 열기
  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-duto: update
    show-sql: true
  • 내 컴퓨터에서 실행할 때만 쓰는 설정
  • 데이터 베이스 연결 정보
    • url: jdbc:h2:mem:testdb : H2라는 연습용 DB를 메모리 안에서 쓰겠다
    • driver-class-name: org.h2.Driver : H2 DB랑 대화하려면 H2용 통역사를 쓰겠다
  • ddl-auto: update : 자바 클래스가 바뀌면 DB 테이블도 알아서 비슷하게 맞춰줌
  • show-sql: true : 장하거나 조회할 때 뒤에서 어떤 SQL이 나가는지 볼 수 있음
  • 의존성 추가
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'

 

3) application-prod.yml

// prod 모드일 때만 이 파일 사용
spring:
  config:
    activate:
      on-profile: prod

// MySQL 연결 설정
datasource:
  url: jdbc:mysql://localhost:3306/memberdb
  driver-class-name: com.mysql.cj.jdbc.Driver
  username: root
  password: 12345678

jpa:
  hibernate:
    ddl-auto: update
  show-sql: false
  • 진짜 서버에서 실행할 때 쓰는 설정
  • show-sql: false
    : 운영 서버에선 보기 X = 많아서 보기 불편하고 정보 노출 위험

4. 로그 전략

  •  MemberController
더보기
더보기
@Slf4j
public class MemberController {

    @PostMapping
    public Member createMember(@Valid @RequestBody Member request) {

        log.info("[API - LOG] 회원 생성 요청 name={}, age={}, mbti={}",
                request.getName(),
                request.getAge(),
                request.getMbti()
        );

        return memberService.create(
                request.getName(),
                request.getAge(),
                request.getMbti()
        );
    }

    @GetMapping("/{id}")
    public Member getMember(@PathVariable Long id) {

        log.info("[API - LOG] 회원 조회 요청 id={}", id);
        return memberService.getById(id);
    }
  • @Slf4j 어노테이션 추가
  • @RequiredArgsConstructor : 기본생성자 자동 생성
  • log.info : INFO 레벨로 로그 생성
  • GlobalExceptionHandler
더보기
더보기
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> handleRuntimeException(RuntimeException e) {
        log.error("[ERROR] 요청 처리 실패", e);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleException(Exception e) {
        log.error("[ERROR] 서버 오류 발생", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("서버 오류가 발생했습니다.");
    }
}
  • e : 에러메시지 + 스택 트레이스 함께 남음

5. 상태 모니터링 (Actuator) 추가

  • 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-actuator'
  • 설정파일에 헬스 체크 엔드포인트를 노출
management:
  endpoints:
    web:
      exposure:
        include: health

 

6. 배포 및 검증

 

1) 프로젝트 JAR 파일 만들기 (빌드)

  • 프로젝트를 EC2에서 실행할 수 있게 .jar 파일로 포장
./gradlew build
  • JAR 파일 생성

 

2) EC2 서버 접속

  • AWS에서 생성한 EC2 인스턴스에 SSH를 통해 접속
ssh -i /c/Users/User/Downloads/ec2-key.pem ec2-user@54.180.158.20
  • 접속 성공!
[ec2-user@ip-10-0-14-164 ~]$

 

3) JAR 파일 EC2 서버로 업로드

  • 로컬 환경에서 생성한 JAR 파일을 EC2 서버로 복사
scp -i /c/Users/User/Downloads/ec2-key.pem build/libs/cloud-0.0.1-SNAPSHOT.jar ec2-user@54.180.158.20:/home/ec2-user
  • 업로드 여부를 확인
[ec2-user@ip-10-0-14-164 ~]$ ls
cloud-0.0.1-SNAPSHOT.jar  web

 

4) EC2에서 실행

  • EC2 서버에 Java 설치
sudo yum install java-17-amazon-corretto -y
  • 업로드한 JAR 파일을 실행하여 서버에서 애플리케이션을 실행
java -jar cloud-0.0.1-SNAPSHOT.jar --spring.profiles.active=local

 

5) 외부 접속 확인

  • http://54.180.158.20:8080/actuator/health

 

Lv2 DB 분리 및 보안 연결하기

 

1. 인프라 요구사항

 

1) RDS 생성 

 

  • 엔드포인트
member-db.cp26aqmkiuti.ap-northeast-2.rds.amazonaws.com
  • DB URL
jdbc:mysql://member-db.cp26aqmkiuti.ap-northeast-2.rds.amazonaws.com:3306/memberdb?serverTimezone=Asia/Seoul&characterEncoding=UTF-8

 

2) 보안 그룹 체이닝

  • 보안 그룹 인바운드 규칙 추가

 

3) Parameter Store

  • 파라미터 생성

 

2. 애플리케이션 요구사항 + 검증

 

1) Parameter Store 값을 주입받기

더보기
더보기
  • application-prod.yml
spring:
  config:
    activate:
      on-profile: prod
    import: "aws-parameterstore:/member-service/prod/"

  datasource:
    url: ${db.url}
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: ${db.username}
    password: ${db.password}

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false

management:
  info:
    env:
      enabled: true
  endpoints:
    web:
      exposure:
        include: "*"

cloud:
  aws:
    region:
      static: ap-northeast-2
    s3:
      bucket: camp-health-eunjiom-files
  • application.yml
spring:
  application:
    name: member

  profiles:
    active: prod

server:
      port: 8080

2) 트러블 슈팅

  • 검증했을 때 에러 발생

  • IAM 역할 만들기

  • yml 파일 코드 수정
spring:
  application:
    name: member

  profiles:
    active: prod

server:
      port: 8080

info:
    team-name: ${team-name}
  • 재빌드 후 실행

 

Lv 3 프로필 사진 기능 추가와 권한 관리

 

1. S3 버킷 생성

  • 버킷 생성

 

2. IAM Role, IAM Policy

  • IAM Role을 생성

  • EC2에 연결

  • application-prod.yml 설정 추가
cloud:
  aws:
    region:
      static: ap-northeast-2
    s3:
      bucket: camp-health-eunjiom-files
  • 의존성 추가
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:s3-presigner'

 

3. POST /api/members/{id}/profile-image

  • MultipartFile로 이미지를 받아 S3 버킷에 업로드하고, 이미지 URL을 DB에 업데이트하는 기능
  • Entity에 프로필 이미지 필드 추가
    // S3 이미지 key 저장
    private String profileImageKey;
.
.
.
    public void updateProfileImageKey(String profileImageKey){
        this.profileImageKey = profileImageKey;
    }
}
  • S3Service 생성
@Service
public class S3Service {

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${cloud.aws.region.static}")
    private String region;

    public String uploadFile(Long memberId, MultipartFile file)
        throws IOException{
        if(file == null || file.isEmpty()){
            throw new IllegalArgumentException("업로드할 파일이 없습니다.");
        }

        String originalFilename = file.getOriginalFilename();
        String extension = extractExtension(originalFilename);

        String key = "profiles/" + memberId + "/" + UUID.randomUUID()
                + "." + extension;

        S3Client s3Client = S3Client.builder()
                .region(Region.of(region))
                .build();

        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType(file.getContentType())
                .build();

        s3Client.putObject(putObjectRequest, RequestBody.fromBytes(file.getBytes()));

        return key;
    }

    private String extractExtension(String filename) {
        if (filename == null || !filename.contains(".")) {
            throw new IllegalArgumentException("파일 확장자가 없습니다.");
        }
        return filename.substring(filename.lastIndexOf(".") + 1);
    }
}
  • MemberService 수정
    private final S3Service s3Service;

    // 프로필 이미지 업로드
    @Transactional
    public void uploadProfileImage(Long id, MultipartFile file)
        throws IOException{
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("해당 팀원을 찾을 수 없습니다" + id));

        String key = s3Service.uploadFile(id, file);
        member.updateProfileImageKey(key);
    }
  • MemberController
    // 이미지 업로드
    @PostMapping(value = "/{id}/profile-image",
            consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String uploadProfileImage(@PathVariable Long id,
                                     @RequestPart("file")MultipartFile file)
        throws IOException{
        log.info("[API - LOG] 프로필 이미지 업로드 요청 memberId={}", id);

        memberService.uploadProfileImage(id, file);

        return "프로필 이미지 업로드 완료";
    }
  • config 생성
@Configuration
public class S3Config {
    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.AP_NORTHEAST_2)
                .build();
    }

    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
                .region(Region.AP_NORTHEAST_2)
                .build();
    }
}
  • 이미지 업로드 테스트 실행

 

4. 트러블 슈팅

  • Entity 이미지키 필드 위에 @JsonIgnore 어노테이션 사용

 

5. GET /api/members/{id}/profile-image

  • S3 Presigned URL 만드는 서비스
public String createPresignedUrl(String key) {
    S3Presigner presigner = S3Presigner.builder()
            .region(Region.of(region))
            .build();

    GetObjectRequest getObjectRequest = GetObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .build();

    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
            .signatureDuration(Duration.ofDays(7))
            .getObjectRequest(getObjectRequest)
            .build();

    URL url = presigner.presignGetObject(presignRequest).url();

    presigner.close();

    return url.toString();
}
  • MemberService
    // 프로필 이미지 Presigned URL 조회
    @Transactional(readOnly = true)
    public String getProfileImagePresignedUrl(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("해당 팀원을 찾을 수 없습니다. id=" + id));

        if (member.getProfileImageKey() == null || member.getProfileImageKey().isBlank()) {
            throw new RuntimeException("해당 팀원의 프로필 이미지가 없습니다.");
        }

        return s3Service.createPresignedUrl(member.getProfileImageKey());
    }
  • Controller GET API
@GetMapping("/{id}/profile-image")
public String getProfileImage(@PathVariable Long id) {
    log.info("[API - LOG] 프로필 이미지 조회 요청 memberId={}", id);
    return memberService.getProfileImagePresignedUrl(id);
}
  • 테스트 실행

  • 에러 테스트 실행