시작

우테코 최종에서 떨어진 후에 SW 창업경진대회 우수상을 수상했던 대학생을 위한 프로젝트/창업 팀원 매칭 플랫폼 팀에서 백엔드를 구한다고 하길래 지원했다.
스프링 부트로 간단한 CRUD 및 페이지네이션, 여러 조건 조합의 검색쿼리를 처리 해야 했고 AWS로 배포까지 진행하였다. 사이트는 여기에서 확인할 수 있고, API 문서는 git book에서 확인할 수 있다.


과정

2021년 1월부터 2021년 2월 말까지 진행하였는데, 오랜만에 팀 프로젝트였고, 백엔드가 두 명이어서 코드리뷰를 할 수 있어서 신이 났다. 오랜만에 스프링 부트로 해보기도 했고, 디비 설계부터 할 수 있어서 더 재밌었던 것 같다. 코로나 때문에 팀원들을 직접 한 번도 보지 못한 채로 끝나서 아쉬웠지만, 좀 더 깊이있게 스프링 부트를 할 수 있었던 계기가 된 것 같다.

협업

GitHub

일단 브랜치 전략은 각자 자신의 브랜치를 띄우는 Git Flow를 기반으로 했다. 그 대신 백엔드가 딱 두명이라 머지는 리뷰를 한 다른 쪽 한명이 해주는 걸로 하였다.
커밋 메시지는 이전에 우테코에서 했던(지금은 모든 커밋 메시지를 이렇게 쓰고 있다.) AngularJS Git convention을 따라서 하였다.

FE

같은 대상을 지칭하는 용어가 FE와 다르다는 것을 느꼈다. 프로젝트로 FE 코드를 처음 봤는데, 리액트를 조금 할 줄 안다고 생각했는데도 어떻게 작동되는지 잘 모르겠다.
그냥 팀원분들을 믿는 수밖에..!

이미지 저장

multipart form

이미지를 DB에 저장하는 방법은 multipart form data를 이용하거나 Base 64 encoding을 하는 방식이 있는 것 같았다.
사실 이미지 저장은 직접적으로 구현해 본 적은 없었고, 대충 방법만 알고 있었다. 이전 백엔드 개발하신 분들은 Base 64로 인코딩을 이용하여 스트링 타입으로 저장하셨던 것 같다. 하지만, 아무리 스트링값이라고 해도 오버헤드가 있고 이미지 수정을 하는 경우도 있을 것 같아서 따로 스토리지를 구축하여 해당 스토리지 URL만 저장하는 방식으로 이미지 저장 및 수정을 구현하였다.

DB를 구축할 때 AWS RDS를 이미 구축한 상태였으므로, S3를 구축하였고, org.springframework.cloudspring-cloud-starter-aws를 사용하여 공식 s3 API를 사용하였다.

대충 소스코드는 이렇게 된다.

@Service
@RequiredArgsConstructor
@PropertySource("classpath:application-aws.yml")
public class S3Uploader {
	private final static List<String> IMAGE_EXTENSIONS = Arrays
		.asList(".jpg", ".jpeg", ".gif", ".png", ".img", ".tiff", ".heif");
	private final static String TEMP_FILE_PATH = //////// 비공개 /////////
	private final AmazonS3Client amazonS3Client;
	@Value("${cloud.aws.s3.bucket}")
	public String bucket;

	public File convert(MultipartFile file) {
		File convertFile = new File(TEMP_FILE_PATH + file.getOriginalFilename());
		try {
			if (convertFile.createNewFile()) {
				try (FileOutputStream fos = new FileOutputStream(convertFile)) {
					fos.write(file.getBytes());
				}
				return convertFile;
			}
		} catch (Exception e) {
			System.err.println(e.getMessage());
		}
		return null;
	}

	public String upload(File uploadFile, String dirName, String id) {
		String fileName = dirName + "/" + id;
		String uploadImageUrl = putS3(uploadFile, fileName);
		deleteLocalFile(uploadFile);
		return uploadImageUrl;
	}

	public boolean isValidExtension(File uploadFile) {
		String fileName = uploadFile.getName();
		String extension = "." + fileName.substring(fileName.toLowerCase().lastIndexOf(".") + 1);
		if (!IMAGE_EXTENSIONS.contains(extension)) {
			deleteLocalFile(uploadFile);
			return false;
		}
		return true;
	}

	public void delete(String dirName, String id) {
		deleteFromS3(dirName + "/" + id);
	}

	private void deleteFromS3(String key) {
		amazonS3Client.deleteObject(bucket, key);
	}

	private String putS3(File uploadFile, String fileName) {
		amazonS3Client.putObject(
			new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(
				CannedAccessControlList.PublicRead));
		return amazonS3Client.getUrl(bucket, fileName).toString();
	}

	private void deleteLocalFile(File uploadFile) {
		if (!uploadFile.delete()) {
			System.err.println(ErrorCase.FAIL_FILE_DELETE_ERROR);
		}
	}
}

s3bucket은 클래스 변수이지만, 배포 환경에 따라 바뀔 수 있으므로 프로퍼티를 분리하였다. 하지만, 보안 상 이슈로 깃헙에 직접적으로 올릴 수 없으니 메인 프로퍼티와는 분리하였다.
또한, AWS 자격 증명도 해야 하므로 프로퍼티를 이용하는 게 좋겠다고 생각했다. (AWS SDK for JavaSystemPropertiesCredentialsProvider, 여기 참고)

cloud:
  aws:
    s3:
      bucket: //// 비공개 ////
    region:
      static: ap-northeast-2
    stack:
      auto: false
    credentials:
      accessKey: //// 비공개 ////
      secretKey: //// 비공개 ////
      instanceProfile: true

AWS에서 권장하는 로컬 파일 형태로 키들을 저장하거나 환경 변수를 이용한 자격 증명은, 당시 배포 환경이 정해지지 않아서 고려하지 않았었다.

파일을 직접 s3put하기 이전에 파일 형태로 바꿔서(S3Multipartfile 타입은 전송이 안된다.) classpath 어딘가에 미리 저장해 놓는다. null을 리턴하는게 마음에 안 들긴 하지만, 컨트롤러 쪽에서 null을 받으면 에러를 출력하게 해놓았다. 또한 업로드 이전에 최소한의 보안을 위해 파일 확장자 검사를 white-list 형식으로 해주었다.

찾아보니 공식 지원 JAVA API에 멀티파티 업로더가 있던데, 나중에는 그걸 써봐야 겠다.
전체적인 흐름은 이 블로그를 많이 참고하였다.

S3

S3 버전 관리

S3로 새로운 이미지 등록, 기존 이미지 삭제는 잘 구현이 되는데, 수정이 잘 되지 않아서 애를 먹었다.
찾아보니, S3는 버전이라는 게 존재하고, 이미지를 수정하면 맨 처음 생성한 이미지의 URL과는 달라(URL 뒤에 추가 파라미터가 붙는 형태)진다.

따라서 이미지를 수정한 후에 RDS에도 바뀐 URL을 넣어주게 하였고, 버전이 바뀐 이미지나 삭제된 이미지를 주기적으로 영구 삭제하는 수명 주기 규칙을 만들었다. 해당 내용은 여기를 참고하였다.

JPA

RDS 연결

사실 RDS 연결은 그냥 MySQL Driver 연결과 똑같다.
urlRDS URL로 써주면 되는데, 사이트에서 버전에 맞는 디펜던시를 추가해주면 된다.

매핑

JPA에서 매핑의 종류가 꽤 많아서 자주 헷갈렸다. 결론적으로 @OneToMany@ManyToOne를 사용하였는데, 기준은 해당 엔티티가 앞이다.

1. @OneToMany

어노테이션이 붙는 엔티티(편의상 ‘나’라고 하겠다.)가 One이고, 나를 기준으로 여러개가 연결되는 것이다.
따라서 ListSet 등의 Collection 인터페이스에 어노테이션이 붙어야 한다.

사실 전통적인 RDBMS에서는 엔티티 매핑을 양방향으로 구성하지 않는다. 이는 리스트 형태이기 때문에 당연한 말이다. 따라서 @OneToManyORM의 장점이다. 원래라면 @ManyToOne에서 설정된 것처럼 구성하고, 참고하는 테이블에서 FK를 모두 찾아서 리스트 등으로 가져와야 한다.

@OneToMany(mappedBy = "portfolio", fetch = FetchType.LAZY, cascade = CascadeType.ALL)

이런 식으로 어노테이션이 붙는데, 이렇게 되면 1:M양방향으로 접근하는 것이다. 만약 dto로 값을 받지 않고 entity로 그대로 받는다면, 상황에 따라 리턴값이 재귀적으로 호출될 수 있다… 여러 해결 방법이 있었지만 그냥 dto를 만드는 게 좋다고 생각하여 만들어주었다.

내가 @OneToMany 이므로, @ManyToOne에서 나를 해당 엔티티 객체로 접근하게 된다.
따라서 저쪽에서는 변수 타입이 나일 것이고, mappedBy에는 변수을 적어주면 된다. 따라서 에러가 난다면, @ManyToOne 어노테이션이 붙어있는 엔티티 코드를 확인해 봐야 한다.

fetchLAZYEAGER을 쓰는데, EAGER은 한 객체가 바뀌면 연관된 객체가 거의 준 실시간으로 바뀌도록 쿼리가 나가게 된다. 이는 성능에 영향을 미치므로 거의 LAZY를 쓰는데, 모든 호출에서 객체를 호출하면 그때그때 마다 새로운 업데이트 값이 생기길 원할 수 있으니 그 때는 EAGER을 쓰는 게 좋다.

2. @ManyToOne

이번엔 내가 Mnay고, 상대방이 One이다. 따라서 변수 타입은 위에서 말한 것처럼 엔티티 클래스이다.

@ManyToOne
@JoinColumn(name = "users_id")

위에서 말한 것처럼 전통적인 RDBMS에서는, 참조되는 PK를 참조하는 쪽에서 FK 형태로 포함한다. 따라서 @JoinColumn에는 실제 데이터베이스의 테이블에서 FK로 설정되어있는 컬럼명을 써주어야 한다. 실제로 에러가 나면 @OneToMany와는 달리 테이블에 키가 없다고 뜨며, 이 때는 코드를 살펴보기보다는 MySQL의 테이블 설정을 살펴보는 게 더 도움이 되었다.

3. @ManyToMany

사실 다대다 매핑에 @ManyToMany를 사용할 수 있으나, 실제로 적용해보면 필요 이상으로 쿼리가 나가는 경우가 많아 성능 이슈가 있다. 따라서 전통적인 다대다 매핑에서 처럼 연결 테이블을 엔티티로 승격하는 방식을 사용하여 위의 @OneToMany@ManyToOne로 연결하였다.

Specification

사실 간단한 CRUD API만을 위한 레포지토리가 필요한 경우에는 extends CrudRepository<엔티티클래스, PK타입>으로 상속받으면 된다.
거기에 추가로 페이지네이션이나 정렬을 사용할 경우에는 JpaRepository를 많이 상속한다.

하지만, 이번 프로젝트에서 페이지네이션 검색 조건이 선택적이었고, 많았다. 검색 조건 은 0개부터 5개까지 였고, 값이 들어오지 않으면 검색 조건으로 넣지 않았어야 했다.
따라서 아무것도 선택 안했을 때 ~ 모두 선택 했을 때의 검색 조건 조합의 경우는 32가지였다. 32개의 if문을 만들 수는 없었고, 하나는 내부에 OR 조건이 필요했다. findBy 뒤에 AndOr을 넣을 경우 우선순위나 or 범위를 설정할 수 없다.

