Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId) {
return savedBookJpaRepository.existsByUserIdAndBookId(userId, bookId);
}

@Override
public boolean existsBookByIsbn(String isbn) {
return bookJpaRepository.existsByIsbn(isbn);
}

@Override
public List<Book> findSavedBooksByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public interface BookJpaRepository extends JpaRepository<BookJpaEntity, Long> {
"AND r.startDate <= CURRENT_TIMESTAMP " + // 진행 중인 방만 조회 (모집 중 / 만료된 방 x)
"ORDER BY r.roomPercentage DESC") // 방의 진행률이 높은 순서로 정렬
List<BookJpaEntity> findJoiningRoomsBooksByUserId(Long userId);

boolean existsByIsbn(String isbn);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public interface BookQueryPort {

boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId);

boolean existsBookByIsbn(String isbn);

List<Book> findSavedBooksByUserId(Long userId);

List<Book> findJoiningRoomsBooksByUserId(Long userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse;
import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookQuery;
import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookSortType;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.common.swagger.annotation.ExceptionDescription;
Expand All @@ -26,6 +30,7 @@ public class FeedQueryController {
private final FeedShowUserInfoUseCase feedShowUserInfoUseCase;
private final FeedShowSingleUseCase feedShowSingleUseCase;
private final FeedShowWriteInfoUseCase feedShowWriteInfoUseCase;
private final FeedRelatedWithBookUseCase feedRelatedWithBookUseCase;

@Operation(
summary = "피드 전체 조회",
Expand Down Expand Up @@ -106,4 +111,27 @@ public BaseResponse<FeedShowSingleResponse> showSingleFeed(
public BaseResponse<FeedShowWriteInfoResponse> showFeedWriteInfo() {
return BaseResponse.ok(feedShowWriteInfoUseCase.showFeedWriteInfo());
}

@GetMapping("/feeds/related-books/{isbn}")
@Operation(
summary = "특정 책으로 작성된 피드 조회",
description = "책의 ISBN을 통해 해당 책과 관련된 피드를 조회합니다."
)
public BaseResponse<FeedRelatedWithBookResponse> showFeedsByBook(
@Parameter(description = "책의 ISBN 번호 (13자리 숫자)", example = "9781234567890")
@PathVariable("isbn") @Pattern(regexp = "\\d{13}", message = "ISBN은 13자리 숫자여야 합니다.") final String isbn,
@Parameter(description = "정렬 기준 (like: 좋아요순, latest: 최신순) / 기본 : 좋아요 순", example = "like")
@RequestParam(required = false, defaultValue = "like") final String sort,
@Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)")
@RequestParam(required = false) final String cursor,
@Parameter(hidden = true) @UserId final Long userId
) {
return BaseResponse.ok(feedRelatedWithBookUseCase.getFeedsByBook(FeedRelatedWithBookQuery.builder()
.isbn(isbn)
.sortType(FeedRelatedWithBookSortType.from(sort))
.cursor(cursor)
.userId(userId)
.build())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package konkuk.thip.feed.adapter.in.web.response;

import lombok.Builder;

import java.util.List;

@Builder
public record FeedRelatedWithBookResponse(
List<FeedRelatedWithBookDto> feeds,
String nextCursor,
boolean isLast
) {
public record FeedRelatedWithBookDto(
Long feedId,
Long creatorId,
boolean isWriter,
String creatorNickname,
String creatorProfileImageUrl,
String aliasName,
String aliasColor,
String postDate,
String isbn,
String bookTitle,
String bookAuthor,
String contentBody,
String[] contentUrls,
int likeCount,
int commentCount,
boolean isSaved,
boolean isLiked
) {}

public static FeedRelatedWithBookResponse of(List<FeedRelatedWithBookDto> feeds, String nextCursor, boolean isLast) {
return new FeedRelatedWithBookResponse(feeds, nextCursor, isLast);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ public CursorBasedList<FeedQueryDto> findSpecificUserFeedsByCreatedAt(Long feedO

@Override
public int countAllFeedsByUserId(Long userId) {
// int 로 강제 형변환 해도 괜찮죠??
return (int) feedJpaRepository.countAllFeedsByUserId(userId, StatusType.ACTIVE);
}

Expand Down Expand Up @@ -153,4 +152,32 @@ public Set<Long> findSavedFeedIdsByUserIdAndFeedIds(Set<Long> feedIds, Long user
public List<TagCategoryQueryDto> findAllTags() {
return feedJpaRepository.findAllTags();
}

@Override
public CursorBasedList<FeedQueryDto> findFeedsByBookIsbnOrderByLike(String isbn, Long userId, Cursor cursor) {
LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0);
Integer lastLikeCount = cursor.isFirstRequest() ? null : cursor.getInteger(1);
int size = cursor.getPageSize();

List<FeedQueryDto> feedQueryDtos = feedJpaRepository.findFeedsByBookIsbnOrderByLikeCount(isbn, userId, lastCreatedAt, lastLikeCount, size);

return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> {
Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString(),
feedQueryDto.likeCount().toString()));
return nextCursor.toEncodedString();
});
}

@Override
public CursorBasedList<FeedQueryDto> findFeedsByBookIsbnOrderByLatest(String isbn, Long userId, Cursor cursor) {
LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0);
int size = cursor.getPageSize();

List<FeedQueryDto> feedQueryDtos = feedJpaRepository.findFeedsByBookIsbnOrderByCreatedAt(isbn, userId, lastCreatedAt, size);

return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> {
Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString()));
return nextCursor.toEncodedString();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ public interface FeedQueryRepository {
List<FeedQueryDto> findSpecificUserFeedsByCreatedAt(Long feedOwnerId, LocalDateTime lastCreatedAt, int size);

List<TagCategoryQueryDto> findAllTags();

List<FeedQueryDto> findFeedsByBookIsbnOrderByLikeCount(String isbn, Long userId, LocalDateTime lastCreatedAt, Integer lastLikeCount, int size);

List<FeedQueryDto> findFeedsByBookIsbnOrderByCreatedAt(String isbn, Long userId, LocalDateTime lastCreatedAt, int size);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package konkuk.thip.feed.adapter.out.persistence.repository;

import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.*;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.annotation.Nullable;
import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity;
import konkuk.thip.common.entity.StatusType;
import konkuk.thip.feed.adapter.out.jpa.*;
import konkuk.thip.feed.application.port.out.dto.FeedQueryDto;
import konkuk.thip.feed.application.port.out.dto.QFeedQueryDto;
import konkuk.thip.feed.application.port.out.dto.QTagCategoryQueryDto;
import konkuk.thip.feed.application.port.out.dto.TagCategoryQueryDto;
import konkuk.thip.feed.application.port.out.dto.FeedQueryDto;
import konkuk.thip.room.adapter.out.jpa.QCategoryJpaEntity;
import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity;
import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity;
Expand Down Expand Up @@ -227,7 +227,6 @@ public List<TagCategoryQueryDto> findAllTags() {
.orderBy(c.categoryId.asc(), t.tagId.asc()) //Id 순 정렬
.fetch();
}

private List<Long> fetchMyFeedIdsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size) {
return jpaQueryFactory
.select(feed.postId)
Expand Down Expand Up @@ -283,4 +282,105 @@ private FeedQueryDto toDto(FeedJpaEntity e, Integer priority) {
.isPriorityFeed(isPriorityFeed)
.build();
}

/**
* 책 ISBN으로 피드를 조회하고, 좋아요 수 기준으로 정렬하여 페이징 처리
*/
@Override
public List<FeedQueryDto> findFeedsByBookIsbnOrderByLikeCount(
String isbn,
Long userId,
@Nullable LocalDateTime lastCreatedAt,
@Nullable Integer lastLikeCount,
int size
) {
BooleanExpression where = feedByBooksFilter(isbn, userId);
if (lastLikeCount != null && lastCreatedAt != null) {
// likeCount DESC → createdAt DESC
where = where.and(
feed.likeCount.lt(lastLikeCount)
.or(feed.likeCount.eq(lastLikeCount)
.and(feed.createdAt.lt(lastCreatedAt)))
);
Comment on lines +299 to +304

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3 : 좋아요 수가 동일할 경우, feedId가 아니라 createdAt 을 두번째 커서 기준으로 잡으신 이유가 있을까요?? 단순 궁금

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두번째 정렬 기준과 너무 다른 순서를 보여줄 필요가 없지 않나 라는 생각에 그냥 createdAt을 두번째 커서로 잡았던 것 같아요!

}

return jpaQueryFactory
.select(toQueryDto())
.from(feed)
.join(feed.userJpaEntity, user)
.join(feed.bookJpaEntity, book)
.where(where)
.orderBy(feed.likeCount.desc(), feed.createdAt.desc())
.limit(size + 1)
.fetch();
}

/**
* 책 ISBN으로 피드를 조회하고, 최신순 정렬하여 페이징 처리
*/
@Override
public List<FeedQueryDto> findFeedsByBookIsbnOrderByCreatedAt(
String isbn,
Long userId,
@Nullable LocalDateTime lastCreatedAt,
int size
) {
BooleanExpression where = feedByBooksFilter(isbn, userId);
if (lastCreatedAt != null) {
// createdAt DESC
where = where.and(
feed.createdAt.lt(lastCreatedAt)
);
}

return jpaQueryFactory
.select(toQueryDto())
.from(feed)
.join(feed.userJpaEntity, user)
.join(feed.bookJpaEntity, book)
.where(where)
.orderBy(feed.createdAt.desc())
.limit(size + 1)
.fetch();
}

private QFeedQueryDto toQueryDto() {
return new QFeedQueryDto(
feed.postId,
feed.userJpaEntity.userId,
user.nickname,
user.aliasForUserJpaEntity.imageUrl,
user.aliasForUserJpaEntity.value,
feed.createdAt,
book.isbn,
book.title,
book.authorName,
feed.content,
// 서브쿼리로 N:1 방지
JPAExpressions
.select(contentUrlAggExpr())
.from(content)
.where(content.postJpaEntity.postId.eq(feed.postId)),
Comment on lines +359 to +363

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 이렇게 n+1 문제를 해결하면서 QueryProjection을 사용할 수 있군요!! 좋습니다

feed.likeCount,
feed.commentCount,
feed.isPublic,
Expressions.nullExpression()
);
}

// contentUrl을 GROUP_CONCAT으로 묶어서 반환하는 표현식

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 좋습니다 굿굿👍🏻👍🏻

private StringExpression contentUrlAggExpr() {
return Expressions.stringTemplate(
"group_concat({0})",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반환할 url 순서를 보장할 수도 있다고 하는데, 이건 저희가 곧 contents 테이블을 제거할거니 고려하지 않아도 될 것 같네요!!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얍얍

content.contentUrl
);
}
Comment on lines +372 to +377

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

GROUP_CONCAT 사용 시 주의사항을 고려하세요.

GROUP_CONCAT은 MySQL의 group_concat_max_len 설정값(기본 1024바이트)을 초과하면 결과가 잘릴 수 있습니다. 피드당 콘텐츠 URL이 많을 경우 이 제한에 걸릴 수 있습니다.

다음 스크립트로 현재 MySQL 설정값과 실제 데이터를 확인해보세요:

필요시 다음과 같은 방법으로 개선할 수 있습니다:

  1. 애플리케이션 시작 시 group_concat_max_len 값 증가 설정
  2. 또는 콘텐츠가 많은 피드의 경우 별도 쿼리로 분리 처리

🏁 Script executed:

#!/bin/bash
# MySQL GROUP_CONCAT 설정 확인 및 콘텐츠 URL 길이 검증

# application.yml 또는 properties 파일에서 DB 설정 정보 확인
echo "=== 데이터베이스 설정 파일 확인 ==="
fd -e yml -e yaml -e properties | xargs rg -n "group_concat|GROUP_CONCAT" || echo "GROUP_CONCAT 관련 설정 없음"

# 테스트 코드에서 많은 콘텐츠를 가진 피드 테스트 케이스 확인
echo -e "\n=== 콘텐츠 URL 관련 테스트 확인 ==="
fd -e java -g "*Test*.java" | xargs rg -n "contentUrl|content.*add|addContent" -A 3 -B 3

Length of output: 52422


group_concat_max_len 기본값 설정 누락 확인됨

application.yml/properties에서 group_concat_max_len 설정이 없으며, MySQL 기본값(1024바이트) 초과 시 group_concat(content_url) 결과가 잘릴 수 있습니다. 테스트에서는 최대 2개의 URL만 검증했으나, 실제 피드당 URL 수가 많아지면 문제 발생 가능성이 있습니다.

• 대상 위치

  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java: contentUrlAggExpr (Lines 372–377)

• 해결 방안 제안

  1. JDBC URL에 sessionVariables=group_concat_max_len=4096(또는 적절한 값) 추가
  2. 애플리케이션 시작 시 SET SESSION group_concat_max_len=… 쿼리 실행
  3. MySQL 5.7+ 환경이라면 JSON_ARRAYAGG(content_url) 사용으로 대체
  4. 콘텐츠 URL이 많은 피드는 별도 서브쿼리로 분리하여 애플리케이션에서 조합

운영 DB 및 테스트 환경에서 최대 길이를 확인 후 적절히 설정을 적용해주세요.


// 필터링 조건: 책 ISBN과 사용자 ID를 제외한 다른 사용자 공개 피드
private BooleanExpression feedByBooksFilter(String isbn, Long userId) {
return feed.status.eq(StatusType.ACTIVE)
.and(feed.bookJpaEntity.isbn.eq(isbn))
.and(feed.userJpaEntity.userId.ne(userId))
.and(feed.isPublic.eq(true));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
)
public interface FeedQueryMapper {

/**
* 피드 전체 조회 응답 DTO 변환
*/
@Mapping(target = "aliasName", source = "dto.alias")
@Mapping(target = "aliasColor", expression = "java(Alias.from(dto.alias()).getColor())")
@Mapping(target = "isSaved", expression = "java(savedFeedIds.contains(dto.feedId()))")
Expand All @@ -39,6 +42,9 @@ FeedShowAllResponse.FeedDto toFeedShowAllResponse(
@Context Long userId
);

/**
* 내 피드 조회 응답 DTO 변환
*/
@Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))")
@Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter")
FeedShowMineResponse.FeedDto toFeedShowMineDto(FeedQueryDto dto, @Context Long userId);
Expand All @@ -59,6 +65,9 @@ FeedShowByUserResponse.FeedDto toFeedShowByUserResponse(
@Context Long userId
);

/**
* 특정 유저의 피드 조회 응답 DTO 변환
*/
@Mapping(target = "creatorId", source = "feedOwner.id")
@Mapping(target = "profileImageUrl", source = "feedOwner.alias.imageUrl")
@Mapping(target = "nickname", source = "feedOwner.nickname")
Expand All @@ -70,6 +79,9 @@ FeedShowByUserResponse.FeedDto toFeedShowByUserResponse(
@Mapping(target = "latestFollowerProfileImageUrls", source = "latestFollowerProfileImageUrls")
FeedShowUserInfoResponse toFeedShowUserInfoResponse(User feedOwner, int totalFeedCount, boolean isFollowing, List<String> latestFollowerProfileImageUrls);

/**
* 피드 상세 조회 응답 DTO 변환
*/
@Mapping(target = "feedId", source = "feed.id")
@Mapping(target = "creatorId", source = "feedCreator.id")
@Mapping(target = "creatorNickname", source = "feedCreator.nickname")
Expand Down Expand Up @@ -121,4 +133,43 @@ default List<TagsWithCategoryResult> toTagsWithCategoryResult(List<TagCategoryQu
default boolean isWriter(Long creatorId, @Context Long userId) {
return creatorId != null && creatorId.equals(userId);
}

/**
* 특정 책 관련 피드 조회 응답 DTO 변환
*/
@Mapping(target = "feedId", source = "dto.feedId")
@Mapping(target = "creatorId", source = "dto.creatorId")
@Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter")
@Mapping(target = "creatorNickname", source = "dto.creatorNickname")
@Mapping(target = "creatorProfileImageUrl", source = "dto.creatorProfileImageUrl")
@Mapping(target = "aliasName", expression = "java(Alias.from(dto.alias()).getValue())")
@Mapping(target = "aliasColor", expression = "java(Alias.from(dto.alias()).getColor())")
@Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))")
@Mapping(target = "isbn", source = "dto.isbn")
@Mapping(target = "bookTitle", source = "dto.bookTitle")
@Mapping(target = "bookAuthor", source = "dto.bookAuthor")
@Mapping(target = "contentBody", source = "dto.contentBody")
@Mapping(target = "contentUrls", source = "dto.contentUrls")
@Mapping(target = "likeCount", source = "dto.likeCount")
@Mapping(target = "commentCount", source = "dto.commentCount")
@Mapping(target = "isSaved", source = "isSaved")
@Mapping(target = "isLiked", source = "isLiked")
FeedRelatedWithBookResponse.FeedRelatedWithBookDto toFeedRelatedWithBookDto(
FeedQueryDto dto,
boolean isSaved,
boolean isLiked,
@Context Long userId
);

default List<FeedRelatedWithBookResponse.FeedRelatedWithBookDto> toFeedRelatedWithBookDtos(
List<FeedQueryDto> dtos,
Set<Long> savedFeedIds,
Set<Long> likedFeedIds,
@Context Long userId
) {
return dtos.stream()
.map(dto -> toFeedRelatedWithBookDto(dto, savedFeedIds.contains(dto.feedId()), likedFeedIds.contains(dto.feedId()), userId))
.collect(Collectors.toList());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package konkuk.thip.feed.application.port.in;

import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse;
import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookQuery;

public interface FeedRelatedWithBookUseCase {

FeedRelatedWithBookResponse getFeedsByBook(FeedRelatedWithBookQuery query);
}
Loading