Skip to content
IT-SSUMPublic

About

버려질 자원을 가장 필요한 곳으로, IT-SSUM

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

IT:SSUM

기부자와 복지기관을 연결하는 물품 기부 매칭 서비스 IT:SSUM의 백엔드 레포지토리입니다.


주요 기능

기부자

  • 기부 물품 등록 (카테고리, 품목, 수량과 단위, 물품 상태 상·중·하, 사진 1~5장, 물품 위치 주소, 지역은 경기 부천시 고정)
  • 기관이 올린 필요 물품 탐색 (검색어·카테고리·지역·희망 상태 필터), 내가 등록한 물품 관리 (수정·삭제)
  • 등록한 물품 기준으로 추천 기관 목록 확인 후 기부 제안
  • 기관이 보낸 기부 요청 수락·거절 및 거래 수량 예약
  • 채팅으로 전달 방법·일정·장소 협의

복지기관

  • 회원가입 시 국세청 사업자등록정보 진위확인 API로 사업자 확인 (운영자 승인 절차 없음)
  • 필요 물품 등록 (필요 수량과 단위, 희망 최소 상태, 수령 지역, 참고 사진 선택)
  • 기부자가 올린 기부 물품 탐색 (검색어·카테고리·지역·물품 상태 필터), 우리 기관 필요 물품 관리 (수정·삭제·모집 종료)
  • 탐색한 기부 물품에 기부 요청
  • 기부자가 보낸 기부 제안 수락·거절
  • 물품을 받으면 수령 확인 → 전달 완료

공통

  • STOMP 기반 1:1 채팅 (수락한 개별 거래 1건당 채팅방 1개, 거래중부터 이용 가능)
  • 매칭 상태가 바뀔 때마다 알림 (실시간 구독 + 목록 조회, 읽음 처리)
  • 이메일 링크로 비밀번호 재설정

기술 스택

구분 사용
Language Java 21
Framework Spring Boot 4.1.1, Spring MVC
DB MySQL 8.4, Spring Data JPA (Hibernate)
인증 Spring Security, JWT (OAuth2 Resource Server)
실시간 Spring WebSocket + STOMP
기타 Validation, Java Mail Sender (Gmail SMTP), Caffeine, Lombok
외부 API 국세청 사업자등록정보 진위확인 (공공데이터포털), 카카오 로컬 (주소 → 좌표)
파일 저장 AWS S3 (Presigned URL), AWS Lambda (채팅 썸네일)
Build Gradle (Groovy)
배포 GitHub Actions, Docker, AWS EC2 · RDS, Nginx, Let's Encrypt
API 문서 springdoc-openapi (Swagger UI)
알림 FCM (기기 푸시)

ERD

ERD

색 도메인 테이블
파랑 회원·인증 users, terms_agreements, password_reset_tokens, refresh_tokens, donors, organizations, organization_verifications
노랑 기준 데이터 regions, item_categories
초록 물품 donation_items, donation_item_images, needed_items
빨강 매칭 matches
보라 추천 organization_recommendations (donation_item_recommendations는 지금 쓰지 않음)
청록 채팅 chat_rooms, chat_participants, chat_messages
주황 알림 notifications, notification_settings, push_registrations

