💻 뚝딱뚝딱/북북클럽
[개발일지 #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
📌 주요 흐름
- lastFeedId가 없다면 최신 피드부터 조회 (최초 요청)
- 있다면 WHERE feed.id < lastFeedId 조건으로 조회
- 정렬은 ORDER BY feed.id DESC
- 결과가 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