{"id":47737918,"url":"https://github.com/bh1848/usw-circle-link-server","last_synced_at":"2026-04-02T23:02:42.766Z","repository":{"id":344130887,"uuid":"1178839523","full_name":"bh1848/usw-circle-link-server","owner":"bh1848","description":"수원대학교 동아리 연합회 공식 플랫폼 | Spring Boot · MySQL · Redis · AWS","archived":false,"fork":false,"pushed_at":"2026-03-20T19:08:34.000Z","size":2827,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-21T09:48:01.465Z","etag":null,"topics":["aws-s3","java","jwt","mysql","redis","spring-boot"],"latest_commit_sha":null,"homepage":"https://admin.donggurami.net","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bh1848.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-11T12:24:33.000Z","updated_at":"2026-03-20T19:08:37.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bh1848/usw-circle-link-server","commit_stats":null,"previous_names":["bh1848/usw-circle-link-server"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/bh1848/usw-circle-link-server","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bh1848%2Fusw-circle-link-server","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bh1848%2Fusw-circle-link-server/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bh1848%2Fusw-circle-link-server/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bh1848%2Fusw-circle-link-server/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bh1848","download_url":"https://codeload.github.com/bh1848/usw-circle-link-server/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bh1848%2Fusw-circle-link-server/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31318142,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-02T21:35:00.834Z","status":"ssl_error","status_checked_at":"2026-04-02T21:34:59.806Z","response_time":89,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aws-s3","java","jwt","mysql","redis","spring-boot"],"created_at":"2026-04-02T23:02:41.948Z","updated_at":"2026-04-02T23:02:42.759Z","avatar_url":"https://github.com/bh1848.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg width=\"100%\" alt=\"main-banner\" src=\"https://github.com/user-attachments/assets/5e9f9dcd-717a-4f33-843b-4a1d985e53b4\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003e동구라미\u003c/h1\u003e\n\u003cp align=\"center\"\u003e수원대학교 동아리 연합회 공식 플랫폼\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://admin.donggurami.net\"\u003e웹 서비스\u003c/a\u003e ·\n  \u003ca href=\"https://apps.apple.com/kr/app/%EB%8F%99%EA%B5%AC%EB%9D%BC%EB%AF%B8/id6692607046\"\u003eiOS\u003c/a\u003e ·\n  \u003ca href=\"https://play.google.com/store/apps/details?id=com.usw.flag.temp.usw_circle_link\"\u003eAndroid\u003c/a\u003e ·\n  \u003ca href=\"https://linktr.ee/woochang4862\"\u003e시연 영상\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n## 목차\n\n1. [프로젝트 개요](#1-프로젝트-개요)\n2. [성과](#2-성과)\n3. [기술 스택](#3-기술-스택)\n4. [시스템 설계](#4-시스템-설계)\n5. [담당 영역](#5-담당-영역)\n6. [테스트](#6-테스트)\n\n---\n\n## 1. 프로젝트 개요\n\n동구라미는 수원대학교 동아리 연합회의 공식 통합 관리 플랫폼입니다. 기존에 카카오톡·엑셀로 분산 관리되던 동아리 정보와 지원 프로세스를 디지털화했습니다.  \n\n- **기간:** 2024년 4월 ~ 2025년 3월 (약 12개월, 이후 후임 팀 인계)\n- **팀 구성:** 백엔드 4명 / 웹 프론트엔드 4명 / 모바일(iOS·Android) 3명 / 디자인 1명\n\n| 구분 | 성명 |\n|------|------|\n| BE (Web/Mobile) | 김지오, 남궁다연, 방혁, 한지형 |\n| Design | 이보영 |\n| FE (Web) | 이동수, 박성재, 노경미, 김수민 |\n| FE (Mobile) | 정우창, 유지석, 이수빈 |\n\n**해결한 문제**\n- 동아리별 개별 홍보로 인한 정보 분산 → 단일 플랫폼에서 전체 동아리 탐색\n- 종이/구글폼 기반 지원서 관리 → 실시간 지원 현황 조회 및 결과 알림(FCM)\n- 행사 당일 소속 인원 수기 확인 → QR 기반 디지털 식별\n\n**서비스 링크**\n\n| 플랫폼 | 링크 | 상태 |\n|--------|------|------|\n| 웹 (권장) | [admin.donggurami.net](https://admin.donggurami.net) | 운영 중 |\n| iOS | [App Store](https://apps.apple.com/kr/app/%EB%8F%99%EA%B5%AC%EB%9D%BC%EB%AF%B8/id6692607046) | 운영 중 |\n| Android | [Google Play](https://play.google.com/store/apps/details?id=com.usw.flag.temp.usw_circle_link) | 운영 중 |\n\n**주요 기능**\n\n| 기능 | 설명 | 웹 | iOS | Android |\n|------|------|:--:|:---:|:-------:|\n| 동아리 탐색 | 카테고리·모집상태 필터 및 상세 정보 조회 | ✓ | ✓ | ✓ |\n| 지원서 제출 | 중복 방지 및 실시간 지원 현황 확인 | ✓ | ✓ | ✓ |\n| 합격 알림 | FCM 기반 합격/불합격 푸시 알림 | - | ✓ | ✓ |\n| 회원 관리 | 엑셀 업로드 및 정회원/비회원 분류 | ✓ | ✓ | ✓ |\n| 프로필 관리 | 프로필 수정 및 소속 동아리 조회 | ✓ | ✓ | ✓ |\n\n---\n\n## 2. 성과\n\n2024년 4월 ~ 2025년 3월 개발 참여 후 후임 팀에 인계했으며 2025년 10월 30일 동아리 축제 입장 식별 시스템으로 실서비스 적용됐습니다.\n\n| 지표 | 수치 | 측정 시점 |\n|------|------|-----------|\n| 일일 활성 사용자 (출시 당일) | 249명 | 2025.10.30 |\n| 누적 가입자 수 | 498명 | 2025.10.30 |\n\n\u003e 동아리 연합회 측으로부터 \"기존 수기 확인 방식보다 원활하게 진행됐다\"는 평가를 받았고 다수 동아리 회장으로부터 \"기존 문제점이 잘 해결됐다\"는 피드백을 받았습니다.  \n\n---\n\n## 3. 기술 스택\n\n### Backend\n| 기술 | 선택 이유 |\n|------|-----------|\n| Spring Boot 3.3.1 | Spring Security·Data JPA·트랜잭션 추상화가 일관되게 통합되어 있어 역할별 인증 분리, 벌크 연산, afterCommit() 패턴을 별도 설정 없이 조합할 수 있었음 |\n| Spring Security | 필터 기반 인증 체계를 역할별로 분리하기 위한 표준 프레임워크 |\n| Spring Data JPA | 도메인 중심 설계와 JPQL 벌크 연산을 혼용해 유지보수성과 성능을 균형 있게 확보 |\n\n### Database\n| 기술 | 선택 이유 |\n|------|-----------|\n| MySQL 8.0 | 복잡한 조인·페이징·트랜잭션이 필요한 관계형 데이터에 적합 |\n| Redis | Refresh Token 저장소(TTL 자동 만료) 및 Rate Limiter(Bucket4j) 분산 상태 관리 |\n\n### 인증 / 보안\n| 기술 | 선택 이유 |\n|------|-----------|\n| JWT (jjwt) | Stateless 인증으로 수평 확장에 유리하고 role claim으로 토큰 검증 직후 인증 주체 확정 가능 |\n| Bucket4j + Redis | 분산 환경에서도 동일한 Rate Limit 정책 적용 가능 |\n| Jsoup | XSS 방지를 위한 입력 정제를 `@Sanitize` 어노테이션으로 선언적으로 적용 |\n\n### 인프라\n| 기술 | 선택 이유 |\n|------|-----------|\n| AWS EC2 | 서버 환경 제어 및 IAM 역할 기반 자격증명으로 키 없는 S3 접근 |\n| AWS RDS | 자동 백업과 멀티 AZ로 데이터 내구성 확보 |\n| AWS S3 + Presigned URL | 파일 트래픽을 서버가 직접 처리하지 않고 클라이언트가 S3와 직접 통신하도록 분리 |\n| Nginx | 리버스 프록시 및 SSL 터미네이션 |\n\n### 외부 연동\n- Firebase FCM — 합격/불합격 결과 푸시 알림 (iOS·Android)\n- Naver SMTP — 이메일 인증 / 아이디 찾기 / 비밀번호 재설정\n\n---\n\n## 4. 시스템 설계\n\n### 서버 구성도\n![Server Architecture](./docs/images/circle_link_arch.png)\n\n- Prod·Test 환경을 EC2 인스턴스로 분리해 운영하며 각각 독립된 S3 버킷과 RDS를 사용\n- 클라이언트(PC·Mobile) → CloudFront → S3 → EC2(Nginx) 순으로 트래픽이 흐르며 Nginx가 리버스 프록시와 SSL 터미네이션을 담당\n- EC2에 IAM 역할을 부여해 액세스 키 없이 S3에 접근하도록 구성\n- Redis는 EC2 내에서 Refresh Token 저장소와 Rate Limiter 상태 관리에 사용\n\n### ERD\n![ERD](./docs/images/circle_link_erd.png)\n\n- Club이 중심 엔티티로, ClubIntro·ClubMainPhoto·ClubIntroPhoto·ClubMembers·ClubApplication이 Club에 연결되는 구조\n- User와 Profile을 1:1로 분리해 인증 정보(User)와 프로필 정보(Profile)의 책임을 구분\n- ClubApplication이 Profile과 Club을 잇는 다대일 구조로 지원서 중복을 DB 유니크 제약으로 보장\n\n### API 명세서\n- [[모바일(User) API]](https://documenter.getpostman.com/view/36800939/2sA3s1nrcY)\n- [[웹(Leader/Admin) API]](https://documenter.getpostman.com/view/29405740/2sA3s6Doda)\n\n---\n\n## 5. 담당 영역\n\n백엔드 팀원으로 인증/인가, 동아리·지원서·공지·Admin 기능, 공통 예외 처리, 파일 업로드 검증, 입력 정제를 담당했습니다.\n\n### 인증/인가 구조\n\n```\nRoleBasedUserDetailsService (interface)\n├── CustomUserDetailsService    → Role.USER\n├── CustomLeaderDetailsService  → Role.LEADER\n└── CustomAdminDetailsService   → Role.ADMIN\n \nUserDetailsServiceManager\n└── EnumMap\u003cRole, RoleBasedUserDetailsService\u003e  → O(1) 조회\n```\n\n- JWT Access Token에 `role` claim 포함 → 토큰 검증 직후 인증 대상 확정, JwtFilter에서 역할 판단 로직 제거\n- Refresh Token은 `RefreshTokenStore`(Redis 읽기·쓰기) / `RefreshTokenCookieService`(쿠키 처리) / `RefreshTokenService`(발급·검증·재발급) 세 클래스로 책임 분리\n- 로그아웃·비밀번호 변경 시 Redis에서 Refresh Token 즉시 무효화\n\n### 동아리 관련 기능\n\n- 동아리 목록 조회 시 메인 사진·해시태그·회원 수·리더를 일괄 조회해 쿼리 수를 최소화\n\n| 항목 | 변경 전 | 변경 후 |\n|------|---------|---------|\n| 메인 사진·해시태그 조회 | 동아리당 개별 조회 (N+1) | `findByClubIds()` 일괄 조회 (2번) |\n| 회원 수·리더 조회 | 페이지당 21번 (목록 1 + 동아리당 회원 수·리더 각 1씩 × 10) | 단일 집계 JPQL (1번) |\n\n### 지원서 기능\n\n- `ClubApplication` 엔티티에 `(profile_id, club_id)` 복합 유니크 제약 추가\n- `saveAndFlush()`로 커밋 전 즉시 플러시 → `DataIntegrityViolationException`을 `ALREADY_APPLIED`로 변환\n\n### 공지 기능\n\n- 공지사항 CRUD (제목·내용·사진)\n- 사진 삭제 시 `afterCommit()`으로 DB 커밋 이후에만 S3 삭제 실행\n- 사진 순서(`order`) 필드로 업로드 순서 보장\n\n### 동아리 연합회(관리자) 기능\n\n- 동아리 생성 시 Club·Leader·ClubIntro·ClubMainPhoto·ClubIntroPhoto 기본 데이터 일괄 생성\n- 동아리 삭제 시 JPQL 벌크 DELETE로 연관 테이블 9개를 테이블당 쿼리 1개로 처리 (265ms → 116ms, 약 56% 개선, 로컬 MySQL 기준)\n- `afterCommit()`으로 DB 커밋 이후에만 S3 삭제 실행해 두 저장소 간 불일치 방지\n- 동아리 카테고리 CRUD, 층별 사진 업로드·조회·삭제\n\n### 파일 업로드 검증\n\n- `FileSignatureValidator` — `@Component`로 분리해 `S3FileUploadService`·`ClubLeaderService`에 주입\n- 파일 매직 바이트를 직접 읽어 확장자와 실제 포맷 일치 여부 검증, PNG는 스펙 기준 8바이트 시그니처 전체 검증\n- S3 업로드는 Presigned URL 방식으로 처리해 파일 트래픽이 서버를 경유하지 않음\n\n### 입력 검증 및 정제\n\n- `@ValidClubRoomNumber` — 유효한 동아리방 번호 집합과 정규식을 결합한 커스텀 검증\n- `@Sanitize` + `SanitizationBinder` — `@RequestBody`·`@RequestPart`·`WebDataBinder` 경로를 모두 커버하며 `@Sanitize`가 붙은 필드에만 선택적으로 Jsoup 정제 적용, `List\u003cString\u003e` 같은 컬렉션 필드도 원소 단위로 정제\n\n| 구분 | 대상 |\n|------|------|\n| 적용 | 화면에 노출되는 자유 서술형 필드 (제목·본문·소개·이름·해시태그·카테고리명 등) |\n| 비적용 | 비밀번호·토큰·UUID·이메일·학번·전화번호·계정 ID·URL·enum·숫자 등 정형 필드 |\n\n### 공통 예외 처리\n\n- `ApiResponse\u003cT\u003e` — 성공 응답 구조를 `message`·`data` 필드로 통일, `data`는 `@JsonInclude(NON_NULL)`로 null 시 미포함\n- `@RestControllerAdvice`로 전역 예외를 한 곳에서 처리\n- 운영 프로파일(`prod`)에서는 4xx 클라이언트 에러 로그를 출력하지 않아 노이즈 감소\n- UUID 파싱 실패, JSON 역직렬화 오류, 파일 크기 초과 등 Spring 내부 예외도 일관된 형식으로 반환\n\n---\n\n## 6. 테스트\n\n### 테스트 구성\n\n총 **23개 테스트 클래스**, **191개 테스트 케이스**를 작성했습니다.\n\n| 유형 | 클래스 수 | 주요 내용 |\n|------|-----------|-----------|\n| Service (단위) | 7 | Mockito 기반 비즈니스 로직·예외 분기 검증 |\n| Repository (통합) | 4 | `@DataJpaTest` JPQL 커스텀 쿼리·벌크 DELETE 실행 검증 |\n| Controller (슬라이스) | 3 | `@WebMvcTest` HTTP 상태코드·응답 본문 검증 |\n| Security | 5 | JWT 발급·검증·만료, JwtFilter 동작, RefreshToken 생명주기 |\n| Validator | 3 | 파일 시그니처 검증, 동아리방 번호 유효성, 입력 정제 |\n| 기타 | 1 | `IntegrationAuthService` 로그아웃·토큰 갱신 흐름 |\n\n### 테스트 전략\n\n#### 단위 테스트(Service)\n\n외부 의존성을 Mockito로 모킹해 비즈니스 로직에만 집중합니다. `MockedStatic`으로 `SecurityContextHolder`를 격리해 인증 컨텍스트를 제어합니다.\n\n```java\n// 예: 동시성 - DB 유니크 제약 위반을 도메인 예외로 변환\ngiven(clubApplicationRepository.saveAndFlush(any()))\n        .willThrow(new DataIntegrityViolationException(\"unique constraint\"));\n\nassertThatThrownBy(() -\u003e service.submitClubApplication(clubUUID))\n        .isInstanceOf(ClubApplicationException.class)\n    .extracting(e -\u003e ((ClubApplicationException) e).getExceptionType())\n        .isEqualTo(ExceptionType.ALREADY_APPLIED);\n```\n\n#### 통합 테스트(Repository)\n\n`@DataJpaTest`로 실제 JPA 쿼리를 검증합니다. `afterCommit()` 콜백 등록 여부와 S3 삭제 호출은 `AdminClubServiceTest`에서 `MockedStatic\u003cTransactionSynchronizationManager\u003e`를 활용해 분리 검증합니다.\n\n```java\n// 예: 커밋 후 S3 삭제 보장 검증\ntransactionMocked.verify(() -\u003e TransactionSynchronizationManager\n        .registerSynchronization(transactionSynchronizationCaptor.capture()));\n\n        transactionSynchronizationCaptor.getValue().afterCommit();\nverify(s3FileUploadService).deleteFiles(List.of(\"main.jpg\", \"intro-1.jpg\", \"intro-2.jpg\"));\n```\n\n`afterCommit()`을 직접 트리거한 뒤 빈 문자열 key는 제외하고 유효한 S3 key만 `deleteFiles()`에 전달되는 것을 검증합니다.\n\n#### 슬라이스 테스트(Controller)\n\n`@WebMvcTest` + `addFilters = false`로 Security 필터를 제거하고 HTTP 레이어만 검증합니다. MockMvc로 상태코드, 응답 JSON 구조, 예외 코드를 단언합니다.\n\n```java\nmockMvc.perform(get(\"/apply/can-apply/{clubUUID}\", clubUUID))\n        .andExpect(status().isOk())\n        .andExpect(jsonPath(\"$.message\").value(\"지원 가능\"))\n        .andExpect(jsonPath(\"$.data\").doesNotExist());\n```\n\n#### Security 테스트\n\n유효·만료·변조 토큰 세 케이스를 각각 검증하고 JwtFilter에서 각 케이스에 따라 `CustomAuthenticationEntryPoint`가 올바른 에러 코드로 호출되는지 `ArgumentCaptor`로 확인합니다.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbh1848%2Fusw-circle-link-server","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbh1848%2Fusw-circle-link-server","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbh1848%2Fusw-circle-link-server/lists"}