[DEV] J-Jay

스프링 부트 Blog 만들기 (1) 본문

Back-end/Spring

스프링 부트 Blog 만들기 (1)

J-Jay 2023. 9. 4. 23:39
728x90

프로젝트 소개

프로젝트 나만의 Blog 만들기
기능 블로그 글 작성
블로그 글 조회(단건 조회/전체 조회)
블로그 글 삭제
블로그 글 수정
기술 Spring Boot 
Spring Data JPA
Lombok
H2

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    runtimeOnly 'com.h2database:h2'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Entity

Column Type Nullable Key Comment
id BIGINT N PK 일련번호(PK)
title VARCHAR(255) N   게시물의 제목
content VARCHAR(255) N   내용

Article.java

@NoArgsConstructor
@Entity //엔티티 지정
@Getter
public class Article {
    @Id //ID가 기본키
    @GeneratedValue(strategy = GenerationType.IDENTITY) //기본키는 자동으로 1씩 증가
    @Column(name="id", updatable = false)
    private Long id;

    @Column(name = "title", nullable = false) //title not null 처리
    private String title;

    @Column(name ="content", nullable = false)
    private String content;
    
    @Builder // 빌더 패턴으로 객체 생성
    public Article(String title, String content){
        this.title = title;
        this.content = content;
    }
}

@Builder 어노테이션은 Lombok에서 지원하는 어노테이션이다.

이 어노테이션을 생성자 위에 입력하면 빌더 패턴 방식으로 객체르 생서할 수 있다.

빌더 패턴을  사용하면 어느 필드에 어떤 값이 들어가는지 명시적으로 파악할 수 있다.

 

빌더 패턴(Builder Pattern) 예시

//적용 전
new Article("abc" , "123");

//적용 후
Article.builder()
    .title("abc")
    .content("123")
    .build()

Article 객체를 생성할 때 title 에는 'abc', content에는 '123' 값으로 초기화 한다고 하면,

필더 패턴을 사용하지 않으면 abc는 어느필드에 들어가고, '123'은 어디 필드에 들어가는지 한 눈에 알아보기 힘들다.

하지만 필더 패턴을 사용하면 어느 필드에 어느 값이 매칭이 되는지 직관적으로 보여 객체 생성 코드의 가독성을 높인다.

 

@NoArgsConstructor / @Getter  

    //기본 생성자
    protected Article() {
    }
    //게터
    public Long getId(){
    	return id;
    }
    public String getTitle(){
    	return title;
    }
    public String getContent(){
    	return content;
    }

 

위 코드는 @NoArgsConstructor와 @Getter 어노테이션을 선언하지 않았다면 작성해야 할 코드이다.

Article.java 코드는 @NoArgsConstructor 어노테이션을 사용해 접근 제어자가 protected인 기본 생성자를 별도의 코드 없이 생성했고, @Getter 어노테이션을 클래스 필드에 대해 별도 코드 없이 생성자 메서드를 만들수 있다.

이렇게 Lombok 어노테이션을 사용하면 코드의 반복을 줄이고, 가독성이 향상된다.

 

BlogRepository.java

@Repository
public interface BlogRepository extends JpaRepository<Article, Long> {
}

JpaRepository 클래스를 상속받을 때 엔티티 Article과 엔티티의 PK 타입 Long을 인수로 넣는다.

이제 이 레포지토리를 사용할 때 JpaRepository에서 제공하는 메서드들이 사용 가능해진다.

 

BlogRepository는 JpaRepository를 상속받는다. JpaRepository의 부모 클래스인 CrudRepository에 바로 save() 메서드가 선언되어 있다. 이 메서드를 사용하면 데이터베이스에 Article 엔티티를 저장 할 수 있다.


블로그 글 작성 API

AddArticleRequest.java (DTO)

DTO(Data transfer object)는 계층끼리 데이터를 교환하기 위해 사용하는 객체이다.

DAO는 데이터베이스와 연결되고 데이터를 조회하고 수정하는데 사용하는 객체라 데이터 수정과 관련된 로직이

포함되지만, DTO는 단순하게 데이터를 옮기기 위해 사용하는 전달자 역할을 하는 객체이기 떄문에 별도의 비지니스

로직을 포함하지 않는다.

@NoArgsConstructor   // 기본생성자 추가
@AllArgsConstructor  // 모든 필드 값을 파라미터로 받는 생성자 추가
@Getter
public class AddArticleRequest {
    private String title;
    private String content;
    
    public Article toEntity() { //생성자를 사용해 객체 생성
        return Article.builder()
                .title(title)
                .content(content)
                .build();
    }
    
}

toEntitiy()는 빌더 패턴을 사용해 DTO를 엔티티로 만들어주는 메서드이다.

 

BlogService.java

@RequiredArgsConstructor //final 이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;

    //블로그 글 추가 메서드
    public Article save(AddArticleRequest request) {
        return blogRepository.save(request.toEntity());
    }
}

@RequiredArgsConstructor는 빈을 생성자로 생성하는 어노테이션이다 (Lombok) final 키워드나 @NotNuill이 붙은 필드를 생성자로 만들어 준다.

@Service 어노테이션은 해당 클래스를 빈으로 서블릿 컨테이너에 등록해준다.

save()메서드는 JpaRepository에서 지원하는 저장 메서드로 AddArticeRequest 클래스에 저장된 값들을 article

데이터 베이스에 저장한다.

 

BlogApiContorller.java

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;

    //HTTP 메서드가 POST일 때 전달 받은 URL와 동일하면 메서드로 매핑
    @PostMapping("api/articles")
    //요청 본문 값 매핑
    public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request){
        Article savedArticle = blogService.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);
    }
}

@RestController 어노테이션을 클래스에 붙이면 HTTP 응답으로 객체 데이터를 JSON 형식으로 반환한다.

@PostMapping() 어노테이션은 HTTP 메서드가 POST일 때 요청 받은 URL가 동일한 메서드와 매핑한다.

현재의 경우 /api/articles는 addArticle() 메서드와 매핑된다.

@RequestBody 어노테이션은 HTTP를 요청할 때 응답에 해당하는 값을 @RequestBody에 어노테이션이 붙은 객체인

AddArticleRequest에 매핑한다. ResponseEntity.status().body()는 응답코드로 201 (Create)를 응답하고 테이블에 저장된 객체를 반환한다.

 

블로그 글 작성 API TEST

 

application.yml

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true
  datasource:
    url: jdbc:h2:mem:testdb
  h2:
    console:
      enabled: true

Postman (POST http://localhost:8080/api/articles)

BlogApiControllerTest.java

 

@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    BlogRepository blogRepository;

    @BeforeEach //테스트 실행 전 메서드 (모두 삭제)
    public void mockMvcSetUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .build();
        blogRepository.deleteAll();
    }
}

ObjectMapper 클래스로 만든 객체는 자바 객체를 JSON 데이터로 변환하는 직렬화(Serialization) 또는 반대로 

JSON 데이터를 자바에서 사용하기 위해 자바 객체로 변환하는 역직렬화(Deserialization)할 때 사용한다. (Jackson 제공)

 

직렬화와 역직렬화

 

블로그 글 작성 API TEST CODE

Given 블로그 글 추가에 필요한 요청 객체 생성
When 블로그 글 추가 API 요청, 요청 타입은 JSON, Given에서 만들어둔 객체를 요청 본문으로 함께 전달
Then 응답 코드가 201 Created인지 확인
Blog를 전체 조회해 크기가 1인지 확인
실제로 저장된 데이터와 요청값 비교
    @DisplayName("addArticle: 블로그 글 추가.")
    @Test
    public void addArticle() throws Exception {
        // given
        final String url = "/api/articles";
        final String title = "title";
        final String content = "content";
        final AddArticleRequest userRequest = new AddArticleRequest(title, content);

        final String requestBody = objectMapper.writeValueAsString(userRequest);

        // when
        ResultActions result = mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(requestBody));

        // then
        result.andExpect(status().isCreated());

        List<Article> articles = blogRepository.findAll();

        assertThat(articles.size()).isEqualTo(1);
        assertThat(articles.get(0).getTitle()).isEqualTo(title);
        assertThat(articles.get(0).getContent()).isEqualTo(content);
    }

wirtleValueAsString() 메서드를 사용해서 객체를 JSON으로 직렬화 한다. 

MockMvc를 사용해 HTTP 메서드, URL, 요청 본문, 요청 타입 등을 설정한 뒤 설정한 내용을 바탕으로 요청한다.

 

TEST 결과


블로그 글 목록 조회 API (전체 조회)

 

BlogService.java

@RequiredArgsConstructor //final 이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;
    
	...
    
    public List<Article> findAll(){
        return blogRepository.findAll();
    }

JPA 지원 메서드인 findAll()을 호출하여 article 테이블에 저장되어 있는 모든 데이터를 조회한다.

 

