diff --git a/.gitignore b/.gitignore index 9212457..b88a82c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ out/ ### VS Code ### .vscode/ -.env \ No newline at end of file +.env +docs/bohyeong/ diff --git a/Feed.md b/Feed.md new file mode 100644 index 0000000..26d057f --- /dev/null +++ b/Feed.md @@ -0,0 +1,302 @@ +## 피드 개선해야할 점 + +1. 전체 피드 중 N개 추출해서 메인페이지에서 보여주는 형태로 변경 필요(여기서 DTO는 FeedDetail으로 사용) +2. 각 커뮤니티마다 피드가 있을텐데 커뮤니티별로 피드는 무한 스크롤 하는 형태로 변경 필요 +3. 피드 Summary에 좋아요 수, 댓글 수 추가 필요. 그리고 본문 내용을 일부 보여주는 형태로 변경 필요 +4. 좋아요는 아직 구현이 안되어있는데 목 업 데이터라도 넣어서 좋아요 수를 보여줄 수 있도록 변경 필요 +5. 블라인드의 페이지 형태를 따르고 싶음(메인페이지에서는 각 커뮤니티별로 N개 피드 노출(근데 여기서는 본문이 없고 오로지 제목만 있는 형태), 커뮤니티 페이지에서는 해당 + 커뮤니티 피드 무한스크롤 형태) + +--- + +## 구현 로드맵 + +### Phase 1: 기존 DTO 확장 및 정리 + +#### 1.1 FeedSummary / FeedDetail 필드 확장 +- [ ] **파일**: `feed/application/query/dto/FeedSummary.java`, `feed/application/query/dto/FeedDetail.java` +- [ ] **작업 내용**: + - `FeedSummary`에 `contentPreview`, `likeCount`, `commentCount` 필드를 추가하여 목록 조회만으로도 핵심 정보를 노출 + - `FeedDetail`에 `likeCount`, `commentCount`를 추가하여 상세 페이지에서도 동일한 수치를 재사용 + - DTO 파일은 기존처럼 개별 클래스로 유지하고, 생성자/레코드 필드 순서를 맞춰 추후 유지보수 비용 최소화 +- [ ] **고려사항**: + - `contentPreview`는 서비스 계층에서 100자 내외로 잘라 전달 (DB에서 substring 하지 않음) + - 새로운 필드가 추가되므로 Lombok/Record 생성자 변경에 따른 컴파일 오류 포인트 점검 + +#### 1.2 TitleOnly DTO 및 레이어별 적용 +- [ ] **파일**: `feed/application/query/dto/FeedTitleOnly.java` (신규), 관련 Service/Controller +- [ ] **작업 내용**: + - 메인페이지 제목 전용 조회를 위해 `FeedTitleOnly` DTO를 신설 (필드: `id`, `title`, `likeCount`, `commentCount`, `CommunitySummary` 등) + - Presentation 계층 응답 DTO에서도 `FeedTitleOnly`를 그대로 활용하거나 필요한 필드만 매핑 + - QueryPort/Service/Adapter/RowMapper가 새 DTO를 반환하도록 조정 +- [ ] **고려사항**: + - 기존 DTO 네이밍은 유지하고, 새 DTO가 추가되더라도 import 충돌이 없도록 패키지 경로를 명확히 유지 + - 테스트 스텁에서도 새 DTO를 생성해주어야 하므로 Fixture 헬퍼 추가 검토 + +### Phase 2: Like(좋아요) 바운디드 컨텍스트 구축 + +#### 2.1 독립 Like 도메인 모델 정의 +- [x] **파일**: `like/domain/Like.java`, `like/domain/LikeTargetType.java` +- [ ] **작업 내용**: + - `LikeTargetType(FEED, COMMENT, …)`를 통해 어떤 애그리거트에 속한 좋아요인지 구분 + - `Like` 엔티티는 `targetType`, `targetId`, `userId`, `createdAt`을 보유하고 유니크 키로 중복 방지 + - 향후 댓글/DM 등 다른 컨텍스트에서도 재사용 가능 + +#### 2.2 Like Command/Validator 포트 구성 +- [x] **파일**: `like/application/command/**` +- [ ] **작업 내용**: + - `LikeCommandPort`/`LikeCommandService`를 생성하여 `like/unlike` 케이스를 통합 처리 + - `LikeTargetValidator` 인터페이스를 정의하고, Feed/Comment 등 각 도메인이 구현하여 존재 여부 검증 + - 기존 Feed 전용 Service/Adapter 제거 → Feed 컨텍스트는 Like BC를 의존하도록 변경 + +#### 2.3 Like 집계 쿼리 및 스키마 공통화 +- [x] **파일**: `FeedJdbcRepositoryImpl.java`, `schema.sql`, `data.sql` +- [ ] **작업 내용**: + - `likes` 테이블을 도입해 `target_type` + `target_id` 기준으로 집계 + - Feed 조회 시 `LEFT JOIN likes fl ON fl.target_type = 'FEED' AND fl.target_id = f.id` + - 테스트 시드도 동일 테이블을 활용해 댓글/피드 모두 검증 가능 + +### Phase 3: Query Port 및 Service 확장 + +#### 3.1 FeedQueryPort 메서드 추가 +- [ ] **파일**: `FeedQueryPort.java` (수정) +- [ ] **작업 내용**: + - `findTopNByOrderByCreatedAtDesc(int limit)` - 메인페이지용 최신 N개 조회 + - `findByCommunityId(Long communityId, Pageable pageable)` - 커뮤니티별 피드 조회 + - `findTitleOnlyByCommunityId(Long communityId, int limit)` - 커뮤니티별 제목만 N개 조회 + - `findAllTitleOnlyGroupedByCommunity(int limitPerCommunity)` - 메인페이지용 커뮤니티별 N개씩 조회 + +#### 3.2 FeedQueryService 메서드 추가 +- [ ] **파일**: `FeedQueryService.java` (수정) +- [ ] **작업 내용**: + - `getMainPageFeeds(int limit)` - 메인페이지용 FeedDetail 리스트 반환 + - `getFeedsByCommunity(Long communityId, int page, int size)` - 커뮤니티별 무한 스크롤용 + - `getFeedTitlesGroupedByCommunity(int limitPerCommunity)` - 블라인드 스타일 메인페이지용 +- [ ] **비즈니스 로직**: + - contentPreview는 본문 앞 100자로 제한 (서비스 레이어에서 처리) + - null 처리 및 예외 처리 추가 + +### Phase 4: Adapter 구현 + +#### 4.1 JDBC Query Adapter 수정 +- [x] **파일**: `FeedJdbcRepositoryImpl.java` (수정) +- [ ] **작업 내용**: + - 좋아요 수 집계 쿼리 추가 (LEFT JOIN `likes`) + - 커뮤니티별 조회 쿼리 구현 + - 메인페이지용 최신 N개 조회 쿼리 + - 커뮤니티별 그룹핑 쿼리 (WITH 절 또는 서브쿼리 활용) + +#### 4.2 RowMapper 수정 +- [x] **파일**: `FeedSummaryRowMapper.java` (수정) +- [ ] **작업 내용**: + - `likeCount` 컬럼 매핑 추가 + - `contentPreview` 컬럼 매핑 추가 (SUBSTRING 함수 활용) +- [ ] **파일**: `FeedTitleOnlyRowMapper.java` (신규 생성) +- [ ] **작업 내용**: 제목만 조회하는 RowMapper 구현 + +### Phase 5: Presentation 계층 API 추가 + +#### 5.1 FeedController 엔드포인트 추가 +- [x] **파일**: `FeedController.java` (수정) +- [ ] **작업 내용**: + - `GET /api/v1/feeds/main?limit=10` - 메인페이지용 FeedDetail 리스트 + - `GET /api/v1/feeds/main/titles?limit=5` - 블라인드 스타일 메인페이지용 (커뮤니티별 N개) + - `GET /api/v1/communities/{communityId}/feeds?page=0&size=20` - 커뮤니티별 무한 스크롤 + - 기존 `GET /api/v1/feeds` 유지 (전체 피드 조회) + +#### 5.2 Response DTO 정리 +- [x] **파일**: 기존 DTO 활용 또는 Presentation 계층 전용 Response 생성 +- [ ] **작업 내용**: + - `MainPageFeedsResponse` - FeedDetail 리스트 래핑 + - `CommunityFeedsResponse` - 커뮤니티별 피드 + 페이징 정보 + - `MainPageTitlesResponse` - 커뮤니티별 FeedTitleOnly 그룹화 + +#### 5.3 Swagger 문서 갱신 +- [x] **파일**: `FeedSwagger.java` (수정) +- [ ] **작업 내용**: + - 새로운 API 엔드포인트 인터페이스 정의 + - 요청/응답 스키마 예시 추가 + - 파라미터 설명 추가 + +### Phase 6: 데이터베이스 및 Mock 데이터 + +#### 6.1 Like 테이블 스키마 추가 +- [ ] **파일**: `schema.sql` (테스트용) +- [ ] **위치**: `src/test/resources/sql/` +- [ ] **작업 내용**: + ```sql + CREATE TABLE likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + target_type VARCHAR(50) NOT NULL, + target_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_like_target_user (target_type, target_id, user_id) + ); + ``` +- [ ] **운영 DB**: 동일 스키마 적용 (마이그레이션 스크립트 작성) + +#### 6.2 Mock 데이터 생성 +- [ ] **파일**: `data.sql` (테스트용) +- [ ] **위치**: `src/test/resources/sql/` +- [ ] **작업 내용**: + - 각 피드당 랜덤 좋아요 수 삽입 (0~50개) + - 테스트 시나리오별 데이터 준비 +- [ ] **고려사항**: 실제 운영 환경에서는 좋아요 기능 완전 구현 전까지 Mock 유지 + +### Phase 7: 테스트 작성 + +#### 7.1 단위 테스트 (Application Layer) +- [ ] **파일**: `FeedQueryServiceTest.java` (수정) +- [ ] **작업 내용**: + - `getMainPageFeeds()` 테스트 + - `getFeedsByCommunity()` 테스트 + - `getFeedTitlesGroupedByCommunity()` 테스트 + - Stub 저장소에서 좋아요 수 모킹 +- [ ] **파일**: `FeedQueryPortStub.java` (신규 생성) +- [ ] **작업 내용**: 메모리 기반 Stub 구현 + +#### 7.2 통합 테스트 (Infrastructure Layer) +- [ ] **파일**: `FeedJdbcRepositoryImplTest.java` (신규 또는 수정) +- [ ] **작업 내용**: + - Testcontainers MySQL로 실제 쿼리 검증 + - 좋아요 집계 쿼리 정확성 테스트 + - 커뮤니티별 조회 쿼리 테스트 + - 페이징 및 정렬 검증 + +#### 7.3 API 테스트 +- [ ] **파일**: `FeedControllerTest.java` (수정) +- [ ] **작업 내용**: + - 새로운 엔드포인트 MockMvc 테스트 + - 응답 구조 검증 + - 페이징 파라미터 검증 + +### Phase 8: 성능 최적화 및 리팩토링 + +#### 8.1 쿼리 최적화 +- [ ] N+1 문제 확인 및 해결 (JOIN FETCH 또는 배치 조회) +- [ ] 인덱스 추가: `feeds(community_id, created_at)`, `likes(target_type, target_id)` +- [ ] 실행 계획 분석 (EXPLAIN) + +#### 8.2 캐싱 전략 (선택사항) +- [ ] 메인페이지 피드 리스트 캐싱 (Redis 또는 Spring Cache) +- [ ] 좋아요 수 집계 캐싱 +- [ ] TTL 설정 (예: 5분) + +#### 8.3 코드 리뷰 및 리팩토링 +- [ ] 중복 코드 제거 +- [ ] 매직 넘버 상수화 (본문 미리보기 길이 등) +- [ ] 에러 메시지 일관성 검토 + +### Phase 9: 문서화 및 배포 준비 + +#### 9.1 API 문서 업데이트 +- [ ] Swagger UI 확인 (`/api-test`) +- [ ] 엔드포인트별 예시 응답 추가 +- [ ] 에러 케이스 문서화 + +#### 9.2 README 갱신 +- [ ] 새로운 기능 설명 추가 +- [ ] API 사용 예시 추가 +- [ ] 좋아요 기능 Mock 데이터 사용 안내 + +#### 9.3 배포 체크리스트 +- [ ] `./gradlew test` 전체 테스트 통과 확인 +- [ ] `./gradlew bootRun` 로컬 실행 검증 +- [ ] DB 마이그레이션 스크립트 준비 +- [ ] 환경 변수 설정 확인 +- [ ] 롤백 계획 수립 + +--- + +## 구현 우선순위 + +### 🔴 High Priority (우선 구현) +1. Phase 1.1 - FeedSummary DTO 확장 (좋아요 수, 본문 미리보기) +2. Phase 2 - Like Mock 데이터 구조 +3. Phase 3 - Query Service 확장 (커뮤니티별 조회, 메인페이지 조회) +4. Phase 5.1 - API 엔드포인트 추가 + +### 🟡 Medium Priority (2차 구현) +5. Phase 1.2 - 블라인드 스타일 메인페이지용 DTO +6. Phase 4 - Adapter 구현 (JDBC 쿼리) +7. Phase 6 - 데이터베이스 스키마 및 Mock 데이터 +8. Phase 7 - 테스트 작성 + +### 🟢 Low Priority (추후 개선) +9. Phase 8 - 성능 최적화 +10. Phase 9 - 문서화 및 배포 + +--- + +## 주요 고려사항 + +1. **좋아요 기능의 임시성**: 현재는 Mock 데이터로 구현하지만, 추후 실제 좋아요 기능 구현 시 독립 도메인(`like` 패키지)로 분리 필요 +2. **무한 스크롤 구현**: 커서 기반 페이징 vs 오프셋 기반 페이징 트레이드오프 검토 (현재는 오프셋 방식 사용) +3. **메인페이지 전략**: 블라인드 스타일(제목만) vs 본문 미리보기 스타일 중 선택 또는 두 가지 모두 제공 +4. **CQRS 준수**: Command는 JPA, Query는 JDBC로 명확히 분리 유지 +5. **테스트 격리**: Stub 저장소는 각 테스트마다 독립적으로 생성하여 상태 공유 방지 + +--- + +## 예상 변경 파일 목록 + +### 신규 생성 +- `like/domain/Like.java` +- `like/domain/LikeTargetType.java` +- `like/application/command/port/LikeCommandPort.java` +- `like/application/command/LikeCommandService.java` +- `like/application/command/validator/LikeTargetValidator.java` +- `like/infra/command/LikeJpaRepository.java` +- `like/infra/command/LikeCommandAdapter.java` +- `feed/application/validator/FeedLikeTargetValidator.java` +- `feed/infra/query/jdbc/mapper/FeedTitleOnlyRowMapper.java` + +- `feed/application/query/dto/FeedSummary.java` (필드 확장) +- `feed/application/query/dto/FeedDetail.java` (필드 확장) +- `feed/application/query/dto/FeedTitleOnly.java` (메인페이지 전용 DTO 연결) +- `feed/application/query/port/FeedQueryPort.java` +- `feed/application/query/FeedQueryService.java` +- `feed/application/validator/FeedLikeTargetValidator.java` +- `feed/infra/query/FeedQueryAdapter.java` +- `feed/infra/query/jdbc/FeedJdbcRepositoryImpl.java` +- `feed/infra/query/jdbc/mapper/FeedSummaryRowMapper.java` +- `feed/presentation/FeedController.java` +- `feed/presentation/swagger/FeedSwagger.java` +- `global/exception/ErrorCode.java` +- 테스트 파일들 (`*Test.java`), `schema.sql`, `sql/feed/data.sql` + +### 영향 받는 파일 +- `community/presentation/CommunityController.java` (커뮤니티 상세 페이지에서 피드 조회 시) + +--- + +## DTO 관리 가이드 + +### 개별 DTO 유지 원칙 +1. **클래스 단위 유지**: `FeedSummary`, `FeedDetail`, `FeedTitleOnly`처럼 역활별 DTO를 개별 파일로 두고 필요한 필드만 선언한다. +2. **명시적 의존성**: 어떤 레이어에서 어떤 DTO를 쓰는지 주석 또는 패키지 구조로 드러나게 하여 import 만으로도 의도를 파악할 수 있게 한다. +3. **호환성 고려**: 기존 DTO를 수정할 때는 동일 시그니처를 사용하는 다른 서비스/테스트에 영향이 없는지 먼저 검색(`rg FeedSummary`)으로 확인한다. +4. **네이밍 규칙**: `FeedSummaryResponse` 같이 용도가 Presentation이라면 접미사를 붙이고, Application 계층 DTO는 `FeedSummary`처럼 단순화한다. + +### 필드 확장/변경 체크리스트 +- **생성자/Record 업데이트**: 필드 추가 시 모든 생성자, 빌더, Mapper에서 값을 전달하는지 확인한다. +- **테스트 픽스처 동기화**: Stub이나 Fixture 객체가 새 필드를 채우지 않으면 NPE가 발생할 수 있으므로 공통 Fixture 유틸을 업데이트한다. +- **Swagger/문서 반영**: Presentation DTO 구조가 바뀌면 `FeedSwagger`의 예시 응답도 즉시 수정한다. +- **Backward Compatibility**: API 응답 스키마 변경 시 버전 관리 또는 프론트 협의 후 반영한다. + +### 신규 DTO 도입 절차 +```java +// 1단계: 새로운 용도 정의 +public record FeedTitleOnly( + Long id, + String title, + int likeCount, + int commentCount, + CommunitySummary community +) {} + +// 2단계: QueryPort/Service/Adapter에 메서드 및 매퍼 추가 + +// 3단계: Controller/Swagger/Test에서 DTO 연결 확인 +``` diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..6ddd60f --- /dev/null +++ b/PROJECT_OVERVIEW.md @@ -0,0 +1,185 @@ +# 프로젝트 개요 + +이 문서는 새로운 에이전트가 레포에 합류했을 때 가장 먼저 읽고 전체 구조를 +파악할 수 있도록 작성한 가이드입니다. + +## 기술 스택 + +- **런타임:** Spring Boot 3.5.x / Java 21 +- **빌드:** Gradle (`./gradlew bootRun`, `./gradlew test`) +- **영속성:** 운영은 MySQL, 테스트는 H2 + Testcontainers +- **API:** Spring MVC 기반 REST + SpringDoc Swagger UI (`/api-test`) +- **인증:** JWT 커스텀 구현 + Spring Security 암호화(`application-*.yaml`에서 + 비밀값 주입) + +## 전체 아키텍처 + +`src/main/java/com/example/bak` 아래에 도메인별 패키지가 구성돼 있으며, +Ports & Adapters + CQRS 패턴을 따릅니다. + +1. **도메인 패키지** (`auth`, `comment`, `community`, `company`, `feed`, + `privatemessage`, `user`, `global` 등) 는 엔티티와 비즈니스 규칙을 소유합니다. + 각 도메인은 보통 `application`, `infra`, `presentation`, `domain` 하위 패키지를 + 가집니다. +2. **Application 계층**은 커맨드/쿼리 서비스를 통해 Use Case 를 오케스트레이션하며 + 포트 인터페이스에만 의존합니다. `application.command`는 상태 변경, + `application.query`는 읽기 전용 서비스를 제공합니다. +3. **Port** (`application.*.port`)는 서비스가 기대하는 계약을 정의합니다. + 예: ValidationPort, CommandPort, QueryPort 등. +4. **Adapter** (`infra.*`)는 포트를 구현합니다. 쓰기 경로는 JPA, 읽기 경로는 + JDBC/QueryDSL 기반 구현이 많으며 JWT, 패스워드 인코더 등 외부 연동도 포함됩니다. + 스테레오타입은 주로 `@Component`/`@Repository`입니다. +5. **Presentation 계층** (`presentation`)은 REST 컨트롤러와 DTO, 그리고 Swagger + 명세(`presentation.swagger`)를 제공합니다. + +`global` 패키지에는 예외, 응답 래퍼, 보안 필터/어노테이션, 공통 유틸이 모여 있습니다. + +## 데이터베이스 및 설정 + +- 주요 설정은 `src/main/resources` 의 `application-local.yaml` / `application-prod.yaml`에 + 있으며 DB 접속 정보와 JWT 키는 환경 변수에서 주입합니다. +- 엔티티는 JPA `@Entity` 를 사용합니다. 단순 존재 여부 확인을 위해 + `FeedValidationPort`, `CommunityValidationPort` 같은 전용 포트를 두는 패턴을 사용합니다. +- 통합 테스트는 `src/test/resources/sql/**` 의 스키마/데이터 스크립트를 활용합니다. + +## 테스트 전략 + +- **계층별 분리 목적**: 애플리케이션 레이어와 인프라 레이어를 명확히 나누기 위해 + 단위 테스트에서는 스텁/페이크 저장소를 사용하고, 실제 DB 의존성은 통합 테스트에서만 + 검증합니다. 이렇게 하면 비즈니스 로직 회귀를 빠르게 잡고, 인프라 이슈는 별도의 + 단계에서 확인할 수 있습니다. +- **Stub 저장소 활용**: `FeedRepositoryStub`처럼 메모리 기반 구현을 각 테스트 클래스에서 + 새로 생성하여 사용합니다. 여러 스텁 인스턴스가 존재해도 서로 상태를 공유하지 않아 + “DB 이중화”나 데이터 레이스가 생기지 않는다는 점이 장점입니다. 실제 DB를 흉내 내되 + 독립된 저장소를 각 테스트가 갖게 하여 테스트 간 간섭을 제거합니다. +- **통합 테스트**: Testcontainers(MySQL) + JDBC 리포지토리를 사용해 실제 SQL 경로를 + 검증합니다. `src/test/resources/sql` 아래 스키마/데이터 스크립트를 활용하며, + `./gradlew test` 실행 시 자동으로 컨테이너가 올라옵니다. + +## 도메인 예시 흐름 + +- **Feed 생성 플로우** + 1. `FeedController` → `FeedCommandService.createFeed()` 호출 + 2. `CommunityValidationPort`로 커뮤니티 존재 여부 확인 + 3. `FeedCommandPort.save()`를 통해 JPA 어댑터(`FeedCommandAdapter`)가 DB에 저장 +- **Comment 작성 플로우** + 1. `CommentCommandService.createComment()`가 `FeedValidationPort.existsById()`로 피드 존재만 확인 + 2. `ProfileDataPort`에서 작성자 스냅샷 조회 후 `CommentCommandPort.save()` 호출 + 3. 댓글 수정/삭제 시 `Comment` 애그리거트 메서드(`isWrittenBy`, `updateComment`)로 권한 검증 +- **User/Profile 참고** + - User가 애그리거트 루트이며 Profile은 전용 저장소를 통해 명시적으로 관리합니다. + - 추후 SecondaryTable/Value Object 등으로 합칠 계획이 있다면 Ports & Adapters 경계를 유지한 채 + 설계를 재검토해야 합니다. + + +## 작업 가이드 + +- Ports & Adapters 경계를 지켜 Application 서비스는 반드시 포트에만 의존하도록 합니다. +- 핵심 검증/인가 로직은 가능하면 애그리거트 내부 메서드(예: `Feed.validateAuthor`)로 이동합니다. +- 새로운 조회 기능을 추가할 때는 기존 CQRS 패턴을 따른다: 커맨드는 JPA, 쿼리는 JDBC/QueryDSL. +- 새 엔드포인트는 `presentation` 계층에 추가하고 Swagger 인터페이스를 갱신합니다. +- 파일을 수정하기 전, 해당 디렉터리에 `AGENTS.md` 등 추가 지침이 있는지 확인합니다. +- 어떤 변경이든 사이드 이펙트를 먼저 점검하세요. 서비스/엔티티 수정은 인프라 계층(JPA, + JDBC, SQL 스크립트), 테스트 픽스처, DTO, Swagger 명세까지 파급될 수 있습니다. 커맨드 경로에 + SQL/JDBC 구현이 있는 경우, 비즈니스 로직을 고쳤다면 대응하는 쿼리도 꼭 함께 검토하세요. +- 동일한 이유로 단위 테스트 외에도 필요한 경우 통합 테스트나 SQL 스크립트를 갱신해 + 런타임 오류를 예방합니다. +- 변경을 마친 뒤에는 반드시 “왜 이렇게 했는지”를 설명할 근거를 정리하세요. 대안 대비 + 장단점과 트레이드오프를 명확히 언급하면 리뷰어와 후속 작업자가 의사결정을 빠르게 + 추적할 수 있습니다. + +## 유용한 명령 + +- `./gradlew bootRun` — 애플리케이션 실행 (DB/환경 변수 필요) +- `./gradlew test` — 전체 단위/통합 테스트 실행 +- `./run.sh` — 로컬 실행용 스크립트(존재할 경우) + +이 문서를 기준으로 각 도메인 패키지를 살피면 공통 패턴을 빠르게 파악할 수 있습니다. +구조가 크게 바뀌면 이 파일도 함께 업데이트해주세요. + +--- + +## 구현 체크리스트 + +새로운 기능을 추가하거나 기존 기능을 수정할 때 아래 항목을 참고하세요. + +### 아키텍처 및 패키지 구조 + +- [ ] **도메인 패키지 구조 준수**: 새 도메인은 `domain`, `application`, `infra`, `presentation` 하위 패키지로 구성 +- [ ] **Application 계층 분리**: `application.command`(상태 변경)와 `application.query`(읽기 전용)로 명확히 구분 +- [ ] **Port 인터페이스 정의**: Application 계층은 Port 인터페이스에만 의존 (구체 구현 참조 금지) +- [ ] **Adapter 구현**: Infra 계층에서 Port 인터페이스를 구현 (`@Component` 또는 `@Repository` 사용) +- [ ] **CQRS 패턴 준수**: Command는 JPA 기반, Query는 JDBC/QueryDSL 기반 구현 + +### 도메인 계층 (Domain Layer) + +- [ ] **엔티티 선언**: `@Entity` 어노테이션 사용, JPA 스펙 준수 +- [ ] **팩토리 메서드**: `create()` 정적 메서드로 엔티티 생성 +- [ ] **테스트 인스턴스**: 필요시 `testInstance()` 메서드 제공 (ID 포함 생성) +- [ ] **비즈니스 로직**: 핵심 검증/인가 로직은 애그리거트 내부 메서드로 구현 (예: `validateAuthor()`, `isWrittenBy()`) +- [ ] **불변성 보장**: 생성자는 `protected`, Lombok `@NoArgsConstructor(access = AccessLevel.PROTECTED)` 사용 +- [ ] **도메인 규칙**: 상태 변경은 도메인 메서드를 통해서만 가능하도록 캡슐화 + +### Application 계층 (Application Layer) + +- [ ] **서비스 어노테이션**: `@Service` 어노테이션 사용 +- [ ] **트랜잭션 관리**: Command 서비스는 `@Transactional` 적용 +- [ ] **Port 의존성**: 생성자 주입으로 Port만 의존 (`@RequiredArgsConstructor` 활용) +- [ ] **Validation Port 활용**: 존재 여부 확인용 전용 Port 사용 (예: `CommunityValidationPort`, `FeedValidationPort`) +- [ ] **예외 처리**: `BusinessException`과 `ErrorCode` 사용 +- [ ] **DTO 반환**: Application 계층은 DTO로 결과 반환 (엔티티 직접 노출 금지) + +### Infra 계층 (Infrastructure Layer) + +- [ ] **Adapter 네이밍**: `*Adapter` 또는 `*Adaptor` 네이밍 규칙 준수 +- [ ] **Repository 인터페이스**: JPA Repository는 `JpaRepository` 상속 +- [ ] **Query 구현**: 복잡한 조회는 QueryDSL 또는 JDBC Template 사용 +- [ ] **외부 연동**: JWT, 암호화 등 외부 의존성도 Adapter로 추상화 +- [ ] **Component 등록**: `@Component` 또는 `@Repository` 어노테이션으로 빈 등록 + +### Presentation 계층 (Presentation Layer) + +- [ ] **컨트롤러 어노테이션**: `@RestController` 사용 +- [ ] **요청/응답 DTO**: Request/Response DTO 명확히 정의 +- [ ] **Swagger 문서화**: `presentation.swagger` 패키지에 Swagger 인터페이스 작성 +- [ ] **API 경로**: RESTful 규칙 준수 +- [ ] **보안 어노테이션**: 필요시 인증/인가 어노테이션 적용 +- [ ] **예외 처리**: Global Exception Handler로 일관된 에러 응답 제공 + +### 테스트 전략 + +- [ ] **단위 테스트**: Application 계층은 Stub/Fake 저장소로 테스트 +- [ ] **Stub 구현**: 각 테스트 클래스마다 독립적인 Stub 저장소 생성 (상태 공유 방지) +- [ ] **통합 테스트**: Infra 계층은 Testcontainers + 실제 DB로 검증 +- [ ] **테스트 데이터**: `src/test/resources/sql` 스키마/데이터 스크립트 활용 +- [ ] **테스트 격리**: 테스트 간 데이터 간섭이 없도록 독립성 보장 +- [ ] **빌드 확인**: `./gradlew test` 실행하여 전체 테스트 통과 확인 + +### 코드 컨벤션 + +- [ ] **Lombok 활용**: `@Getter`, `@RequiredArgsConstructor`, `@NoArgsConstructor` 등 적극 활용 +- [ ] **네이밍**: 명확하고 의도가 드러나는 이름 사용 (Port, Adapter, Service 등) +- [ ] **포맷팅**: 일관된 코드 스타일 유지 +- [ ] **주석 최소화**: 코드로 의도를 표현, 필요한 경우에만 주석 추가 + +### 변경 영향 분석 + +- [ ] **엔티티 변경 시**: JPA 매핑, DTO, 테스트 픽스처, SQL 스크립트 함께 수정 +- [ ] **비즈니스 로직 변경 시**: 커맨드/쿼리 양쪽 경로 모두 검토 +- [ ] **API 변경 시**: Request/Response DTO, Swagger 문서, 컨트롤러 모두 갱신 +- [ ] **Port 변경 시**: 모든 Adapter 구현체와 테스트 Stub 동시 수정 +- [ ] **사이드 이펙트 점검**: 변경 전 영향 범위 파악 및 회귀 테스트 실행 + +### 문서화 + +- [ ] **변경 이유 기록**: 왜 이렇게 구현했는지 근거 정리 +- [ ] **트레이드오프 명시**: 대안 대비 장단점 문서화 +- [ ] **README 갱신**: 구조 변경 시 PROJECT_OVERVIEW.md 업데이트 +- [ ] **도메인 문서**: 필요시 각 도메인 디렉터리에 AGENTS.md 추가 + +### 배포 및 환경 설정 + +- [ ] **환경 변수**: 민감 정보는 `application-*.yaml`에서 환경 변수로 주입 +- [ ] **프로파일 분리**: local/prod 프로파일 설정 명확히 구분 +- [ ] **DB 마이그레이션**: 스키마 변경 시 운영 DB 영향 검토 +- [ ] **빌드 검증**: `./gradlew bootRun` 로컬 실행 테스트 diff --git a/src/main/java/com/example/bak/comment/application/command/port/CommentValidationPort.java b/src/main/java/com/example/bak/comment/application/command/port/CommentValidationPort.java new file mode 100644 index 0000000..8251435 --- /dev/null +++ b/src/main/java/com/example/bak/comment/application/command/port/CommentValidationPort.java @@ -0,0 +1,6 @@ +package com.example.bak.comment.application.command.port; + +public interface CommentValidationPort { + + boolean existsById(Long commentId); +} diff --git a/src/main/java/com/example/bak/comment/application/validator/CommentLikeTargetValidator.java b/src/main/java/com/example/bak/comment/application/validator/CommentLikeTargetValidator.java new file mode 100644 index 0000000..8179e16 --- /dev/null +++ b/src/main/java/com/example/bak/comment/application/validator/CommentLikeTargetValidator.java @@ -0,0 +1,28 @@ +package com.example.bak.comment.application.validator; + +import com.example.bak.comment.application.command.port.CommentValidationPort; +import com.example.bak.global.exception.BusinessException; +import com.example.bak.global.exception.ErrorCode; +import com.example.bak.like.application.command.validator.LikeTargetValidator; +import com.example.bak.like.domain.LikeTargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentLikeTargetValidator implements LikeTargetValidator { + + private final CommentValidationPort commentValidationPort; + + @Override + public LikeTargetType targetType() { + return LikeTargetType.COMMENT; + } + + @Override + public void validateExists(Long targetId) { + if (!commentValidationPort.existsById(targetId)) { + throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/example/bak/comment/infra/persistence/CommentValidationAdapter.java b/src/main/java/com/example/bak/comment/infra/persistence/CommentValidationAdapter.java new file mode 100644 index 0000000..99f76fc --- /dev/null +++ b/src/main/java/com/example/bak/comment/infra/persistence/CommentValidationAdapter.java @@ -0,0 +1,17 @@ +package com.example.bak.comment.infra.persistence; + +import com.example.bak.comment.application.command.port.CommentValidationPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class CommentValidationAdapter implements CommentValidationPort { + + private final CommentJpaRepository commentJpaRepository; + + @Override + public boolean existsById(Long commentId) { + return commentJpaRepository.existsById(commentId); + } +} diff --git a/src/main/java/com/example/bak/comment/presentation/CommentController.java b/src/main/java/com/example/bak/comment/presentation/CommentController.java index 77d3b4e..26993fd 100644 --- a/src/main/java/com/example/bak/comment/presentation/CommentController.java +++ b/src/main/java/com/example/bak/comment/presentation/CommentController.java @@ -9,9 +9,12 @@ import com.example.bak.global.common.response.ApiResponseFactory; import com.example.bak.global.common.utils.UriUtils; import com.example.bak.global.security.annotation.AuthUser; +import com.example.bak.like.application.command.LikeCommandService; +import com.example.bak.like.domain.LikeTargetType; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -27,6 +30,7 @@ public class CommentController implements FeedCommentSwagger { private final CommentCommandService commentCommandService; private final CommentQueryService commentQueryService; + private final LikeCommandService likeCommandService; @Override @PostMapping("/feeds/{feedId}/comments") @@ -64,4 +68,26 @@ public ResponseEntity updateComment( ApiResponse response = ApiResponseFactory.successVoid("댓글을 성공적으로 수정하였습니다."); return ResponseEntity.ok(response); } + + @Override + @PostMapping("/comments/{commentId}/likes") + public ResponseEntity likeComment( + @AuthUser Long userId, + @PathVariable Long commentId + ) { + likeCommandService.like(LikeTargetType.COMMENT, commentId, userId); + ApiResponse response = ApiResponseFactory.successVoid("댓글에 좋아요를 추가했습니다."); + return ResponseEntity.ok(response); + } + + @Override + @DeleteMapping("/comments/{commentId}/likes") + public ResponseEntity unlikeComment( + @AuthUser Long userId, + @PathVariable Long commentId + ) { + likeCommandService.unlike(LikeTargetType.COMMENT, commentId, userId); + ApiResponse response = ApiResponseFactory.successVoid("댓글 좋아요를 취소했습니다."); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/example/bak/comment/presentation/swagger/FeedCommentSwagger.java b/src/main/java/com/example/bak/comment/presentation/swagger/FeedCommentSwagger.java index a13ff65..61f60a4 100644 --- a/src/main/java/com/example/bak/comment/presentation/swagger/FeedCommentSwagger.java +++ b/src/main/java/com/example/bak/comment/presentation/swagger/FeedCommentSwagger.java @@ -294,4 +294,158 @@ ResponseEntity updateComment( @Parameter(hidden = true, description = "인증된 사용자 ID", required = true) Long userId ); + + @Operation( + summary = "댓글 좋아요", + description = "특정 댓글에 좋아요를 추가합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "댓글 좋아요 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "CommentLikeSuccess", + summary = "좋아요 성공", + value = """ + { + \"status\": \"SUCCESS\", + \"message\": \"댓글에 좋아요를 추가했습니다.\", + \"data\": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "이미 좋아요를 누른 상태", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "CommentLikeAlreadyExists", + summary = "중복 좋아요", + value = """ + { + \"status\": \"ERROR\", + \"message\": \"이미 좋아요를 누른 상태입니다.\", + \"data\": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "CommentLikeUnauthorized", + summary = "토큰 없음", + value = """ + { + \"status\": \"ERROR\", + \"message\": \"토큰이 없습니다.\", + \"data\": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "댓글을 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "CommentLikeNotFound", + summary = "댓글 미존재", + value = """ + { + \"status\": \"ERROR\", + \"message\": \"댓글 리소스를 찾을 수 없습니다.\", + \"data\": null + } + """ + ) + ) + ) + }) + ResponseEntity likeComment( + @Parameter(hidden = true, description = "인증된 사용자 ID", required = true) + Long userId, + @Parameter(description = "댓글 ID", required = true, example = "1") + @PathVariable Long commentId + ); + + @Operation( + summary = "댓글 좋아요 취소", + description = "특정 댓글의 좋아요를 취소합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "댓글 좋아요 취소 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "CommentUnlikeSuccess", + summary = "취소 성공", + value = """ + { + \"status\": \"SUCCESS\", + \"message\": \"댓글 좋아요를 취소했습니다.\", + \"data\": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "CommentUnlikeUnauthorized", + summary = "토큰 없음", + value = """ + { + \"status\": \"ERROR\", + \"message\": \"토큰이 없습니다.\", + \"data\": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "댓글을 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "CommentUnlikeNotFound", + summary = "댓글 미존재", + value = """ + { + \"status\": \"ERROR\", + \"message\": \"댓글 리소스를 찾을 수 없습니다.\", + \"data\": null + } + """ + ) + ) + ) + }) + ResponseEntity unlikeComment( + @Parameter(hidden = true, description = "인증된 사용자 ID", required = true) + Long userId, + @Parameter(description = "댓글 ID", required = true, example = "1") + @PathVariable Long commentId + ); } diff --git a/src/main/java/com/example/bak/feed/application/query/FeedQueryService.java b/src/main/java/com/example/bak/feed/application/query/FeedQueryService.java index a61c143..ece7c66 100644 --- a/src/main/java/com/example/bak/feed/application/query/FeedQueryService.java +++ b/src/main/java/com/example/bak/feed/application/query/FeedQueryService.java @@ -1,21 +1,28 @@ package com.example.bak.feed.application.query; +import com.example.bak.community.application.query.dto.CommunityResult; +import com.example.bak.feed.application.query.dto.FeedCommunityPageResult; +import com.example.bak.feed.application.query.dto.FeedCommunityTitleSection; import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; import com.example.bak.feed.application.query.port.FeedQueryPort; import com.example.bak.global.exception.BusinessException; import com.example.bak.global.exception.ErrorCode; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class FeedQueryService { + private static final int CONTENT_PREVIEW_LIMIT = 100; + private static final int MIN_LIMIT = 1; + private final FeedQueryPort feedQueryPort; public FeedDetail getFeedDetail(Long feedId) { @@ -25,12 +32,80 @@ public FeedDetail getFeedDetail(Long feedId) { public FeedSummary getFeedSummary(Long feedId) { return feedQueryPort.findSummaryById(feedId) + .map(this::applyContentPreviewLimit) .orElseThrow(() -> new BusinessException(ErrorCode.FEED_NOT_FOUND)); } - public List getFeeds(int page, int size) { - Pageable pageable = PageRequest.of(page, size); - Page feedPage = feedQueryPort.findAll(pageable); - return feedPage.getContent(); + public List getFeeds(int limit) { + int sanitizedLimit = sanitizeLimit(limit); + return feedQueryPort.findRecentSummaries(sanitizedLimit).stream() + .map(this::applyContentPreviewLimit) + .toList(); + } + + public List getMainPageFeeds(int limit) { + int sanitizedLimit = sanitizeLimit(limit); + return feedQueryPort.findTopDetails(sanitizedLimit); + } + + public FeedCommunityPageResult getFeedsByCommunity(Long communityId, Long cursor, int size) { + int sanitizedSize = sanitizeLimit(size); + List fetched = fetchCommunityFeeds(communityId, cursor, sanitizedSize + 1); + return buildCommunityFeedPageResult(fetched, sanitizedSize); + } + + private List fetchCommunityFeeds(Long communityId, Long cursor, int fetchSize) { + return feedQueryPort.findByCommunityIdWithCursor(communityId, cursor, fetchSize); + } + + private FeedCommunityPageResult buildCommunityFeedPageResult(List fetched, + int size) { + boolean hasNext = fetched.size() > size; + + List feeds = fetched.stream() + .limit(size) + .map(this::applyContentPreviewLimit) + .toList(); + + Long nextCursor = feeds.isEmpty() ? null : feeds.getLast().id(); + return new FeedCommunityPageResult(feeds, nextCursor, hasNext); + } + + public List getCommunityTitleSectionsForMainPage( + int limitPerCommunity) { + int sanitizedLimit = sanitizeLimit(limitPerCommunity); + List titles = feedQueryPort.findLatestTitlesByCommunity(sanitizedLimit); + + Map> grouped = new LinkedHashMap<>(); + for (FeedTitleOnly title : titles) { + grouped.computeIfAbsent(title.community(), key -> new ArrayList<>()).add(title); + } + + return grouped.entrySet().stream() + .map(entry -> FeedCommunityTitleSection.of(entry.getKey(), entry.getValue())) + .toList(); + } + + public List getFeedTitlesByCommunity(Long communityId, int limit) { + int sanitizedLimit = sanitizeLimit(limit); + return feedQueryPort.findTitleOnlyByCommunity(communityId, sanitizedLimit); + } + + private int sanitizeLimit(int value) { + return Math.max(MIN_LIMIT, value); + } + + private FeedSummary applyContentPreviewLimit(FeedSummary summary) { + String preview = summary.contentPreview(); + if (preview == null) { + return summary.withContentPreview(""); + } + + if (preview.length() <= CONTENT_PREVIEW_LIMIT) { + return summary; + } + + return summary.withContentPreview(preview.substring(0, CONTENT_PREVIEW_LIMIT)); } + } diff --git a/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityPageResult.java b/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityPageResult.java new file mode 100644 index 0000000..13c1c7c --- /dev/null +++ b/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityPageResult.java @@ -0,0 +1,10 @@ +package com.example.bak.feed.application.query.dto; + +import java.util.List; + +public record FeedCommunityPageResult( + List feeds, + Long nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityTitleSection.java b/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityTitleSection.java new file mode 100644 index 0000000..441be98 --- /dev/null +++ b/src/main/java/com/example/bak/feed/application/query/dto/FeedCommunityTitleSection.java @@ -0,0 +1,22 @@ +package com.example.bak.feed.application.query.dto; + +import com.example.bak.community.application.query.dto.CommunityResult; +import java.util.List; + +public record FeedCommunityTitleSection( + Long communityId, + String communityName, + String jobGroup, + List feeds +) { + + public static FeedCommunityTitleSection of(CommunityResult.Detail community, + List feeds) { + return new FeedCommunityTitleSection( + community.id(), + community.name(), + community.jobGroup(), + feeds + ); + } +} diff --git a/src/main/java/com/example/bak/feed/application/query/dto/FeedDetail.java b/src/main/java/com/example/bak/feed/application/query/dto/FeedDetail.java index eb4e253..9e3098c 100644 --- a/src/main/java/com/example/bak/feed/application/query/dto/FeedDetail.java +++ b/src/main/java/com/example/bak/feed/application/query/dto/FeedDetail.java @@ -11,6 +11,8 @@ public record FeedDetail( String title, String content, UserResult author, - CommunityResult.Detail community + CommunityResult.Detail community, + int likeCount, + int commentCount ) { } diff --git a/src/main/java/com/example/bak/feed/application/query/dto/FeedSummary.java b/src/main/java/com/example/bak/feed/application/query/dto/FeedSummary.java index 8af1bbd..71cda98 100644 --- a/src/main/java/com/example/bak/feed/application/query/dto/FeedSummary.java +++ b/src/main/java/com/example/bak/feed/application/query/dto/FeedSummary.java @@ -9,9 +9,22 @@ public record FeedSummary( Long id, String title, + String contentPreview, UserResult author, CommunityResult.Detail community, + int likeCount, int commentCount ) { + public FeedSummary withContentPreview(String preview) { + return new FeedSummary( + id, + title, + preview, + author, + community, + likeCount, + commentCount + ); + } } diff --git a/src/main/java/com/example/bak/feed/application/query/dto/FeedTitleOnly.java b/src/main/java/com/example/bak/feed/application/query/dto/FeedTitleOnly.java new file mode 100644 index 0000000..22b1480 --- /dev/null +++ b/src/main/java/com/example/bak/feed/application/query/dto/FeedTitleOnly.java @@ -0,0 +1,13 @@ +package com.example.bak.feed.application.query.dto; + +import com.example.bak.community.application.query.dto.CommunityResult; + +public record FeedTitleOnly( + Long id, + String title, + int likeCount, + int commentCount, + CommunityResult.Detail community +) { + +} diff --git a/src/main/java/com/example/bak/feed/application/query/port/FeedQueryPort.java b/src/main/java/com/example/bak/feed/application/query/port/FeedQueryPort.java index a119765..6c7c962 100644 --- a/src/main/java/com/example/bak/feed/application/query/port/FeedQueryPort.java +++ b/src/main/java/com/example/bak/feed/application/query/port/FeedQueryPort.java @@ -2,15 +2,23 @@ import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; +import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; public interface FeedQueryPort { - Page findAll(Pageable pageable); + List findRecentSummaries(int limit); Optional findSummaryById(Long feedId); Optional findDetailById(Long feedId); + + List findTitleOnlyByCommunity(Long communityId, int limit); + + List findTopDetails(int limit); + + List findLatestTitlesByCommunity(int limitPerCommunity); + + List findByCommunityIdWithCursor(Long communityId, Long cursorId, int size); } diff --git a/src/main/java/com/example/bak/feed/application/validator/FeedLikeTargetValidator.java b/src/main/java/com/example/bak/feed/application/validator/FeedLikeTargetValidator.java new file mode 100644 index 0000000..be64eda --- /dev/null +++ b/src/main/java/com/example/bak/feed/application/validator/FeedLikeTargetValidator.java @@ -0,0 +1,28 @@ +package com.example.bak.feed.application.validator; + +import com.example.bak.feed.application.command.port.FeedValidationPort; +import com.example.bak.global.exception.BusinessException; +import com.example.bak.global.exception.ErrorCode; +import com.example.bak.like.application.command.validator.LikeTargetValidator; +import com.example.bak.like.domain.LikeTargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FeedLikeTargetValidator implements LikeTargetValidator { + + private final FeedValidationPort feedValidationPort; + + @Override + public LikeTargetType targetType() { + return LikeTargetType.FEED; + } + + @Override + public void validateExists(Long targetId) { + if (!feedValidationPort.existsById(targetId)) { + throw new BusinessException(ErrorCode.FEED_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/example/bak/feed/infra/query/FeedQueryAdapter.java b/src/main/java/com/example/bak/feed/infra/query/FeedQueryAdapter.java index f5f3af0..66fd1f1 100644 --- a/src/main/java/com/example/bak/feed/infra/query/FeedQueryAdapter.java +++ b/src/main/java/com/example/bak/feed/infra/query/FeedQueryAdapter.java @@ -2,12 +2,12 @@ import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; import com.example.bak.feed.application.query.port.FeedQueryPort; import com.example.bak.feed.infra.query.jdbc.FeedJdbcRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @Repository @@ -17,8 +17,8 @@ public class FeedQueryAdapter implements FeedQueryPort { private final FeedJdbcRepository feedJdbcRepository; @Override - public Page findAll(Pageable pageable) { - return feedJdbcRepository.findAll(pageable); + public List findRecentSummaries(int limit) { + return feedJdbcRepository.findRecentSummaries(limit); } @Override @@ -30,4 +30,25 @@ public Optional findSummaryById(Long feedId) { public Optional findDetailById(Long feedId) { return feedJdbcRepository.findDetailById(feedId); } + + @Override + public List findTitleOnlyByCommunity(Long communityId, int limit) { + return feedJdbcRepository.findTitleOnlyByCommunity(communityId, limit); + } + + @Override + public List findTopDetails(int limit) { + return feedJdbcRepository.findTopDetails(limit); + } + + @Override + public List findLatestTitlesByCommunity(int limitPerCommunity) { + return feedJdbcRepository.findLatestTitlesByCommunity(limitPerCommunity); + } + + @Override + public List findByCommunityIdWithCursor(Long communityId, Long cursorId, + int size) { + return feedJdbcRepository.findByCommunityIdWithCursor(communityId, cursorId, size); + } } diff --git a/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepository.java b/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepository.java index 1357342..fa69866 100644 --- a/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepository.java +++ b/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepository.java @@ -2,15 +2,22 @@ import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; +import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - public interface FeedJdbcRepository { - Page findAll(Pageable pageable); + List findRecentSummaries(int limit); Optional findSummaryById(Long feedId); Optional findDetailById(Long feedId); + + List findTitleOnlyByCommunity(Long communityId, int limit); + + List findTopDetails(int limit); + + List findLatestTitlesByCommunity(int limitPerCommunity); + + List findByCommunityIdWithCursor(Long communityId, Long cursorId, int size); } diff --git a/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepositoryImpl.java b/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepositoryImpl.java index 68d2745..b1a6f7d 100644 --- a/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepositoryImpl.java +++ b/src/main/java/com/example/bak/feed/infra/query/jdbc/FeedJdbcRepositoryImpl.java @@ -2,16 +2,13 @@ import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; import com.example.bak.feed.infra.query.jdbc.mapper.FeedDetailRowMapper; import com.example.bak.feed.infra.query.jdbc.mapper.FeedSummaryRowMapper; -import java.util.ArrayList; +import com.example.bak.feed.infra.query.jdbc.mapper.FeedTitleOnlyRowMapper; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @@ -22,25 +19,34 @@ public class FeedJdbcRepositoryImpl implements FeedJdbcRepository { private static final FeedSummaryRowMapper feedSummaryMapper = new FeedSummaryRowMapper(); private static final FeedDetailRowMapper feedDetailMapper = new FeedDetailRowMapper(); + private static final FeedTitleOnlyRowMapper feedTitleOnlyMapper = new FeedTitleOnlyRowMapper(); private static final String SUMMARY_SELECT = """ SELECT f.id AS id, f.title AS title, + f.content AS content, u.id AS author_id, p.nickname AS author_nickname, c.id AS community_id, c.name AS community_name, c.job_group AS community_job_group, - COUNT(cm.id) AS comment_count + COALESCE(comment_counts.comment_count, 0) AS comment_count, + COALESCE(like_counts.like_count, 0) AS like_count FROM feeds f JOIN users u ON f.author_id = u.id JOIN profiles p ON p.user_id = u.id JOIN communities c ON f.community_id = c.id - LEFT JOIN comments cm ON cm.feed_id = f.id - """; - - private static final String SUMMARY_GROUP_BY = """ - GROUP BY f.id, f.title, u.id, p.nickname, c.id, c.name, c.job_group + LEFT JOIN ( + SELECT feed_id, COUNT(*) AS comment_count + FROM comments + GROUP BY feed_id + ) comment_counts ON comment_counts.feed_id = f.id + LEFT JOIN ( + SELECT target_id AS feed_id, COUNT(*) AS like_count + FROM likes + WHERE target_type = 'FEED' + GROUP BY target_id + ) like_counts ON like_counts.feed_id = f.id """; private static final String DETAIL_SELECT = """ @@ -52,33 +58,134 @@ public class FeedJdbcRepositoryImpl implements FeedJdbcRepository { p.nickname AS author_nickname, c.id AS community_id, c.name AS community_name, - c.job_group AS community_job_group + c.job_group AS community_job_group, + COALESCE(comment_counts.comment_count, 0) AS comment_count, + COALESCE(like_counts.like_count, 0) AS like_count FROM feeds f JOIN users u ON f.author_id = u.id JOIN profiles p ON p.user_id = u.id JOIN communities c ON f.community_id = c.id + LEFT JOIN ( + SELECT feed_id, COUNT(*) AS comment_count + FROM comments + GROUP BY feed_id + ) comment_counts ON comment_counts.feed_id = f.id + LEFT JOIN ( + SELECT target_id AS feed_id, COUNT(*) AS like_count + FROM likes + WHERE target_type = 'FEED' + GROUP BY target_id + ) like_counts ON like_counts.feed_id = f.id WHERE f.id = :feedId LIMIT 1 """; private static final String DEFAULT_ORDER_BY = "ORDER BY f.id DESC"; + private static final String DETAIL_MAIN_SELECT = """ + SELECT + f.id AS id, + f.title AS title, + f.content AS content, + u.id AS author_id, + p.nickname AS author_nickname, + c.id AS community_id, + c.name AS community_name, + c.job_group AS community_job_group, + COALESCE(comment_counts.comment_count, 0) AS comment_count, + COALESCE(like_counts.like_count, 0) AS like_count + FROM feeds f + JOIN users u ON f.author_id = u.id + JOIN profiles p ON p.user_id = u.id + JOIN communities c ON f.community_id = c.id + LEFT JOIN ( + SELECT feed_id, COUNT(*) AS comment_count + FROM comments + GROUP BY feed_id + ) comment_counts ON comment_counts.feed_id = f.id + LEFT JOIN ( + SELECT target_id AS feed_id, COUNT(*) AS like_count + FROM likes + WHERE target_type = 'FEED' + GROUP BY target_id + ) like_counts ON like_counts.feed_id = f.id + ORDER BY f.id DESC + LIMIT :limit + """; + private static final String TITLE_ONLY_SELECT = """ + SELECT + f.id AS id, + f.title AS title, + c.id AS community_id, + c.name AS community_name, + c.job_group AS community_job_group, + COALESCE(comment_counts.comment_count, 0) AS comment_count, + COALESCE(like_counts.like_count, 0) AS like_count + FROM feeds f + JOIN communities c ON f.community_id = c.id + LEFT JOIN ( + SELECT feed_id, COUNT(*) AS comment_count + FROM comments + GROUP BY feed_id + ) comment_counts ON comment_counts.feed_id = f.id + LEFT JOIN ( + SELECT target_id AS feed_id, COUNT(*) AS like_count + FROM likes + WHERE target_type = 'FEED' + GROUP BY target_id + ) like_counts ON like_counts.feed_id = f.id + WHERE f.community_id = :communityId + ORDER BY f.id DESC + LIMIT :limit + """; + + private static final String TITLE_GROUPED_SELECT = """ + SELECT id, + title, + community_id, + community_name, + community_job_group, + comment_count, + like_count + FROM ( + SELECT + f.id AS id, + f.title AS title, + c.id AS community_id, + c.name AS community_name, + c.job_group AS community_job_group, + COALESCE(comment_counts.comment_count, 0) AS comment_count, + COALESCE(like_counts.like_count, 0) AS like_count, + ROW_NUMBER() OVER (PARTITION BY f.community_id ORDER BY f.id DESC) AS rn + FROM feeds f + JOIN communities c ON f.community_id = c.id + LEFT JOIN ( + SELECT feed_id, COUNT(*) AS comment_count + FROM comments + GROUP BY feed_id + ) comment_counts ON comment_counts.feed_id = f.id + LEFT JOIN ( + SELECT target_id AS feed_id, COUNT(*) AS like_count + FROM likes + WHERE target_type = 'FEED' + GROUP BY target_id + ) like_counts ON like_counts.feed_id = f.id + ) ranked + WHERE rn <= :limit + ORDER BY community_id, id DESC + """; private final NamedParameterJdbcTemplate jdbc; @Override - public Page findAll(Pageable pageable) { + public List findRecentSummaries(int limit) { MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("limit", pageable.getPageSize()) - .addValue("offset", pageable.getOffset()); - - String sql = SUMMARY_SELECT + '\n' + SUMMARY_GROUP_BY + '\n' - + buildOrderByClause(pageable) + '\n' - + "LIMIT :limit OFFSET :offset"; + .addValue("limit", limit); - List content = jdbc.query(sql, params, feedSummaryMapper); - long total = countFeeds(); + String sql = SUMMARY_SELECT + '\n' + + DEFAULT_ORDER_BY + '\n' + + "LIMIT :limit"; - return new PageImpl<>(content, pageable, total); + return jdbc.query(sql, params, feedSummaryMapper); } @Override @@ -87,8 +194,7 @@ public Optional findSummaryById(Long feedId) { .addValue("feedId", feedId); String sql = SUMMARY_SELECT + '\n' - + "WHERE f.id = :feedId\n" - + SUMMARY_GROUP_BY; + + "WHERE f.id = :feedId"; return jdbc.query(sql, params, feedSummaryMapper).stream().findFirst(); } @@ -101,37 +207,51 @@ public Optional findDetailById(Long feedId) { return jdbc.query(DETAIL_SELECT, params, feedDetailMapper).stream().findFirst(); } - private long countFeeds() { - Long total = jdbc.queryForObject("SELECT COUNT(*) FROM feeds", new MapSqlParameterSource(), - Long.class); - return total == null ? 0L : total; + @Override + public List findTitleOnlyByCommunity(Long communityId, int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("communityId", communityId) + .addValue("limit", limit); + + return jdbc.query(TITLE_ONLY_SELECT, params, feedTitleOnlyMapper); } - private String buildOrderByClause(Pageable pageable) { - if (pageable.getSort().isUnsorted()) { - return DEFAULT_ORDER_BY; - } + @Override + public List findTopDetails(int limit) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("limit", limit); - List orderParts = new ArrayList<>(); - for (Sort.Order order : pageable.getSort()) { - String column = mapPropertyToColumn(order.getProperty()); - if (column != null) { - orderParts.add(column + " " + order.getDirection().name()); - } - } + return jdbc.query(DETAIL_MAIN_SELECT, params, feedDetailMapper); + } - if (orderParts.isEmpty()) { - return DEFAULT_ORDER_BY; - } + @Override + public List findLatestTitlesByCommunity(int limitPerCommunity) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("limit", limitPerCommunity); - return "ORDER BY " + String.join(", ", orderParts); + return jdbc.query(TITLE_GROUPED_SELECT, params, feedTitleOnlyMapper); } - private String mapPropertyToColumn(String property) { - return switch (property) { - case "id" -> "f.id"; - case "title" -> "f.title"; - default -> null; - }; + @Override + public List findByCommunityIdWithCursor(Long communityId, Long cursorId, int size) { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("communityId", communityId) + .addValue("limit", size) + .addValue("cursorId", cursorId); + + String sql = SUMMARY_SELECT + '\n' + + "WHERE f.community_id = :communityId\n" + + (cursorId != null ? "AND f.id < :cursorId\n" : "") + + DEFAULT_ORDER_BY + '\n' + + "LIMIT :limit"; + + return jdbc.query(sql, params, feedSummaryMapper); } + + private long countFeeds() { + Long total = jdbc.queryForObject("SELECT COUNT(*) FROM feeds", new MapSqlParameterSource(), + Long.class); + return total == null ? 0L : total; + } + } diff --git a/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedDetailRowMapper.java b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedDetailRowMapper.java index e8e352b..6acf685 100644 --- a/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedDetailRowMapper.java +++ b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedDetailRowMapper.java @@ -27,7 +27,9 @@ public FeedDetail mapRow(ResultSet rs, int rowNum) throws SQLException { rs.getString("title"), rs.getString("content"), author, - community + community, + rs.getInt("like_count"), + rs.getInt("comment_count") ); } } diff --git a/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedSummaryRowMapper.java b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedSummaryRowMapper.java index 7419866..48e3cf5 100644 --- a/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedSummaryRowMapper.java +++ b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedSummaryRowMapper.java @@ -25,8 +25,10 @@ public FeedSummary mapRow(ResultSet rs, int rowNum) throws SQLException { return new FeedSummary( rs.getLong("id"), rs.getString("title"), + rs.getString("content"), author, community, + rs.getInt("like_count"), rs.getInt("comment_count") ); } diff --git a/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedTitleOnlyRowMapper.java b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedTitleOnlyRowMapper.java new file mode 100644 index 0000000..1bee2d3 --- /dev/null +++ b/src/main/java/com/example/bak/feed/infra/query/jdbc/mapper/FeedTitleOnlyRowMapper.java @@ -0,0 +1,27 @@ +package com.example.bak.feed.infra.query.jdbc.mapper; + +import com.example.bak.community.application.query.dto.CommunityResult; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.springframework.jdbc.core.RowMapper; + +public class FeedTitleOnlyRowMapper implements RowMapper { + + @Override + public FeedTitleOnly mapRow(ResultSet rs, int rowNum) throws SQLException { + CommunityResult.Detail community = new CommunityResult.Detail( + rs.getLong("community_id"), + rs.getString("community_name"), + rs.getString("community_job_group") + ); + + return new FeedTitleOnly( + rs.getLong("id"), + rs.getString("title"), + rs.getInt("like_count"), + rs.getInt("comment_count"), + community + ); + } +} diff --git a/src/main/java/com/example/bak/feed/presentation/FeedController.java b/src/main/java/com/example/bak/feed/presentation/FeedController.java index 4ba411d..b37e6ae 100644 --- a/src/main/java/com/example/bak/feed/presentation/FeedController.java +++ b/src/main/java/com/example/bak/feed/presentation/FeedController.java @@ -3,15 +3,22 @@ import com.example.bak.feed.application.command.FeedCommandService; import com.example.bak.feed.application.command.dto.FeedResult; import com.example.bak.feed.application.query.FeedQueryService; +import com.example.bak.feed.application.query.dto.FeedCommunityPageResult; import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; +import com.example.bak.feed.presentation.dto.CommunityFeedsResponse; import com.example.bak.feed.presentation.dto.FeedRequest; import com.example.bak.feed.presentation.dto.FeedUpdateRequest; +import com.example.bak.feed.presentation.dto.MainPageFeedsResponse; +import com.example.bak.feed.presentation.dto.MainPageTitlesResponse; import com.example.bak.feed.presentation.swagger.FeedSwagger; import com.example.bak.global.common.response.ApiResponse; import com.example.bak.global.common.response.ApiResponseFactory; import com.example.bak.global.common.utils.UriUtils; import com.example.bak.global.security.annotation.AuthUser; +import com.example.bak.like.application.command.LikeCommandService; +import com.example.bak.like.domain.LikeTargetType; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -26,14 +33,15 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/feeds") +@RequestMapping("/api/v1") @RequiredArgsConstructor public class FeedController implements FeedSwagger { private final FeedCommandService feedCommandService; + private final LikeCommandService likeCommandService; private final FeedQueryService feedQueryService; - @PostMapping() + @PostMapping("/feeds") public ResponseEntity createFeed(@AuthUser Long userId, @RequestBody FeedRequest request) { FeedResult feedResult = feedCommandService.createFeed( @@ -47,31 +55,79 @@ public ResponseEntity createFeed(@AuthUser Long userId, .body(response); } - @GetMapping() + @GetMapping("/feeds") public ResponseEntity getFeeds( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int limit ) { - List feeds = feedQueryService.getFeeds(page, size); + List feeds = feedQueryService.getFeeds(limit); ApiResponse response = ApiResponseFactory.success("피드 목록을 성공적으로 조회하였습니다.", feeds); return ResponseEntity.ok(response); } - @GetMapping("/{feedId}/summary") + @GetMapping("/feeds/main") + public ResponseEntity getMainPageFeeds( + @RequestParam(defaultValue = "10") int limit + ) { + List feeds = feedQueryService.getMainPageFeeds(limit); + ApiResponse response = ApiResponseFactory.success( + "메인 페이지 피드를 성공적으로 조회하였습니다.", + new MainPageFeedsResponse(feeds) + ); + return ResponseEntity.ok(response); + } + + @GetMapping("/feeds/main/titles") + public ResponseEntity getMainPageTitles( + @RequestParam(defaultValue = "5") int limit + ) { + ApiResponse response = ApiResponseFactory.success( + "메인 페이지 커뮤니티별 제목을 성공적으로 조회하였습니다.", + new MainPageTitlesResponse( + feedQueryService.getCommunityTitleSectionsForMainPage(limit)) + ); + return ResponseEntity.ok(response); + } + + @GetMapping("/communities/{communityId}/feeds") + public ResponseEntity getFeedsByCommunity( + @PathVariable Long communityId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + FeedCommunityPageResult result = feedQueryService.getFeedsByCommunity(communityId, cursor, + size); + ApiResponse response = ApiResponseFactory.success( + "커뮤니티 피드를 성공적으로 조회하였습니다.", + CommunityFeedsResponse.from(result) + ); + return ResponseEntity.ok(response); + } + + @GetMapping("/communities/{communityId}/feeds/titles") + public ResponseEntity getFeedTitlesByCommunity( + @PathVariable Long communityId, + @RequestParam(defaultValue = "5") int limit + ) { + List titles = feedQueryService.getFeedTitlesByCommunity(communityId, limit); + ApiResponse response = ApiResponseFactory.success("커뮤니티 피드 제목을 성공적으로 조회하였습니다.", titles); + return ResponseEntity.ok(response); + } + + @GetMapping("/feeds/{feedId}/summary") public ResponseEntity getFeedSummary(@PathVariable Long feedId) { FeedSummary summary = feedQueryService.getFeedSummary(feedId); ApiResponse response = ApiResponseFactory.success("피드 요약을 성공적으로 조회하였습니다.", summary); return ResponseEntity.ok(response); } - @GetMapping("/{feedId}") + @GetMapping("/feeds/{feedId}") public ResponseEntity getFeedDetail(@PathVariable Long feedId) { FeedDetail detail = feedQueryService.getFeedDetail(feedId); ApiResponse response = ApiResponseFactory.success("피드 상세를 성공적으로 조회하였습니다.", detail); return ResponseEntity.ok(response); } - @PutMapping("/{feedId}") + @PutMapping("/feeds/{feedId}") public ResponseEntity updateFeed( @AuthUser Long userId, @PathVariable Long feedId, @@ -82,7 +138,7 @@ public ResponseEntity updateFeed( return ResponseEntity.ok(response); } - @DeleteMapping("/{feedId}") + @DeleteMapping("/feeds/{feedId}") public ResponseEntity deleteFeed( @AuthUser Long userId, @PathVariable Long feedId @@ -91,4 +147,24 @@ public ResponseEntity deleteFeed( ApiResponse response = ApiResponseFactory.successVoid("피드를 성공적으로 삭제하였습니다."); return ResponseEntity.ok(response); } + + @PostMapping("/feeds/{feedId}/likes") + public ResponseEntity likeFeed( + @AuthUser Long userId, + @PathVariable Long feedId + ) { + likeCommandService.like(LikeTargetType.FEED, feedId, userId); + ApiResponse response = ApiResponseFactory.successVoid("피드에 좋아요를 추가했습니다."); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/feeds/{feedId}/likes") + public ResponseEntity unlikeFeed( + @AuthUser Long userId, + @PathVariable Long feedId + ) { + likeCommandService.unlike(LikeTargetType.FEED, feedId, userId); + ApiResponse response = ApiResponseFactory.successVoid("피드 좋아요를 취소했습니다."); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/example/bak/feed/presentation/dto/CommunityFeedsResponse.java b/src/main/java/com/example/bak/feed/presentation/dto/CommunityFeedsResponse.java new file mode 100644 index 0000000..3bcccc3 --- /dev/null +++ b/src/main/java/com/example/bak/feed/presentation/dto/CommunityFeedsResponse.java @@ -0,0 +1,16 @@ +package com.example.bak.feed.presentation.dto; + +import com.example.bak.feed.application.query.dto.FeedCommunityPageResult; +import com.example.bak.feed.application.query.dto.FeedSummary; +import java.util.List; + +public record CommunityFeedsResponse( + List feeds, + Long nextCursor, + boolean hasNext +) { + + public static CommunityFeedsResponse from(FeedCommunityPageResult result) { + return new CommunityFeedsResponse(result.feeds(), result.nextCursor(), result.hasNext()); + } +} diff --git a/src/main/java/com/example/bak/feed/presentation/dto/MainPageFeedsResponse.java b/src/main/java/com/example/bak/feed/presentation/dto/MainPageFeedsResponse.java new file mode 100644 index 0000000..e33fdf1 --- /dev/null +++ b/src/main/java/com/example/bak/feed/presentation/dto/MainPageFeedsResponse.java @@ -0,0 +1,7 @@ +package com.example.bak.feed.presentation.dto; + +import com.example.bak.feed.application.query.dto.FeedDetail; +import java.util.List; + +public record MainPageFeedsResponse(List feeds) { +} diff --git a/src/main/java/com/example/bak/feed/presentation/dto/MainPageTitlesResponse.java b/src/main/java/com/example/bak/feed/presentation/dto/MainPageTitlesResponse.java new file mode 100644 index 0000000..e33593f --- /dev/null +++ b/src/main/java/com/example/bak/feed/presentation/dto/MainPageTitlesResponse.java @@ -0,0 +1,7 @@ +package com.example.bak.feed.presentation.dto; + +import com.example.bak.feed.application.query.dto.FeedCommunityTitleSection; +import java.util.List; + +public record MainPageTitlesResponse(List communities) { +} diff --git a/src/main/java/com/example/bak/feed/presentation/swagger/FeedSwagger.java b/src/main/java/com/example/bak/feed/presentation/swagger/FeedSwagger.java index 9ea7e63..1abddde 100644 --- a/src/main/java/com/example/bak/feed/presentation/swagger/FeedSwagger.java +++ b/src/main/java/com/example/bak/feed/presentation/swagger/FeedSwagger.java @@ -125,7 +125,7 @@ ResponseEntity createFeed( @Operation( summary = "피드 목록 조회", - description = "페이지 번호와 페이지 크기를 지정해 최신 피드 목록을 조회합니다." + description = "페이지 번호와 페이지 크기를 지정해 최신 피드 목록을 조회하며, 각 항목은 본문 미리보기와 좋아요/댓글 수를 함께 제공합니다." ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -145,6 +145,7 @@ ResponseEntity createFeed( { "id": 7, "title": "Infra 스터디 회고", + "contentPreview": "스터디에서 다룬 주제와 배운 내용을 간단히 정리했습니다...", "author": { "id": 21, "nickname": "infra-cat" @@ -154,6 +155,7 @@ ResponseEntity createFeed( "name": "백엔드 개발자", "jobGroup": "개발" }, + "likeCount": 0, "commentCount": 2 } ] @@ -182,17 +184,93 @@ ResponseEntity createFeed( ) }) ResponseEntity getFeeds( - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0", - schema = @Schema(minimum = "0", defaultValue = "0")) - @RequestParam(defaultValue = "0") int page, - @Parameter(description = "페이지 크기 (1 이상)", example = "10", + @Parameter(description = "조회할 개수", example = "20", + schema = @Schema(minimum = "1", defaultValue = "20")) + @RequestParam(defaultValue = "20") int limit + ); + + @Operation( + summary = "메인 페이지 피드 조회", + description = "최신 순으로 정렬된 피드 상세 정보를 제한 개수만큼 조회합니다." + ) + ResponseEntity getMainPageFeeds( + @Parameter(description = "조회할 개수", example = "10", schema = @Schema(minimum = "1", defaultValue = "10")) - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") int limit + ); + + @Operation( + summary = "메인 페이지 커뮤니티별 제목 조회", + description = "각 커뮤니티별로 최신 N개의 피드 제목만 묶어서 조회합니다." + ) + ResponseEntity getMainPageTitles( + @Parameter(description = "커뮤니티별 조회할 개수", example = "5", + schema = @Schema(minimum = "1", defaultValue = "5")) + @RequestParam(defaultValue = "5") int limit + ); + + @Operation( + summary = "커뮤니티 피드 목록 조회", + description = "특정 커뮤니티에 속한 피드를 페이지네이션 형태로 조회합니다. 응답에는 nextCursor와 hasNext가 포함됩니다." + ) + ResponseEntity getFeedsByCommunity( + @Parameter(description = "커뮤니티 ID", required = true, example = "1") + @PathVariable Long communityId, + @Parameter(description = "다음 페이지 조회를 위한 커서(이전 응답의 nextCursor)") + @RequestParam(required = false) Long cursor, + @Parameter(description = "한 번에 가져올 개수", example = "20", + schema = @Schema(minimum = "1", defaultValue = "20")) + @RequestParam(defaultValue = "20") int size + ); + + @Operation( + summary = "커뮤니티별 피드 제목 조회", + description = "특정 커뮤니티에서 최신 N개의 피드 제목과 좋아요/댓글 요약을 조회합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "커뮤니티 피드 제목 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "FeedTitleList", + summary = "커뮤니티별 제목 리스트", + value = """ + { + "status": "SUCCESS", + "message": "커뮤니티 피드 제목을 성공적으로 조회하였습니다.", + "data": [ + { + "id": 10, + "title": "블라인드 스타일 데이터", + "likeCount": 12, + "commentCount": 4, + "community": { + "id": 3, + "name": "백엔드 개발자", + "jobGroup": "개발" + } + } + ] + } + """ + ) + ) + ) + }) + ResponseEntity getFeedTitlesByCommunity( + @Parameter(description = "커뮤니티 ID", required = true, example = "1") + @PathVariable Long communityId, + @Parameter(description = "조회할 개수", example = "5", + schema = @Schema(minimum = "1", defaultValue = "5")) + @RequestParam(defaultValue = "5") int limit ); @Operation( summary = "피드 요약 조회", - description = "피드 카드 노출용으로 필요한 최소 정보(제목, 작성자, 커뮤니티, 댓글 수)를 조회합니다." + description = "피드 카드 노출용으로 필요한 최소 정보(제목, 본문 미리보기, 작성자, 커뮤니티, 좋아요/댓글 수)를 조회합니다." ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -211,6 +289,7 @@ ResponseEntity getFeeds( "data": { "id": 1, "title": "신입 개발자 채용 정보 공유", + "contentPreview": "채용 절차를 준비하면서 도움이 되었던 자료를 정리했습니다...", "author": { "id": 5, "nickname": "backend-dev" @@ -220,6 +299,7 @@ ResponseEntity getFeeds( "name": "백엔드 개발자", "jobGroup": "개발" }, + "likeCount": 0, "commentCount": 5 } } @@ -281,7 +361,9 @@ ResponseEntity getFeedSummary( "id": 2, "name": "백엔드 개발자", "jobGroup": "개발" - } + }, + "likeCount": 0, + "commentCount": 5 } } """ @@ -519,4 +601,68 @@ ResponseEntity deleteFeed( @Parameter(description = "피드 ID", required = true, example = "1") @PathVariable Long feedId ); + + @Operation( + summary = "피드 좋아요 등록", + description = "로그인 사용자가 특정 피드에 좋아요를 추가합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "좋아요 등록 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "FeedLikeSuccess", + summary = "좋아요 성공", + value = """ + { + "status": "SUCCESS", + "message": "피드에 좋아요를 추가했습니다.", + "data": null + } + """ + ) + ) + ) + }) + ResponseEntity likeFeed( + @Parameter(hidden = true, description = "인증된 사용자 ID", required = true) + Long userId, + @Parameter(description = "피드 ID", required = true, example = "1") + @PathVariable Long feedId + ); + + @Operation( + summary = "피드 좋아요 취소", + description = "로그인 사용자가 자신이 누른 좋아요를 취소합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "좋아요 취소 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "FeedUnlikeSuccess", + summary = "좋아요 취소", + value = """ + { + "status": "SUCCESS", + "message": "피드 좋아요를 취소했습니다.", + "data": null + } + """ + ) + ) + ) + }) + ResponseEntity unlikeFeed( + @Parameter(hidden = true, description = "인증된 사용자 ID", required = true) + Long userId, + @Parameter(description = "피드 ID", required = true, example = "1") + @PathVariable Long feedId + ); } diff --git a/src/main/java/com/example/bak/global/exception/ErrorCode.java b/src/main/java/com/example/bak/global/exception/ErrorCode.java index 5931e66..b7e1151 100644 --- a/src/main/java/com/example/bak/global/exception/ErrorCode.java +++ b/src/main/java/com/example/bak/global/exception/ErrorCode.java @@ -39,9 +39,12 @@ public enum ErrorCode { // Private Message MESSAGE_NOT_FOUND("PM001", HttpStatus.NOT_FOUND, "쪽지 리소스를 찾을 수 없습니다."), + // Like + INVALID_LIKE_TARGET("LK001", HttpStatus.BAD_REQUEST, "지원하지 않는 좋아요 대상입니다."), + LIKE_ALREADY_EXISTS("LK002", HttpStatus.BAD_REQUEST, "이미 좋아요를 누른 상태입니다."), + // Global UNAUTHORIZED_ACTION("CM002", HttpStatus.FORBIDDEN, "권한이 없는 작업입니다."); - private final String code; private final HttpStatus status; private final String message; diff --git a/src/main/java/com/example/bak/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/bak/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4768a27 --- /dev/null +++ b/src/main/java/com/example/bak/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,18 @@ +package com.example.bak.global.exception; + +import com.example.bak.global.common.response.ApiResponse; +import com.example.bak.global.common.response.ApiResponseFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException exception) { + ErrorCode code = exception.getErrorCode(); + ApiResponse response = ApiResponseFactory.failure(code.getMessage()); + return ResponseEntity.status(code.getStatus()).body(response); + } +} diff --git a/src/main/java/com/example/bak/like/application/command/LikeCommandService.java b/src/main/java/com/example/bak/like/application/command/LikeCommandService.java new file mode 100644 index 0000000..0836095 --- /dev/null +++ b/src/main/java/com/example/bak/like/application/command/LikeCommandService.java @@ -0,0 +1,63 @@ +package com.example.bak.like.application.command; + +import com.example.bak.global.exception.BusinessException; +import com.example.bak.global.exception.ErrorCode; +import com.example.bak.like.application.command.port.LikeCommandPort; +import com.example.bak.like.application.command.validator.LikeTargetValidator; +import com.example.bak.like.domain.Like; +import com.example.bak.like.domain.LikeTargetType; +import jakarta.annotation.PostConstruct; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeCommandService { + + private final LikeCommandPort likeCommandPort; + private final List validators; + private Map validatorMap; + + @PostConstruct + void initValidatorMap() { + validatorMap = new EnumMap<>(LikeTargetType.class); + for (LikeTargetValidator validator : validators) { + validatorMap.put(validator.targetType(), validator); + } + } + + public void like(LikeTargetType targetType, Long targetId, Long userId) { + LikeTargetValidator validator = getValidator(targetType); + validator.validateExists(targetId); + + if (likeCommandPort.exists(targetType, targetId, userId)) { + throw new BusinessException(ErrorCode.LIKE_ALREADY_EXISTS); + } + + likeCommandPort.save(Like.create(targetType, targetId, userId)); + } + + public void unlike(LikeTargetType targetType, Long targetId, Long userId) { + LikeTargetValidator validator = getValidator(targetType); + validator.validateExists(targetId); + + if (!likeCommandPort.exists(targetType, targetId, userId)) { + return; + } + + likeCommandPort.delete(targetType, targetId, userId); + } + + private LikeTargetValidator getValidator(LikeTargetType targetType) { + LikeTargetValidator validator = validatorMap.get(targetType); + if (validator == null) { + throw new BusinessException(ErrorCode.INVALID_LIKE_TARGET); + } + return validator; + } +} diff --git a/src/main/java/com/example/bak/like/application/command/port/LikeCommandPort.java b/src/main/java/com/example/bak/like/application/command/port/LikeCommandPort.java new file mode 100644 index 0000000..7c32cb1 --- /dev/null +++ b/src/main/java/com/example/bak/like/application/command/port/LikeCommandPort.java @@ -0,0 +1,13 @@ +package com.example.bak.like.application.command.port; + +import com.example.bak.like.domain.Like; +import com.example.bak.like.domain.LikeTargetType; + +public interface LikeCommandPort { + + Like save(Like like); + + boolean exists(LikeTargetType targetType, Long targetId, Long userId); + + void delete(LikeTargetType targetType, Long targetId, Long userId); +} diff --git a/src/main/java/com/example/bak/like/application/command/validator/LikeTargetValidator.java b/src/main/java/com/example/bak/like/application/command/validator/LikeTargetValidator.java new file mode 100644 index 0000000..fbba8f2 --- /dev/null +++ b/src/main/java/com/example/bak/like/application/command/validator/LikeTargetValidator.java @@ -0,0 +1,10 @@ +package com.example.bak.like.application.command.validator; + +import com.example.bak.like.domain.LikeTargetType; + +public interface LikeTargetValidator { + + LikeTargetType targetType(); + + void validateExists(Long targetId); +} diff --git a/src/main/java/com/example/bak/like/domain/Like.java b/src/main/java/com/example/bak/like/domain/Like.java new file mode 100644 index 0000000..095918a --- /dev/null +++ b/src/main/java/com/example/bak/like/domain/Like.java @@ -0,0 +1,52 @@ +package com.example.bak.like.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "likes", + uniqueConstraints = @UniqueConstraint(name = "uk_like_target_user", + columnNames = {"target_type", "target_id", "user_id"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 50) + private LikeTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + private Like(LikeTargetType targetType, Long targetId, Long userId, LocalDateTime createdAt) { + this.targetType = targetType; + this.targetId = targetId; + this.userId = userId; + this.createdAt = createdAt; + } + + public static Like create(LikeTargetType targetType, Long targetId, Long userId) { + return new Like(targetType, targetId, userId, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/example/bak/like/domain/LikeTargetType.java b/src/main/java/com/example/bak/like/domain/LikeTargetType.java new file mode 100644 index 0000000..8e62343 --- /dev/null +++ b/src/main/java/com/example/bak/like/domain/LikeTargetType.java @@ -0,0 +1,6 @@ +package com.example.bak.like.domain; + +public enum LikeTargetType { + FEED, + COMMENT +} diff --git a/src/main/java/com/example/bak/like/infra/command/LikeCommandAdapter.java b/src/main/java/com/example/bak/like/infra/command/LikeCommandAdapter.java new file mode 100644 index 0000000..985a471 --- /dev/null +++ b/src/main/java/com/example/bak/like/infra/command/LikeCommandAdapter.java @@ -0,0 +1,29 @@ +package com.example.bak.like.infra.command; + +import com.example.bak.like.application.command.port.LikeCommandPort; +import com.example.bak.like.domain.Like; +import com.example.bak.like.domain.LikeTargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LikeCommandAdapter implements LikeCommandPort { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public boolean exists(LikeTargetType targetType, Long targetId, Long userId) { + return likeJpaRepository.existsByTargetTypeAndTargetIdAndUserId(targetType, targetId, userId); + } + + @Override + public void delete(LikeTargetType targetType, Long targetId, Long userId) { + likeJpaRepository.deleteByTargetTypeAndTargetIdAndUserId(targetType, targetId, userId); + } +} diff --git a/src/main/java/com/example/bak/like/infra/command/LikeJpaRepository.java b/src/main/java/com/example/bak/like/infra/command/LikeJpaRepository.java new file mode 100644 index 0000000..2aeeb28 --- /dev/null +++ b/src/main/java/com/example/bak/like/infra/command/LikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.example.bak.like.infra.command; + +import com.example.bak.like.domain.Like; +import com.example.bak.like.domain.LikeTargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByTargetTypeAndTargetIdAndUserId(LikeTargetType targetType, Long targetId, Long userId); + + void deleteByTargetTypeAndTargetIdAndUserId(LikeTargetType targetType, Long targetId, Long userId); +} diff --git a/src/test/java/com/example/bak/feed/infra/query/FeedJdbcRepositoryIntegrationTest.java b/src/test/java/com/example/bak/feed/infra/query/FeedJdbcRepositoryIntegrationTest.java index 3c19c58..e8f013d 100644 --- a/src/test/java/com/example/bak/feed/infra/query/FeedJdbcRepositoryIntegrationTest.java +++ b/src/test/java/com/example/bak/feed/infra/query/FeedJdbcRepositoryIntegrationTest.java @@ -4,6 +4,7 @@ import com.example.bak.feed.application.query.dto.FeedDetail; import com.example.bak.feed.application.query.dto.FeedSummary; +import com.example.bak.feed.application.query.dto.FeedTitleOnly; import com.example.bak.feed.infra.query.jdbc.FeedJdbcRepository; import com.example.bak.global.AbstractMySqlContainerTest; import java.util.List; @@ -12,8 +13,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.test.context.jdbc.Sql; @JdbcTest @@ -37,6 +36,8 @@ void success() { assertThat(detail.title()).isEqualTo("title1"); assertThat(detail.author().nickname()).isEqualTo("nick1"); assertThat(detail.community().name()).isEqualTo("backend"); + assertThat(detail.commentCount()).isEqualTo(2); + assertThat(detail.likeCount()).isEqualTo(2); } } @@ -52,22 +53,98 @@ void success() { assertThat(summary.id()).isEqualTo(1L); assertThat(summary.commentCount()).isEqualTo(2); assertThat(summary.author().nickname()).isEqualTo("nick1"); + assertThat(summary.contentPreview()).isEqualTo("content1"); + assertThat(summary.likeCount()).isEqualTo(2); } } @Nested - @DisplayName("findAll") - class FindAll { + @DisplayName("findRecentSummaries") + class FindRecentSummaries { @Test - @DisplayName("페이지네이션으로 피드 목록을 조회한다") + @DisplayName("최신 순으로 지정한 개수만큼 피드 요약을 조회한다") void success() { - Page page = feedJdbcRepository.findAll(PageRequest.of(0, 2)); + List summaries = feedJdbcRepository.findRecentSummaries(2); - assertThat(page.getTotalElements()).isEqualTo(3); - List content = page.getContent(); - assertThat(content).hasSize(2); - assertThat(content.getFirst().id()).isEqualTo(3L); // DESC order + assertThat(summaries).hasSize(2); + assertThat(summaries.getFirst().id()).isEqualTo(3L); + assertThat(summaries.get(1).id()).isEqualTo(2L); + } + } + + @Nested + @DisplayName("findTitleOnlyByCommunity") + class FindTitleOnlyByCommunity { + + @Test + @DisplayName("커뮤니티별 최신 피드 제목을 제한 개수만큼 조회한다") + void success() { + List titles = feedJdbcRepository.findTitleOnlyByCommunity(1L, 1); + + assertThat(titles).hasSize(1); + FeedTitleOnly titleOnly = titles.getFirst(); + assertThat(titleOnly.title()).isEqualTo("title2"); + assertThat(titleOnly.likeCount()).isZero(); + assertThat(titleOnly.commentCount()).isEqualTo(1); + assertThat(titleOnly.community().id()).isEqualTo(1L); + } + } + + @Nested + @DisplayName("findTopDetails") + class FindTopDetails { + + @Test + @DisplayName("최신 피드 상세를 개수 제한으로 조회한다") + void success() { + List details = feedJdbcRepository.findTopDetails(2); + + assertThat(details).hasSize(2); + assertThat(details.get(0).id()).isEqualTo(3L); + assertThat(details.get(1).id()).isEqualTo(2L); + } + } + + @Nested + @DisplayName("findByCommunityIdWithCursor") + class FindByCommunityIdWithCursor { + + @Test + @DisplayName("커멘티별 피드를 커서 기반으로 조회한다") + void success() { + List firstPage = feedJdbcRepository.findByCommunityIdWithCursor(1L, null, 1); + + assertThat(firstPage).hasSize(1); + Long nextCursor = firstPage.getFirst().id(); + + List secondPage = feedJdbcRepository.findByCommunityIdWithCursor(1L, nextCursor, 1); + assertThat(secondPage).hasSize(1); + assertThat(secondPage.getFirst().id()).isLessThan(nextCursor); + } + + @Test + @DisplayName("커멘티별 피드를 다음 페이지 존재 여부 확인을 위해 size+1로 조회할 수 있다") + void canFetchSizePlusOne() { + List fetched = feedJdbcRepository.findByCommunityIdWithCursor(1L, null, 2); + + assertThat(fetched).hasSize(2); + } + } + + @Nested + @DisplayName("findLatestTitlesByCommunity") + class FindTitleOnlyGroupedByCommunity { + + @Test + @DisplayName("커뮤니티별로 제한 개수만큼 제목을 그룹핑해 조회한다") + void success() { + List titles = feedJdbcRepository.findLatestTitlesByCommunity(1); + + assertThat(titles) + .hasSize(2) + .extracting(title -> title.community().id()) + .containsExactlyInAnyOrder(1L, 2L); } } } diff --git a/src/test/java/com/example/bak/like/application/command/LikeCommandServiceUnitTest.java b/src/test/java/com/example/bak/like/application/command/LikeCommandServiceUnitTest.java new file mode 100644 index 0000000..2377d1c --- /dev/null +++ b/src/test/java/com/example/bak/like/application/command/LikeCommandServiceUnitTest.java @@ -0,0 +1,133 @@ +package com.example.bak.like.application.command; + +import static com.example.bak.global.utils.AssertionsErrorCode.assertBusiness; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.bak.comment.application.command.port.CommentValidationPort; +import com.example.bak.comment.application.validator.CommentLikeTargetValidator; +import com.example.bak.global.exception.ErrorCode; +import com.example.bak.like.application.command.port.LikeCommandPort; +import com.example.bak.like.application.command.validator.LikeTargetValidator; +import com.example.bak.like.domain.Like; +import com.example.bak.like.domain.LikeTargetType; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("LikeCommandService 단위 테스트") +class LikeCommandServiceUnitTest { + + private static final Long EXISTING_COMMENT_ID = 1L; + private static final Long NOT_FOUND_COMMENT_ID = 999L; + private static final Long USER_ID = 10L; + + private LikeCommandService likeCommandService; + private InMemoryLikeCommandPort likeCommandPort; + + @BeforeEach + void setUp() { + CommentValidationPort commentValidationPort = new InMemoryCommentValidationPort( + Set.of(EXISTING_COMMENT_ID)); + LikeTargetValidator commentValidator = new CommentLikeTargetValidator(commentValidationPort); + + likeCommandPort = new InMemoryLikeCommandPort(); + likeCommandService = new LikeCommandService(likeCommandPort, List.of(commentValidator)); + likeCommandService.initValidatorMap(); + } + + @Nested + @DisplayName("like") + class LikeOperation { + + @Test + @DisplayName("댓글 좋아요에 성공한다") + void likeComment_success() { + assertThatCode(() -> likeCommandService.like(LikeTargetType.COMMENT, EXISTING_COMMENT_ID, + USER_ID)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("이미 좋아요를 누른 상태면 예외") + void likeComment_whenAlreadyExists() { + likeCommandService.like(LikeTargetType.COMMENT, EXISTING_COMMENT_ID, USER_ID); + + assertBusiness( + () -> likeCommandService.like(LikeTargetType.COMMENT, EXISTING_COMMENT_ID, USER_ID), + ErrorCode.LIKE_ALREADY_EXISTS + ); + } + + @Test + @DisplayName("존재하지 않는 댓글이면 예외") + void likeComment_whenCommentNotFound() { + assertBusiness( + () -> likeCommandService.like(LikeTargetType.COMMENT, NOT_FOUND_COMMENT_ID, USER_ID), + ErrorCode.COMMENT_NOT_FOUND + ); + } + } + + @Nested + @DisplayName("unlike") + class Unlike { + + @Test + @DisplayName("좋아요가 없어도 예외 없이 종료한다") + void unlike_whenNotExists() { + assertThatCode(() -> likeCommandService.unlike(LikeTargetType.COMMENT, EXISTING_COMMENT_ID, + USER_ID)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("존재하지 않는 댓글이면 예외") + void unlike_whenCommentNotFound() { + assertBusiness( + () -> likeCommandService.unlike(LikeTargetType.COMMENT, NOT_FOUND_COMMENT_ID, USER_ID), + ErrorCode.COMMENT_NOT_FOUND + ); + } + } + + private static final class InMemoryCommentValidationPort implements CommentValidationPort { + + private final Set existing; + + private InMemoryCommentValidationPort(Set existing) { + this.existing = existing; + } + + @Override + public boolean existsById(Long commentId) { + return existing.contains(commentId); + } + } + + private static final class InMemoryLikeCommandPort implements LikeCommandPort { + + private final Set keys = new HashSet<>(); + + @Override + public com.example.bak.like.domain.Like save(com.example.bak.like.domain.Like like) { + keys.add(key(like.getTargetType(), like.getTargetId(), like.getUserId())); + return like; + } + + @Override + public boolean exists(LikeTargetType targetType, Long targetId, Long userId) { + return keys.contains(key(targetType, targetId, userId)); + } + + @Override + public void delete(LikeTargetType targetType, Long targetId, Long userId) { + keys.remove(key(targetType, targetId, userId)); + } + + private String key(LikeTargetType targetType, Long targetId, Long userId) { + return targetType + ":" + targetId + ":" + userId; + } + } +} diff --git a/src/test/resources/sql/feed/data.sql b/src/test/resources/sql/feed/data.sql index c6e54ba..b7a1f7b 100644 --- a/src/test/resources/sql/feed/data.sql +++ b/src/test/resources/sql/feed/data.sql @@ -1,4 +1,6 @@ DELETE +FROM likes; +DELETE FROM comments; DELETE FROM feeds; @@ -36,4 +38,9 @@ VALUES (1, 'title1', 'content1', 1, 1), INSERT INTO comments (id, content, author_id, author_nickname, feed_id) VALUES (1, 'c1', 2, 'nick2', 1), (2, 'c2', 3, 'nick3', 1), - (3, 'c3', 1, 'nick1', 2); \ No newline at end of file + (3, 'c3', 1, 'nick1', 2); + +INSERT INTO likes (id, target_type, target_id, user_id) +VALUES (1, 'FEED', 1, 2), + (2, 'FEED', 1, 3), + (3, 'FEED', 3, 1); diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index 0b8924d..834c539 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -1,4 +1,5 @@ -- Drop tables if they exist to start with a clean slate +DROP TABLE IF EXISTS likes; DROP TABLE IF EXISTS comments; DROP TABLE IF EXISTS feeds; DROP TABLE IF EXISTS communities; @@ -57,6 +58,18 @@ CREATE TABLE feeds FOREIGN KEY (author_id) REFERENCES users (id) ); +-- Table for Likes +CREATE TABLE likes +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + target_type VARCHAR(50) NOT NULL, + target_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_like_target_user UNIQUE (target_type, target_id, user_id), + FOREIGN KEY (user_id) REFERENCES users (id) +); + -- Table for Comments CREATE TABLE comments (