프로젝트 목록

Project

Meeny

여행·모임에서 함께 쓴 돈을 영수증 사진과 위치로 기록하고 최소 송금 횟수로 마무리하는 모바일 정산 앱.

기간
2026.05 ~ 진행 중
역할
1인 풀스택 (Backend · Mobile · Infra)
기술 스택
Spring Boot 3.5Java 21React NativePostgreSQLJWTOAuth2AWS S3AWS EC2DockerTerraformGitHub Actions OIDC

주요 기능

  • 회전 refresh + 재사용 감지 JWT 인증 (3개 소셜 provider 추상화)
  • S3 presigned PUT/GET으로 private 버킷 직접 업로드
  • 두 우선순위 큐 기반 최소 횟수 송금 알고리즘
  • EKS 비용 회피 — EC2 + systemd + GitHub Actions OIDC 무중단 배포

프로젝트 개요

크루(여행·모임) 단위로 함께 쓴 돈을 기록하고, 정산을 최소 송금 횟수로 마감하는 모바일 서비스. 백엔드 · AWS 인프라(Terraform IaC) · GitHub Actions OIDC 배포 파이프라인 까지 1인이 직접 설계·구축·운영했습니다.

기간2026.05 ~ 진행 중
역할1인 풀스택 — Backend (Spring Boot) / Infra (Terraform · AWS) / CI·CD (GitHub Actions OIDC) / Mobile (React Native)

서비스 화면

서비스화면 펼쳐보기
로그인크루 목록크루 상세플레이핀 추가정산정산 마감

기술 스택

영역기술
BackendJava 21 · Spring Boot · Spring Security · JPA · Flyway · JWT
InfraAWS EC2 · RDS · S3 · ECR · ALB · VPC · CloudWatch · Secrets Manager
DevOpsDocker · GitHub Actions · Terraform · OIDC
DatabasePostgreSQL 16
FrontendReact Native · TypeScript

전체 시스템 아키텍처

Meeny 전체 시스템 아키텍처 — Mobile · AWS VPC · Managed Services · CI/CD

핵심 포인트:

  • 이미지는 백엔드를 거치지 않는다 — presigned PUT으로 클라이언트가 S3에 직접 업로드, 백엔드 multipart 대역폭·메모리 부하 0
  • 정적 AWS Access Key 0개 — GitHub Actions OIDC + IAM role sub 클레임 화이트리스트로 무키 배포
  • 단일 EC2 + systemd-docker — 1인 사이드에 EKS 컨트롤플레인 $73/월 + NAT $36/월은 과하다는 판단, 월 $109 고정비 회피
  • 시크릿이 git·userData·tfstate 어디에도 없다 — Secrets Manager → atomic env file(mode 600) → systemd EnvironmentFile 주입

구현 내용

백엔드 (Spring Boot)

1. 소셜 로그인 + 무상태 JWT
  • 카카오·구글·애플 + 게스트 3종 + 1 provider 를 OAuthClient 인터페이스 + OAuthClientRegistry Strategy 구조로 통합. 신규 provider 추가가 빈 등록 1줄로 끝남
  • Spring Security OAuth2 client 풀스택은 redirect 기반이라 모바일 흐름과 어긋남 — 백엔드는 "토큰 검증 → 멤버 식별·생성" 만 수행
  • Apple은 NimbusJwtDecoder 의 issuer + audience 검증을 직접 조합, Google은 플랫폼별 client_id 다중 허용(web/iOS/Android), Kakao 는 WebClientkapi.kakao.com/v2/user/me 호출해 검증
  • JwtAuthenticationFilter 기반 stateless 필터체인 (세션·CSRF 비활성)
  • Kakao API 호출은 WebClient + MockWebServer 로 격리 테스트 — provider 응답을 mock 으로 갈아끼우기 편함