설계하면서 정한 것들

  • donors, organizations, notification_settings는 users의 PK를 그대로 PK로 씁니다. 엔티티에서는 @MapsId로 매핑합니다.
  • 개별 거래는 기부자 1명과 기관 1곳 사이에서 기부 물품 1개와 필요 물품 1개를 잇습니다. 물품 하나는 여러 개별 거래에 참여할 수 있습니다. 기부자가 먼저 제안한 건지 기관이 먼저 요청한 건지는 requested_by로 구분합니다.
  • 기부 물품 하나를 여러 기관에 나눠 줄 수 있어서 수량을 quantity, reserved_quantity, completed_quantity 세 개로 관리합니다. 필요 물품도 같은 방식입니다.
  • 매칭 목록 조회가 잦아서 matches에 donor_id, organization_id를 중복 저장했습니다.
  • 회원 탈퇴, 물품 삭제는 deleted_at을 채우는 soft delete입니다. 응답 대기 중인 제안·요청이나 거래중·예약중인 거래가 걸린 물품은 지울 수 없습니다.
  • 회원 탈퇴 때는 이름·연락처·이메일 등 개인정보를 자리표시 값으로 덮어써서, 같은 이메일·사업자번호로 다시 가입할 수 있게 합니다. 응답 대기 중인 제안·요청이나 거래중·예약중인 거래가 있으면 탈퇴할 수 없습니다.
  • 파일럿 지역은 경기 부천시 한 곳입니다. regions에는 region_id = 1인 부천시만 있고, 기부자 활동 지역과 물품 지역은 이 값으로 고정합니다. 거리 계산은 물품 상세주소와 기관 주소의 좌표로 하기 때문에 regions에는 좌표를 두지 않습니다.
  • 기부 물품의 물품 위치(pickup_address, 도로명 주소)는 등록·수정 때 카카오 주소 검색으로 좌표를 구해 latitude·longitude에 저장합니다. 변환에 실패해도 등록은 되고 좌표만 비워 둡니다. 다른 회원에게는 구 단위 지역만 보입니다. 필요 물품은 기관 주소 좌표를 씁니다.
  • 물품 카테고리 7개는 data.sql 기준 데이터(ID 고정)이고, 추천은 같은 카테고리끼리 묶습니다. 단위는 정해진 목록(개, 권, 장, 벌, 세트, 병, 박스, 켤레, 기타)에서만 받습니다. 매칭은 단위가 같아야 성립해서, 자유 입력이면 같은 물건이 표기 차이로 빠집니다.
  • 기관 추천은 학습 데이터 없이 동작하는 규칙 기반입니다. 같은 카테고리·단위, 기관의 희망 상태 이상, 인증된 기관, 모집 중, 진행 중인 거래 없음을 만족하는 필요 물품을 후보로 두고, 총점 = 100 × (0.6 × 수량 적합도 + 0.4 × 거리 점수)로 순위를 매깁니다. 수량 적합도는 min(기부 수량, 남은 필요 수량) / max(…), 거리 점수는 max(0, 1 − 거리(km) / 20)이고 좌표가 없으면 0입니다. 기관당 1개, 상위 3개를 organization_recommendations에 저장합니다. 추천 이유(reason)는 비워 두고 따로 붙입니다. 기관 쪽 추천(필요 물품 → 기부 물품)은 없습니다.

스키마 관리

Flyway는 쓰지 않고 spring.jpa.hibernate.ddl-auto: update로 관리합니다. 그래서 실제 스키마의 기준은 엔티티입니다.

  • 엔티티를 고치면 docs/erd/itssum_erd_v3.sql과 ERDCloud도 같이 고쳐주세요.
  • update는 컬럼 추가만 반영하고 타입 변경이나 컬럼 삭제는 반영하지 않습니다. 이런 변경이 있으면 로컬 DB를 지우고 다시 띄우면 됩니다.
  • 지역 같은 기준 데이터는 src/main/resources/data.sql이 서버가 뜰 때마다 넣습니다. INSERT IGNORE라서 이미 있으면 건너뜁니다.

매칭 상태

상태 화면 표시 다음으로 넘어가는 조건
PENDING 응답 대기 받은 사람이 수락하면 TRADING, 거절하면 REJECTED
TRADING 거래중 양측이 채팅으로 협의합니다. 기부자가 수량을 예약하면 RESERVED, 취소하면 CANCELED
RESERVED 예약중 기관이 실제 수령 확인하면 COMPLETED, 취소하면 CANCELED
COMPLETED 전달 완료 끝
REJECTED 거절 끝
CANCELED 취소 끝
  • 수락하면 채팅방이 열리지만 수량은 확보되지 않습니다. 거래중에도 수량을 확보하지 않습니다. 기부자가 예약중으로 바꾸는 순간 기부 물품과 필요 물품 양쪽의 reserved_quantity에 거래 수량이 더해집니다.
  • 거래중·예약중 거래는 양측 모두 취소할 수 있습니다. 예약중 취소 시 예약 수량을 돌려놓습니다.
  • 수령 확인 시 reserved_quantity에서 빼고 completed_quantity(필요 물품은 received_quantity)로 옮깁니다.
  • 신청 가능 수량은 quantity - reserved_quantity - completed_quantity, 추가 필요 수량은 required_quantity - reserved_quantity - received_quantity입니다. 필요 물품의 수령 수량이 필요 수량을 채우면 모집을 종료합니다.
  • 기관은 필요 수량을 다 받기 전에 모집을 직접 종료할 수 있습니다. 응답 대기 중인 기부자 제안은 거절되고(거절 알림), 기관이 보낸 요청은 철회됩니다. 거래중·예약중 거래는 그대로이며 종료 뒤에도 예약·수령 확인까지 진행할 수 있습니다. 직접 종료한 물품은 예약이 취소돼도 다시 열리지 않습니다.
  • 예약중 거래의 당사자 누구나 채팅에서 합의한 전달 방법·일정·장소를 저장·변경할 수 있습니다. 저장할 때마다 확정 당시 내용을 담은 전달 약속 카드가 채팅에 생성됩니다.

