diff --git a/.github/workflows/pipeline-monorepo.yaml b/.github/workflows/pipeline-monorepo.yaml
index 3435442..f80ff26 100644
--- a/.github/workflows/pipeline-monorepo.yaml
+++ b/.github/workflows/pipeline-monorepo.yaml
@@ -4,6 +4,7 @@ on:
push:
branches:
- dev
+ - perf/after-optimization
paths-ignore:
- 'tests/**' # k6 스크립트
- 'docs/**' # 문서 폴더
diff --git a/apigateway-service/pom.xml b/apigateway-service/pom.xml
index 2ece791..e34cd50 100644
--- a/apigateway-service/pom.xml
+++ b/apigateway-service/pom.xml
@@ -76,6 +76,15 @@
2.5.0
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
com.example
common-module
diff --git a/apigateway-service/src/main/resources/application.yml b/apigateway-service/src/main/resources/application.yml
index 4ea154c..d72ceb9 100644
--- a/apigateway-service/src/main/resources/application.yml
+++ b/apigateway-service/src/main/resources/application.yml
@@ -74,6 +74,9 @@ spring:
uri: http://community-service:80
predicates:
- Path=/community-service/**
+ httpclient:
+ connect-timeout: 10000
+ response-timeout: 30s
app:
gateway:
@@ -146,4 +149,19 @@ springdoc:
# 게이트웨이의 공인 주소(ngrok)를 명시하여 Mixed Content 문제 해결
servers:
- url: https://impressionless-connaturally-jonie.ngrok-free.dev
- description: NGrok Development Server
\ No newline at end of file
+ description: NGrok Development Server
+
+# --- Monitoring Configuration (Added) ---
+management:
+ endpoints:
+ web:
+ exposure:
+ include: prometheus, health, info # 프로메테우스 지표 노출 허용
+ endpoint:
+ health:
+ show-details: always
+ prometheus:
+ enabled: true
+ metrics:
+ tags:
+ application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가
\ No newline at end of file
diff --git a/auth-service/pom.xml b/auth-service/pom.xml
index 1ef14c0..8370665 100644
--- a/auth-service/pom.xml
+++ b/auth-service/pom.xml
@@ -98,10 +98,6 @@
runtime
-
- org.springframework.cloud
- spring-cloud-starter-openfeign
-
org.springframework.cloud
spring-cloud-starter-kubernetes-client-loadbalancer
diff --git a/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java b/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java
index 8e130d1..75974e7 100644
--- a/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java
+++ b/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java
@@ -2,7 +2,6 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
@@ -11,7 +10,6 @@
@SpringBootApplication(scanBasePackages = "com.example")
@EnableJpaAuditing
@EnableAsync
-@EnableFeignClients
public class AuthServiceApplication {
public static void main(String[] args) {
diff --git a/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java b/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java
index 8848e9f..6571957 100644
--- a/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java
+++ b/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java
@@ -40,7 +40,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
- .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
+ .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/actuator/**").permitAll()
// 모든 /auth/** 경로는 인증 없이 허용
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
diff --git a/auth-service/src/main/resources/application.yml b/auth-service/src/main/resources/application.yml
index 4d3dba7..d265c5d 100644
--- a/auth-service/src/main/resources/application.yml
+++ b/auth-service/src/main/resources/application.yml
@@ -106,10 +106,23 @@ jwt:
# Feign (User Service ???)
app:
- feign:
- user-service-url: http://user-service:80 # k8s ?? ??? ?? (?? 80)
frontend:
redirect-url: ${APP_FRONTEND_REDIRECT_URL} # ex) http://127.0.0.1:5173/auth/callback
springdoc:
- override-with-generic-response: false
\ No newline at end of file
+ override-with-generic-response: false
+
+# --- Monitoring Configuration (Added) ---
+management:
+ endpoints:
+ web:
+ exposure:
+ include: prometheus, health, info # 프로메테우스 지표 노출 허용
+ endpoint:
+ health:
+ show-details: always
+ prometheus:
+ enabled: true
+ metrics:
+ tags:
+ application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가
\ No newline at end of file
diff --git a/chat-service/pom.xml b/chat-service/pom.xml
index 8a13326..dcd7255 100644
--- a/chat-service/pom.xml
+++ b/chat-service/pom.xml
@@ -124,6 +124,16 @@
spring-boot-starter-test
test
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
diff --git a/chat-service/src/main/resources/application.yml b/chat-service/src/main/resources/application.yml
index c0b29c8..e29ebb8 100644
--- a/chat-service/src/main/resources/application.yml
+++ b/chat-service/src/main/resources/application.yml
@@ -37,4 +37,19 @@ spring:
logging:
level:
org.springframework.web.socket: DEBUG
- com.example.chatservice: DEBUG
\ No newline at end of file
+ com.example.chatservice: DEBUG
+
+# --- Monitoring Configuration (Added) ---
+management:
+ endpoints:
+ web:
+ exposure:
+ include: prometheus, health, info # 프로메테우스 지표 노출 허용
+ endpoint:
+ health:
+ show-details: always
+ prometheus:
+ enabled: true
+ metrics:
+ tags:
+ application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가
\ No newline at end of file
diff --git a/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java b/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java
index 15c5b59..aea9382 100644
--- a/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java
+++ b/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java
@@ -9,5 +9,5 @@
@AllArgsConstructor
public class UserProfileCreationFailureEvent {
private Long userId;
- private String reason; // 실패 사유
+ private String reason;
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/controller/PostController.java b/community-service/src/main/java/com/example/communityservice/controller/PostController.java
index 04c640a..5866e19 100644
--- a/community-service/src/main/java/com/example/communityservice/controller/PostController.java
+++ b/community-service/src/main/java/com/example/communityservice/controller/PostController.java
@@ -6,8 +6,7 @@
import com.example.communityservice.dto.request.PostCreateRequest;
import com.example.communityservice.dto.request.PostUpdateRequest;
import com.example.communityservice.dto.request.RecruitmentStatusRequest;
-import com.example.communityservice.dto.response.PostDetailResponse;
-import com.example.communityservice.dto.response.PostResponse;
+import com.example.communityservice.dto.response.*;
import com.example.communityservice.entity.enumerate.PostCategory;
import com.example.communityservice.service.PostService;
import io.swagger.v3.oas.annotations.Operation;
@@ -79,12 +78,13 @@ public ResponseEntity deletePost(@AuthenticationPrincipal AuthUser authUse
@Operation(summary = "게시글 목록 조회 (검색/필터)", description = "카테고리, 키워드, 해결 여부 등을 조건으로 게시글 목록을 조회합니다. (인증 불필요)")
@GetMapping("/posts")
- public ResponseEntity> getPosts(
+ public ResponseEntity> getPosts(
@Parameter(description = "카테고리 (QNA, INFO, RECRUIT)") @RequestParam(required = false) PostCategory category,
@Parameter(description = "검색 키워드 (제목 + 내용)") @RequestParam(required = false) String keyword,
@Parameter(description = "해결 여부 (QnA 전용)") @RequestParam(required = false) Boolean isSolved,
@Parameter(description = "페이징 설정 (기본: 최신순 10개)") @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
- return ResponseEntity.ok(postService.getPosts(category, keyword, isSolved, pageable));
+ Page page = postService.getPosts(category, keyword, isSolved, pageable);
+ return ResponseEntity.ok(new CustomPageResponse<>(page));
}
@Operation(summary = "게시글 상세 조회", description = "게시글의 상세 내용과 계층형 댓글 목록을 조회합니다. 로그인 시 북마크 여부가 포함됩니다.")
@@ -202,27 +202,26 @@ public ResponseEntity updateRecruitmentStatus(@AuthenticationPrincipal Aut
return ResponseEntity.ok().build();
}
- @Operation(summary = "내가 쓴 게시글 조회", description = "로그인한 사용자가 작성한 게시글 목록을 조회합니다.")
+ @Operation(summary = "내가 쓴 게시글 조회", description = "로그인한 사용자가 작성한 게시글 목록을 조회합니다. (카테고리 필터링 가능)")
@SecurityRequirement(name = "BearerAuthentication")
@GetMapping("/posts/me")
- public ResponseEntity> getMyPosts(@AuthenticationPrincipal AuthUser authUser,
- @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
- return ResponseEntity.ok(postService.getMyPosts(authUser.getUserId(), pageable));
+ public ResponseEntity> getMyPosts(
+ @AuthenticationPrincipal AuthUser authUser,
+ @Parameter(description = "카테고리 (QNA, INFO, RECRUIT), 미입력 시 전체") @RequestParam(required = false) PostCategory category,
+ @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
+ Page page = postService.getMyPosts(authUser.getUserId(), category, pageable);
+ return ResponseEntity.ok(new CustomPageResponse<>(page));
}
- @Operation(summary = "내가 북마크한 글 조회", description = "로그인한 사용자가 북마크한 게시글 목록을 조회합니다.")
+ @Operation(summary = "내가 북마크한 글 조회", description = "로그인한 사용자가 북마크한 게시글 목록을 조회합니다. (카테고리 필터링 가능)")
@SecurityRequirement(name = "BearerAuthentication")
@GetMapping("/posts/me/bookmarks")
- public ResponseEntity> getMyBookmarkedPosts(@AuthenticationPrincipal AuthUser authUser,
- @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
- return ResponseEntity.ok(postService.getMyBookmarkedPosts(authUser.getUserId(), pageable));
+ public ResponseEntity> getMyBookmarkedPosts(
+ @AuthenticationPrincipal AuthUser authUser,
+ @Parameter(description = "카테고리 (QNA, INFO, RECRUIT), 미입력 시 전체") @RequestParam(required = false) PostCategory category,
+ @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
+ Page page = postService.getMyBookmarkedPosts(authUser.getUserId(), category, pageable);
+ return ResponseEntity.ok(new CustomPageResponse<>(page));
}
- @Operation(summary = "내가 댓글 단 글 조회", description = "로그인한 사용자가 댓글을 작성한 게시글 목록을 조회합니다.")
- @SecurityRequirement(name = "BearerAuthentication")
- @GetMapping("/posts/me/commented")
- public ResponseEntity> getMyCommentedPosts(@AuthenticationPrincipal AuthUser authUser,
- @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
- return ResponseEntity.ok(postService.getMyCommentedPosts(authUser.getUserId(), pageable));
- }
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java b/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java
index db60af4..836efe2 100644
--- a/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java
+++ b/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java
@@ -40,12 +40,18 @@ public ResponseEntity setupPosts(@RequestBody Map request)
// 2. 데이터 생성
List posts = new ArrayList<>();
+
+ PostCategory[] categories = PostCategory.values();
+
for (int i = 0; i < count; i++) {
+
+ PostCategory category = categories[i % categories.length];
+
posts.add(PostEntity.builder()
.userId(userId)
.title("성능 테스트용 게시글 " + i)
.content("이 게시글은 성능 테스트를 위해 생성된 더미 데이터입니다. ".repeat(5)) // 내용 불리기
- .category(PostCategory.INFO)
+ .category(category)
.build());
}
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java
index e0e67ff..5f6dc3b 100644
--- a/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java
@@ -1,38 +1,48 @@
package com.example.communityservice.dto.response;
-import com.example.communityservice.entity.PostCommentEntity;
-import lombok.Builder;
-import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
-@Data
+@Getter
+@Setter
@Builder
+@NoArgsConstructor
+@AllArgsConstructor
public class CommentResponse {
private Long id;
- private Long userId;
-
- // 작성자 정보 (로컬 캐시에서 매핑)
+ private Long postId;
+ private Long userId; // 작성자 ID
private String writerName;
private String writerEmail;
-
private String content;
- private boolean isAccepted; // QnA 채택 여부
+ private boolean isAccepted; // 채택 여부
+
+ @JsonIgnore // 계층 구조 생성용 (클라이언트 응답에는 제외 가능)
+ private Long parentId;
+
private LocalDateTime createdAt;
+ private LocalDateTime lastModifiedAt;
- // 대댓글 리스트 (계층형 구조)
- private List children;
+ @Builder.Default
+ private List children = new ArrayList<>();
- public static CommentResponse from(PostCommentEntity entity) {
- return CommentResponse.builder()
- .id(entity.getId())
- .userId(entity.getUserId())
- .content(entity.getContent())
- .isAccepted(entity.isAccepted())
- .createdAt(entity.getCreatedAt())
- .children(new ArrayList<>()) // 초기화
- .build();
+ // QueryDSL Projections.constructor 사용을 위한 생성자 (children 제외)
+ public CommentResponse(Long id, Long postId, Long userId, String writerName, String writerEmail,
+ String content, boolean isAccepted, Long parentId,
+ LocalDateTime createdAt, LocalDateTime lastModifiedAt) {
+ this.id = id;
+ this.postId = postId;
+ this.userId = userId;
+ this.writerName = writerName;
+ this.writerEmail = writerEmail;
+ this.content = content;
+ this.isAccepted = isAccepted;
+ this.parentId = parentId;
+ this.createdAt = createdAt;
+ this.lastModifiedAt = lastModifiedAt;
}
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java
new file mode 100644
index 0000000..8a4335f
--- /dev/null
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java
@@ -0,0 +1,27 @@
+package com.example.communityservice.dto.response;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+@Getter
+@NoArgsConstructor
+public class CustomPageResponse {
+ private List content;
+ private int pageNumber;
+ private int pageSize;
+ private long totalElements;
+ private int totalPages;
+ private boolean last;
+
+ public CustomPageResponse(Page page) {
+ this.content = page.getContent();
+ this.pageNumber = page.getNumber();
+ this.pageSize = page.getSize();
+ this.totalElements = page.getTotalElements();
+ this.totalPages = page.getTotalPages();
+ this.last = page.isLast();
+ }
+}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java
new file mode 100644
index 0000000..920a609
--- /dev/null
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java
@@ -0,0 +1,42 @@
+package com.example.communityservice.dto.response;
+
+import com.example.communityservice.entity.PostEntity;
+import com.example.communityservice.entity.enumerate.PostCategory;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+public class MyBookmarkPostResponse {
+ private Long id;
+ private String title;
+ private String content; // 요약본
+ private PostCategory category;
+ private LocalDateTime createdAt;
+ private String writerName; // 원 글 작성자 이름
+
+ // QueryDSL Projections.constructor 사용을 위한 생성자
+ public MyBookmarkPostResponse(Long id, String title, String content, PostCategory category, LocalDateTime createdAt, String writerName) {
+ this.id = id;
+ this.title = title;
+ this.content = content;
+ this.category = category;
+ this.createdAt = createdAt;
+ this.writerName = writerName;
+ }
+
+ public static MyBookmarkPostResponse from(PostEntity entity) {
+ return MyBookmarkPostResponse.builder()
+ .id(entity.getId())
+ .title(entity.getTitle())
+ .content(entity.getContent())
+ .category(entity.getCategory())
+ .createdAt(entity.getCreatedAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java
new file mode 100644
index 0000000..0e596f3
--- /dev/null
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java
@@ -0,0 +1,31 @@
+package com.example.communityservice.dto.response;
+
+import com.example.communityservice.entity.PostEntity;
+import com.example.communityservice.entity.enumerate.PostCategory;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Builder
+public class MyPostResponse { // 내가 쓴 글 반환 목적 DTO
+ private Long id;
+ private String title;
+ private String content;
+ private PostCategory category;
+ private LocalDateTime createdAt;
+ private LocalDateTime lastModifiedAt;
+
+ // Entity -> DTO 변환 메서드
+ public static MyPostResponse from(PostEntity entity) {
+ return MyPostResponse.builder()
+ .id(entity.getId())
+ .title(entity.getTitle())
+ .content(entity.getContent())
+ .category(entity.getCategory())
+ .createdAt(entity.getCreatedAt())
+ .lastModifiedAt(entity.getLastModifiedAt())
+ .build();
+ }
+}
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java
index 0e471c5..01c03ce 100644
--- a/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java
@@ -1,56 +1,55 @@
package com.example.communityservice.dto.response;
-import com.example.communityservice.entity.PostEntity;
import com.example.communityservice.entity.enumerate.PostCategory;
-import com.example.communityservice.entity.enumerate.RecruitmentStatus;
-import lombok.Builder;
-import lombok.Data;
+import lombok.*;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.List;
-@Data
+@Getter
+@Setter
@Builder
+@NoArgsConstructor
+@AllArgsConstructor
public class PostDetailResponse {
private Long id;
private Long userId;
-
- // 작성자 정보
private String writerName;
private String writerEmail;
-
private PostCategory category;
private String title;
private String content;
private Long viewCount;
private Long bookmarkCount;
-
- // 상태 값
- private Boolean isSolved;
- private RecruitmentStatus recruitmentStatus;
-
- // 현재 사용자의 북마크 여부
+ private Long commentCount;
+ private boolean isSolved;
private boolean isBookmarked;
-
- // 댓글 목록 (계층형)
- private List comments;
-
private LocalDateTime createdAt;
private LocalDateTime lastModifiedAt;
- public static PostDetailResponse from(PostEntity entity) {
- return PostDetailResponse.builder()
- .id(entity.getId())
- .userId(entity.getUserId())
- .category(entity.getCategory())
- .title(entity.getTitle())
- .content(entity.getContent())
- .viewCount(entity.getViewCount())
- .bookmarkCount(entity.getBookmarkCount())
- .isSolved(entity.isSolved())
- .recruitmentStatus(entity.getRecruitmentStatus())
- .createdAt(entity.getCreatedAt())
- .lastModifiedAt(entity.getLastModifiedAt())
- .build();
+ @Builder.Default
+ private List comments = new ArrayList<>();
+
+ // QueryDSL용 생성자 (comments 제외)
+ public PostDetailResponse(Long id, Long userId, String writerName, String writerEmail,
+ PostCategory category, String title, String content,
+ Long viewCount, Long bookmarkCount, Long commentCount,
+ boolean isSolved, boolean isBookmarked,
+ LocalDateTime createdAt, LocalDateTime lastModifiedAt) {
+ this.id = id;
+ this.userId = userId;
+ this.writerName = writerName;
+ this.writerEmail = writerEmail;
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ this.viewCount = viewCount;
+ this.bookmarkCount = bookmarkCount;
+ this.commentCount = commentCount;
+ this.isSolved = isSolved;
+ this.isBookmarked = isBookmarked;
+ this.createdAt = createdAt;
+ this.lastModifiedAt = lastModifiedAt;
}
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java
index 2d4858e..7f1c778 100644
--- a/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java
+++ b/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java
@@ -3,20 +3,21 @@
import com.example.communityservice.entity.PostEntity;
import com.example.communityservice.entity.enumerate.PostCategory;
import com.example.communityservice.entity.enumerate.RecruitmentStatus;
+import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
-import java.util.List;
-import java.util.stream.Collectors;
@Data
@Builder
+@NoArgsConstructor
public class PostResponse {
private Long id;
private Long userId;
- // 작성자 정보 (Service에서 채움)
+ // 작성자 정보
private String writerName;
private String writerEmail;
@@ -25,11 +26,34 @@ public class PostResponse {
private String content;
private Long viewCount;
private Long bookmarkCount;
+ private Long commentCount;
private Boolean isSolved;
private RecruitmentStatus recruitmentStatus;
private LocalDateTime createdAt;
private LocalDateTime lastModifiedAt;
+ // QueryDSL Projections.constructor 사용을 위한 생성자
+ public PostResponse(Long id, Long userId, String writerName, String writerEmail,
+ PostCategory category, String title, String content,
+ Long viewCount, Long bookmarkCount, Long commentCount, // [추가]
+ Boolean isSolved, RecruitmentStatus recruitmentStatus,
+ LocalDateTime createdAt, LocalDateTime lastModifiedAt) {
+ this.id = id;
+ this.userId = userId;
+ this.writerName = writerName;
+ this.writerEmail = writerEmail;
+ this.category = category;
+ this.title = title;
+ this.content = content;
+ this.viewCount = viewCount;
+ this.bookmarkCount = bookmarkCount;
+ this.commentCount = commentCount;
+ this.isSolved = isSolved;
+ this.recruitmentStatus = recruitmentStatus;
+ this.createdAt = createdAt;
+ this.lastModifiedAt = lastModifiedAt;
+ }
+
public static PostResponse from(PostEntity entity) {
return PostResponse.builder()
.id(entity.getId())
@@ -39,6 +63,7 @@ public static PostResponse from(PostEntity entity) {
.content(entity.getContent())
.viewCount(entity.getViewCount())
.bookmarkCount(entity.getBookmarkCount())
+ .commentCount(entity.getCommentCount())
.isSolved(entity.getCategory() == PostCategory.QNA ? entity.isSolved() : null)
.recruitmentStatus(entity.getCategory() == PostCategory.RECRUIT ? entity.getRecruitmentStatus() : null)
.createdAt(entity.getCreatedAt())
diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java
index 35fddd3..88ab792 100644
--- a/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java
+++ b/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java
@@ -7,9 +7,13 @@
import lombok.NoArgsConstructor;
@Entity
-@Table(name = "post_bookmark", uniqueConstraints = {
- @UniqueConstraint(name = "uk_post_user_bookmark", columnNames = {"post_id", "user_id"})
-})
+@Table(name = "post_bookmark",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_post_user_bookmark", columnNames = {"post_id", "user_id"})
+ }, indexes = {
+ @Index(name = "idx_bookmark_user_date", columnList = "user_id, created_at DESC") // 내 북마크 목록 조회용 (userId로 조회 + 최신순 정렬)
+ }
+)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostBookmarkEntity extends BaseEntity {
diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java
index 38daa80..d902b4c 100644
--- a/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java
+++ b/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java
@@ -12,7 +12,9 @@
import java.util.List;
@Entity
-@Table(name = "community_comment")
+@Table(name = "community_comment", indexes = {
+ @Index(name = "idx_comment_post", columnList = "post_id, created_at ASC") // 게시글별 댓글 목록 조회
+})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostCommentEntity extends BaseEntity {
diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java
index f75c198..6eddc32 100644
--- a/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java
+++ b/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java
@@ -14,7 +14,11 @@
import java.util.List;
@Entity
-@Table(name = "community_post")
+@Table(name = "community_post", indexes = {
+ @Index(name = "idx_post_category_view", columnList = "category, view_count DESC"), // 1. 카테고리별 조회수 정렬 (인기글)
+ @Index(name = "idx_post_category_date", columnList = "category, created_at DESC"), // 2. 카테고리별 최신순 정렬 (목록)
+ @Index(name = "idx_post_user_id", columnList = "user_id") // 3. 내가 쓴 글 조회
+})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostEntity extends BaseEntity {
@@ -32,8 +36,7 @@ public class PostEntity extends BaseEntity {
@Column(nullable = false)
private String title;
- @Lob
- @Column(nullable = false, columnDefinition = "LONGTEXT")
+ @Column(nullable = false, columnDefinition = "MEDIUMTEXT")
private String content;
@Column(nullable = false)
@@ -44,6 +47,10 @@ public class PostEntity extends BaseEntity {
@ColumnDefault("0")
private Long bookmarkCount = 0L;
+ @Column(nullable = false)
+ @ColumnDefault("0")
+ private Long commentCount = 0L;
+
@Column(nullable = false)
@ColumnDefault("false")
private boolean isSolved = false; // QNA 전용
@@ -71,20 +78,32 @@ public void update(String title, String content) {
this.content = content;
}
- public void increaseViewCount() {
- this.viewCount++;
+ // === 통계 데이터 증감 메서드 ===
+
+ // 조회수 증가 (스케줄러가 사용)
+ public void increaseViewCount(Long count) {
+ this.viewCount += count;
}
- // 북마크 증가
+ // 댓글 수 관리
+ public void increaseCommentCount() {
+ this.commentCount++;
+ }
+
+ public void decreaseCommentCount() {
+ this.commentCount = Math.max(0, this.commentCount - 1);
+ }
+
+ // 북마크 수 관리
public void increaseBookmarkCount() {
this.bookmarkCount++;
}
- // 북마크 감소
public void decreaseBookmarkCount() {
this.bookmarkCount = Math.max(0, this.bookmarkCount - 1);
}
+ // 상태 변경 메서드
public void markAsSolved() {
if (this.category == PostCategory.QNA) {
this.isSolved = true;
@@ -96,5 +115,4 @@ public void updateRecruitmentStatus(RecruitmentStatus status) {
this.recruitmentStatus = status;
}
}
-
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java
index e6738d8..d55ca3f 100644
--- a/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java
+++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java
@@ -4,6 +4,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -11,11 +12,26 @@ public interface PostRepository extends JpaRepository, PostRep
// 내가 쓴 게시글 조회
Page findAllByUserId(Long userId, Pageable pageable);
- // 내가 북마크한 게시글 조회 (북마크 엔티티와 조인)
+ // 내가 북마크한 게시글 조회
@Query("SELECT b.post FROM PostBookmarkEntity b WHERE b.userId = :userId ORDER BY b.createdAt DESC")
Page findBookmarkedPosts(@Param("userId") Long userId, Pageable pageable);
- // 내가 댓글 단 게시글 조회 (중복 제거)
+ // 내가 댓글 단 게시글 조회
@Query("SELECT DISTINCT c.post FROM PostCommentEntity c WHERE c.userId = :userId ORDER BY c.post.createdAt DESC")
Page findCommentedPosts(@Param("userId") Long userId, Pageable pageable);
+
+ // 조회수 Bulk Update (스케줄러용)
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE PostEntity p SET p.viewCount = p.viewCount + :count WHERE p.id = :id")
+ void incrementViewCount(@Param("id") Long id, @Param("count") Long count);
+
+ // 북마크 Bulk Update (스케줄러용)
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE PostEntity p SET p.bookmarkCount = p.bookmarkCount + :delta WHERE p.id = :id")
+ void updateBookmarkCount(@Param("id") Long id, @Param("delta") Long delta);
+
+ // 댓글 수 Bulk Update (스케줄러용)
+ @Modifying(clearAutomatically = true)
+ @Query("UPDATE PostEntity p SET p.commentCount = p.commentCount + :delta WHERE p.id = :id")
+ void updateCommentCount(@Param("id") Long id, @Param("delta") Long delta);
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java
index 329d279..43c5582 100644
--- a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java
+++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java
@@ -1,10 +1,24 @@
package com.example.communityservice.repository;
-import com.example.communityservice.dto.response.PostResponse;
+import com.example.communityservice.dto.response.*;
import com.example.communityservice.entity.enumerate.PostCategory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
+import java.util.List;
+import java.util.Optional;
+
public interface PostRepositoryCustom {
+ // 기존 메서드
Page searchPosts(PostCategory category, String keyword, Boolean isSolved, Pageable pageable);
-}
+
+ // 내가 쓴 글 조회 (경량 DTO)
+ Page findMyPosts(Long userId, PostCategory category, Pageable pageable);
+
+ // 내가 북마크한 글 조회 (경량 DTO + 카테고리 분류)
+ Page findMyBookmarkedPosts(Long userId, PostCategory category, Pageable pageable);
+
+ Optional findPostDetailById(Long postId, Long loginUserId);
+
+ List findCommentsByPostId(Long postId);
+}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java
index 2ed663b..a424c1d 100644
--- a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java
+++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java
@@ -1,10 +1,13 @@
package com.example.communityservice.repository;
-import com.example.communityservice.dto.response.PostResponse;
-import com.example.communityservice.entity.PostEntity;
-import com.example.communityservice.entity.QPostEntity;
+import com.example.communityservice.dto.response.*;
+import com.example.communityservice.entity.*;
import com.example.communityservice.entity.enumerate.PostCategory;
+import com.querydsl.core.types.ExpressionUtils;
+import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -13,6 +16,7 @@
import org.springframework.stereotype.Repository;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@@ -21,13 +25,33 @@ public class PostRepositoryImpl implements PostRepositoryCustom {
private final JPAQueryFactory queryFactory;
private final QPostEntity post = QPostEntity.postEntity;
+ private final QPostBookmarkEntity postBookmark = QPostBookmarkEntity.postBookmarkEntity;
+ private final QPostUserProfileEntity userProfile = QPostUserProfileEntity.postUserProfileEntity;
+ private final QPostCommentEntity postComment = QPostCommentEntity.postCommentEntity;
+
+ // 1. 게시글 목록 조회 (작성자 정보 Join 추가)
@Override
public Page searchPosts(PostCategory category, String keyword, Boolean isSolved, Pageable pageable) {
-
- // 1. 조건에 맞는 게시글 조회 (작성자 정보 조인 제외)
- List posts = queryFactory
- .selectFrom(post)
+ List content = queryFactory
+ .select(Projections.constructor(PostResponse.class,
+ post.id,
+ post.userId,
+ userProfile.name,
+ userProfile.email,
+ post.category,
+ post.title,
+ post.content,
+ post.viewCount,
+ post.bookmarkCount,
+ post.commentCount,
+ post.isSolved,
+ post.recruitmentStatus,
+ post.createdAt,
+ post.lastModifiedAt
+ ))
+ .from(post)
+ .leftJoin(userProfile).on(post.userId.eq(userProfile.userId))
.where(
categoryEq(category),
keywordContains(keyword),
@@ -38,7 +62,6 @@ public Page searchPosts(PostCategory category, String keyword, Boo
.orderBy(post.createdAt.desc())
.fetch();
- // 2. 전체 카운트 조회 (페이징 처리를 위해 필요)
Long total = queryFactory
.select(post.count())
.from(post)
@@ -51,15 +74,127 @@ public Page searchPosts(PostCategory category, String keyword, Boo
if (total == null) total = 0L;
- // 3. Entity -> DTO 변환
- // 작성자 정보(WriterName, WriterEmail)는 Service 계층의 mapWriterInfo()에서 채워지므로 여기서는 변환만 수행
- List responses = posts.stream()
- .map(PostResponse::from)
+ return new PageImpl<>(content, pageable, total);
+ }
+
+ // 게시글 상세 조회 (작성자 Join + 북마크 여부 확인)
+ @Override
+ public Optional findPostDetailById(Long postId, Long loginUserId) {
+ return Optional.ofNullable(queryFactory
+ .select(Projections.constructor(PostDetailResponse.class,
+ post.id,
+ post.userId,
+ userProfile.name,
+ userProfile.email,
+ post.category,
+ post.title,
+ post.content,
+ post.viewCount,
+ post.bookmarkCount,
+ post.commentCount,
+ post.isSolved,
+ ExpressionUtils.as(
+ new com.querydsl.core.types.dsl.CaseBuilder()
+ .when(isBookmarkedCondition(postId, loginUserId))
+ .then(true)
+ .otherwise(false),
+ "isBookmarked"
+ ),
+ post.createdAt,
+ post.lastModifiedAt
+ ))
+ .from(post)
+ .leftJoin(userProfile).on(post.userId.eq(userProfile.userId))
+ .where(post.id.eq(postId))
+ .fetchOne());
+ }
+
+ // 댓글 목록 조회 (작성자 Join)
+ @Override
+ public List findCommentsByPostId(Long postId) {
+ return queryFactory
+ .select(Projections.constructor(CommentResponse.class,
+ postComment.id,
+ postComment.post.id, // postId
+ postComment.userId,
+ userProfile.name, // writerName
+ userProfile.email, // writerEmail
+ postComment.content,
+ postComment.isAccepted,
+ postComment.parent.id, // parentId
+ postComment.createdAt,
+ postComment.lastModifiedAt
+ ))
+ .from(postComment)
+ .leftJoin(userProfile).on(postComment.userId.eq(userProfile.userId))
+ .where(postComment.post.id.eq(postId))
+ .orderBy(postComment.createdAt.asc()) // 작성순 정렬
+ .fetch();
+ }
+
+ @Override
+ public Page findMyPosts(Long userId, PostCategory category, Pageable pageable) {
+ List posts = queryFactory
+ .selectFrom(post)
+ .where(
+ post.userId.eq(userId),
+ categoryEq(category)
+ )
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .orderBy(post.createdAt.desc())
+ .fetch();
+
+ Long total = queryFactory
+ .select(post.count())
+ .from(post)
+ .where(post.userId.eq(userId), categoryEq(category))
+ .fetchOne();
+
+ if (total == null) total = 0L;
+
+ List responses = posts.stream()
+ .map(MyPostResponse::from)
.collect(Collectors.toList());
return new PageImpl<>(responses, pageable, total);
}
+ @Override
+ public Page findMyBookmarkedPosts(Long userId, PostCategory category, Pageable pageable) {
+ List content = queryFactory
+ .select(Projections.constructor(MyBookmarkPostResponse.class,
+ post.id,
+ post.title,
+ post.content,
+ post.category,
+ post.createdAt,
+ userProfile.name // writerName
+ ))
+ .from(post)
+ .join(postBookmark).on(post.id.eq(postBookmark.post.id))
+ .leftJoin(userProfile).on(post.userId.eq(userProfile.userId)) // 작성자 정보 조인 (Post.userId = UserProfile.userId)
+ .where(
+ postBookmark.userId.eq(userId),
+ categoryEq(category)
+ )
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .orderBy(postBookmark.createdAt.desc())
+ .fetch();
+
+ Long total = queryFactory
+ .select(post.count())
+ .from(post)
+ .join(postBookmark).on(post.id.eq(postBookmark.post.id))
+ .where(postBookmark.userId.eq(userId), categoryEq(category))
+ .fetchOne();
+
+ if (total == null) total = 0L;
+
+ return new PageImpl<>(content, pageable, total);
+ }
+
private BooleanExpression categoryEq(PostCategory category) {
return category != null ? post.category.eq(category) : null;
}
@@ -73,4 +208,16 @@ private BooleanExpression keywordContains(String keyword) {
private BooleanExpression isSolvedEq(Boolean isSolved) {
return isSolved != null ? post.isSolved.eq(isSolved) : null;
}
+
+ private BooleanExpression isBookmarkedCondition(Long postId, Long loginUserId) {
+ if (loginUserId == null) {
+ return Expressions.asBoolean(false).isTrue(); // 로그인 안 했으면 false
+ }
+ // SubQuery: select 1 from bookmark where post_id = ? and user_id = ?
+ return JPAExpressions.selectOne()
+ .from(postBookmark)
+ .where(postBookmark.post.id.eq(postId)
+ .and(postBookmark.userId.eq(loginUserId)))
+ .exists();
+ }
}
\ No newline at end of file
diff --git a/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java b/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java
new file mode 100644
index 0000000..6582a66
--- /dev/null
+++ b/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java
@@ -0,0 +1,87 @@
+package com.example.communityservice.scheduler;
+
+import com.example.communityservice.repository.PostRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class PostBatchScheduler {
+
+ private final PostRepository postRepository;
+ private final StringRedisTemplate redisTemplate;
+
+ // Keys
+ private static final String VIEW_BATCH_KEY = "post:views";
+ private static final String VIEW_SYNC_KEY = "post:views:sync";
+
+ private static final String BOOKMARK_BATCH_KEY = "post:bookmarks:delta";
+ private static final String BOOKMARK_SYNC_KEY = "post:bookmarks:sync";
+
+ private static final String COMMENT_BATCH_KEY = "post:comments:delta";
+ private static final String COMMENT_SYNC_KEY = "post:comments:sync";
+
+ @Scheduled(fixedRate = 600000, initialDelay = 30000) // 10분마다 실행 (Portfolio와 겹치지 않게 30초 딜레이)
+ public void syncCounts() {
+ log.info("[Scheduler] 커뮤니티 통계 데이터 DB 동기화 시작");
+
+ syncViewCounts();
+ syncBookmarkCounts();
+ syncCommentCounts();
+
+ log.info("[Scheduler] 동기화 작업 완료");
+ }
+
+ // 1. 조회수 동기화
+ private void syncViewCounts() {
+ processBatch(VIEW_BATCH_KEY, VIEW_SYNC_KEY, (postId, count) -> postRepository.incrementViewCount(postId, count));
+ }
+
+ // 2. 북마크 수 동기화
+ private void syncBookmarkCounts() {
+ processBatch(BOOKMARK_BATCH_KEY, BOOKMARK_SYNC_KEY, (postId, delta) -> {
+ if (delta != 0) postRepository.updateBookmarkCount(postId, delta);
+ });
+ }
+
+ // 3. 댓글 수 동기화
+ private void syncCommentCounts() {
+ processBatch(COMMENT_BATCH_KEY, COMMENT_SYNC_KEY, (postId, delta) -> {
+ if (delta != 0) postRepository.updateCommentCount(postId, delta);
+ });
+ }
+
+ // 공통 배치 처리 로직
+ private void processBatch(String batchKey, String syncKey, BatchUpdateAction action) {
+ if (Boolean.TRUE.equals(redisTemplate.hasKey(batchKey))) {
+ redisTemplate.rename(batchKey, syncKey);
+ Map