💻 뚝딱뚝딱/북북클럽

[개발일지 #042] 리팩토링 - (4) Feed 도메인 (단위 테스트, Postman 테스트 포함)

뚜루리 2025. 6. 16. 15:23
728x90
320x100

🎯 오늘의 개발 내용 (요약)

  • FeedCommandService, FeedQueryService로 역할 분리 (커서기반 조회 적용)
  • FeedCommandController, FeedQueryController로 역할 분리

 


🛠️ 개발내용

 

Feed

/**
 * 피드(게시글) 엔티티
 */
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Feed {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long userId;

    /** 책 ID */
    @Column(nullable = false)
    private Long bookId;

    /** 피드 본문 내용 */
    @Column(nullable = false, length = 1000)
    private String content;

    /** 피드 상태 */
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private FeedStatus status = FeedStatus.ACTIVE;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    /**
     * 피드 내용 수정
     */
    public void updateContent(String content) {
        this.content = content;
    }

    /**
     * 피드 블라인드 처리
     */
    public void blind() {
        this.status = FeedStatus.BLINDED;
    }

    /**
     * 피드 삭제 처리 (논리삭제)
     */
    public void delete() {
        this.status = FeedStatus.DELETED;
    }

    /**
     * 피드 복구 처리
     * @return
     */
    public boolean isActive() {
        return this.status == FeedStatus.ACTIVE;
    }
    /**
     * 피드 생성 메서드
     */
    public static Feed create(Long userId, Long bookId, String content) {
        Feed feed = new Feed();
        feed.userId = userId;
        feed.bookId = bookId;
        feed.content = content;
        feed.status = FeedStatus.ACTIVE;
        return feed;
    }

}

 

 

FeedCommandService

  • 기존의 FeedService에 관련 메서드들을 다 넣었는데, 이번엔 FeedCommandService와 FeedQuerySerivce로 책임구분을 명확하게 나눠 리팩토링하였다.
/**
 * 피드 등록, 수정, 삭제 등 쓰기 작업을 담당하는 서비스
 */
@Service
@RequiredArgsConstructor
@Transactional
public class FeedCommandService {

    private final FeedRepository feedRepository;

    /**
     * 피드 생성
     */
    public Long createFeed(Long userId, Long bookId, String content) {
        Feed feed = Feed.create(userId, bookId, content);
        return feedRepository.save(feed).getId();
    }

    /**
     * 피드 내용 수정
     * */
    public void updateFeed(Long feedId, String content) {
        Feed feed = getFeedOrThrow(feedId);
        feed.updateContent(content);
    }

    /**
     * 피드 삭제 (논리 삭제)
     * */
    public void deleteFeed(Long feedId) {
        Feed feed = getFeedOrThrow(feedId);
        feed.delete();
    }

    /**
     * 피드 블라인드 처리
     * */
    public void blindFeed(Long feedId) {
        Feed feed = getFeedOrThrow(feedId);
        feed.blind();
    }

    /**
     * 피드 복구 처리
     * */
    public void activeFeed(Long feedId) {
        Feed feed = getFeedOrThrow(feedId);
        feed.isActive();
    }

    /**
     * 피드 ID로 조회 (없으면 예외)
     * */
    private Feed getFeedOrThrow(Long feedId) {
        return feedRepository.findByIdAndStatus(feedId, FeedStatus.ACTIVE)
                .orElseThrow(() -> new FeedException(FeedErrorCode.FEED_NOT_FOUND));
    }

}

 

FeedCommandServiceTest

package com.bookbookclub.bbc_post_service.feed.service;

import com.bookbookclub.bbc_post_service.feed.entity.Feed;
import com.bookbookclub.bbc_post_service.feed.exception.FeedErrorCode;
import com.bookbookclub.bbc_post_service.feed.exception.FeedException;
import com.bookbookclub.bbc_post_service.feed.repository.FeedRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
class FeedQueryServiceTest {

    @Mock
    private FeedRepository feedRepository;

    @InjectMocks
    private FeedQueryService feedQueryService;

    private Feed sampleFeed;

    @BeforeEach
    void setUp() {
        sampleFeed = Feed.create(1L, 100L, "테스트 내용");
    }

    @Test
    void 단건_피드_조회_성공() {
        Long feedId = 1L;
        given(feedRepository.findByIdAndIsBlindedFalse(feedId)).willReturn(Optional.of(sampleFeed));

        Feed result = feedQueryService.getVisibleFeedById(feedId);

        assertThat(result).isEqualTo(sampleFeed);
    }

    @Test
    void 단건_피드_조회_실패() {
        Long feedId = 999L;
        given(feedRepository.findByIdAndIsBlindedFalse(feedId)).willReturn(Optional.empty());

        assertThatThrownBy(() -> feedQueryService.getVisibleFeedById(feedId))
                .isInstanceOf(FeedException.class)
                .hasMessageContaining(FeedErrorCode.FEED_NOT_FOUND.getMessage());
    }

    @Test
    void 유저_피드_커서기반_조회() {
        Long userId = 1L;
        Long lastFeedId = null;
        int size = 3;

        given(feedRepository.findFeedsByUserIdAndCursorWithoutBlinded(lastFeedId, userId, size))
                .willReturn(List.of(sampleFeed));

        List<Feed> result = feedQueryService.getVisibleFeedsByUser(lastFeedId, userId, size);

        assertThat(result).containsExactly(sampleFeed);
    }

    @Test
    void 전체_피드_커서기반_조회() {
        Long lastFeedId = null;
        int size = 3;

        given(feedRepository.findAllVisibleFeedsByCursor(lastFeedId, size))
                .willReturn(List.of(sampleFeed));

        List<Feed> result = feedQueryService.getVisibleFeedsByCursor(lastFeedId, size);

        assertThat(result).containsExactly(sampleFeed);
    }
}

 

FeedQueryService

/**
 * 피드 단건 및 목록 조회를 담당하는 서비스 (블라인드 제외 + 커서 기반)
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FeedQueryService {

    private final FeedRepository feedRepository;

    /**
     * 단건 피드 조회 (블라인드 제외)
     */
    public Feed getVisibleFeedById(Long feedId) {
        return feedRepository.findByIdAndIsBlindedFalse(feedId)
                .orElseThrow(() -> new FeedException(FeedErrorCode.FEED_NOT_FOUND));
    }

    /**
     * 특정 유저의 피드 목록 조회 (블라인드 제외, 최신순 커서 기반)
     */
    public List<Feed> getVisibleFeedsByUser(Long lastFeedId, Long userId, int size) {
        return feedRepository.findFeedsByUserIdAndCursorWithoutBlinded(lastFeedId, userId, size);
    }

    /**
     * 전체 피드 목록 조회 (블라인드 제외, 최신순 커서 기반)
     */
    public List<Feed> getVisibleFeedsByCursor(Long lastFeedId, int size) {
        return feedRepository.findAllVisibleFeedsByCursor(lastFeedId, size);
    }
}

 