프로젝트 구조

도메인별로 패키지를 나누고, 각 도메인 안에 controller / dto / entity / repository / service를 둡니다. 여러 도메인이 같이 쓰는 설정과 외부 연동은 global에 모읍니다.

src/main/java/com/itssum
├── domain
│   ├── auth            # 로그인, 회원가입, 토큰, 비밀번호 재설정
│   ├── user            # 회원, 기부자, 복지기관, 약관 동의, 사업자 인증 이력
│   ├── item            # 기부 물품, 이미지, 필요 물품, 카테고리, 지역
│   ├── recommendation  # 기관 추천 (규칙 기반 점수 계산과 결과)
│   ├── match           # 매칭
│   ├── chat            # 채팅방, 참여자, 메시지
│   └── notification    # 알림, 알림 설정, 푸시 등록
│
│   (도메인 공통)
│   ├── controller
│   ├── dto
│   │   ├── request
│   │   └── response
│   ├── entity
│   ├── repository
│   └── service
│
└── global
    ├── common      # BaseTimeEntity, ApiResponse
    ├── config      # JPA Auditing, Security, WebSocket 설정
    ├── exception   # ErrorCode, BusinessException, GlobalExceptionHandler
    ├── security    # JWT 필터, 인증 처리
    └── infra       # S3, 국세청 사업자 API, 메일, FCM

로컬 실행

MySQL 8.4에 itssum 스키마를 먼저 만들어 두고, DB 접속 정보와 JWT 키는 환경변수로 넘깁니다. 비밀값은 커밋하지 않습니다.

export DB_URL="jdbc:mysql://localhost:3306/itssum?serverTimezone=Asia/Seoul"
export DB_USERNAME=root
export DB_PASSWORD=비밀번호
export JWT_SECRET=32자-이상-무작위-문자열
# 인증 메일을 실제로 보낼 때만 (Gmail 앱 비밀번호)
export MAIL_USERNAME=발신용@gmail.com
export MAIL_PASSWORD=앱비밀번호
# 기관 사업자 확인을 실제로 호출할 때만 (공공데이터포털 국세청 API 디코딩 키)
export NTS_SERVICE_KEY=서비스키
# 기관 주소를 좌표로 바꿀 때만 (카카오 디벨로퍼스 REST API 키). 없으면 좌표 없이 가입된다
export KAKAO_REST_API_KEY=REST키

./gradlew bootRun

IntelliJ에서 띄울 때는 Run Configuration의 Environment variables에 같은 값을 넣으면 됩니다.


배포

수동 배포는 하지 않습니다. main에 머지되면 GitHub Actions가 EC2에 배포합니다.

