AWS로 프론트엔드 CD 구축하기

hyerin·2024년 11월 21일
post-thumbnail

누군가가 나에게 다음과 같이 묻는다면..

프로젝트를 진행하면서 가장 어려웠던 점이 뭔가요?

나는 주저 없이 CD 를 구축했던 것 이라고 말할 것이다.🥲
사실 이 프로젝트를 하기 전까진, 서버나 자동 배포에 대한 개념이 별로 없어서 CD 가 왜 필요한지, 그래서 어떠한 방법을 쓸 수 있는지 알지 못했다. 많은 동료들의 도움을 받았고, 실제로 내가 AWS 랑 친해지기 전까지 많은 시간이 걸렸다.

그럼에도 프론트엔드를 포함한 모든 과정에서 CD 를 구축하는 과정은 정말 중요하며, 노력 대비 효용 이 정말 좋은, 정말 좋은 방법이라고 생각한다.
오늘은 <방끗> 프로젝트에서 구축한 CD 과정을 설명할 것이다.

우선 CD(Continuous Deployment)는 지속적인 배포를 의미한다. 코드를 브랜치에 머지하면, 이 최신 코드를 사용한 사이트로 배포해야 하는 작업이 필요한데, 이 귀찮은 작업을 자동화할 수 있는 정말 좋은 메커니즘이다. 그렇다면 CD를 구축해야 하는 이유는 뭘까?

1. CD가 필요한 이유

(1) 프론트엔드 개발 효율성 향상

매번 배포를 수동으로 클릭해서 진행하고,다시 변경 사항이 생기면 또 다시 수동으로 배포하고..한 두번이면 문제가 안 될지 모르지만, 변경사항은 타켓 브랜치에 PR 이 머지될 때마다 일어난다. 이렇게 같은 작업을 하면서 버리는 시간이 개발 할때 생각보다 크다는 점을 고려하면, 자동화된 배포 과정은 필수이다!

(2) 수동 배포 시 휴먼 에러의 발생 가능성 방지

이 또한 수동 배포로 일어나는 부작용이다. CD 를 구축하기 전에 매번 수동으로 S3 에 있는 기존 파일을 지우고 => 새로운 빌드 파일로 바꿔주는 작업을 했는데, 이는 중간에 실수할 가능성이 엄청 많다. 우리가 쓰는 S3의 버킷의 경우, 다른 팀들의 프로젝트 파일도 많이 들어있다. 이 중에서 우리의 파일의 내용만 '매번 삭제하는 것'은 여간 귀찮은 일일 뿐더러, 실제로 실수를 한 사례를 봤었다. (어찌어찌 해결했지만, 그때만 생각하면 식은 땀이 난다😅) 충분히 같은 휴먼 에러가 발생할 수 있는 상황이었고, 이 때 CD를 구축해야 겠다는 필요성을 강하게 느꼈다!

우리는 이 S3 + Cloudfront 를 사용해서 서비스를 배포하고 있다.

✅ S3(Simple Storage Service)

S3란 AWS에서 제공하는 데이터를 저장할 수 있는 온라인 웹 스토리지 서비스이다.

✅ Cloudfront

CloudFront는 AWS에서 제공하는 CDN 서비스이다. 캐싱을 통해 사용자에게 좀 더 빠른 전송 속도를 제공한다.

CDN(Content Delivery Network)이란? 전세계적으로 분산된 엣지 서버를 연결한 네트워크로서, 사용자가 가장 가까운 엣지 서버로부터 콘텐츠를 받을 수 있게 하여 빠른 콘텐츠 전송을 가능하게 하는 서비스이다!

EC2S3 + cloudfront 조합 중 고민했었는데, 다음과 같은 이유로 선택했다.

  1. 저렴한 비용
  2. 프로젝트의 복잡성이 EC2 를 쓸만큼 높지 않음.
    EC2 는 S3 랑 달리 저장소가 아닌 서버를 대여하는 것이기 때문에, 실시간 데이터 처리나 동적 콘텐츠 제공 같은 추가 기능이 있다. 우리 프론트에서는 단순히 만들어진 정적 파일을 배포하면 되기 때문에, S3 로도 충분할 것이라 판단했다.
  3. Cloudfront와 S3 를 결합하면, 배포가 충분히 빠르다.

또한 우리의 경우 '개발' / '운영' 서버 환경을 분리했기 때문에, S3 버킷 내 폴더 또한! dev / prod 로 분리했다. 해당 폴더에 다음과 같이 배포된 파일이 들어간다.

우리는 여기에다가 cloudfront 를 달아서 CDN 의 이점까지 누리고 있다. CDN(Content Delivery Network) 이란 글로벌로 사용자가 가장 가까운 위치의 엣지 서버에서 리소스를 가져와 속도가 향상되는 서비스이다. cloudfront 가 이 CDN 기능을 제공하며, 우리는 적절한 캐시 설정 최적화를 통해 S3에 연결해주었다.

cloudfront에서 배포 생성하기를 누르고 <원본> 파트에서 다음과 같이 연결하면 된다.

이렇게 하면 S3 + cloudfront 로 배포하기가 완료되었다. 이 cloudfront는 캐시 기능을 적극적으로 활용해, 리소스를 효율적으로 관리한다. 하지만, S3에 새로운 파일이 배포되었을 때는 어떨까? cloudfront의 캐시를 무효화해주어야 한다. 이 또한 우리는 CD 과정에 넣어줄 것이다. (이렇게 필요한 CD의 과정을 하나씩 추가해주면 된다. 자세한 내용은 밑에..😁)

CD를 위해 AWS의 CodePipeline을 선택하였다.

✅ CodePipeline

CodePipeline은 마치 하나의 파이프처럼, 배포 시 복잡한 과정을 하나의 파이프로 묶어서 자동으로 실행되게 하는 기능이다. 코드가 변경될 때마다 릴리스 프로세스의 빌드, 테스트 및 배포 단계를 자동화한다.

이 CodePipeline을 사용하면, PR이 브랜치에 머지시 필요한 빌드 -> 배포 -> CDN 캐시 무효화 과정들을 자동화할 수 있다!

이 외에도 github actions 등의 선택지가 있었으나, 우아한테크코스의 설정 특성상 접근할 수 없는 key 가 많았기 때문에, AWS 연동성이 좋은 Codepipeline을 사용하였다.

우리가 CodePipeline을 통해 구성한 파이프라인은 다음과 같다.

각 스테이지들을 하나씩 설명해보겠다.

2. CodePipeline 단계

(1) Source : github 브랜치의 변경을 감지해 파이프라인을 실행한다.

맨 처음에 레포지토리의 변경을 감지하고, 파이프라인의 실행을 트리거하는 단계이다.
다음과 같이 Source라는 스테이지를 만들고, 우리 프로젝트의 github 레포지토리의 main 브랜치(최종 배포 브랜치)에 다음과 같이 연결해주었다!

github에 연결이 제대로 완료되어야, 파이프라인이 제대로 트리거되니 잘 확인해야 한다.

(2) deleteOldFiles : 기존에 올라간 오래된 배포 파일을 삭제한다.

이건 마지막에 추가한 스테이지이다. 이 단계를 추가한 이유는, CodePipeline으로 deploy 시 기존의 S3 파일을 유지하고 새로운 파일이 추가되는 형식으로 진행된다. 이 때문에, CD가 돌아갈 때마다 S3에 불필요한 기존 파일들이 계속해서 쌓이는 문제가 있었다! 그래서 새로운 빌드 코드를 올리기 전에, 기존 S3를 싹 비워주는 단계를 추가하기로 했다.

AWS Lambda 를 사용하여 기존 파일을 지워주는 함수를 만들고, 스테이지에 이 함수를 추가하였다.

✅ Lambda

이벤트에 대해서 코드 실행을 알아서 해주는 컴퓨팅 서비스이다. 이 Lambda를 통해서 실행시켜주고 싶은 함수를 작성하고, 이를 CodePipeline에 연결할 수 있다.

아래와 같이 AWS Lambda에 해당 코드를 가진 bang-ggood-delete-old-files 라는 함수를 추가해주었다.

import boto3
from botocore.exceptions import ClientError

codepipeline = boto3.client('codepipeline')
def lambda_handler(event, context):
    bucket_name = '버킷 명'
    prefix = 'bang-ggood/prod/'  # 폴더 이름은 슬래시('/')로 끝나야 합니다.
    
    job_id = event.get('CodePipeline.job', {}).get('id')
    if not job_id: raise Exception("CodePipeline job ID를 찾을 수 없습니다.")
        
    s3 = boto3.client('s3')
    
    try:
        # 객체 목록을 페이징 처리하여 가져오기
        paginator = s3.get_paginator('list_objects_v2')
        pages = paginator.paginate(Bucket=bucket_name, Prefix=prefix)
        
        objects_to_delete = []
        for page in pages:
            if 'Contents' in page:
                for obj in page['Contents']:
                    objects_to_delete.append({'Key': obj['Key']})

                    # 한 번에 최대 1000개의 객체를 삭제할 수 있습니다.
                    if len(objects_to_delete) == 1000:
                        delete_batch(s3, bucket_name, objects_to_delete)
                        objects_to_delete = []

        # 남아있는 객체 삭제
        if objects_to_delete:
            delete_batch(s3, bucket_name, objects_to_delete)

        codepipeline.put_job_success_result(jobId=job_id)
        return {
            'statusCode': 200,
            'body': json.dumps('작업이 성공적으로 완료되었습니다.')
        }

    except ClientError as e:
        print(e)
        codepipeline.put_job_failure_result(
            jobId=job_id,
            failureDetails={
                'type': 'JobFailed',
                'message': str(e),
                # 'externalExecutionId': context.aws_request_id  # 선택 사항
            }
        )
        return {
            'statusCode': 500,
            'body': f"Error deleting objects: {e}"
        }

def delete_batch(s3_client, bucket, objects):
    try:
        response = s3_client.delete_objects(
            Bucket=bucket,
            Delete={
                'Objects': objects,
                'Quiet': True
            }
        )
        deleted = response.get('Deleted', [])
        print(f"Deleted {len(deleted)} objects.")
    except ClientError as e:
        print(f"Error deleting batch: {e}")
        raise e

이후 이를 CodePipeline의 스테이지에 연결해주었다.

이제 파이프라인이 돌면, 빌드 되기 전에 알아서 기존 파일을 지워준다! 👍

(3) Build : 코드를 자동으로 빌드한다.

이 단계에서는 CodeBuild 라는 새로운 기능을 사용해야 한다.

✅ CodeBuild

AWS 에서 제공하는 코드 자동 빌드 서비스이다. 설정한 배포 환경에서 코드를 컴파일하고, 단위 테스트도 진행할 수 있고(우리는 테스트의 경우 github actions로 수행해서 패쓰!) 배포할 준비가 된 빌드 파일을 만들어준다.

작업 공급자를 AWS CodeBuild 로 설정하고 다음과 같이 해주었다.

(4) Deploy : 빌드한 코드를 S3에 배포한다.

다음은 빌드한 코드를 S3에 배포하는 단계이다. 이 또한 간단하게 다음과 같이 설정해 주었다. 배포할 S3의 버킷과 배포 경로만 확실하게 잘 설정해주도록 하자.

(5) Cloudfront Cache Invalidation : CDN의 캐시를 무효화한다.

마지막 단계로 cloudfront 의 캐시를 무효화하는 작업을 추가하였다. 이 또한 2단계인 deleteOldFiles 와 마찬가지로 Lambda 기능을 사용해 함수로 구현해 사용하였다. 이 기능이 필요한 이유는, 우리가 최적화를 위해서 Cloudfront에 캐시 기능을 1년으로 설정해주었기 때문이다. Cloudfront는 따로 캐시를 무효화하지 않는 이상, 기존의 캐시를 사용하게 된다. 당연히 이는 최신 배포된 S3의 파일이 아니기 때문에, 마지막 단계로 이 캐시를 무효화하는 단계를 추가한 것이다.