ArticleResponse.java

@Getter
public class ArticleResponse {

    private final String title;
    private final String content;

    public ArticleResponse(Article article){
        this.title = article.getTitle();
        this.content = article.getContent();
    }
}

 

 

전체글 목록 조회 응답을 위한 DTO, 글은 제목과 내용 구성이므로 해당 필드를 가지는 클래스를 만든 다음, 엔티티를 인수로 받는 생성자를 추가.

 

BlogApiController.java 

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;

	...

    @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> findAllArticles() {
        List<ArticleResponse> articles = blogService.findAll()
                .stream()
                .map(ArticleResponse::new)
                .toList();

        return ResponseEntity.ok()
                .body(articles);
    }
}

 

/api/articles GET 요청이 오면 글 전체를 조회하는 findAll() 메서드를 호출한 다음 응답용 객체인 ArticleResponse로 파싱

body에 담아 클라이언트에게 전송한다. (stream 사용)

 

블로그 글 목록 조회 API (전체 조회) TEST

 

data.sql

INSERT INTO article (title, content) VALUES ('제목 1', '내용 1')
INSERT INTO article (title, content) VALUES ('제목 2', '내용 2')
INSERT INTO article (title, content) VALUES ('제목 3', '내용 3')

Postman (GET http://localhost:8080/api/articles)

블로그 글 목록 조회 API (전체 조회) TEST CODE

 

BlogApiControllerTest.java

Given 블로그 글 저장
When 목록 조회 API 호출
Then 응답코드가 200 OK이고, 반환 받은 값 중에 0번째 요소의 content와 title이 저장된 값과 같은지 확인
    @DisplayName("findAllArticles: 블로그 글 목록 조회 (전체 조회)")
    @Test
    public void findAllArticles() throws Exception {
        // given
        final String url = "/api/articles";
        final String title = "title";
        final String content = "content";

        blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        // when
        final ResultActions resultActions = mockMvc.perform(get(url)
                .accept(MediaType.APPLICATION_JSON));

        // then
        resultActions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].content").value(content))
                .andExpect(jsonPath("$[0].title").value(title));
    }

TEST 결과

 


블로그 글 조회 API (단건 조회)

 

BlogService.java

@RequiredArgsConstructor //final 이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;

	...

    public Article findById(long id) {
        return blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found: " + id));
    }
}

findById() 메서드는 JPA에서 제공하는 findById() 메서드를 사용해 ID값을 받아 엔티티를 조회하고 없으면 

IllegalArgumentException 예외를 발생 시킨다.

 

BlogApiController.java

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;
    
    ...

    @GetMapping("/api/articles/{id}")
    //URL 경로에서 값 추출
    public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
        Article article = blogService.findById(id);

        return ResponseEntity.ok().body(new ArticleResponse(article));
    }
}

@PathVariable 어노테이션은 URL에서 값을 가져오는 어노테이션이다. 이 어노테이션이 붙은 메서드의 동작 원리는 

/api/articles/3 GET 요청을 받으면 id에 3이 들어온다. 이 값은 앞서 만든 서비스 클래스의  findById() 메서드로 넘어가

Id값이 3번인 블로그 글을 찾는다. 그 후, 3번 글의 정보를 body에 담아 웹 브라우저로 전송한다.

 

블로그 글 조회 API (단건 조회) TEST

 

Postman (GET http://localhost:8080/api/articles/1)

 

블로그 글 조회 API (단건 조회) TEST CODE

BlogApiControllerTest.java

Given 블로그 글 저장
When 저장한 블로그 글의 id 값으로 API 호출
Then 응답코드가 200 OK이고, 반환 받은 값 중에 0번째 요소의 content와 title이 저장된 값과 같은지 확인
    @DisplayName("findArticle: 블로그 글 조회. (단건 조회)")
    @Test
    public void findArticle() throws Exception {
        // given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        // when
        final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));

        // then
        resultActions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").value(content))
                .andExpect(jsonPath("$.title").value(title));
    }

TEST 결과


블로그 글 삭제 API

 

BlogService.java

@RequiredArgsConstructor //final 이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;

    ...

    public void delete(long id){
        blogRepository.deleteById(id);
    }
}

BlogApiController.java

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;

    ...

    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<ArticleResponse> deleteArticle(@PathVariable long id) {
        blogService.delete(id);

        return ResponseEntity.ok().build();
    }
}

/api/articles/{id} DELETE 요청이 오면 id 값에 해당하는 값이 @PathVariable 어노테이션을 통해 들어온다.

 