워크플로우 실행 시점 하는 일
CI (.github/workflows/ci.yml) develop, main 대상 PR MySQL 8.4 컨테이너를 띄워 빌드·테스트
CD (.github/workflows/cd.yml) main push, Actions 탭 수동 실행 이미지 빌드 → Docker Hub push → EC2에서 컨테이너 교체 → /actuator/health 확인
  • 운영 주소는 https://api.itssum.site입니다. 가비아 DNS의 api A 레코드가 EC2 탄력적 IP를 가리킵니다.
  • 인프라는 EC2(Ubuntu 24.04) 한 대와 RDS MySQL 8.4입니다. EC2의 호스트 Nginx가 443(HTTPS)을 받아 127.0.0.1:8080으로 프록시하고, 80은 HTTPS로 리다이렉트합니다. 초기 세팅 스크립트는 docs/infra/ec2-user-data.sh에 있습니다.
  • 인증서는 Let's Encrypt(certbot --nginx)이고, EC2의 certbot.timer가 만료 전에 자동으로 갱신합니다. 도메인이 아니라 IP로 HTTP 접속하면 404입니다.
  • FE는 Vercel에 배포하고 https://itssum.site, https://www.itssum.site로 접속합니다. 가비아 DNS의 @ A 레코드와 www CNAME 레코드가 Vercel을 가리킵니다.
  • 브라우저에서 API와 채팅 WebSocket(/ws)을 부를 수 있는 출처는 application.yml의 cors.allowed-origins입니다. 지금은 로컬 http://localhost:3000, http://localhost:5173과 배포 FE https://itssum.site, https://www.itssum.site입니다. 출처를 바꿀 때는 S3 버킷 CORS도 같이 바꿉니다.
  • 비밀값은 GitHub Secrets에만 둡니다. CD가 배포할 때마다 EC2의 .env를 새로 만들기 때문에 EC2에서 .env를 직접 고치면 다음 배포에서 덮어써집니다.
  • 환경변수를 새로 추가할 때는 GitHub Secrets 등록과 cd.yml의 .env 블록 수정을 같이 해야 합니다.
  • 이미지 태그는 커밋 SHA입니다. 롤백은 Actions에서 이전 CD 실행을 Re-run 하면 됩니다.
  • FCM 서비스 계정 JSON은 GitHub Secret FIREBASE_SERVICE_ACCOUNT_BASE64에 base64 한 줄로 넣습니다. CD가 EC2의 ~/itssum-app/firebase-service-account.json(권한 600)으로 풀고, compose가 컨테이너의 /run/secrets/firebase-service-account에 읽기 전용으로 연결합니다(FCM_ENABLED=true, GOOGLE_APPLICATION_CREDENTIALS). JSON은 저장소와 이미지에 넣지 않습니다.
  • 서비스 계정 키를 바꿀 때는 새 JSON을 base64로 바꿔 같은 Secret을 덮어쓰고 CD를 다시 실행합니다. PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes("<JSON 경로>")) | Set-Clipboard

필요한 GitHub Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, EC2_HOST, EC2_USER, EC2_SSH_KEY, DB_URL, DB_USERNAME, DB_PASSWORD, JWT_SECRET, MAIL_USERNAME, MAIL_PASSWORD, NTS_SERVICE_KEY, KAKAO_REST_API_KEY, FIREBASE_SERVICE_ACCOUNT_BASE64

이미지 저장소 (S3)

채팅 이미지와 물품 사진은 비공개 S3 버킷에 올리고, DB에는 객체 key만 저장합니다. 버킷이 비공개라 업로드와 조회 모두 서버가 발급한 서명 URL로 합니다.

항목 값
버킷 itssum-images-6318 (서울 ap-northeast-2), 퍼블릭 접근 차단, SSE-S3
서명 URL 업로드(PUT) 10분, 조회(GET) 1시간
key {접두사}/{소유자ID}/{uuid}.{jpg,png,webp} — 채팅은 chat/{채팅방ID}/..., 기부 물품은 donation-items/{기부자ID}/..., 필요 물품은 needed-items/{기관ID}/... (물품은 사진을 올릴 때 아직 없어서 올린 회원 기준)
CORS http://localhost:3000, http://localhost:5173, https://itssum.site, https://www.itssum.site만 허용 (PUT·GET, 헤더 Content-Type·If-None-Match, ETag 노출)
서버 권한 EC2 IAM 역할 itssum-ec2-s3: 이 버킷의 객체 읽기·쓰기·삭제 + ListBucket
  • 버킷 이름과 리전은 비밀값이 아니라 application-prod.yml에 있고, 자격 증명은 EC2 역할에서 받습니다. S3 때문에 추가할 GitHub Secret은 없습니다.
  • 조회 URL은 서명에 쓴 EC2 역할 임시 자격 증명이 먼저 만료되면 1시간보다 일찍 끊길 수 있습니다. FE는 원본(content)도 못 불러오면 메시지를 다시 조회해 새 URL을 받습니다.
  • ListBucket이 없으면 S3가 없는 객체 조회에 404 대신 403을 돌려줘서, 업로드 확인에서 "아직 안 올라옴"을 구분하지 못합니다.
  • 서버가 Docker 컨테이너 안에서 EC2 메타데이터(IMDSv2)로 자격 증명을 받기 때문에 인스턴스의 메타데이터 홉 제한을 2로 둡니다.
  • 로컬·테스트에는 버킷 설정이 없어 S3 빈이 만들어지지 않고, 채팅 이미지 API는 503(CHAT_IMAGE_STORAGE_UNAVAILABLE), 물품 사진 업로드 URL 발급과 사진이 필요한 등록·수정은 503(ITEM_503)을 돌려줍니다. 물품 조회는 사진 URL만 비운 채로 됩니다. 이미지 흐름은 운영 서버에서 확인합니다.
  • 이미지 메시지를 보낼 때 업로드된 객체의 크기(10MB 이하)·Content-Type과 파일 앞 12바이트의 형식 시그니처(JPEG·PNG·WebP)를 확인합니다. 선언한 형식과 실제 파일이 다르면 객체를 지우고 거절합니다.
  • 업로드 URL만 받고 메시지·물품에 쓰지 않은 파일과, 물품 수정·삭제로 빠진 사진은 지우지 않고 남겨 둡니다 (파일럿 범위).
  • 썸네일 Lambda는 chat/만 처리합니다. 물품 목록에도 원본 서명 URL을 줍니다.

채팅 썸네일 Lambda