2. Refresh 토큰 회전 + 재사용 감지
  • access JWT 1시간 / refresh UUID 7일 회전, refresh 는 사용 즉시 used_at 마킹 후 새 토큰 발급
  • 동일 refresh 가 두 번 들어오면 → 탈취로 간주, 해당 멤버의 모든 refresh 토큰 즉시 폐기
  • 폐기는 RefreshTokenInvalidator@Transactional(propagation = REQUIRES_NEW) 로 별도 트랜잭션 분리 — 바깥 트랜잭션이 401 예외로 롤백되어도 폐기는 살아남는다
  • 클라이언트는 in-flight Promise dedup 으로 동시 401 N개를 단일 refresh 호출로 수렴 — 회전 토큰의 invariant("한 시점에 한 번만 사용") 를 양쪽에서 지킨다
3. 정산 알고리즘 — 두 우선순위 큐 매칭

PlaySettlementResult.of() — 모든 핀의 결제/분담을 합산해 멤버별 잔액 계산. calculateTransfers() — 채권자/채무자 두 PriorityQueue 를 큰 금액 순으로 매칭.

  • 잔액 = paid − share. 양수는 채권자 큐, 음수는 채무자 큐
  • 두 큐의 머리에서 min(채권, 채무) 만큼 매칭하며 큐를 줄여나감
  • 매 매칭마다 최소 한 쪽은 큐에서 빠지므로 transfer 횟수 ≤ N−1
  • 도메인 record 50줄 안에 응집 — 서비스 계층 비대화 방지

→ 송금 횟수가 N(N−1)/2 → 최대 N−1회로 수렴.

4. S3 presigned URL — 업로드/표시 양쪽

UploadService.createPresignedUpload() — 클라이언트에 PUT URL 발급. S3UrlSigner — 응답 DTO 직렬화 시점에 GET-presigned URL 주입.

  • MIME 화이트리스트 3종(image/jpeg|png|webp) — 그 외엔 UPLOAD_INVALID_CONTENT_TYPE
  • 객체 키를 서버가 결정 ({purpose}/{memberId}/{UUID}.{ext}) — 클라이언트 임의 키 주입 차단
  • presigned URL 만료: upload 10분 / read 60분
  • private 버킷이라 외부 URL(Kakao 프로필 등) 은 서명 제외, 우리 버킷만 변환

→ 프로필 / 크루 생성 / 크루 커버 / 핀 추가 4개 화면에 동일 흐름 재사용. → private S3 이미지 표시용 추가 API 호출 0회 (모든 GET URL 이 응답 DTO 안에 동봉).

5. 도메인 객체에 검증 내재화

핵심 invariant 는 Service 가 아니라 Domain 이 강제한다.

  • Pin.verifySettlement() — 분담 금액 합 = 총액
  • Play.verifyContainsMembers() — 플레이 멤버는 반드시 크루 멤버
  • Play.verifyMutable() — 정산 마감 후 어떤 mutation 도 차단
  • Crew.verifyMember() — 멤버십 검증
  • Crew.leave() — 잔액 잔존 멤버의 크루 탈퇴 거부 (OUTSTANDING_SETTLEMENT_BALANCE)
  • Member 만 deleted_at selective soft delete — 정산 기록의 시점성 보존, 다른 테이블은 hard delete 로 단순화

도메인 예외는 BusinessException(ErrorCode) 단일 채널 → GlobalExceptionHandlerApiResponse<T> { success, data, message } 표준 응답.

인프라 (Terraform · AWS)

6. Terraform 11개 모듈 + 모듈별 state 격리

aws/vpc, aws/rds, aws/ec2-backend, aws/ec2-iam, aws/alb, aws/ecr, aws/backend-secrets, aws/cloudwatch-logs, aws/cloudwatch-alarms, terraform/s3, terraform/github-oidc11개 모듈.

  • S3 backend 의 key 를 모듈별로 다르게 둬 plan/apply 폭발 반경 축소
  • 콘솔로 만든 EC2/ALB/ECR/IAM 라이브 자원을 terraform import + 0-diff plan 매칭 으로 무중단 흡수
  • user_data_replace_on_change = false 옵션으로 userData 코드 갱신 시 인스턴스 재생성 차단
  • IAM 권한은 deprecated inline_policy / managed_policy_arns 회피하고 별도 aws_iam_role_policy / aws_iam_role_policy_attachment 리소스로 비-exclusive 부여 — 모듈 간 policy 덮어쓰기 사고 차단
