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
7 changes: 7 additions & 0 deletions src/main/java/eatda/controller/story/StoryController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -25,4 +26,10 @@ public ResponseEntity<Void> registerStory(
storyService.registerStory(request, image, member.id());
return ResponseEntity.status(HttpStatus.CREATED).build();
}

@GetMapping("api/stories")
public ResponseEntity<StoriesResponse> getStories() {
return ResponseEntity.status(HttpStatus.OK)
.body(storyService.getPagedStoryPreviews());
}
}
18 changes: 5 additions & 13 deletions src/main/java/eatda/repository/story/StoryRepository.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
package eatda.repository.story;

import eatda.domain.story.Story;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import java.util.Optional;
import org.springframework.data.repository.Repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StoryRepository extends Repository<Story, Long> {
public interface StoryRepository extends JpaRepository<Story, Long> {

Story save(Story story);

Optional<Story> findById(Long id);

default Story getById(Long id) {
return findById(id)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.STORY_NOT_FOUND));
}
Page<Story> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
Comment on lines -9 to 11
Copy link
Member

Choose a reason for hiding this comment

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

[제안] 이건 제가 이전 PR에서 JpaRepository로 잘못 작성했었네요;; 그래서 다시 Repository를 상속하게 하면 좋을 것 같아요!

Copy link
Member Author

Choose a reason for hiding this comment

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

으악 이 부분은 제가 사용하면서도 의문이 들었던 지점인데요.
리포지토리 계층에서 도메인 예외를 던지는 게 맞나...? 라는 고민이 들었습니다.
서비스에서 orElseThrow로 처리하는 게 귀찮은 건 맞지만,
구조적으로 이게 올바른 방향인가? 라는 생각이 들어요.

특히 기본 메서드인 save, findById 등을 다시 선언하면서
Repository만 상속하는 방식이 실용적인가에 대한 의문도 들고요.

그래서 지금은 기능도 많지 않고 복잡하지 않으니
그냥 JpaRepository 상속 방식으로 가는 게 어떨까… 조심스럽게 의견 드려봅니다 🙏

Copy link
Member

Choose a reason for hiding this comment

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

서비스에서 orElseThrow로 처리하는 게 귀찮은 건 맞지만, 구조적으로 이게 올바른 방향인가? 라는 생각이 들어요.

저도 내용에 동의하는 바에요! Repository에서 서비스 관련 예외를 던진다는게 조금 애매하다고 생각합니다.

그냥 JpaRepository 상속 방식으로 가는 게 어떨까… 조심스럽게 의견 드려봅니다 🙏

저도 좋다고 생각합니다. 대신 제 몇가지 의견이 있어요

  1. 대신 findById().orElseThrow() 로직을 줄이기 위해서 서비스가 서비스를 참조하는 일은 없었으면 좋겠습니다. (생각보다 순환 참조가 종종 일어나더라구요...)
  2. 그렇게 되면, findById().orElseThrow() 가 여러 서비스 로직에서 중복적으로 일어날 거에요. 매번 "어 얘 없으면 던지는 에러 코드가 뭐였지?", "이거 에러 코드 바꿔야하는데 전부 어디에 쓰여 있지?" 등의 골치아픈 일이 일어날 수 있어요.

이번 PR에서는 넘어가고, 한 번 이런 주제들을 모아서 시간 잡아서 의견 나눠보죠!

Copy link
Member Author

@lvalentine6 lvalentine6 Jul 18, 2025

Choose a reason for hiding this comment

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

험... 그러게요

대신 findById().orElseThrow() 로직을 줄이기 위해서 서비스가 서비스를 참조하는 일은 없었으면 좋겠습니다. (생각보다 순환 참조가 종종 일어나더라구요...)

순환 참조 문제는 저도 공감합니다.
외부 API를 사용하지 않는 서비스라면, 다른 서비스를 직접 참조하는 건 최대한 피하는 방향으로 생각하고 있었어요.

그렇게 되면, findById().orElseThrow() 가 여러 서비스 로직에서 중복적으로 일어날 거에요. 매번 "어 얘 없으면 던지는 에러 코드가 뭐였지?", "이거 에러 코드 바꿔야하는데 전부 어디에 쓰여 있지?" 등의 골치아픈 일이 일어날 수 있어요.

흠 이거는 충분히 발생할수 있는일이긴 하네요...
뭔가 좀 절충점은 리포지토리마다 사이드카 느낌으로 헬퍼 클래스를 두는 방법도 있을것 같은데..
이건 또 너무 오버가 아닌가 싶기도 하고 어렵네요...

public class MemberQueryHelper {
    public Member getById(Long id) {
        return memberRepository.findById(id)
            .orElseThrow(...);
    }
}