항목 값
함수 itssum-chat-thumbnail (Node.js 24.x, x86_64, 1024MB, 30초)
트리거 버킷 PUT 이벤트, 접두사 chat/
결과 thumbnails/chat/{채팅방ID}/{uuid}.webp — 긴 변 400px, WebP 품질 80, 원본보다 키우지 않음
실행 역할 itssum-thumbnail-lambda: chat/* 읽기, thumbnails/* 쓰기, CloudWatch 로그
  • 이미지 메시지 응답의 thumbnailUrl이 이 썸네일의 서명 URL입니다. 서버는 썸네일이 있는지 확인하지 않으므로, FE는 썸네일을 못 불러오면 content의 원본 URL로 바꿔 보여줍니다.
  • 결과를 thumbnails/에 쓰기 때문에 트리거(chat/)가 다시 실행되지 않습니다.
  • 함수 코드는 이 레포에 올리지 않습니다 (lambda/는 gitignore). 원본 소스는 담당자 PC의 lambda/chat-thumbnail/에만 있고, 배포된 코드가 기준입니다. 다른 사람이 고칠 때는 Lambda 콘솔에서 함수 코드를 .zip으로 내려받아 lambda/chat-thumbnail/에 풀고 시작합니다.
  • 코드를 바꾸면 lambda/chat-thumbnail에서 sharp를 리눅스용으로 받아 zip으로 묶고 Lambda 콘솔 코드 탭에서 .zip 파일로 올립니다. zip 안의 경로 구분자가 /여야 해서 Windows Compress-Archive 대신 Python zipfile로 묶습니다.
cd lambda/chat-thumbnail
npm install --omit=dev --os=linux --cpu=x64 --libc=glibc
python -c "import shutil; shutil.make_archive('../chat-thumbnail', 'zip', '.')"   # lambda/chat-thumbnail.zip

API 명세

API 명세는 노션에서 관리하고, 실제 요청·응답은 Swagger UI에서 확인합니다.

  • 로컬: http://localhost:8080/swagger-ui.html

  • 배포 서버: https://api.itssum.site/swagger-ui.html

  • 로그인·회원가입 응답의 accessToken을 우측 상단 Authorize에 넣고 호출합니다. 인증 API(/api/auth/...)는 토큰 없이 호출합니다.

  • REST: /api/{리소스 복수형} 형태, 내 정보는 /me로 접근 (예: GET /api/donors/me/donation-items)

  • 상태 변경은 PATCH /api/{리소스}/{id}/{동작} (예: PATCH /api/needed-items/{id}/close)

  • WebSocket 연결 /ws, 채팅 구독 /topic/chat-rooms/{chatRoomId}, 전송 /app/chat-rooms/{chatRoomId}/messages, 알림 구독 /user/queue/notifications


컨벤션

브랜치

브랜치 용도
main 배포용. 직접 푸시하지 않습니다.
develop 개발 통합 브랜치. 모든 PR은 여기로 올립니다.
feature/{이슈번호}-{작업} 기능 개발 (예: feature/12-donation-item-create)
fix/{이슈번호}-{작업} 버그 수정
refactor/{이슈번호}-{작업} 리팩토링

작업 전에 이슈부터 만들고, 이슈 번호로 브랜치를 땁니다.

커밋 메시지

타입: 작업 내용 (#이슈번호)
타입 언제 쓰는지
feat 새로운 기능
fix 버그 수정
refactor 동작은 그대로 두고 코드 구조만 변경
docs README, ERD, API 명세 등 문서
test 테스트 코드 추가·수정
chore 빌드 설정, 의존성, 설정 파일
style 포맷팅, import 정리처럼 로직과 상관없는 변경
  • 제목은 한글로, 50자 이내로 씁니다. 끝에 마침표는 찍지 않습니다.
  • 왜 바꿨는지 설명이 필요하면 한 줄 띄우고 본문에 적습니다.
  • 한 커밋에는 한 가지 작업만 담습니다.
feat: 기부 물품 등록 API 구현 (#12)
feat: 필요 물품 모집 종료 API 추가 (#18)
fix: 매칭 취소 시 예약 수량이 복원되지 않던 문제 수정 (#31)
refactor: 추천 점수 계산을 RecommendationScorer로 분리 (#40)
docs: ERD v3 반영
chore: Spring Boot 4.1.1로 버전 변경

PR

  • develop 브랜치로 올리고, 제목은 커밋 메시지 형식을 따릅니다.
  • 본문에는 작업 내용, 관련 이슈(closes #12), 확인 방법을 적습니다.
  • 리뷰어 1명이 승인하면 Squash and merge로 합칩니다.
  • 머지한 브랜치는 삭제합니다.

코드

DTO

  • DTO는 전부 record로 만들고 도메인의 dto/request, dto/response에 둡니다.
  • 이름은 {동작}{대상}Request, {대상}{용도}Response 형식입니다. 예: CreateDonationItemRequest, DonationItemDetailResponse
  • 요청 값 검증은 record 필드에 @NotBlank, @Positive 같은 Bean Validation을 붙여서 합니다.
  • 응답 DTO는 from(Entity) 정적 메서드로 만들고, 컨트롤러에서 엔티티를 그대로 반환하지 않습니다.

Entity

  • setter를 만들지 않고, 기본 생성자는 protected로 둡니다.
  • 생성은 @Builder나 정적 팩토리 메서드로, 값 변경은 의미 있는 이름의 메서드로 합니다. 예: match.accept(), neededItem.close()
  • 연관관계는 전부 FetchType.LAZY로 걸고, 필요한 경우에만 양방향으로 만듭니다.
  • enum은 @Enumerated(EnumType.STRING)으로 저장합니다.

응답·예외

  • 모든 응답은 ApiResponse로 감싸서 내려줍니다. 성공·실패 모두 success, code, message가 같은 위치에 있습니다.
    • 성공: {"success": true, "code": "AUTH_201", "message": "회원가입이 완료되었습니다.", "data": {...}}
    • 실패: {"success": false, "code": "AUTH_401", "message": "..."} (입력값 검증 실패는 fieldErrors 추가)
  • 성공 코드는 SuccessCode에 추가하고 ApiResponse.ok(SuccessCode.XXX, data)로 반환합니다. HTTP 상태(예: 회원가입 201)는 SuccessCode를 따라 자동으로 정해집니다.
  • 예외는 ErrorCode에 코드를 추가하고 BusinessException으로 던집니다. 처리는 GlobalExceptionHandler 한 곳에서 합니다.

팀

이름 담당 (백엔드)
이창훈 인증·인가, 회원, 기부·필요 물품, AI 추천, 기관 사업자 인증, 인프라
허건우 매칭, 채팅, 알림

About

버려질 자원을 가장 필요한 곳으로, IT-SSUM

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages