spring/게시판 api

Spring boot 게시판 API 서버 제작 (19) - 게시글 - 생성

얼킴 2023. 3. 3. 18:06

이번에는 게시글 생성 기능을 구현해 보겠습니다.

서비스 로직부터 API생성까지 진행하겠습니다.

 

DTO

먼저 서비스 로직을 요청할 때 필요한 요청클래스 PostCreateRequest와 반환클래스 PostCreateResponse를 만들어 보겠습니다.

 

PostCreateRequest

@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(hidden = true)
    @Null // AOP 를 이용하여 token 에 저장된 사용자 ID를 PostCreateRequest 에 직접 주입
    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,
                memberRepository.findById(req.memberId).orElseThrow(MemberNotFoundException::new),
                categoryRepository.findById(req.categoryId).orElseThrow(CategoryNotFoundException::new),
                req.images.stream().map(i -> new Image(i.getOriginalFilename())).collect(Collectors.toList())
        );
    }
}
  • memberId : @Null 어노테이션을 사용하여 해당 데이터는 클라이어트로부터 받지 않도록 합니다. 대신에 AOP를 이용하여 token에 저장된 memberId를 PostCreateRequest에 직접 주입하겠습니다.

PostCreateResponse

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

 

Validation 유효성 검사

PostCreateRequest에 대한 유효성 검사를 진행하겠습니다. (설명은 생략)

더보기

PostCreateRequestValidationTest

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

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

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

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

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

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

        //then
        assertThat(validate.size()).isEqualTo(1);
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
    }

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

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

        //then
        assertThat(validate.size()).isEqualTo(1);
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
    }

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

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

        //then
        assertThat(validate.size()).isEqualTo(1);
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
    }

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

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

        //then
        assertThat(validate.size()).isEqualTo(1);
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidateValue);
    }

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

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

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

    @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);
    }
}

 

PostCreateRequestFactory

