반응형

이번 시간에는 게시글 생성 API를 작성해보겠습니다.

기본적인 요구사항은,

게시글은 특정한 작성자와 카테고리가 지정되어야하고, 여러 개의 이미지도 함께 첨부할 수 있어야합니다.

 

 

지난 시간에 작성했던 Post, Image 엔티티를 이용하여 기능 구현을 해보도록 하겠습니다.

먼저 service.post.PostService를 작성해주겠습니다.

package kukekyakya.kukemarket.service.post;

import ...

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;
    private final CategoryRepository categoryRepository;
    private final FileService fileService;

    @Transactional
    public PostCreateResponse create(PostCreateRequest req) {
        Post post = postRepository.save(
                PostCreateRequest.toEntity(
                        req,
                        memberRepository,
                        categoryRepository
                )
        );
        uploadImages(post.getImages(), req.getImages());
        return new PostCreateResponse(post.getId());
    }

    private void uploadImages(List<Image> images, List<MultipartFile> fileImages) {
        IntStream.range(0, images.size()).forEach(i -> fileService.upload(fileImages.get(i), images.get(i).getUniqueName()));
    }
}

게시글을 생성하기위한 서비스 로직입니다.

전달받은 PostCreateRequest를 엔티티로 변환하고, 이미지가 있다면 FileServlice.upload를 통해 이미지 업로드를 수행합니다.

 

각각에 대해 세부적으로 살펴보겠습니다.

먼저 dto.post.PostCreateRequest 입니다.

package kukekyakya.kukemarket.dto.post;

import ...

@ApiModel(value = "게시글 생성 요청")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostCreateRequest {

    @ApiModelProperty(value = "게시글 제목", notes = "게시글 제목을 입력해주세요", required = true, example = "my title")
    @NotBlank(message = "게시글 제목을 입력해주세요.")
    private String title;

    @ApiModelProperty(value = "게시글 본문", notes = "게시글 본문을 입력해주세요", required = true, example = "my content")
    @NotBlank(message = "게시글 본문을 입력해주세요.")
    private String content;

    @ApiModelProperty(value = "가격", notes = "가격을 입력해주세요", required = true, example = "50000")
    @NotNull(message = "가격을 입력해주세요.")
    @PositiveOrZero(message = "0원 이상을 입력해주세요")
    private Long price;

    @ApiModelProperty(hidden = true)
    @Null
    private Long memberId;

    @ApiModelProperty(value = "카테고리 아이디", notes = "카테고리 아이디를 입력해주세요", required = true, example = "3")
    @NotNull(message = "카테고리 아이디를 입력해주세요.")
    @PositiveOrZero(message = "올바른 카테고리 아이디를 입력해주세요.")
    private Long categoryId;

    @ApiModelProperty(value = "이미지", notes = "이미지를 첨부해주세요.")
    private List<MultipartFile> images = new ArrayList<>();

    public static Post toEntity(PostCreateRequest req, MemberRepository memberRepository, CategoryRepository categoryRepository) {
        return new Post(
                req.title,
                req.content,
                req.price,
                memberRepository.findById(req.getMemberId()).orElseThrow(MemberNotFoundException::new),
                categoryRepository.findById(req.getCategoryId()).orElseThrow(CategoryNotFoundException::new),
                req.images.stream().map(i -> new Image(i.getOriginalFilename())).collect(toList())
        );
    }
}

검증 어노테이션에 대한 설명은 생략하겠습니다.

게시글에 대한 정보와 첨부하기 위한 이미지 목록을 MultipartFile로 가지고 있습니다.

스태틱 메소드 toEntity는, 전달받은 의존성을 이용하여 새로운 Post 인스턴스를 생성하여 반환합니다.

전달받은 이미지 파일의 원본 이름으로 images를 Image 리스트로 변환해주었습니다.

이미지가 없을 경우의 NullPointerException을 대비하여 images는 비어있는 리스트로 초기화해주었습니다.

 

주의할 점은, memberId는 @Null 제약 조건을 가지고 있다는 것입니다.

누군가가 작성자를 조작할 수 있으므로, 해당 데이터는 클라이언트에게 전달받지 않겠습니다.

대신, AOP를 이용하여 토큰에 저장된 사용자의 ID를 PostCreateRequest에 직접 주입해주도록 하겠습니다.

문서에 나타나지 않도록 hidden=true로 설정해주었습니다.

 

 

PostCreateRequest의 검증 어노테이션에 대한 테스트를 진행해보겠습니다.

test 디렉토리에서 동일한 패키지 경로 내에 PostCreateRequestValidationTest를 작성해줍니다.

package kukekyakya.kukemarket.dto.post;

import ...