from __future__ import print_function
import boto3
import time

def lambda_handler(event, context):
    path = ['/*']  
    print("Paths to invalidate:", path)
    
    client = boto3.client('cloudfront')
    
    # Invalidation 요청을 생성한다.
    try:
        invalidation = client.create_invalidation(
            DistributionId='Cloudfont 배포 ID',
            InvalidationBatch={
                'Paths': {
                    'Quantity': len(path),
                    'Items': path
                },
                'CallerReference': str(time.time())  
            }
        )
        print("Invalidation ID:", invalidation['Invalidation']['Id'])
        
        # CodePipeline에 작업 성공 결과 보고
        try:
            job_id = event['CodePipeline.job']['id']
            code_pipeline = boto3.client('codepipeline')
            code_pipeline.put_job_success_result(jobId=job_id)
            print("Job success reported to CodePipeline")
        except KeyError as e:
            print(f"Error retrieving job ID or reporting to CodePipeline: {e}")
            # 작업 실패 상태 보고
            code_pipeline.put_job_failure_result(
                jobId=job_id,
                failureDetails={
                    'message': 'Job ID retrieval error or reporting error',
                    'type': 'JobFailed'
                }
            )
        except Exception as e:
            print(f"Unexpected error: {e}")
            # 작업 실패 상태 보고
            code_pipeline.put_job_failure_result(
                jobId=job_id,
                failureDetails={
                    'message': str(e),
                    'type': 'JobFailed'
                }
            )
    except Exception as e:
        print(f"Unexpected error during invalidation: {e}")
        # 작업 실패 상태 보고
        job_id = event.get('CodePipeline.job', {}).get('id', 'Unknown')
        code_pipeline.put_job_failure_result(
            jobId=job_id,
            failureDetails={
                'message': f"Invalidation error: {str(e)}",
                'type': 'JobFailed'
            }
        )

이렇게 프로젝트의 CodePipeline 을 완성했다. 처음에는 간단하게 1(Source),3(Build), 4(Deploy) 만 추가했으며, 나중에 필요에 따라 2(deleteOldFiles),5(Cloudfront Cache Invalidation) 를 추가해서 사용했다.

3. AWS로 CD를 구축하면서 느낀점...

배포나 서버, 자동화에 대해서 아예 몰랐던 내가 AWS 라는 좋은 서비스를 사용함으로서, 개발 효율성이 이렇게 좋아질 수 있다는 것을 깨달았다. 처음에는 CD를 구축하는 것, 단계를 추가하는 것이 귀찮게 느껴질 수도 있지만, 한번 구축하고 나면 다시 그전으로 돌아갈 수 없을 정도이다. 다음에 프로젝트를 하게 되면 초반부터 도입하고 싶다. 😁

profile
글쓰기의 시작은 나를 위해, 끝은 읽는 당신을 위해

5개의 댓글

comment-user-thumbnail
2024년 11월 22일

저도 프로젝트 하면서 배포 자동화에 구성하는게 가장 어려웠고 시간도 가장 오래 걸렸던것 같네요 ㅎㅎ
역시 생산성을 위해 인프라가 중요하다는 것을 다시금 생각하게 됐어요

글 잘 읽고 갑니다요 리안~~ 👍

1개의 답글
comment-user-thumbnail
2024년 11월 22일

CICD 참 어렵죠.. 리안이랑 대화하면서 인프라에 관해 정말 많은 도움이 되었습니다. 글까지 남겨주셔서 고마워요 !!! 나중에 CICD를 구축하게 되면 꼭 참고할게요!!

1개의 답글
comment-user-thumbnail
2024년 11월 26일

그림까지 있어서 이해가 쉽네요! AWS 옮기면서 CD를 또 해야하는데 직전에 이 글을 만나서 좋네요👏

답글 달기