21 changes: 21 additions & 0 deletions src/main/java/eatda/service/story/StoryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import eatda.client.map.StoreSearchResult;
import eatda.controller.story.FilteredSearchResult;
import eatda.controller.story.StoriesResponse;
import eatda.controller.story.StoryRegisterRequest;
import eatda.domain.member.Member;
import eatda.domain.story.Story;
Expand All @@ -14,13 +15,18 @@
import eatda.service.store.StoreService;
import java.util.List;
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;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
public class StoryService {
private static final int PAGE_START_NUMBER = 0;
private static final int PAGE_SIZE = 5;
Comment on lines +28 to +29
Copy link
Member

Choose a reason for hiding this comment

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

[선택] 현재는 FE 분들과 size는 5개 고정하시기로 이야기 된거죠?
조금 유연하게 만드려면 쿼리 파라미터로 size를 받는 것도 좋을 것 같아요!

Copy link
Member Author

@lvalentine6 lvalentine6 Jul 18, 2025

Choose a reason for hiding this comment

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

맞습니다.. 5개로 고정해버리기로 이야기 했는데...
사실 이 부분이 기획에 따라 하도 변동이 많을것 같아서 일단 고정해뒀습니다..
1차 이후에 요구사항에 맞춰서 수정해보도록 하겠습니다!


private final StoreService storeService;
private final ImageService imageService;
Expand Down Expand Up @@ -59,4 +65,19 @@ private FilteredSearchResult filteredSearchResponse(List<StoreSearchResult> resp
))
.orElseThrow(() -> new BusinessException(BusinessErrorCode.STORE_NOT_FOUND));
}

@Transactional(readOnly = true)
public StoriesResponse getPagedStoryPreviews() {
Pageable pageable = PageRequest.of(PAGE_START_NUMBER, PAGE_SIZE);
Page<Story> orderByPage = storyRepository.findAllByOrderByCreatedAtDesc(pageable);

return new StoriesResponse(
orderByPage.getContent().stream()
.map(story -> new StoriesResponse.StoryPreview(
story.getId(),
imageService.getPresignedUrl(story.getImageKey())
))
.toList()
);
}
}
5 changes: 5 additions & 0 deletions src/main/resources/db/seed/dev/V4__dev_add_story_data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
INSERT INTO story (member_id, store_kakao_id, store_name, store_address,
store_category, description, image_key)
VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', 'story/preview/1.jpg'),
(2, '99999999998', '아름다운 양식집', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', 'story/preview/2.jpg'),
(3, '99999999997', '정통 중식당', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', 'story/preview/3.jpg');
9 changes: 9 additions & 0 deletions src/main/resources/db/seed/local/V4__local_add_story_data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
INSERT INTO story (member_id, store_kakao_id, store_name, store_address,
store_category, description, image_key)
VALUES (1, '99999999999', '맛있는 한식집', '서울시 강남구 역삼동 123-45', 'KOREAN', '진짜 여기 곱창 맛집임. 다시 또 갈 듯!', 'story/preview/1.jpg'),
(2, '99999999998', '아름다운 양식집', '서울시 강남구 역삼동 67-89', 'WESTERN', '스테이크가 부드럽고 서비스도 좋아요.', 'story/preview/2.jpg'),
(3, '99999999997', '정통 중식당', '서울시 강남구 역삼동 101-112', 'CHINESE', '짜장면이 정통의 맛. 강력 추천.', 'story/preview/3.jpg'),
(4, '99999999996', '고급 양식 레스토랑', '서울시 강남구 역삼동 131-415', 'WESTERN', '분위기가 연인 데이트하기 좋아요.', 'story/preview/4.jpg'),
(5, '99999999995', '달콤한 디저트 카페', '서울시 강남구 역삼동 161-718', 'ETC', '케이크가 촉촉하고 맛있어요.', 'story/preview/5.jpg'),
(6, '99999999994', '아늑한 카페', '서울시 강남구 역삼동 192-021', 'ETC', '조용해서 공부하기 좋아요.', 'story/preview/6.jpg'),
(7, '99999999993', '빠른 패스트푸드점', '서울시 강남구 역삼동 222-324', 'ETC', '햄버거 나오는데 3분도 안 걸림. 굿.', 'story/preview/7.jpg');
Empty file.
24 changes: 24 additions & 0 deletions src/test/java/eatda/controller/story/StoryControllerTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package eatda.controller.story;

import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
Expand All @@ -9,6 +10,7 @@
import eatda.service.common.ImageDomain;
import io.restassured.response.Response;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -48,4 +50,26 @@ class SearchStores {
response.then().statusCode(201);
}
}

@Test
void 스토리_목록을_조회할_수_있다() {
StoriesResponse mockResponse = new StoriesResponse(List.of(
new StoriesResponse.StoryPreview(1L, "https://dummy-s3.com/story1.png"),
new StoriesResponse.StoryPreview(2L, "https://dummy-s3.com/story2.png")
));

doReturn(mockResponse)
.when(storyService)
.getPagedStoryPreviews();

Response response = given()
.when()
.get("/api/stories");

response.then()
.statusCode(200)
.body("stories.size()", equalTo(2))
.body("stories[0].storyId", equalTo(1))
.body("stories[0].imageUrl", equalTo("https://dummy-s3.com/story1.png"));
}
}
45 changes: 42 additions & 3 deletions src/test/java/eatda/document/story/StoryDocumentTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;

import eatda.controller.story.StoriesResponse;
import eatda.document.BaseDocumentTest;
import eatda.document.RestDocsRequest;
import eatda.document.RestDocsResponse;
Expand All @@ -16,6 +18,7 @@
import eatda.service.common.ImageDomain;
import io.restassured.response.Response;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
Expand Down Expand Up @@ -127,10 +130,46 @@ class RegisterStory {
.multiPart("image", "image.txt", invalidImage, "text/plain")
.when().post("/api/stories");

System.out.println("응답 상태코드 >>> " + response.statusCode());
System.out.println("응답 바디 >>> " + response.asString());

response.then().statusCode(BusinessErrorCode.INVALID_IMAGE_TYPE.getStatus().value());
}
}

@Nested
class GetStories {

RestDocsRequest requestDocument = request()
.tag(Tag.STORY_API)
.summary("스토리 목록 조회")
.description("스토리 목록을 페이지네이션하여 조회합니다.");

RestDocsResponse responseDocument = response()
.responseBodyField(
fieldWithPath("stories").description("스토리 프리뷰 리스트"),
fieldWithPath("stories[].storyId").description("스토리 ID"),
fieldWithPath("stories[].imageUrl").description("스토리 이미지 URL")
);

@Test
void 스토리_목록_조회_성공() {
StoriesResponse mockResponse = new StoriesResponse(List.of(
new StoriesResponse.StoryPreview(1L, "https://dummy-s3.com/story1.png"),
new StoriesResponse.StoryPreview(2L, "https://dummy-s3.com/story2.png")
));

doReturn(mockResponse)
.when(storyService)
.getPagedStoryPreviews();

RestDocumentationFilter document = document("story/get-stories", 200)
.request(requestDocument)
.response(responseDocument)
.build();

Response response = given(document)
.header(HttpHeaders.AUTHORIZATION, accessToken())
.when().get("/api/stories");

response.then().statusCode(200);
}
}
}
3 changes: 0 additions & 3 deletions src/test/java/eatda/service/BaseServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@
import eatda.repository.store.StoreRepository;
import eatda.service.common.ImageService;
import org.junit.jupiter.api.BeforeEach;
import eatda.repository.story.StoryRepository;
import eatda.service.common.ImageService;
import eatda.service.store.StoreService;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
Expand Down
60 changes: 60 additions & 0 deletions src/test/java/eatda/service/story/StoryServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package eatda.service.story;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.doReturn;
Expand All @@ -9,12 +10,16 @@
import eatda.client.map.StoreSearchResult;
import eatda.controller.story.StoryRegisterRequest;
import eatda.domain.member.Member;
import eatda.domain.story.Story;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import eatda.repository.story.StoryRepository;
import eatda.service.BaseServiceTest;
import eatda.service.common.ImageDomain;
import eatda.service.store.StoreService;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -24,6 +29,10 @@ public class StoryServiceTest extends BaseServiceTest {

@Autowired
private StoryService storyService;
@Autowired
private StoryRepository storyRepository;
@Autowired
private StoreService storeService;

@Nested
class RegisterStory {
Expand Down Expand Up @@ -58,4 +67,55 @@ class RegisterStory {
.hasMessageContaining(BusinessErrorCode.STORE_NOT_FOUND.getMessage());
}
}

@Nested
class GetPagedStoryPreviews extends BaseServiceTest {

private StoryService storyService;

@BeforeEach
void setUp() {
storyService = new StoryService(storeService, imageService, storyRepository, memberRepository);
}
Comment on lines +71 to +79
Copy link
Member

Choose a reason for hiding this comment

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

[제안] 제가 머지 작업 진행하면서 파일 위쪽을 바꿔 놓았는데, 머지하시기 전에 확인해주시면 좋을 것 같아요!


@Test
void 스토리_목록을_조회할_수_있다() {
Member member = memberGenerator.generate("12345");

Story story1 = Story.builder()
.member(member)
.storeKakaoId("1")
.storeName("곱창집")
.storeAddress("서울시")
.storeCategory("한식")
.description("미쳤다 진짜")
.imageKey("image-key-1")
.build();

Story story2 = Story.builder()
.member(member)
.storeKakaoId("2")
.storeName("순대국밥집")
.storeAddress("부산시")
.storeCategory("한식")
.description("뜨끈한 국밥 최고")
.imageKey("image-key-2")
.build();

storyRepository.saveAll(List.of(story1, story2));

when(imageService.getPresignedUrl("image-key-1")).thenReturn("https://s3.com/story1.jpg");
when(imageService.getPresignedUrl("image-key-2")).thenReturn("https://s3.com/story2.jpg");

var response = storyService.getPagedStoryPreviews();

assertThat(response.stories()).hasSize(2);
assertThat(response.stories())
.extracting("imageUrl")
.containsExactly(
"https://s3.com/story2.jpg",
"https://s3.com/story1.jpg"
);
}
}
}