class PostCreateRequestValidationTest {
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validateTest() {
        // given
        PostCreateRequest req = createPostCreateRequestWithMemberId(null);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isEmpty();
    }

    @Test
    void invalidateByEmptyTitleTest() {
        // given
        String invalidValue = null;
        PostCreateRequest req = createPostCreateRequestWithTitle(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankTitleTest() {
        // given
        String invalidValue = " ";
        PostCreateRequest req = createPostCreateRequestWithTitle(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByEmptyContentTest() {
        // given
        String invalidValue = null;
        PostCreateRequest req = createPostCreateRequestWithContent(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankContentTest() {
        // given
        String invalidValue = " ";
        PostCreateRequest req = createPostCreateRequestWithContent(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNullPriceTest() {
        // given
        Long invalidValue = null;
        PostCreateRequest req = createPostCreateRequestWithPrice(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNegativePriceTest() {
        // given
        Long invalidValue = -1L;
        PostCreateRequest req = createPostCreateRequestWithPrice(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNotNullMemberIdTest() {
        // given
        Long invalidValue = 1L;
        PostCreateRequest req = createPostCreateRequestWithMemberId(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNullCategoryIdTest() {
        // given
        Long invalidValue = null;
        PostCreateRequest req = createPostCreateRequestWithCategoryId(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByNegativeCategoryIdTest() {
        // given
        Long invalidValue = -1L;
        PostCreateRequest req = createPostCreateRequestWithCategoryId(invalidValue);

        // when
        Set<ConstraintViolation<PostCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }
}

자세한 설명은 생략하겠습니다. 각 제약 조건에 대해 테스트해주었습니다.

 

 

다음으로 dto.post.PostCreateResponse를 살펴보겠습니다.

package kukekyakya.kukemarket.dto.post;

import ...

@Data
@AllArgsConstructor
public class PostCreateResponse {
    private Long id;
}

단순히 생성된 게시글의 id 값을 응답해줄 것입니다.

 

 

다시 PostService로 돌아가서 uploadImages 메소드를 살펴보도록 하겠습니다.

// PostService.java

@Transactional
public PostCreateResponse create(PostCreateRequest req) {
    Post post = postRepository.save(
            PostCreateRequest.toEntity(
                    req,
                    memberRepository,
                    categoryRepository
            )
    );
    uploadImages(post.getImages(), req.getImages());
    return new PostCreateResponse(post.getId());
}

private void uploadImages(List<Image> images, List<MultipartFile> fileImages) {
    IntStream.range(0, images.size()).forEach(i -> fileService.upload(fileImages.get(i), images.get(i).getUniqueName()));
}

우리는 PostCreateReuqest.toEntity를 이용하여 Post 엔티티를 얻었습니다.

Post 엔티티는 Image 엔티티를 가지고 있고, Image 엔티티는 각 이미지의 고유한 이름(uniqueName)에 대해 생성하였습니다.

실제 이미지 파일을 가지고 있는 MultipartFile을, Image가 가지고 있는 uniqueName을 파일명으로 하여 파일 저장소에 업로드해줘야합니다.

Post가 가지고 있는 Image 리스트는, MultipartFile 리스트를 이용하여 생성하였기 때문에, 동일한 순서와 길이가 보장되어 있습니다.

따라서 이에 대해 uploadImages로 전달해주고, FileService.upload에 각각의 MultipartFile과 uniqueName을 인자로 보내주면서 파일 업로드를 수행하는 것입니다.

 

 

이제 service.file.FileService를 살펴보겠습니다.

package kukekyakya.kukemarket.service.file;

import ...

public interface FileService {
    void upload(MultipartFile file, String filename);
    void delete(String filename);
}

파일 서비스는 단순히, 파일 업로드와 삭제를 수행하기 위한 인터페이스입니다.

이에 대한 구현체를 작성하여 실제 파일 업로드 및 삭제 로직을 작성해줘야합니다.

 

우리는 지금 별다른 저장소를 가지고 있지 않습니다.

따라서 현 단계에서는, 업로드된 이미지를 로컬 파일시스템에 저장하도록 하겠습니다.

service.file.LocalFileService를 작성해줍니다.

package kukekyakya.kukemarket.service.file;

import ...

@Service
@Slf4j
public class LocalFileService implements FileService {

    @Value("${upload.image.location}")
    private String location; // 1

    @PostConstruct
    void postConstruct() { // 2
        File dir = new File(location);
        if (!dir.exists()) {
            dir.mkdir();
        }
    }

    @Override
    public void upload(MultipartFile file, String filename) { // 3
        try {
            file.transferTo(new File(location + filename));
        } catch(IOException e) {
            throw new FileUploadFailureException(e);
        }
    }

    @Override
    public void delete(String filename) {

    }
}

1. 파일을 업로드할 위치를 주입 받습니다.

2. 파일을 업로드할 디렉토리를 생성해줍니다.

3. MultipartFile을 실제 파일로 지정된 위치에 저장해줍니다. 예외가 발생하면, FileUploadFailureException에 감싸서 던져줍니다.

지금은 게시글 생성 기능을 작성하고있으므로, delete 구현은 나중으로 미루도록 하겠습니다.

 

이렇게 작성된 LocalFileService를 테스트해봅시다.

test 디렉토리에서 동일한 패키지 경로 내에 LocalFileServiceTest를 작성해줍니다.

package kukekyakya.kukemarket.service.file;

import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;

import ...

class LocalFileServiceTest {
    LocalFileService localFileService = new LocalFileService();
    String testLocation = new File("src/test/resources/static").getAbsolutePath() + "/"; // 1

    @BeforeEach
    void beforeEach() throws IOException { // 2
        ReflectionTestUtils.setField(localFileService, "location", testLocation);
        FileUtils.cleanDirectory(new File(testLocation));
    }

    @Test
    void uploadTest() { // 3
        // given
        MultipartFile file = new MockMultipartFile("myFile", "myFile.txt", MediaType.TEXT_PLAIN_VALUE, "test".getBytes());
        String filename = "testFile.txt";

        // when
        localFileService.upload(file, filename);

        // then
        assertThat(isExists(testLocation + filename)).isTrue();
    }

    boolean isExists(String filePath) {
        return new File(filePath).exists();
    }
}

1. 테스트 단계에서 파일 업로드 성공 여부를 확인할 수 있도록, 테스트 전용 location을 지정해주었습니다. test디렉토리에 resources/static 디렉토리를 생성하고, 해당 디렉토리를 파일 업로드 테스트에 이용하겠습니다.

2. ReflectionTestUtils를 이용하여 LocalFileService에 location을 주입해주고, 각 테스트는 독립적으로 수행될 수 있도록 테스트 시작 전에 testLocation의 모든 파일을 제거해주겠습니다.

3. 파일 업로드를 테스트하기 위해 MockMultipartFile을 이용하였습니다. 파일 명, 미디어타입, 바이트 배열을 이용하여 MockMultipartFile을 생성하고, 실제로 업로드 되었는지 테스트합니다.

 

.gitignore에

# .gitignore
/src/test/resources/static/*

테스트 파일들을 추적하지 않도록 설정해줍시다.

 

 

FileUtils의 사용법과 동작 여부를 확인하기 위해 간단한 학습테스트도 진행하였습니다.

learning 패키지에 FileUtilsTest를 작성해줍니다.

package learning;

import org.apache.tomcat.util.http.fileupload.FileUtils;
import ...

public class FileUtilsTest {

    String testLocation = new File("src/test/resources/static").getAbsolutePath() + "/";

    @Test
    void cleanDirectoryTest() throws Exception{
        // given
        String filePath = testLocation + "cleanDirectoryTest.txt";
        MultipartFile file = new MockMultipartFile("myFile", "myFile.txt", MediaType.TEXT_PLAIN_VALUE, "test".getBytes());
        file.transferTo(new File(filePath));
        boolean beforeCleaning = isExists(filePath);

        // when
        FileUtils.cleanDirectory(new File(testLocation));

        // then
        boolean afterCleaning = isExists(filePath);
        assertThat(beforeCleaning).isTrue();
        assertThat(afterCleaning).isFalse();
    }

    boolean isExists(String filePath) {
        return new File(filePath).exists();
    }
}

자세한 설명은 생략하겠습니다.

생성된 파일이 FileUtils.cleanDirectory에 의해서 제거되는지 테스트하였습니다.

 

 

이제 LocalFileService와 PostService에서 지정해줬던 @Value들을 주입하기 위해 application-local.yml을 확인해보겠습니다.

application-<profile>.yml을 작성하면, 해당 profile이 활성화되었을 때만 설정 값들을 주입받을 수 있게 됩니다.

# application-local.yml
upload:
  image:
    location: C:/Users/gmlwo/Desktop/kukemarket/image/

실제 파일이 저장되는 경로인 location을 지정해주었습니다.

운영 환경에 따라 location은 달라질 수 있으므로, local profile이 활성화되었을 때만 해당 값을 주입받도록 하겠습니다.

 

통합 테스트를 진행할 때, 테스트 환경에서도 이러한 값들을 주입받을 수 있도록, application-test.yml을 작성해줍시다.

# application-test.yml

upload:
  image:
    location: test-location

단순히 임의의 값들을 지정해주었습니다.

우리는 통합테스트를 진행할 때 test profile을 활성화시키고 있으므로, @Value에 해당 값들을 주입받을 수 있을 것입니다.

테스트를 위한 설정 파일이 생겼으니, KukemarketApplicationTests를 수행할 때도 test profile이 활성화되도록 설정해두겠습니다.

package kukekyakya.kukemarket;

import ...

@SpringBootTest
@ActiveProfiles("test")
class KukemarketApplicationTests {
    ...

 

 

application.yml에 multipart로 업로드될 수 있는 크기도 설정해주겠습니다.

spring:
  ..
  servlet.multipart.max-file-size: 5MB
  servlet.multipart.max-request-size: 5MB

임의로 5MB로 지정해주었습니다.

 

 

application-local.yml에 작성된 설정 값에 따라, 파일을 업로드 및 접근할 수 있도록 config.WebConfig를 수정해주겠습니다.

package kukekyakya.kukemarket.config;

import ...

@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${upload.image.location}")
    private String location;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/image/**") // 1
                .addResourceLocations("file:" + location) // 2
                .setCacheControl(CacheControl.maxAge(Duration.ofHours(1L)).cachePublic()); // 3
    }
}

1. url에 /image/ 접두 경로가 설정되어있으면,

2. 파일 시스템의 location 경로에서 파일에 접근합니다.

3. 업로드된 각각의 이미지는 고유한 이름을 가지고 있으며 수정되지 않을 것이기 때문에, 캐시를 설정해주었습니다. 자원에 접근할 때마다 새롭게 자원을 내려받지 않고, 캐시된 자원을 이용할 것입니다. 1시간이 지나면 캐시는 만료되고, 다시 요청하게 될 것입니다.

 

 

이제 PostService를 테스트해보겠습니다.

test 디렉토리에서 동일한 패키지 경로 내에 PostServiceTest를 작성해줍니다.

package kukekyakya.kukemarket.service.post;

import ...

@ExtendWith(MockitoExtension.class)
class PostServiceTest {
    @InjectMocks PostService postService;
    @Mock PostRepository postRepository;
    @Mock MemberRepository memberRepository;
    @Mock CategoryRepository categoryRepository;
    @Mock FileService fileService;

    @Test
    void createTest() {
        // given
        PostCreateRequest req = createPostCreateRequest();
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(categoryRepository.findById(anyLong())).willReturn(Optional.of(createCategory()));
        given(postRepository.save(any())).willReturn(createPostWithImages(
                IntStream.range(0, req.getImages().size()).mapToObj(i -> createImage()).collect(toList()))
        );

        // when
        postService.create(req);

        // then
        verify(postRepository).save(any());
        verify(fileService, times(req.getImages().size())).upload(any(), anyString());
    }

    @Test
    void createExceptionByMemberNotFoundTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));

        // when, then
        assertThatThrownBy(() -> postService.create(createPostCreateRequest())).isInstanceOf(MemberNotFoundException.class);
    }

    @Test
    void createExceptionByCategoryNotFoundTest() {
        // given
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(categoryRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));

        // when, then
        assertThatThrownBy(() -> postService.create(createPostCreateRequest())).isInstanceOf(CategoryNotFoundException.class);
    }

    @Test
    void createExceptionByUnsupportedImageFormatExceptionTest() {
        // given
        PostCreateRequest req = createPostCreateRequestWithImages(
                List.of(new MockMultipartFile("test", "test.txt", MediaType.TEXT_PLAIN_VALUE, "test".getBytes()))
        );
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
        given(categoryRepository.findById(anyLong())).willReturn(Optional.of(createCategory()));

        // when, then
        assertThatThrownBy(() -> postService.create(req)).isInstanceOf(UnsupportedImageFormatException.class);
    }
}

이제 익숙해진 테스트이므로, 자세한 설명은 생략하겠습니다.

 

 

테스트에 사용되는 PostFactory와 PostCreateRequestFactory는 다음과 같습니다.

package kukekyakya.kukemarket.factory.entity;

import ...

public class PostFactory {
    public static Post createPost() {
        return createPost(createMember(), createCategory());
    }

    public static Post createPost(Member member, Category category) {
        return new Post("title", "content", 1000L, member, category, List.of());
    }

    public static Post createPostWithImages(Member member, Category category, List<Image> images) {
        return new Post("title", "content", 1000L, member, category, images);
    }

    public static Post createPostWithImages(List<Image> images) {
        return new Post("title", "content", 1000L, createMember(), createCategory(), images);
    }
}
package kukekyakya.kukemarket.factory.dto;

import ...

public class PostCreateRequestFactory {
    public static PostCreateRequest createPostCreateRequest() {
        return new PostCreateRequest("title", "content", 1000L, 1L, 1L, List.of(
                new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE, "test1".getBytes()),
                new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE, "test2".getBytes()),
                new MockMultipartFile("test3", "test3.PNG", MediaType.IMAGE_PNG_VALUE, "test3".getBytes())
        ));
    }

    public static PostCreateRequest createPostCreateRequest(String title, String content, Long price, Long memberId, Long categoryId, List<MultipartFile> images) {
        return new PostCreateRequest(title, content, price, memberId, categoryId, images);
    }

    public static PostCreateRequest createPostCreateRequestWithTitle(String title) {
        return new PostCreateRequest(title, "content", 1000L, 1L, 1L, List.of());
    }

    public static PostCreateRequest createPostCreateRequestWithContent(String content) {
        return new PostCreateRequest("title", content, 1000L, 1L, 1L, List.of());
    }

    public static PostCreateRequest createPostCreateRequestWithPrice(Long price) {
        return new PostCreateRequest("title", "content", price, 1L, 1L, List.of());
    }

    public static PostCreateRequest createPostCreateRequestWithMemberId(Long memberId) {
        return new PostCreateRequest("title", "content", 1000L, memberId, 1L, List.of());
    }

    public static PostCreateRequest createPostCreateRequestWithCategoryId(Long categoryId) {
        return new PostCreateRequest("title", "content", 1000L, 1L, categoryId, List.of());
    }

    public static PostCreateRequest createPostCreateRequestWithImages(List<MultipartFile> images) {
        return new PostCreateRequest("title", "content", 1000L, 1L, 1L, images);
    }

}

자세한 설명은 생략하겠습니다.

 

 

이제 컨트롤러를 통해 게시글 생성 API를 작성해봅시다.

controller.post.PostController를 작성해줍니다.

package kukekyakya.kukemarket.controller.post;

import ...

@Api(value = "Post Controller", tags = "Post")
@RestController
@RequiredArgsConstructor
@Slf4j
public class PostController {
    private final PostService postService;

    @ApiOperation(value = "게시글 생성", notes = "게시글을 생성한다.")
    @PostMapping("/api/posts")
    @ResponseStatus(HttpStatus.CREATED)
    public Response create(@Valid @ModelAttribute PostCreateRequest req) {
        return Response.success(postService.create(req));
    }
}

게시글 데이터를 이미지와 함께 전달받을 수 있도록, 요청하는 Content-Type이 multipart/form-data를 이용해야합니다.

따라서 PostCreateRequest 파라미터에 @ModelAttribute를 선언해줍니다.

 

@ModelAttribute에 대해 validation 제약 조건이 위배되면, BindException 예외가 발생하게 됩니다.

기존에 다른 API에 작성했던 @RequestBody에서는, MethodArgumentNotValidException 예외가 발생하고 있었습니다.

BindException은 MethodArgumentNotValidException의 상위 클래스입니다.

두 예외는 유사한 상황에 발생하므로, ExceptionAdvice에서 MethodArgumentNotValidException 예외를 잡아내던 것을, BindException 예외를 잡아내도록 수정하여, 두 예외를 모두 잡아낼 수 있도록 하겠습니다.

// ExceptionAdvice.java

@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Response bindException(BindException e) {
    return Response.failure(-1003, e.getBindingResult().getFieldError().getDefaultMessage());
}

 

 

새롭게 추가된 FileUploadFailureException 예외도 지정해주겠습니다.

// ExceptionAdvice.java
@ExceptionHandler(FileUploadFailureException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Response fileUploadFailureException(FileUploadFailureException e) {
    log.info("e = {}", e.getMessage());
    return Response.failure(-1014, "파일 업로드에 실패하였습니다.");
}

파일 업로드에 실패하였을 경우에는, 그 원인을 분석하기 위해 간단한 로그 메시지를 남겨주도록 하겠습니다.

 

해당 예외는 exception 패키지에서 다음과 같습니다.

package kukekyakya.kukemarket.exception;

public class FileUploadFailureException extends RuntimeException {
    public FileUploadFailureException(Throwable cause) {
        super(cause);
    }
}

원인이 되는 예외를 생성자로 전달받습니다.

 

 

새로운 API가 추가되었으니, config.security.SecurityConfig에 해당 API에 대한 접근 제어를 지정해주겠습니다.

// SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            ...
                .authorizeRequests()
                    .antMatchers(HttpMethod.GET, "/image/**").permitAll()
                    ...
                    .antMatchers(HttpMethod.POST, "/api/posts").authenticated()
                    ...
            ...

이미지 파일에는 누구든 접근할 수 있도록 하고, 게시글 생성을 위한 요청은 인증된 사용자만 보낼 수 있도록 하였습니다.

 

 

이제 컨트롤러에서 전달받은 PostCreateRequest에, 요청자의 memberId를 주입할 수 있도록 하겠습니다.

aop를 이용하면 기존의 코드를 건드리지 않고도, 새로운 부가 기능을 추가할 수 있게 됩니다.

즉, 컨트롤러의 코드 수정 없이, 요청이 바인딩된 컨트롤러 파라미터에 memberId를 주입할 수 있게 되는 것입니다.

 

우리는 memberId 주입이 필요한 요청 메소드에 어노테이션을 선언하여, 지정된 요청 파라미터에 memberId 주입이라는 기능을 부여하도록 하겠습니다. 

aop 패키지에 AssignMemberId 어노테이션을 작성해줍니다.

package kukekyakya.kukemarket.aop;

import ...

@Retention(value = RetentionPolicy.RUNTIME)
@Target(value = ElementType.METHOD)
public @interface AssignMemberId {
}

런타임에 어노테이션이 유지될 수 있도록하고, 메소드 타입에 선언할 수 있도록 하였습니다.

 

 

이제 Aspect를 작성해주겠습니다. Aspect는 부가 기능에 대한 하나의 단위라고 보시면 됩니다.

부가 기능과 부가 기능이 수행되는 시점을 지정해야합니다.

package kukekyakya.kukemarket.aop;

import ...

@Aspect // 1
@Component // 2
@RequiredArgsConstructor
@Slf4j
public class AssignMemberIdAspect {

    private final AuthHelper authHelper;

    @Before("@annotation(kukekyakya.kukemarket.aop.AssignMemberId)") // 3
    public void assignMemberId(JoinPoint joinPoint) { // 4
        Arrays.stream(joinPoint.getArgs()) // 5
                .forEach(arg -> getMethod(arg.getClass(), "setMemberId")
                        .ifPresent(setMemberId -> invokeMethod(arg, setMemberId, authHelper.extractMemberId())));
    }

    private Optional<Method> getMethod(Class<?> clazz, String methodName) { // 6
        try {
            return Optional.of(clazz.getMethod(methodName, Long.class));
        } catch (NoSuchMethodException e) {
            return Optional.empty();
        }
    }

    private void invokeMethod(Object obj, Method method, Object... args) { // 7
        try {
            method.invoke(obj, args);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }
}

1. Aspect임을 지정하고,

2. 스프링 컨테이너에 등록해줍니다.

3. 부가 기능이 수행되는 지점을 지정합니다. @Before를 이용하여 메소드 호출 전에 수행되도록 하였고, @AssignMemberId 어노테이션이 적용된 메소드들은, 본래의 메소드 수행 직전에 assignMemberId 메소드가 호출될 것입니다. 

4. 메소드 호출 전에 해당 메소드가 수행됩니다. 파라미터로 전달받은 JoinPoint를 이용하여 호출되어야할 본래의 메소드에 대한 정보를 가져올 수 있습니다.

5. JoinPoint.getArgs()를 이용하여 전달되는 인자들을 확인하고, setMemberId 메소드가 정의된 타입이 있다면, memberId를 주입해줍니다.

6~7. 리플렉션 API에서 발생하는 checked exception에 대한 처리를 도와줍니다. 

 

 

이제 PostController에 @AssignMemberId를 지정해줍시다.

// PostController.java
@ApiOperation(value = "게시글 생성", notes = "게시글을 생성한다.")
@PostMapping("/api/posts")
@ResponseStatus(HttpStatus.CREATED)
@AssignMemberId
public Response create(@Valid @ModelAttribute PostCreateRequest req) {
    return Response.success(postService.create(req));
}

메소드 레벨에 어노테이션을 지정해주었습니다.

 

 

이렇게 해서 서버는, 인증된 사용자의 정보를 통해서 게시글의 작성자를 직접 지정해줄 수 있게 되었습니다.

만약 새로운 요청 DTO 클래스에도 memberId를 주입해야한다면, 기존의 코드를 수정할 필요 없이, 단순히 어노테이션을 지정해주기만 하면 됩니다.

이러한 작업이 번거롭다면, 부가 기능이 수행되는 대상이 어노테이션이 아니라 표현식을 이용하여 세밀하게 지정(패키지 명, 메소드 명 등)할 수도 있겠습니다.

 

* 데이터 바인딩이나 validation 작업이 일어나기 전인, 인터셉터나 필터 레벨에서 memberId 파라미터를 직접 지정해줄 수도 있었습니다.

하지만 @ModelAttribute로 바인딩되는 form-data같은 경우 memberId를 지정하는게 까다롭지 않으나, @RequestBody로 바인딩되는 json과 같은 요청 바디의 경우 새로운 값을 삽입하는 것이 까다로웠습니다.

물론, 게시글 생성은 @ModelAttribute로 바인딩되긴 하지만, 다른 형태의 바인딩에도 수월하게 적용할 수 있도록, aop를 이용하여 주입해주는 방식을 택하게 되었습니다.

 

 

이제 작성된 API에 대하여 테스트해주겠습니다.

test 디렉토리에서 PostControllerTest를 작성해줍니다.

package kukekyakya.kukemarket.controller.post;

import kukekyakya.kukemarket.dto.post.PostCreateRequest;
import kukekyakya.kukemarket.service.post.PostService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

import static kukekyakya.kukemarket.factory.dto.PostCreateRequestFactory.createPostCreateRequestWithImages;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class)
class PostControllerTest {
    @InjectMocks PostController postController;
    @Mock PostService postService;
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(postController).build();
    }

    @Test
    void createTest() throws Exception{
        // given
        // 1
        ArgumentCaptor<PostCreateRequest> postCreateRequestArgumentCaptor = ArgumentCaptor.forClass(PostCreateRequest.class);

        List<MultipartFile> imageFiles = List.of(
                new MockMultipartFile("test1", "test1.PNG", MediaType.IMAGE_PNG_VALUE, "test1".getBytes()),
                new MockMultipartFile("test2", "test2.PNG", MediaType.IMAGE_PNG_VALUE, "test2".getBytes())
        );
        PostCreateRequest req = createPostCreateRequestWithImages(imageFiles);

        // when, then
        mockMvc.perform(
                multipart("/api/posts")
                        .file("images", imageFiles.get(0).getBytes()) // 2
                        .file("images", imageFiles.get(1).getBytes())
                        .param("title", req.getTitle())
                        .param("content", req.getContent())
                        .param("price", String.valueOf(req.getPrice()))
                        .param("categoryId", String.valueOf(req.getCategoryId()))
                        .with(requestPostProcessor -> { // 3
                            requestPostProcessor.setMethod("POST");
                            return requestPostProcessor;
                        })
                        .contentType(MediaType.MULTIPART_FORM_DATA)) // 4
                .andExpect(status().isCreated());

        verify(postService).create(postCreateRequestArgumentCaptor.capture()); // 5

        PostCreateRequest capturedRequest = postCreateRequestArgumentCaptor.getValue(); // 6
        assertThat(capturedRequest.getImages().size()).isEqualTo(2);
    }
}

multipart/form-data 요청을 테스트하기 위해 낯선 코드들이 많이 등장하였습니다.

패키지를 확인할 수 있도록 import는 생략하지 않겠습니다.

처음보는 부분들만 언급하고 넘어가도록 하겠습니다.

1. 요청을 통해 컨트롤러에서 @ModelAttribute로 전달받을 PostCreateRequest를 캡쳐할 수 있도록 선언해둡니다.

2. multipart()를 이용하여 mutlipart/form-data 요청을 보내기 위한 데이터들을 지정해줍니다.

3. 해당 요청은 POST 메소드임을 지정해줍니다.

4. content type을 지정해줍니다.

5. Mock으로 만들어둔 PostService.create가 호출되는지 확인하고, 전달되는 인자를 캡쳐하였습니다.

6. 캡쳐된 값을 꺼내고, 정상적으로 두 건의 이미지가 업로드된 것인지 검증하였습니다.

 

 

다음으로 어드바이스를 테스트하기 위해 PostControllerAdviceTest를 작성해주겠습니다.

package kukekyakya.kukemarket.controller.post;

import ...

@ExtendWith(MockitoExtension.class)
public class PostControllerAdviceTest {
    @InjectMocks PostController postController;
    @Mock PostService postService;
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(postController).setControllerAdvice(new ExceptionAdvice()).build();
    }

    @Test
    void createExceptionByMemberNotFoundException() throws Exception{
        // given
        given(postService.create(any())).willThrow(MemberNotFoundException.class);

        // when, then
        performCreate()
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1007));
    }

    @Test
    void createExceptionByCategoryNotFoundException() throws Exception{
        // given
        given(postService.create(any())).willThrow(CategoryNotFoundException.class);

        // when, then
        performCreate()
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1010));
    }

    @Test
    void createExceptionByUnsupportedImageFormatException() throws Exception{
        // given
        given(postService.create(any())).willThrow(UnsupportedImageFormatException.class);

        // when, then
        performCreate()
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1013));
    }

    private ResultActions performCreate() throws Exception {
        PostCreateRequest req = createPostCreateRequest();
        return mockMvc.perform(
                multipart("/api/posts")
                        .param("title", req.getTitle())
                        .param("content", req.getContent())
                        .param("price", String.valueOf(req.getPrice()))
                        .param("categoryId", String.valueOf(req.getCategoryId()))
                        .with(requestPostProcessor -> {
                            requestPostProcessor.setMethod("POST");
                            return requestPostProcessor;
                        })
                        .contentType(MediaType.MULTIPART_FORM_DATA));
    }
}

자세한 설명은 생략하겠습니다.

 

 

다음으로 Spring Security와 함께 통합테스트를 진행하면서 aop를 통한 게시글 작성자 주입도 검증해보겠습니다.

PostControllerIntegrationTest를 작성해줍니다.

package kukekyakya.kukemarket.controller.post;

import ...

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
public class PostControllerIntegrationTest {
    @Autowired WebApplicationContext context;
    @Autowired MockMvc mockMvc;

    @Autowired TestInitDB initDB;
    @Autowired CategoryRepository categoryRepository;
    @Autowired MemberRepository memberRepository;
    @Autowired PostRepository postRepository;
    @Autowired SignService signService;

    Member member;
    Category category;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
        initDB.initDB();
        member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
        category = categoryRepository.findAll().get(0);
    }

    @Test
    void createTest() throws Exception {
        // given
        SignInResponse signInRes = signService.signIn(createSignInRequest(member1.getEmail(), initDB.getPassword()));
        PostCreateRequest req = createPostCreateRequest("title", "content", 1000L, member1.getId(), category.getId(), List.of());

        // when, then
        mockMvc.perform(
                multipart("/api/posts")
                        .param("title", req.getTitle())
                        .param("content", req.getContent())
                        .param("price", String.valueOf(req.getPrice()))
                        .param("categoryId", String.valueOf(req.getCategoryId()))
                        .with(requestPostProcessor -> {
                            requestPostProcessor.setMethod("POST");
                            return requestPostProcessor;
                        })
                        .contentType(MediaType.MULTIPART_FORM_DATA)
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isCreated());

        Post post = postRepository.findAll().get(0);
        assertThat(post.getTitle()).isEqualTo("title");
        assertThat(post.getContent()).isEqualTo("content");
        assertThat(post.getMember().getId()).isEqualTo(member1.getId()); // 1
    }

    @Test
    void createUnauthorizedByNoneTokenTest() throws Exception {
        // given
        PostCreateRequest req = createPostCreateRequest("title", "content", 1000L, member1.getId(), category.getId(), List.of());

        // when, then
        mockMvc.perform(
                multipart("/api/posts")
                        .param("title", req.getTitle())
                        .param("content", req.getContent())
                        .param("price", String.valueOf(req.getPrice()))
                        .param("categoryId", String.valueOf(req.getCategoryId()))
                        .with(requestPostProcessor -> {
                            requestPostProcessor.setMethod("POST");
                            return requestPostProcessor;
                        })
                        .contentType(MediaType.MULTIPART_FORM_DATA))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }
}

1. 요청자가 게시글의 작성자로 등록된 것을 확인할 수 있습니다.

자세한 설명은 생략하겠습니다.

 

 

 

이제 모든 작성이 끝났으므로 포스트맨을 이용하여 테스트해보겠습니다.

로그인하여 토큰을 발급받고, 다음과 같은 형태의 multipart/form-data를 전송해보겠습니다.

게시글 생성 요청

memberId는 제외하고, title, content, price, categoryId, images를 지정해줍니다.

정상적으로 생성되고, 201 상태코드와 생성된 게시글의 id를 응답받았습니다.

 

 

지정된 경로에 파일이 업로드되었는지도 확인해보겠습니다.

업로드 결과

첨부했던 두 건의 이미지도 정상적으로 업로드되었습니다.

 

 

웹 브라우저를 통해서 해당 이미지에 접근해보고, 정상적으로 캐시되는지도 확인해보겠습니다.

우리는 /image/파일명으로 실제 파일에 접근할 수 있도록 설정해두었습니다.

파일 접근 확인

정상적으로 파일에 접근할 수 있었습니다.

 

응답 헤더를 자세히 살펴봅시다.

캐시 헤더 확인

WebConfig에서 지정해뒀던 Cache-Control 설정 값이 응답 헤더로 지정되어있습니다.

 

 

 

 

브라우저를 새로 열어서 해당 자원에 다시 접근해보겠습니다.

캐시된 접근 확인

Size를 살펴보면 (disk cache)라고 적혀있습니다.

캐시된 접근 문구 확인

from disk cache라는 문구를 확인할 수 있습니다.

Cache-Control에 의해 요청된 자원이 정상적으로 캐시되었고, 다시 요청을 보내도 캐시된 자원을 응답받게 된 것입니다.

 

 

 

이번 시간에는 게시글 생성 기능을 작성해보았습니다.

다음 시간에는 생성된 게시글을 조회하고 확인해볼 수 있도록, 게시글 단건 조회 기능을 작성해보겠습니다.

 

 

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts