스프링 게시판 만들어보기 2 - 게시판 CRUD기능

HiroPark·2022년 9월 7일
2

Spring

목록 보기
4/11

본격적으로 게시판 CRUD 기능을 만드려 한다.

필요한 영역들은 다음과 같다

  • domain : “게시글”이라는 하나의 문제 영역
  • Repository : 도메인의 엔티티로 Database를 접근하기 위해 필요
  • Controller : API요청을 받는 역할
  • Service : 트랜젝션, 도메인 간의 순서를 보장하는 역할
  • dto : Request데이터를 받아서 Service에 넘기는 역할(Data Transfer Object)

Entity

@Getter
@NoArgsConstructor
@Entity
@Table(name = "posts")
public class Posts extends BaseTimeEntity {

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

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public void update(String title , String content) {
        this.title = title;
        this.content = content;
    }
}

이 Posts클래스는 실제 DB테이블과 매칭되는 클래스이다.

JPA를 사용할 시, DB데이터에 실제 쿼리를 날려 작업하기보다는, 이 Posts라는 엔티티 클래스의 수정을 통해 작업한다

  • @Entity : 테이블과 링크될 클래스임을 나타냄
  • @Table : 해당 애노테이션이 붙은 클래스와 링크될 테이블을 구체화한다. @Table의 name값을 설정해주지 않는다면, 엔티티 이름으로 테이블이 만들어진다(SalesManager.java → sales_manager)
  • PK의 생성 규칙을 나타내는 @GeneratedValue에 GenerationType.IDENTITY 옵션을 추가하여서 PK가 auto_increment할 수 있도록 한다.
  • @Column을 굳이 선언하지 않더라도 엔티티 클래스의 모든 필드는 모두 칼럼이 된다
    • 하지만 기본값이 아니라 변경이 필요한 옵션이 있다면 이를 사용한다. (columnDefinition = “TEXT”를 통하여 타입을 TEXT로 변경)
  • NoArgsConstructor : 비어있는 기본 생성자를 추가해준다, 따라서 final필드가 있다면 컴파일 에러가 발생한다
  • **@Builder : 해당 클래스의 빌더 패턴 클래스를 생성한다. 이 애노테이션을 생성자 상단에 선언할 시, 생성자에 포함된 필드만 빌더에 포함된다.**
    • 빌더를 사용시, 어느 필드에 어떤 값을 채워야 할지 명확히 지정할 수 있다.

      public Posts toEntity() {
              return Posts.builder()
                      .title(title)
                      .content(content)
                      .author(author)
                      .build();
          }
  • 엔티티 클래스에는 Setter메소드를 만들지 않는다.
    • Setter 메소드가 있을시, 클래스의 인스턴스 값들이 언제 어디서 변하는지 파악하기 힘들기에 매우 복잡해진다.
    • Setter가 없기 때문에 생성자를 통해 값을 채워 DB에 삽입하며, 값 변경이 필요할시 이에 해당하는 Public 메서드를 호출하여 변경한다.

비즈니스 로직은 Domain이 담당하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서를 보장하는 역할만을 한다 : Update기능

  • update 기능은 값을 변경하는 기능임에도, 데이터베이스에 쿼리를 날리지 않는다. 이는 “영속성 컨텍스트” 때문이다.
  • Persitent Context : 엔티티를 영구 저장하는 환경
    1. Spring data Jpa를 사용한다면 JPA의 Entity Manager가 기본으로 활성화 된다. 이때 한 트랜잭션 내에서 데이터베이스에서 데이터를 가져오면, 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

    2. 이 상태에서 해당 데이터의 값을 변경하면, 트랜잭션이 끝나는 시점에 데이터베이스의 해당 테이블에 변경분을 반영한다.

    3. 이는 즉슨, 엔티티 객체의 값을 변경하는 것이 Update 쿼리를 날리는 것과 같은 효과를 준다는 것이다.

      다시 반복해서 설명하자면,

    4. 트랜잭션 시작

    5. 엔티티 조회

    6. 엔티티의 값 변경

    7. 트랜잭션 커밋

      순서로 더티체킹(=상태 변화 감지)이 이루어진다.

      이 더티체킹은 엔티티 조회 시 스냅샷 생성 → 트랜잭션 끝나는 시점에, 스냅샷과 비교하여 다른점이 있다면 Update 쿼리 전달 의 구조로 이루어진다.

Repository

public interface PostsRepository extends JpaRepository<Posts,Long> { // JpaRepository<Entity, Pk클래스> 를 상속받으면 CRUD메소드 자동 생성

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

DB레이어에 접근하는 Repository이다. MyBatis등에서는 Dao(Data Access Object)라고 부른다.

주석에 쓰여있듯, 인터페이스 생성 후 JpaRepostiroy<Entity클래스, Pk클래스> 를 상속받으면 CRUD메소드가 자동 생성된다.

제공하는 메서드들은 다음과 같다

  • C : S save(S 엔티티객체), Iterable<S> saveAll(Iterable<S> entities)
  • R : findById(Id id), getOne(Id id), List<S> findAll(Example<S> example)
  • U : save
  • deleteById(키 타입) , delete(엔티티객체)

문서를 확인하면, 이보다 더 다양한 데이터 조작 메서드들이 있음을 알 수 있다. 필요한 것을 사용하면 될 듯하다.

Controller

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

    @PostMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById (@PathVariable Long id) {
        return postsService.findById(id);
    }

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }
}
  • save() 와 update()시 Dto를 사용한다. 이는 밑에서 살펴보자

Service

@RequiredArgsConstructor // 생성자로 Bean을 주입받는다
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }

    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id).orElseThrow
                (() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    @Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
        // 존재하는 Posts인지 확인하기 위해 엔티티 조회 후 삭제
        postsRepository.delete(posts);
    }

    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id).
                orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id =" + id));

        return new PostsResponseDto(entity);
    }

    @Transactional(readOnly = true) //트랜젝션 범위는 유지하나, 조회 기능만 남겨서 조회 속도를 개선함
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(posts -> new PostsListResponseDto(posts))
                .collect(Collectors.toList());
    }
}
  • Transactional : 트랜잭션이 필요한 서비스 클래스, 혹은 메서드에 @Transactional을 달아주면 트랜잭션 처리가 가능하다
    • 일반적으로 구체적인 것이 우선 적용되기에, 메서드의 @Transactional이 먼저 적용된다.
    • 트랜잭션의 원자성(Atomicity)에 의해 , 애노테이션 범위 내의 작업 중 하나라도 취소되면 전체 작업은 취소된다.
    • readOnly = true : 주석대로, transaction을 읽기 전용으로 설정하여 최적화를 가능하게 한다.
      • 단, 이는 실제 트랜잭션 시스템에 주는 힌트일뿐이고, 반드시 쓰기 접근을 실패시키진 않는다. readOnly를 이해할 수 없는 트랜잭션 매니저는 readOnly를 읽고 그냥 무시해버릴 것이다.
  • findAllDesc()는 PostRepository에서 findAllDesc()를 실행하여 결과로 넘어온 Posts의 Stream을 map을 통하여 PostListResponseDto로 변환하여, List로 반환한다.

Stream?

데이터의 흐름

  • 배열이나 컬렉션 인스턴스에 함수형으로 처리하여 원하는 결과를 간결하게 얻어낼 수 있다.
  • 병렬 처리가 가능하다는 장점도 있다.

Dto

  • 컨트롤러에서 확인할 수 있듯, PostsSaveRequestDto , PostsUpdateRequestDto, PostsResponseDto , PostsListResponseDto 총 네개의 dto가 필요하다
  • 코드를 보면 알겠지만, Entity 클래스와 거의 유사한 형태임에도 굳이 Dto클래스를 추가로 만든다.
    • 이는 Entity 클래스를 절대로 Request/Response 클래스로 사용해서는 안되기 때문이다.
    • Dto는 화면에 뿌려주기 위한 클래스이기에, 변경이 자주 일어난다.
    • Entity클래스는 실제 데이터베이스와 매칭되는 클래스이다. 테이블과 스키마를 생성하는 핵심클래스를 API호출을 위해 사용하고, 자주 변경하는 것은 잘못된 설계이다.
    • View 계층과, DB계층을 분리해야 한다, 맨 위의 사진을 한번 더 참고하자.