블로그 글 삭제 API TEST

 

Postman(DELETE http://localhost:8080/api/articles/1)

 

\

블로그 글 삭제 API TEST CODE

 

BlogApiControllerTest.java

Given 블로그 글 저장
When 저장한 블로그 글의 id 값으로 삭제 API 호출
Then 응답코드가 200 OK이고, 블로그 글 리스트를 전체 조회해 조히환 배열 크기가 0인지 확인
    @DisplayName("deleteArticle: 블로그 글 삭제")
    @Test
    public void deleteArticle() throws Exception {
        // given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        // when
        mockMvc.perform(delete(url, savedArticle.getId()))
                .andExpect(status().isOk());

        // then
        List<Article> articles = blogRepository.findAll();

        assertThat(articles).isEmpty();
    }

TEST 결과


블로그 글 수정 API

 

Article.java

@NoArgsConstructor
@Entity //엔티티 지정
@Getter
public class Article {
    ...
    
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }

}

 

요청받은 내용으로 값을 수정하는 메서드 작성

 

UpdateArticleRequest.java (DTO)

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class UpdateArticleRequest {
    private String title;
    private String content;
}

글 수정 요청 받을 DTO 생성

 

BlogService.java

@RequiredArgsConstructor //final 이 붙거나 @NotNull 이 붙은 필드의 생성자 추가
@Service
public class BlogService {
    private final BlogRepository blogRepository;
    
    ...

    @Transactional //트랜잭션 메서드
    public Article update(long id, UpdateArticleRequest request){
        Article article = blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found:"  + id));

        article.update(request.getTitle(), request.getContent());

        return article;
    }

}

@Transactional 어노테이션은 매칭한 메서드를 하나의 트랜잭션으로 묶는 역할을 한다.

스프링에서는 트랜잭션을 적용하기 위해 다른 작업할 필요 없이 @Transactional 어노테이션만 사용하면 된다.

여기서 트랜잭션이란, 데이터베이스의 데이터  를 바꾸기 위해 묶은 작업의 단위이다.

예를들어, A계좌에서 출금 B계좌에 입금이라는 트랜잭션이 있다고 하자. 그런데 A계좌에서 출금에 성공하고 B계좌에 입금을 진행하는 도중 실패가 난다면 고객 이밪ㅇ에서는 출금은 됐는데 입금이 안 된 심각한 상황이 벌어지게 된다.

이런 상황이 발생하지 않으려면 출금과 입금을 하나의 작업 단위로 묶어서 (트랜잭션) 두 작업을 한 단위로 실행하면 된다.

 

BlogApiController.java

@RequiredArgsConstructor
@RestController
public class BlogApiController {
    private final BlogService blogService;
    
    ...

    @PutMapping("/api/articles/{id}")
    public ResponseEntity<Article> updateArticle(@PathVariable long id, @RequestBody UpdateArticleRequest request) {
        Article updatedArticle = blogService.update(id, request);
        return ResponseEntity.ok().body(updatedArticle);
    }
}

블로그 글 수정 API TEST

 

Postman (PUT http://localhost:8080/api/articles/1)

블로그 글 수정 API TEST CODE

BlogApiControllerTest.java

Given 블로그 글 저장 후, 블로그 글 수정에 필요한 요청 객체 생성
When UPDATE API로 수정 요청, 요청 타입(JSON), given 절에서 미리 만들어둔 객체 전달
Then 응답코드가 200 OK이고, 블로그 글 id로 조회후 수정되었는지 확인
    @DisplayName("updateArticle: 블로그 글 수정")
    @Test
    public void updateArticle() throws Exception {
        // given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        final String newTitle = "new title";
        final String newContent = "new content";

        UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);

        // when
        ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(request)));

        // then
        result.andExpect(status().isOk());

        Article article = blogRepository.findById(savedArticle.getId()).get();

        assertThat(article.getTitle()).isEqualTo(newTitle);
        assertThat(article.getContent()).isEqualTo(newContent);
    }

TEST 결과


전체 TEST

'Back-end > Spring' 카테고리의 다른 글

스프링 부트 Blog 만들기 (3)  (1) 2023.09.09
스프링 부트 Blog 만들기 (2) - Thymeleaf  (0) 2023.09.05
스프링 부트 ORM (2)  (0) 2023.09.03
스프링 부트 ORM (1)  (0) 2023.09.03
스프링 부트 테스트 코드 (3)  (0) 2023.09.02