7. EC2 부팅 시 Secrets Manager → atomic env file → systemd

userdata.sh 가 amazon-ecr-credential-helper + docker + jq 설치 후 meeny-fetch-secrets.sh 와 systemd unit 을 셋업.

부팅·재시작 시:

  1. meeny-fetch-secrets.sh 가 Secrets Manager 에서 RDS master(JSON) + JWT(plain) 시크릿 fetch
  2. umask 077 + mktemp + mv -f/etc/meeny-backend.env (mode 600) atomic write
  3. systemd EnvironmentFile=/etc/meeny-backend.env 로 컨테이너에 환경변수 주입
  4. docker run -e DB_URL -e DB_USERNAME -e DB_PASSWORD -e JWT_SECRET ... 로 백엔드 컨테이너 기동

평문 시크릿이 git · userData · tfstate · systemd unit 어디에도 남지 않는다. → Terraform 은 Secret 의 metadata 만 관리, 값은 별도 aws secretsmanager put-secret-value 로 주입.

8. RDS + ALB + S3 보안 기본값

RDS PostgreSQL 16:

  • manage_master_user_password = true (RDS-managed Secrets Manager)
  • storage_encrypted = true (gp3)
  • deletion_protection = true + skip_final_snapshot = false + final_snapshot_identifier
  • 보안그룹 ingress 를 EC2 SG 한정 5432 — VPC 전역(0.0.0.0/0) 금지

ALB:

  • HTTP 80 → HTTPS 443 301 redirect
  • TLS 정책 ELBSecurityPolicy-TLS13-1-2-2021-06
  • ACM 인증서 연동

S3 uploads 버킷:

  • public access 전면 차단 (block_public_acls / policy / restrict_public_buckets / ignore_public_acls)
  • SSE-AES256
  • versioning enabled
  • multipart abort lifecycle 7일
  • CORS allow-list (origin 한정)
9. CloudWatch 모니터링 6개 알람 + SNS email
알람트리거의미
rds_cpu_highCPU > threshold (2 periods)db.t4g.micro burst credit 소진 위험
rds_free_storage_lowFreeStorage < 2 GiB곧 storage full
rds_connections_highConnections > threshold (2 periods)connection leak 의심
alb_unhealthy_hostUnhealthyHostCount ≥ 1백엔드 down
alb_target_5xx5xx Count > threshold (5min)백엔드 에러 burst
ec2_status_check_failedStatusCheckFailed ≥ 1 (2 periods)인스턴스 자체 문제

→ SNS email 토픽으로 1인 운영의 모니터링 사각지대 보강. → Docker awslogs log driver 로 컨테이너 stdout 이 CloudWatch Logs (/meeny/backend/prod) 로 직접 전송.

CI/CD (GitHub Actions)

10. OIDC + sub 클레임 화이트리스트 — 정적 키 0개 배포

terraform/github-oidc 모듈로 OIDC provider + IAM role 생성. role 신뢰관계의 sub 클레임을 repo:meeny-team/backend:refs/heads/main 한 줄로 한정 — 다른 repo/브랜치는 assume 불가.

role 권한:

  • ECR push 액션 (PutImage/UploadLayerPart/InitiateLayerUpload 등) → 해당 repository ARN 한정
  • ecr:GetAuthorizationToken → 별도 statement (ARN 받지 않음)
  • SSM SendCommand특정 instance ARN + AWS-RunShellScript document ARN 한정
  • ssm:GetCommandInvocation → 결과 폴링용

GitHub Actions secrets 에 AWS Access Key 0개.

11. SSM RunCommand 배포 — SSH 노출 0

ECR push 완료 후 워크플로우 마지막 step:

CMD_ID=$(aws ssm send-command \
  --instance-ids "$INSTANCE_ID" \
  --document-name AWS-RunShellScript \
  --parameters 'commands=["systemctl restart meeny-backend"]' \
  --query "Command.CommandId" --output text)
 