PostSaveRequestDto

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
    private String title;
    private String content;
    private String author;

    @Builder
    public PostsSaveRequestDto(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

PostUpdateRequestDto

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
    private String title;
    private String content;

    @Builder
    public PostsUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

⇒ Update는 저자가 같은 상황에서 제목과 내용을 바꾸는 것이기에 Dto의 코드가 약간 다르다, 이처럼 dto는 용도에 따라 변경이 자주 일어난다.

PostResponseDto

@Getter
public class PostsResponseDto {
    private Long id;
    private String title;
    private String content;
    private String author;

    public PostsResponseDto(Posts entity) {
        this.id      = entity.getId();
        this.title   = entity.getTitle();
        this.content = entity.getContent();
        this.author  = entity.getAuthor();
    }
}

Service에서 entity를 찾아서 매개변수로 넣어준다. 굳이 생성자가 필요하지 않고, 값을 리턴해주면 되기에 Entity를 받아서 처리한다.

Test

PostRepositoryTest

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @After // 단위 테스트 끝난 이후
    public void cleanup() {
        postsRepository.deleteAll();
    }

    @Test
    public void PostSave_findAll() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        /* save 메서드로 insert/update 쿼리 실행 : id 값 있으면 update, 없으면 insert */
        postsRepository.save(Posts.builder() // .필드(값) ... build()
                .title(title)
                .content(content)
                .author("test@gmail.com")
                .build());

        //when
        List<Posts> postsLists = postsRepository.findAll();

        //then
        Posts posts = postsLists.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }

    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2019,6,4,0,0,0);
        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);

        System.out.println(">>>>>> createdDate = " + posts.getCreatedDate()+ ", modifiedDate = " + posts.getModifiedDate());

        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }
}
  • @After로 단위 테스트가 끝날때마다 수행하는 메소드를 지정한다.
  • BaseTimeEntity는 다음 글에서 설명..

PostApiControllerTest

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles="USER") // MockMVC에서만 작동
    public void posts_등록() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "api/v1/posts";

        //when

        mvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles="USER")
    public void posts_수정() throws Exception {
        // given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;

        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        // when
        mvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        // then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}
  • JPA기능이 적용되지 않는 WebMvCTest를 사용하지 않았다. 이전 글에서 말했듯, WebMvcTest는 Controller와 같은 외부 연동과 관련된 부분만 활성화 된다.
    • JPA기능을 활용하고 싶다면 @SpringBootTest와 TestRestTemplate를 활용하면 된다.
  • @Before
    • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성한다.
  • WebApplicationContext
    • MockMvcBuilders로 mock인스턴스를 생성하기 위해서는 webAppContextSetup 혹은 standaloneSetup 을 세팅해주어야 한다

      public static DefaultMockMvcBuilder webAppContextSetup(WebApplicationContext context) {
      		return new DefaultMockMvcBuilder(context);
      	}
    • 주어진 WebApplicationContext를 이용하여 MockMvc생성

      public static StandaloneMockMvcBuilder standaloneSetup(Object... controllers) {
      		return new StandaloneMockMvcBuilder(controllers);
      	}
    • Build a MockMvc instance by registering one or more @Controller instances and configuring Spring MVC infrastructure programmatically.

    • 자의적으로 컨트롤러와 구성을 선택하여 mock 인스턴스를 생성한다는 것 같다.

      그래서 WebApplicationContext가 무엇인가? 라 한다면..

      Context = 빈들이 담겨있는 컨테이너

    • 웹 어플리케이션에 Configuration을 제공하기 위한 인터페이스

    • ApplicationContext 인터페이스를 상속받아 getServletContext() 메서드를 더하였다.

    • Mock인스턴스를 생성하기 위하여 어플리케이션 구성정보를 제공하는 역할이라고 이해했다..

  • @LocalServerPort
    • 런타임에 HTTP 포트를 주입한다
  • @WithMockUser(roles=”USER”)
    • USER 롤을 가진 인증된 가짜 사용자를 만든다.
    • 후에 스프링 시큐리티를 적용하는데, 이때 사용자 인증을 통과하기 위해 이를 추가한다.
profile
https://de-vlog.tistory.com/ 이사중입니다

0개의 댓글