← 목록으로

Project Detail

Monimentoom

2023.09 — 2024.02Backend · InfraSpring Boot · JPA · MySQL · Nginx · AWS EC2 · S3 · Docker · GitLab CI

개인 굿즈 전시 공간 서비스의 REST API·인프라를 단독 구현. 단일 EC2에서의 Blue-Green 무중단 배포, S3 Presigned URL 업로드 분리, JPA N+1 해결로 배포 다운타임 수 분 → 0 분, 방명록 조회 쿼리 N+1회 → 1회를 달성.

인프라 아키텍처

Monimentoom 인프라 아키텍처 다이어그램 — EC2, Nginx, Blue-Green 배포, MySQL

기술 의사결정

단일 EC2에서의 Blue-Green 무중단 배포

AWS EC2Docker ComposeNginxGitLab CISelf-hosted Runner

Context

k8s·ALB를 도입하기엔 프로젝트 규모·비용이 과도했지만, 배포 빈도가 늘어날수록 단순 `compose up` 방식의 커넥션 drop이 누적되어 사용자 경험을 해치고 있었음.

Problem

기존 배포는 기존 컨테이너를 중단한 뒤 새 컨테이너를 기동하는 순서라, 애플리케이션 워밍업 시간 동안 수 분간 502가 발생. 롤백 전략도 수동이라 실패 감지 시점이 늦음.

Action

Blue(8080)·Green(8081) 두 컨테이너 슬롯을 동시에 운영하고, `deploy.sh`가 (1) 현재 활성 슬롯을 감지 → (2) 반대 슬롯에 신규 이미지 기동 → (3) `/actuator/health`를 3초 간격 최대 20회 폴링 → (4) 통과 시 `sed`로 `nginx.conf`의 upstream 라인을 원자 치환한 뒤 `nginx -s reload`로 커넥션 drop 없이 전환 → (5) 구 컨테이너 stop 및 `docker image prune`. 헬스체크 실패 시 신규 슬롯을 즉시 stop하고 `exit 1`로 파이프라인 실패 처리. GitLab Shared Runner(빌드)와 EC2 Self-hosted Runner(배포)를 분리해 배포 비밀값을 CI 외부로 반출 없이 유지.

Result

배포 다운타임 수 분 → 0 분. 헬스체크 실패 기반 자동 롤백으로 장애 전파 차단. git push 후 평균 약 1분 내 트래픽 스위칭 완료.

S3 Presigned URL로 이미지 업로드 트래픽 분리

AWS S3Presigned URLSSRF 방어SDK v2

Context

사용자가 굿즈·프레임·프로필 이미지를 업로드하는 서비스 특성상, 단일 EC2가 이미지 본문을 경유하면 메모리·네트워크가 쉽게 포화됨.

Problem

멀티파트 업로드를 WAS가 직접 받으면 대용량 이미지마다 힙 점유 증가, 업로드 중 다른 API 응답 지연, 그리고 파일명 조작을 통한 임의 경로 쓰기 위험이 공존.

Action

서버는 `S3Presigner`로 5분 유효 PUT URL만 서명해 반환하고, 업로드 본문은 클라이언트 ↔ S3 직접 경로로 분리. key는 `{category}/{UUID}_{원본명}` 형식으로 서버가 생성해 경로 조작 차단. Content-Type을 서명에 포함해 변조 방지, 확장자 화이트리스트로 비이미지 업로드 거부. 삭제 요청 시 `URI.getHost()`가 `.amazonaws.com`으로 끝나는지 검증해 SSRF·타 버킷 삭제 가능성 제거. 테스트 환경에서는 `@PostConstruct`에서 AWS 자격증명 부재를 감지해 graceful degrade(`s3Client = null`, 503 반환) 처리로 CI 안정화.

Result

이미지 업로드 트래픽이 WAS를 경유하지 않아 EC2 자원 사용량 구조적 완화. 악성 키 주입·SSRF 경로 사전 차단. AWS 자격증명이 없는 CI에서도 컨텍스트 로딩 실패 없이 전체 테스트 통과.

JPA N+1 해결 — JOIN FETCH 기반 단일 쿼리 조회

JPAHibernateJOIN FETCH@Query

Context

방 상세 페이지가 방명록·굿즈·배치(Position)를 모두 보여주는 구조라, 진입 시 연관 엔티티 다수를 동시에 조회해야 했음. 트래픽 증가에 따라 응답 지연이 포착됨.

Problem

기본 지연 로딩 설정으로 방 하나 조회 시 각 댓글마다 작성자 User를 추가 조회하는 N+1이 발생. 방명록 20건 조회 시 DB 쿼리가 21회 발행되어 커넥션 풀을 빠르게 잠식.

Action

EAGER로의 전역 전환은 불필요한 연관 데이터까지 끌어와 전체 성능을 악화시키므로 배제. 대신 조회 경로별로 `@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.room.id = :roomId")` 형태의 명시적 Fetch Join을 사용해 컨텍스트별로 최적화. 굿즈 목록은 `LEFT JOIN FETCH g.positions` + `DISTINCT`로 배치 이력까지 1회에 적재. `positions.room_id`·`positions.goods_id`에 명시적 인덱스를 추가해 조인 선택도도 개선.

Result

방명록 조회 쿼리 N+1회 → 1회. 커넥션 점유 시간 단축으로 동시 요청 처리 용량 증가. EAGER 전환 대비 불필요한 필드 적재 없이 컨텍스트별 최적 쿼리 유지.

도메인 엔티티 내재화된 IDOR 방어

DDD불변식Spring SecurityIDOR

Context

Room·Goods·Comment·Position 등 소유 관계가 있는 리소스가 다수. 서비스 레이어에서만 userId 일치 검사를 하면 신규 메서드 추가 시 체크를 빠뜨리는 순간 IDOR 취약점이 생김.

Problem

`if (!room.getUser().getId().equals(userId)) throw ...` 패턴이 서비스 여러 곳에 흩어져 있어 리뷰로는 누락을 잡기 어려움. 실제 수정·삭제 API마다 반복 작성되며 일관성이 깨짐.

Action

소유권 검증을 엔티티 메서드 `validateOwnership(Long userId)`로 내재화하고, 서비스는 조회 직후 해당 메서드를 무조건 호출하도록 컨벤션화. Spring Security 컨텍스트에는 User 엔티티 대신 `Long userId`만 principal로 보관해 Lazy 프록시 생명주기 문제와 매 요청 DB 재조회를 동시에 제거. 필터 체인 내부에서 발생한 `CustomException`은 `JwtExceptionFilter`가 `ErrorCode` 기반 통일 JSON으로 변환해 `@RestControllerAdvice`로 가지 못하는 예외까지 프런트 일관 처리 가능.

Result

소유권 체크 누락으로 인한 IDOR 가능성 구조적으로 차단. 매 요청당 User 조회 쿼리 제거. 필터·컨트롤러 어디서 던져도 동일한 JSON 에러 코드 체계로 응답.