aws ssm wait command-executed --command-id "$CMD_ID" --instance-id "$INSTANCE_ID"
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \
  --query '{Status:Status,Exit:ResponseCode,Stdout:...,Stderr:...}'
  • SSH 키/포트 노출 0 — instance role 의 SSM agent 만 있으면 됨
  • wait command-executed 로 완료까지 폴링, 결과(Stdout/Stderr/ExitCode) 를 워크플로우 로그에 노출해 실패 추적성 확보
  • systemd unit 의 ExecStartPre=docker pull ${ECR}:${TAG} 가 새 이미지를 가져와 컨테이너 교체
12. 멀티아키 이미지 + 태그 이원화
- uses: docker/build-push-action@v6
  with:
    platforms: linux/amd64,linux/arm64
    tags: |
      ${ECR}/meeny-backend:${{ github.sha }}    # 불변 — 롤백 기준
      ${ECR}/meeny-backend:${{ github.ref_name }}  # 가변 — 운영 자동 pull
    cache-from: type=gha
    cache-to: type=gha,mode=max
  • amd64 (EC2 t3.micro 같은 x86) + arm64 (t4g/Graviton, Apple Silicon 로컬) 2종 동시 빌드
  • :<sha> 불변 태그는 배포 매니페스트가 정확히 가리키는 ID — 롤백 가능
  • :<branch> 가변 태그는 systemd 가 매 restart 마다 docker pull 하는 운영용 편의 태그
  • cache-to: type=gha, mode=max 로 레이어 캐시 재사용
  • concurrency: ci-${{ github.ref }} + cancel-in-progress: true 로 중복 실행 자동 취소
  • 빌드/푸시 job 은 if: github.event_name == 'push' && github.ref == 'refs/heads/main' 게이트로 main push 일 때만 실행

트러블슈팅

1. Refresh 토큰 폐기가 401 예외와 함께 롤백되던 race condition

증상. 재사용된 refresh 를 잡았을 때 "DB 토큰 모두 삭제 + 401 예외" 를 같이 했더니, 공격자가 동일 토큰을 계속 쓸 수 있었다.

원인. 같은 트랜잭션 안에서 보안 정리(deleteByMemberId) 와 실패 통보(throw) 를 함께 수행 → 예외가 트랜잭션 경계를 벗어날 때 정리 작업도 같이 롤백.

해결.

  • RefreshTokenInvalidator.invalidateAllForMember() 를 별도 컴포넌트로 분리
  • @Transactional(propagation = Propagation.REQUIRES_NEW) 부여
  • 호출자(AuthService.refresh) 는 폐기 commit 이후 BusinessException(REFRESH_TOKEN_REUSED) 을 던지는 흐름으로 재배치

결과. 탈취 의심 토큰이 들어오는 즉시 해당 멤버 전체 refresh 가 폐기되고 양쪽 클라이언트 모두 강제 재로그인.

남는 원칙. "롤백되어선 안 되는 작업" 과 "롤백 트리거인 예외" 는 트랜잭션 경계가 달라야 한다.

2. S3 presigned PUT — 403 SignatureDoesNotMatch

증상. RN 클라이언트에서 PUT 시 종종 403 SignatureDoesNotMatch.

원인. presigned URL 발급 시 contentType 이 SigV4 서명에 포함되는데, RN fetch 가 Blob.type 으로 PUT 요청의 Content-Type 을 덮어쓰는 케이스가 있었음. 특히 iOS 가 image/jpg 같은 비표준 type 으로 보내기도.

해결.

  • 서버 — MIME 화이트리스트 3종(image/jpeg|png|webp) 으로 좁히고 그 외엔 UPLOAD_INVALID_CONTENT_TYPE
  • 클라이언트 — 응답으로 받은 contentType 으로 Blob 을 재포장 (new Blob([rawBlob], { type, lastModified })), PUT 헤더와 정확히 일치시킴
  • image/jpgimage/jpeg 표준화 추가

결과. SignatureDoesNotMatch 재현 사례 사라짐. 4개 화면(프로필/크루 생성/크루 커버/핀 추가)에 동일 흐름 재사용.

남는 원칙. S3 presigned 의 "서명에 포함된 헤더" 는 클라이언트가 한 글자도 바꿔선 안 된다. 서버·클라이언트 양쪽에서 동시 강제해야 안전하다.

3. 동시 401 → refresh 폭주로 인한 정상 사용자 강제 로그아웃

증상. 화면 진입 시 여러 API 가 병렬 호출되는데 토큰이 만료돼 있으면 N개가 동시에 refresh 호출 → refresh 가 1회용 회전이라 두 번째부터 reuse 감지에 걸려 정상 사용자도 로그아웃.

원인. 회전 토큰은 "한 시점에 한 번만 사용" 을 클라이언트도 지켜야 하는데, 백엔드의 invariant 만 두고 클라이언트 측은 dedupe 가 없었음.

해결. src/auth/session.ts 에 모듈 레벨 inflightRefresh: Promise<TokenResponse> | null 캐시. 첫 호출자가 refresh 를 시작하면 이후 콜러는 모두 같은 promise 를 await. 완료 후 캐시 비움.

결과. refresh 호출이 정확히 1회로 수렴. 정상 사용자의 의도치 않은 강제 로그아웃 0건.

남는 원칙. 백엔드의 invariant 는 클라이언트도 함께 지켜야 의미가 있다. 한쪽에서만 지키면 나머지 한쪽이 그 invariant 를 우연히 깰 수 있다.

4. 회원 탈퇴 hard delete 로 정산 FK 깨짐

증상. 회원 탈퇴를 hard delete 로 처리했더니 과거 plays/pins 의 결제자·분담자 FK 가 깨지거나 "알 수 없음" 으로 표시.

원인. 정산 기록은 시점 데이터인데 멤버를 물리 삭제하면 그 시점의 의미가 사라진다. 정산은 "그때 그 사람이 얼마 냈다" 가 본질.

해결.

  • members.deleted_at selective soft delete 로 전환 (다른 테이블은 hard delete 유지)
  • Member.getDisplayNickname() 이 탈퇴자에겐 "(탈퇴한 사용자)" fallback
  • PlaySettlementService.loadDisplayNicknames 가 결과에 등장하는 모든 멤버 ID 를 findAllById 한 번으로 조회해 닉네임 맵 주입 — N+1 동시 제거

결과. 과거 정산 화면이 탈퇴자 정보 포함해 정상 렌더링. 정산 응답 쿼리 수가 멤버 수와 무관하게 2~3회로 수렴.

남는 원칙. 시점성을 보존해야 하는 엔티티는 hard delete 가 답이 아니다. 일관성 vs 단순성 트레이드오프에서 "정산 기록을 깨뜨리지 않는 최소한의 테이블" 에만 soft delete.

5. ECR pull 이 12시간 만료로 갑자기 실패

증상. EC2 가 ECR 에서 이미지를 pull 하다가 docker login 12시간 만료로 갑자기 실패. 컨테이너 재시작 시 새 이미지를 못 가져옴.

원인. aws ecr get-login-password | docker login 으로 받은 자격증명의 TTL 한계. 장기 운용 인스턴스에서 주기적 재로그인이 필요한데 안 하고 있었음.

해결. userData 에서 amazon-ecr-credential-helper 설치 + /root/.docker/config.jsoncredHelpers 설정.

{ "credHelpers": { "${ECR_REGISTRY}": "ecr-login" } }

→ docker login 호출 자체를 제거. instance role 의 ECR 권한을 활용해 매 pull 마다 자격증명을 자동 갱신.

결과. 장기 운용 중 ECR pull 실패가 영구 해결. EC2 무인 운용 안정화.

남는 원칙. "주기적으로 갱신해야 하는 자격증명" 은 운영자가 챙기지 말고 helper 에게 맡긴다. cron 으로 재로그인을 돌리는 것은 안티패턴.

6. 콘솔로 만든 자원과 IaC 코드의 drift

증상. 초기에 콘솔로 만든 EC2/ALB/ECR/IAM 이 IaC 밖에 있어 재해 복구가 "콘솔에서 클릭한 기억" 에 의존. userData ↔ 라이브 인스턴스 설정도 어긋남.

원인. 신규 자원은 IaC 로 만들 수 있지만, 이미 운영 중인 라이브 자원은 무중단으로 코드로 옮기는 절차가 따로 필요.

해결 — import-first 전략.

  • terraform import 로 라이브 자원을 state 에 흡수
  • terraform plan0 to change 가 되도록 코드를 라이브에 맞춰감 (라이브를 코드에 맞추는 게 아님)
  • user_data_replace_on_change = false 옵션으로 userData 코드 갱신 시 인스턴스 재생성 차단
  • IAM 권한은 deprecated inline_policy / managed_policy_arns 회피하고 별도 리소스로 비-exclusive 부여 — 다른 모듈의 policy 덮어쓰기 사고 차단

결과. 운영 토폴로지가 Terraform 11개 모듈로 흡수. 인프라 변경 100% PR-review 흐름에 편입.

남는 원칙. 라이브 자원의 IaC 화는 "코드에 맞추는 것" 이 아니라 "코드를 맞추는 것" 이다. 운영 무중단을 위해 import → 0-diff → 점진 정리 순서를 지킨다.


성과

항목BeforeAfter
정산 송금 횟수N(N−1)/2N−1 이하
정산 응답 쿼리 수멤버 수 N 에 선형2~3회 (상수)
이미지 표시용 추가 API 호출화면당 N 회0회 (응답 DTO 동봉)
백엔드 multipart 대역폭/메모리EC2 직접 처리0 (S3 직접 PUT)
GitHub Actions 의 정적 AWS Access Key1+ 개0개 (OIDC)
EC2 의 SSH 노출22 포트닫음 (SSM SendCommand 만)
평문 시크릿 노출 면적git / userData / tfstate0 (Secrets Manager + atomic env file)
ECR pull 12시간 만료간헐 실패영구 해결 (credential-helper)
토큰 reuse 오탐 강제 로그아웃동시 401 시 발생0건 (in-flight dedup)
콘솔↔IaC drift재해 복구 위험PR-review 흐름 편입 (Terraform 11개 모듈)
인프라 고정비 (EKS 컨트롤플레인 + NAT)$109/월$0 (EC2 + systemd-docker 채택)

※ 일부 수치는 카운팅 가능한 구조적 개선 결과이며, 실측 트래픽·응답시간 벤치마크는 별도 측정하지 않음.


회고

  • 인프라 비용 vs 학습 가치의 trade-off 가 가장 큰 의사결정이었다. EKS 를 끝까지 들고 가는 게 학습엔 좋지만, 운영비가 실서비스 규모와 안 맞을 때는 EC2 로 내려놓는 게 맞다. 학습용 EKS/VPC/RDS Terraform 모듈은 별도로 유지해 다음 단계로 옮길 수 있게 했다.
  • 도메인 규칙을 객체 안에 두니 무효 상태가 애초에 만들어지지 않는 흐름이 잡혔다. Service 는 transaction·조합만, 검증은 Domain 이 가져간다. Pin.verifySettlement 같은 invariant 가 어디에 있어야 하는지가 코드 리뷰 없이도 자명해진다.
  • OIDC 무키 배포 · SSM SendCommand · presigned URL · Secrets Manager 주입 — 네 가지가 합쳐지니 "운영 환경에 시크릿이 거의 없다" 는 상태에 도달했다. 시크릿이 적을수록 운영 사고 범위가 줄어든다.
  • 트랜잭션 경계는 데이터베이스 락만의 문제가 아니라 "무엇이 함께 살아남고 무엇이 함께 죽어야 하는가" 의 문제다. Refresh 토큰 폐기와 401 예외의 경계 분리 사례에서 배운 것.