public class PostCreateRequestFactory {
    public static PostCreateRequest createPostCreateRequest(){
        return new PostCreateRequest("title", "content", 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 memberId, Long categoryId, List<MultipartFile> images) {
        return new PostCreateRequest(title, content, memberId, categoryId, images);
    }

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

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

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

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

 

File Service 로직

이번에는 파일을 업로드와 삭제할 수 있는 FileService를 생성하겠습니다. (게시글 생성시에 이미지 처리를 위한 서비스)

FileService

FileService는 interface로 구현하고 실제 구현체는 따로 생성하여 실제 파일 업로드 및 삭제를 진행하겠습니다.

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

 

LocalFileService

이번 프로젝트에서는 로컬에 저장하도록 하겠습니다.

@Service
public class LocalFileService implements FileService{

    @Value("${upload.image.location}")
    private String location; // 파일을 업로드 할 위치

    @PostConstruct
    void postConstruct(){ // 디렉토리 생성
        File dir = new File(location);
        if(!dir.exists()){
            dir.mkdir();
        }
    }
    @Override
    public void upload(MultipartFile file, String filename) { // 업로드
        try {
            file.transferTo(new File(location + filename));
        } catch (IOException e) {
            System.out.println("e = " + e);
            throw new RuntimeException(e);
        }
    }
    @Override
    public void delete(String filename) {

    }
}

 LocalFileService Test

class LocalFileServiceTest {
    LocalFileService localFileService = new LocalFileService();
    String testLocation = new File("src/test/resources/static").getAbsolutePath() + "/"; //저장할 위치

    @BeforeEach
    void beforeEach() throws IOException {
        ReflectionTestUtils.setField(localFileService, "location", testLocation); //location 필드 값을 직접 설정
        FileUtils.cleanDirectory(new File(testLocation));
    }

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

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

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

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

Application.yml 설정

Application.yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/wc/board
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
#        format_sql: true
        default_batch_fetch_size: 100
  profiles:
    active: local // <- application-local.yml 활성화
    include: secret

  servlet.multipart.max-file-size: 5MB 		//<- multipart 설정
  servlet.multipart.max-request-size: 5MB	//<- multipart 설정

Application-local.yml

upload:
  image:
    location: C:\Users\jaeye\wc\spring_board_api\image

Application-test.yml

테스트용으로 하나 더 생성합니다.

upload:
  image:
    location: test-location

 

WebConfig 설정

application-local.yml 값을 설정하였으니 파일 업로드 및 접근할 수 있는 값을 가져오도록 webConfig를 설정하겠습니다.

 

@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {

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

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/image/**") // /imgae/** 패턴의 요청 처리
                .addResourceLocations("file:" + location) // 정적 리소스를 찾을 위치
                .setCacheControl(CacheControl.maxAge(Duration.ofHours(1L)).cachePublic());
    }
}

 

PostService

이제 모든 게시글 생성을 위한 준비가 끝났으니 게시글 생성 로직을 구현해보겠습니다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
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> filenames) {
        IntStream.range(0, images.size()).forEach(i -> fileService.upload(filenames.get(i), images.get(i).getUniqueName()));
    }
}

요청을 위한 PosrCreateRequest를 Post Entity로 변환 후 uploadImages를 통해 이미지 업로드를 수행합니다.

 

PostController

이제 Post Controller를 작성해보겠습니다.

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

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

 

SecurityConfig 추가

.authorizeRequests() // 요청에 대한 인가를 구성
    .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
    .antMatchers(HttpMethod.GET, "/api/**").permitAll()
    .antMatchers(HttpMethod.GET, "/image/**").permitAll() // <- 추가
    .antMatchers(HttpMethod.DELETE,"/api/members/{id}/**").access("@memberGuard.check(#id)")
    .antMatchers(HttpMethod.POST, "/api/categories/**").hasRole("ADMIN")
    .antMatchers(HttpMethod.DELETE, "/api/categories/**").hasRole("ADMIN")
    .antMatchers(HttpMethod.POST, "/api/posts").authenticated() // <- 추가
    .anyRequest().hasAnyRole("ADMIN")
.and()

 

이제 컨트롤러 요청 PostCreateRequest에 memberId를 직접 넣어 보겠습니다.

AOP

AssignMemberId

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

RUNTIME에 어노테이션이 유지될 수 있도록 설정하고, 메소트 타입에 선언할 수 있도록 설정합니다.

 

AssignMemberIdAspect

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AssignMemberIdAspect {

    private final AuthHelper authHelper;

    @Before("@annotation(farm.board.aop.AssignMemberId)")
    public void assignMemberId(JoinPoint joinPoint) {
        Arrays.stream(joinPoint.getArgs())
                .forEach(arg -> getMethod(arg.getClass(), "setMemberId")
                        .ifPresent(setMemberId -> invokeMethod(arg, setMemberId, AuthHelper.extractMemberId())));
    }

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

    private void invokeMethod(Object obj, Method method, Object... args) {
        try {
            method.invoke(obj, args);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }
}
  • Aspect : AssignMemberIdAspect가 Aspect임을 나타냅니다.
  • @Before("@annotation(farm.board.aop.AssignMemberId)") : AssignMemberId 어노테이션이 붙은 메소드에만 어드바이스가 적용되도록 설정합니다.
  • JoinPoint : 현재 실행되는 메소드에 대한 정보를 담고 있스빈다.
  • ifPresent() : Optional 객체가 값이 있는 경우에만 람다 표현식을 실행합니다.
  • invokeMethod() : 주어진 객체의 주어진 메소드를 실행합니다.

 

PostController

다시 Postcontroller로 돌아와서 MemberId 설정 부분을 보겠습니다.

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

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

PostController 테스트

이제 PostController 테스트를 진행하겠습니다. (설명 생략)

더보기

PostControllerTest

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

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

    @Test
    public void createTest() throws Exception {
        //given
        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())
                                .file("images", imageFiles.get(1).getBytes())
                                .param("title", req.getTitle())
                                .param("content", req.getContent())
                                .param("categoryId", String.valueOf(req.getCategoryId()))
                                .with(requestPostProcessor -> {
                                    requestPostProcessor.setMethod("POST");
                                    return requestPostProcessor;
                                })
                                .contentType(MediaType.MULTIPART_FORM_DATA))
                .andExpect(status().isCreated());

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

        PostCreateRequest captureRequest = postCreateRequestArgumentCaptor.getValue();
        Assertions.assertThat(captureRequest.getImages().size()).isEqualTo(2);
    }

}

 

PostControllerIntegrationTest

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
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(member.getEmail(), initDB.getPassword()));
        PostCreateRequest req = createPostCreateRequest("title", "content", member.getId(), category.getId(), List.of());

        // when, then
        mockMvc.perform(
                        multipart("/api/posts")
                                .param("title", req.getTitle())
                                .param("content", req.getContent())
                                .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(member.getId());
    }

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

        // when, then
        mockMvc.perform(
                        multipart("/api/posts")
                                .param("title", req.getTitle())
                                .param("content", req.getContent())
                                .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"));
    }
}

 

 


궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.

github : https://github.com/jaeyeon423/spring_board_api