그래서 찾아보니 JPA에서 검색 조건을 추상화한 객체인 Specification을 사용하면 될 것 같았다.

public interface ProjectRepository extends JpaRepository<Project, Long>, JpaSpecificationExecutor<Project> {
	Page<Project> findAll(Specification<Project> specification, Pageable pageable);
}

레포지토리를 상속 받을 때 JpaSepcificationExecutor<엔티티클래스, PK타입>을 하나 더 상속하면 파라미터로 Specification을 넣을 수 있다.

레포지토리를 위와 같이 정의하고, Specification 클래스를 따로 만들어 준다.

import org.springframework.data.jpa.domain.Specification;

public class ProjectSpecification {
	public static Specification<Project> ByCategory(final String category) {
		return (Specification<Project>) ((root, query, builder) ->
			builder.equal(root.get("category"), category));
	}

	public static Specification<Project> ByRecruitmentField(final String recruitmentField) {
		return (Specification<Project>) ((root, query, builder) ->
			builder.equal(root.get("recruitmentField"), recruitmentField));
	}

	public static Specification<Project> ByRegion(final String region) {
		return (Specification<Project>) ((root, query, builder) ->
			builder.equal(root.get("region"), region));
	}

	public static Specification<Project> ByProjectSection(final String projectSection) {
		return (Specification<Project>) ((root, query, builder) ->
			builder.equal(root.get("projectSection"), projectSection));
	}

	public static Specification<Project> ByKeyword(final String keyword) {
		Specification<Project> specification = ByKeywordInContent(keyword);
		specification = specification.or(ByKeywordInTitle(keyword));
		return specification;
	}

	private static Specification<Project> ByKeywordInContent(final String keyword) {
		return (Specification<Project>) ((root, query, builder) -> builder
			.like(root.get("content"), "%" + keyword + "%"));
	}

	private static Specification<Project> ByKeywordInTitle(final String keyword) {
		return (Specification<Project>) ((root, query, builder) ->
			builder.like(root.get("title"), "%" + keyword + "%"));
	}
}

이후에 서비스에서 적당히 구현해주면 된다.

public ProjectListResponseDto getProjectsList(Pageable pageable, String category,
    String recruitmentField, String region, String projectSection, String keyword) {
    Specification<Project> specification = Specification.where(null);
    if (category != null && !category.isEmpty()) {
        specification = specification
            .and(Specification.where(ProjectSpecification.byCategory(category)));
    }
    if (recruitmentField != null && !recruitmentField.isEmpty()) {
        specification = specification.and(
            Specification.where(ProjectSpecification.byRecruitmentField(recruitmentField)));
    }
    if (region != null && !region.isEmpty()) {
        specification = specification
            .and(Specification.where(ProjectSpecification.byRegion(region)));
    }
    if (projectSection != null && !projectSection.isEmpty()) {
        specification = specification
            .and(Specification.where(ProjectSpecification.byProjectSection(projectSection)));
    }
    if (keyword != null && !keyword.isEmpty()) {
        specification = specification
            .and(Specification.where(ProjectSpecification.byKeyword(keyword)));
    }
    return pageToListResponseDto(projectRepository.findAll(specification, pageable));
}

Criteria

검색 조건 페이지네이션 부분까지는 동적 쿼리를 사용할 일이 없었다. 하지만, 막판에 SQL문에서의 group by나 가상 필드가 필요했다. 따라서 동적 쿼리를 사용하기로 했다.

JPA 2.0부터 JPQL을 자바 코드로 작성할 수 있는 빌더 클래스를 제공한다. 즉, 동적 쿼리를 사용하기 위한 JPA 라이브러린데, 이게 바로 CriteriaBuilder이다. 이를 이용하면 좀 더 넓은 범위에서의 다양한 쿼리(group by, SUM, AVG, ORDER BY 등..)를 사용할 수 있다.

SELECT tag_id, SUM(score) from tag_history GROUP BY tag_id ORDER BY SUM(score) LIMIT 10;

이러한 SQL select문이 있을 때,

CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<TagHistoryGroupByDto> query = builder.createQuery(TagHistoryGroupByDto.class);
Root<TagHistory> root = query.from(TagHistory.class);

query.groupBy(root.get("tag"));
query.multiselect(root.get("tag"), builder.sum(root.get("score")));
query.orderBy(builder.desc(root.get("score")));

return entityManager.createQuery(query).getResultStream().limit(10);

이런 식으로 처리할 수 있다.

TagHistoryGRoupByDtotagSUM(score)로 이루어진 dto로 보면 된다.
원래는 createQuery(query).setMaxResult(10).getResultStream()을 하려고 했는데, 그러면 ORDER BY가 제대로 작동하지 않는다. ORDER BY이전에 limit이 들어가는 것 같다. 그래서 그냥 stream으로 받아서 limit을 해주었다.

스케줄링

스프링에서 스케줄링은 여러가지 방법이 있다. Spring SchedulerQuatz를 이용할 수도 있다.
나는 그냥 Spring Scheduler를 사용하기로 했다. 이전에 써보기도 했고, spring boot starter에 기본적으로 제공되서 어노테이션만 붙여주면 되기 때문이다.

@SpringBootApplication
@EnableScheduling
public class PickmeupApplication {

	public static void main(String[] args) {
		SpringApplication.run(PickmeupApplication.class, args);
	}

}

메인 메서드에서 @EnableScheduling 어노테이션을 붙여준다.


@AllArgsConstructor
@Service
public class TagService {
	private final TagRepository tagRepository;
	private final TagHistoryRepository tagHistoryRepository;

	@Scheduled(cron = "0 0 0/3 * * ?") // save score history every 3 hours
	public void updateScore() {
		Timestamp currentTime = new Timestamp(System.currentTimeMillis());
		System.out.println("[*] " + currentTime + ": update score START");
		tagRepository.findAll().forEach(tag -> addTagHistory(tag, currentTime));
		System.out.println("[*] " + currentTime + ": update score END");
	}

	@Scheduled(cron = "0 0 6 * * ?") // everyday at 6AM
	public void removeOldHistory() {
		Timestamp currentTime = new Timestamp(System.currentTimeMillis());
		System.out.println("[*] remove history START");
		ZonedDateTime zonedDateTime = currentTime.toInstant().atZone(ZoneId.of("UTC"));
		Timestamp standardTime = Timestamp
			.from(zonedDateTime.plus(-1, ChronoUnit.DAYS).toInstant()); // before 1 day
		tagHistoryRepository.deleteOldScore(standardTime);
		System.out.println("[*] remove history END");
	}

...

}

QuatzIoC측면에서 주입성이 좀 떨어진다는 의견이 많길래 간단한 스케줄링이라 Spring Scheduler만 적용하였다. 사실 DB의 양이 많아지면 작업이 느려질 수도 있고, 트래픽 집계 같은 경우는 Spring Batch를 더 많이 사용하는 편이다.
일단은 트래픽이 그렇게 많은 편이 아니라 직접 DB에 접근하여 트래픽 집계 및 오래된 트래픽을 버리는 식으로 적용했는데, 나중에 CI적용한다면 Spring Batch로 같이 마이그레이션 하려고 한다.

배포

AWS 배포는 처음해봤는데, 직접적으로 서버에 접근할 수 있으므로 리눅스가 익숙하다면 매우 쉬운 편이라고 느꼈다.

EC2

EC2에서 인스턴스를 프리티어로 생성한다. AMI는 가장 호환성이 좋다는 Amazon Linux 2 AMI (HVM), SSD Volume Type -으로 설정하였고, 인스턴스에 직접적으로 들어가는 방법은 키 페어(.pem)와 ssh를 이용하면 된다.

인스턴스가 들어가보면 레드햇 계열인지 yumrpm는 되지만, apt-get같은 우분투 기반 명령어는 먹히지 않는다.

1. JAVA 설치

sudo yum install -y java-1.8.0-openjdk-devel.x86_64

devel을 제외하면, javajre를 가리키게 되어, gradlew build 실행 시에 에러가 난다.

2. git clone

3. property .yaml 파일들 수정

아까 aws 프로퍼티나 그 외 깃헙에 보안 이슈 때문에 올리지 못한 프로퍼티들을 직접 수정해준다.

4. gradlew 권한 설정

git clone 으로 가져온 gradlew는 실행 권한이 없으므로 설정해준다.

5. ./gradlew build

빌드가 성공하면 ./build/libs.jar 파일이 생긴다.

6. jar 파일 실행

nohup java -jar ./build/libs/파일이름.jar &

데몬 형태로 실행시키기 위해 nohup으로 실행시켰고, 백그라운드(&)로 실행시켰다.

자동 스크립트

위의 1~6과정을 매번 배포 때마다 하기는 귀찮으므로, 자동 스크립트를 써준다.

#!/bin/bash

PICKMEUP_PROJECT=/home/ec2-user/...비공개.../
cd $PICKMEUP_PROJECT/pick-me-up-server
echo "[*] git update..."
git fetch --all
git reset --hard origin/main
git pull --force
echo "[*] copying properties..."
cp -f ../application.yml ./src/main/resources/application.yml
cp -f ../application-aws.yml ./src/main/resources/application-aws.yml
echo "[*] project building..."
chmod 755 gradlew
./gradlew clean build
echo "[*] PID checking..."
CURRENT_PID=$(pgrep -f pickmeup)
echo "current PID : $CURRENT_PID"

if [ -z $CURRENT_PID ]; then
	echo "[*] no running appplication"
else
	echo "[*] kill -15(SIGTERM) $CURRENT_PID"
	kill -15 $CURRENT_PID
	echo "[*] Please wait..."
	sleep 30
fi

echo "[*] new application deploying!"
JAR_NAME=$(ls ./build/libs/*.jar)
echo "[*] JAR name : $JAR_NAME"
cd ../
rm nohup.out
nohup java -jar ./pick-me-up-server/build/libs/*.jar &
echo "[*] new application Starting..."
sleep 30
tail nohup.out

application.ymlapplication-aws.yml은 pull 후에 덮어쓰므로, 다음 배포 시 pull 할 때에 push하지 않아서 에러가 난다.
따라서 localoverwrite하는 경우를 찾아보니 관련 스택오버플로우 글을 찾았다.
리모트를 로컬과 맞추기보다 업데이트된 리모트 코드 부분만 가져오는 fetch를 사용한 후에, reset --hard로 로컬과 맞추고, pull --force를 한다.
사실 pull --force는 필요없긴 한데, 이후 안정성을 위해 넣어준다.
매번 프로젝트를 삭제하고 git pull하고 하기엔 시간이 너무 오래 걸려서 이러한 방법을 썼는데, 꽤 효과적이었다.

그리고 이전 nohup.out 파일이 계속 쌓이다보니 지워주었는데, 나중에 로깅 스택을 구축하면 다른 곳에 쌓일 거라 생각해 이렇게 하였다.

가끔 이전 .jar파일을 실행한 프로세스가 아직 완전히 종료되지 않았는데, 다시 배포하여 새롭게 생성된 .jar 파일을 실행하는 경우, 포트가 겹치는 문제가 발생하여 30초 동안 sleep하였으나, 메모리가 부족한 경우가 발생하였다.

여러 해결방법을 시도한 끝에 스와핑 방법으로 해결하였다. 구체적인 스와핑 방식은 AWS 글을 참고하였다.

FE 연동

FE 연동에 있어서도 약간의 이슈들이 있었다.

CORS

nginx로 한꺼번에 배포하지 않고 이미 FEvercel으로 떠있었기 때문에 도메인이 달라 CORS 에러가 발생하였다. 이를 해결하기 위해서는 여러 방법이 있는데, baeldung에 잘 정리된 포스트가 있어서 참고하였다. 나는 @Configuration 빈의 WebConfigurer을 구현하였다.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
	@Override
	public void addCorsMappings(CorsRegistry registry) {
		registry.addMapping("/**")
			.allowedOrigins("https://pickmeup.site", "https://pick-me-up.vercel.app")
			.allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH")
			.exposedHeaders("Location");
	}
}

처음에 설정했는데도 DELETE 메서드만 CORS 에러가 나서 살펴보니 allowedMethods를 써주지 않으면 기본적인 http method들만을 허용한다고 공식 문서?에서 본 것 같아서 메서드를 써주니까 해결되었다.

이미지를 등록하거나 수정할 시에 Location 헤더로 S3URL을 보내므로 헤더 허용 설정을 추가해주었다.

https

CORS 설정을 해도 배포 후 에러가 떴다. vercel 앱은 https로 설정되어 있는데, 백은 http로 배포했으므로 보안 상 생기는 문제였다.

1. 도메인 구매

도메인 구입 사이트에서 0$로 골라서 구입했다. 12개월(1년)을 무료로 이용할 수 있다고 한다.

2. Route53

1에서 구입한 도메인의 NSAWS에서 보이는 NS로 교체해야 한다. 1번 사이트에서 Service > My Domains > Manage Domain > Management Tools > Nameservers로 들어가 Route 53에서 보이는 NS로 수정해주면 된다.

3. 인증서 발급

인증서는 Let's EncryptACM을 이용할 수 있는데, Let's Encrypt는 매년 갱신이 필요하고, ACMRoute53 설정으로 쉽게 할 수도 있어서(CNAME 레코드 설정만 하면 된다.) ACM을 이용하였다.

4. 로드밸런서 구성

3에서 만든 인증서를 통해서 EC2 로드밸런서를 구성하여야 한다.
리스너는 로드밸런서가 외부에서 받아들이는 포트인데, 이를 3에서 만든 SSL 인증서를 통해 만들어 준다. 그리고 보안 규칙을 통해 http 80으로 들어온 트래픽을 자동으로 https 443로 리다이렉팅 해준다.

참고로 EC2 보안 규칙 중 인바운드 규칙이 로드밸런서와 비슷해서 헷갈렸었다. 인바운드 규칙은 안에서 어떤 포트로 통신하는지를 고려해야 한다.
나같은 경우는 스프링부트가 기본 포트 8080으로 설정되어 있으므로 TCP 8080을 설정해주어야 한다. 또한 ssh로 인스턴스 쉘에 접근을 하므로(배포 때처럼) 이미 ssh 22가 설정되어 있다.
마찬가지로 접근 시에 필요한 나머지 http 80이나 https 443같은 경우도 기본으로 설정해주어야 한다.


느낀점

개인적으로 아쉬운 점들이 몇 개 있다. 시간 상 테스트 코드 짜기, 로깅이나 모니터링, CI/CD 등을 하지 못했는데, 테스트 코드는 꼭 했어야 한다고 생각한다.

그리고 만약, 쿠버네티스를 통해 배포를 했으면, 스케줄링은 자바 SehcduledExecutorService를 사용하기 보다는 쿠버네티스의 크론잡을 이용할 걸 그랬다는 생각이 든다. 아무 생각 없이 aws로 해야겠다고 생각하고 ec2로 배포해버려서, 깃 액션이고 CI/CD고 뭐고 이용하지 못했다. 배포도 코드 짤 때처럼 계획적으로 해야했는데 말이다..

아직 FE팀도 미완성이라 완성 후에 베타 테스트를 거칠 예정이니, 여름방학 때 부족한 점들을 개선해 봐야겠다.