FeedQueryServiceTest

@ExtendWith(MockitoExtension.class)
class FeedQueryServiceTest {

    @Mock
    private FeedRepository feedRepository;

    @InjectMocks
    private FeedQueryService feedQueryService;

    private Feed sampleFeed;

    @BeforeEach
    void setUp() {
        sampleFeed = Feed.create(1L, 100L, "테스트 내용");
        ReflectionTestUtils.setField(sampleFeed, "id", 1L); // ID 직접 주입
    }

    @Test
    void 단건_피드_조회_성공() {
        // given
        Long feedId = 1L;
        given(feedRepository.findByIdAndStatus(feedId, FeedStatus.ACTIVE))
                .willReturn(Optional.of(sampleFeed));

        // when
        Feed result = feedQueryService.getVisibleFeedById(feedId);

        // then
        assertThat(result).isEqualTo(sampleFeed);
    }

    @Test
    void 단건_피드_조회_실패() {
        // given
        Long feedId = 999L;
        given(feedRepository.findByIdAndStatus(feedId, FeedStatus.ACTIVE))
                .willReturn(Optional.empty());

        // when & then
        assertThatThrownBy(() -> feedQueryService.getVisibleFeedById(feedId))
                .isInstanceOf(FeedException.class)
                .hasMessageContaining(FeedErrorCode.FEED_NOT_FOUND.getMessage());
    }

    @Test
    void 유저_피드_커서기반_조회() {
        // given
        Long userId = 1L;
        Long lastFeedId = null;
        int size = 3;

        given(feedRepository.findFeedsByUserIdAndCursorWithoutBlinded(lastFeedId, userId, size))
                .willReturn(List.of(sampleFeed));

        // when
        List<Feed> result = feedQueryService.getVisibleFeedsByUser(lastFeedId, userId, size);

        // then
        assertThat(result).containsExactly(sampleFeed);
    }

    @Test
    void 전체_피드_커서기반_조회() {
        // given
        Long lastFeedId = null;
        int size = 3;

        given(feedRepository.findAllVisibleFeedsByCursor(lastFeedId, size))
                .willReturn(List.of(sampleFeed));

        // when
        List<Feed> result = feedQueryService.getVisibleFeedsByCursor(lastFeedId, size);

        // then
        assertThat(result).containsExactly(sampleFeed);
    }
}

 

 

FeedQueryController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/feeds")
public class FeedQueryController {

    private final FeedQueryService feedQueryService;

    /**
     * 피드 단건 조회
     * */
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<FeedResponse>> getById(@PathVariable Long id) {
        Feed feed = feedQueryService.getVisibleFeedById(id);
        return ResponseEntity.ok(ApiResponse.success(FeedResponse.from(feed)));
    }

    /**
     * 전체 피드 목록 조회 (커서 기반)
     * */
    @GetMapping
    public ResponseEntity<ApiResponse<List<FeedResponse>>> getAll(
            @RequestParam(required = false) Long lastFeedId,
            @RequestParam(defaultValue = "10") int size
    ) {
        List<Feed> feeds = feedQueryService.getVisibleFeedsByCursor(lastFeedId, size);
        return ResponseEntity.ok(ApiResponse.success(feeds.stream().map(FeedResponse::from).toList()));
    }

    /**
     * 특정 유저의 피드 목록 조회 (커서 기반)
     * */
    @GetMapping("/users/{userId}")
    public ResponseEntity<ApiResponse<List<FeedResponse>>> getByUser(
            @PathVariable Long userId,
            @RequestParam(required = false) Long lastFeedId,
            @RequestParam(defaultValue = "10") int size
    ) {
        List<Feed> feeds = feedQueryService.getVisibleFeedsByUser(lastFeedId, userId, size);
        return ResponseEntity.ok(ApiResponse.success(feeds.stream().map(FeedResponse::from).toList()));
    }
}

 

 

 

📌 커서 기반 조회(Cursor-based Pagination)란?

  • 기존의 offset을 활용한 방식에서 벗어나 이번에 리팩토링하면서 커서 기반 조회 방식으로 변경해주었다.

 

🆚 기존 offset 기반 페이징과 비교

항목 offset 기반 커서 기반
정렬 기준 페이지 번호 (offset, limit) 특정 값 (id, createdAt 등)
성능 데이터가 많아질수록 느려짐 (슬로우 쿼리) 인덱스 기반 조회로 성능 우수
중복/누락 문제 실시간 삽입/삭제로 인한 페이지 밀림 발생 안정적 (기준 이후 데이터만 조회)
유저 경험 페이지 이동 가능 "더보기"에 적합 (연속 조회)

 

 

✅ 왜 커서 기반으로 바꿨는가?

  • 성능 개선: Feed가 많아질수록 offset 기반은 OFFSET n 시 쿼리 성능이 급격히 저하됨. 커서 기반은 WHERE id < ? 같은 인덱스 친화적인 조건으로 빠르게 조회 가능.
  • 더보기 UX: 모바일 앱이나 SNS 피드처럼 "스크롤 시 추가 조회" UI에 더 자연스럽고 안정적임.
  • 중복 방지: 새로운 피드 추가나 삭제가 일어나도, 커서 이후만 보기 때문에 중복/누락 방지됨.

 

⚙️ 구현방법

📌 요청 방식

GET /api/feeds?lastFeedId=123&pageSize=10
 

📌 주요 흐름

  1. lastFeedId가 없다면 최신 피드부터 조회 (최초 요청)
  2. 있다면 WHERE feed.id < lastFeedId 조건으로 조회
  3. 정렬은 ORDER BY feed.id DESC
  4. 결과가 pageSize보다 작으면 마지막 페이지로 간주

 

FeedRepositoryCustomImpl

**
 * 피드 QueryDSL 커스텀 레포지토리 구현체
 */
public class FeedRepositoryCustomImpl implements FeedRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public FeedRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    /**
     * 특정 유저의 피드 목록 (블라인드 제외, 최신순, 커서 기반)
     */
    @Override
    public List<Feed> findFeedsByUserIdAndCursorWithoutBlinded(Long lastFeedId, Long userId, int size) {
        QFeed feed = QFeed.feed;

        BooleanBuilder builder = new BooleanBuilder()
                .and(feed.userId.eq(userId))
                .and(feed.status.eq(FeedStatus.ACTIVE));

        if (lastFeedId != null) {
            builder.and(feed.id.lt(lastFeedId));
        }

        return queryFactory.selectFrom(feed)
                .where(builder)
                .orderBy(feed.id.desc())
                .limit(size)
                .fetch();
    }

    /**
     * 전체 피드 목록 (블라인드 제외, 최신순, 커서 기반)
     */
    @Override
    public List<Feed> findAllVisibleFeedsByCursor(Long lastFeedId, int size) {
        QFeed feed = QFeed.feed;

        BooleanBuilder builder = new BooleanBuilder()
                .and(feed.status.eq(FeedStatus.ACTIVE));

        if (lastFeedId != null) {
            builder.and(feed.id.lt(lastFeedId));
        }

        return queryFactory.selectFrom(feed)
                .where(builder)
                .orderBy(feed.id.desc())
                .limit(size)
                .fetch();
    }
}

 

 

 


 

 

 

1) 피드 단건 조회

 

 

2) 피드 전체 조회

 

 

3) 특정 사용자의 피드 조회

 

 

 

FeedCommandController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/feeds")
public class FeedCommandController {

    private final FeedCommandService feedCommandService;

    /**
     * 피드 생성
     * */
    @PostMapping
    public ResponseEntity<ApiResponse<Long>> create(@AuthenticationPrincipal CustomUserDetails user,
                                                    @RequestBody FeedCreateRequest request) {
        Long feedId = feedCommandService.createFeed(
                user.getUserId(),
                request.bookId(),
                request.content()
        );
        return ResponseEntity.ok(ApiResponse.success(feedId));
    }

    /**
     * 피드 수정
     * */
    @PatchMapping("/{id}")
    public ResponseEntity<ApiResponse<Void>> update(@PathVariable Long id, @RequestBody FeedUpdateRequest request) {
        feedCommandService.updateFeed(id, request.content());
        return ResponseEntity.ok(ApiResponse.success(null));
    }

    /**
     * 피드 삭제
     * */
    @DeleteMapping("/{id}")
    public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
        feedCommandService.deleteFeed(id);
        return ResponseEntity.ok(ApiResponse.success(null));
    }

    /**
     * 피드 블라인드 처리
     * */
    @PatchMapping("/{id}/blind")
    public ResponseEntity<ApiResponse<Void>> blind(@PathVariable Long id) {
        feedCommandService.blindFeed(id);
        return ResponseEntity.ok(ApiResponse.success(null));
    }
}

 

1) 피드 등록

 

 

2) 피드 수정

 

3) 피드 삭제

 

4) 피드 블라인드 처리

 

 

 

 

728x90
320x100