개발자 취업준비/springboot

스프링부트 프로젝트1 계층구조를 구성하여 글 저장 로직 작성하기

naspeciallist 2025. 2. 16. 19:45


이 글은 스프링부트3 백엔드 개발자 되기 책을 바탕으로 공부한 내용을 정리한 게시글 입니다.

 

스프링부트를 이용하여 기본적인 CRUD를 구성하고 있는 간단한 블로그 프로젝트를 구성하겠습니다. 

 

 

1. 프로젝트 구조


 

 

 

프로젝트는 계층형 아키텍처(Layered Architecture)를 기반으로 설계하였습니다. 위 그림과 같은 계층 구조를 구성하여, 각 레이어가 명확한 역할을 수행하도록 하였습니다.

각 계층은 서로 긴밀하게 상호작용하며, 요청과 응답을 주고받는 구조로 이루어져 있습니다.

  1. 클라이언트(Client)
    • 사용자가 직접 요청을 보내는 역할을 합니다.
  2. 프레젠테이션 계층
    • 클라이언트로부터 받은 요청을 처리하고, 적절한 서비스(Service) 계층에 전달합니다.
    • 요청을 검증하고, 필요한 경우 예외 처리를 수행합니다.
    • 일반적으로 REST API의 엔드포인트 역할을 하며, HTTP 요청을 처리합니다.
  3. 비즈니스 계층
    • 비즈니스 로직을 담당하는 핵심 계층입니다.
    • 데이터를 가공하거나, 데이터베이스에서 정보를 가져오고 처리하는 역할을 합니다.
  4. 퍼시스턴스 계층
    • 데이터베이스와 직접적으로 연결되어 데이터를 조회, 저장, 수정, 삭제하는 역할을 합니다.
    • JPA,Hibernate를 통해 데이터 조작이 이루어집니다.

 

클라이언트에서 요청을 보내게 되며 각 계층에서 적절한 요청을 처리하게 된 뒤 응답으로 클라이언트에게 다시 반환하게 됩니다. 계층구조에 관한 더 자세한 내용은 여기 를 참고해 주세요.

 

각 계층구조를 명확하게 구성하기 위해 계층별로 코드를 디렉터리에 넣어 분리하였습니다.

 

• 프레젠테이션 계층 : controller
• 비즈니스 계층 : service
• 퍼시스턴스 계층 : repository
• 데이터베이스와 연결되는 DAO : domain

 

 

2. 퍼시스턴스 계층 구성


 

게시판의 정보를 저장하게 불러올 객체의 역할을 하는 객체인 엔티티를 구성하겠습니다. 

만들 엔티티와 메핑되는 테이블 구조는 아래와 같습니다.

컬럼명 자료형 null 허용 설명
id BIGINT N 기본키 일련번호. 기본키
title VARCHAR(255) N   게시물의 제목
content VARCHAR(255) N   내용

 

그럼 이제 Article.java파일을 만들어서 엔티티를 구성해보겠습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

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

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

 

 객체를 더 유연하고 직관적으로 생성하기 위해 빌더패턴을 이용하였습니다. 빌더 패턴을 이용하면 어느 필드에 어떤 값이 들어가는지 명시적으로 파악 할 수 있다는 장점이 있습니다.

 

그리고 생성자와 getter는 Lombok라이브러리의 @NoArgsConstructor,  @Getter 어노테이션을 이용하여 별도의 코드를 작성하지 않고도 사용할 수 있게 하였습니다.

 

이제 Repository를 구성하겠습니다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.springbootdeveloper.domain.Article;

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

 

Spring Data JPA를 활용한 Repository입니다. JpaRepository<Article, Long>을 상속받아 기본적인 CRUD(Create, Read, Update, Delete) 기능을 자동으로 제공받습니다. 엔티티 클래스인 Article과 그 기본 키(PK) 타입인 Long을 지정하여 데이터베이스와 매핑하였습니다. 

 

Spring Data JPA 를 통해 직접 SQL을 작성하지 않아도 객체 중심으로 데이터를 다룰 수 있도록 하였습니다.

 

 

3. 비즈니스 계층 구성하기


 

 

먼저 블로그에 글을 추가하는 코드를 서비스 계층에 작성하겠습니다. 서비스 계층에서 요청을 받을 객체은 Add ArticleRequest 객체를 생성하고 BlogService 클래스를 생성한 다음 블로그에 추가 메서드인 save()를 구현하겠습니다.

 

먼저 각 계층과의 전송 객체인 dto를 이용하여 계층간에 상호작용을 처리해 줍니다.

 

ArticleRequest를 dto객체로 이용해 계층간에 데이터 전송을 처리하겠습니다.

 

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
    private String title;

    private String content;

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

 

생성자 대신에 빌더객체를 이용하여 객체를 생성하였습니다. 빌더객체를 이용하면 가독성이 좋아지고 유연성이 증가하게 됩니다. 이 객체를 이용하여 DTO를 엔티티로 만들어줍니다. 이 메서드는 추후에 글을 추가 할 때 저장할 엔티티로 변환하는 용도로 사용합니다.

 

이제 service클래스를 구현하겠습니다.

@RequiredArgsConstructor
@Service
public class BlogService {

    private final BlogRepository blogRepository;

    public Article save(AddArticleRequest request) {
        return blogRepository.save(request.toEntity());
    } 
}

 

@RequestArgsConstructor는 빈을 생성자로 생성하는 에너테이션입니다. @Service는 해당 클래스를 빈으로 서블릿 컨테이너에 등록해줍니다. 

 

save()메서드는 JPA를 사용하기 위해 상속받았던 JpaRepository에서 지원하는 저장 메서드 save()로 AddArticelRequest클래스에 지정된 값을 article 데이터 베이스에 저장합니다. 이 메서드를 이용하여 따로 쿼리문을 작성하지 않고도 바로 데이터 베이스에 저장 할 수 있게 됩니다.

 

 

4. 프레젠테이션 계층 구성하기


이제 URL에 매핑하기  위한 컨트롤러 메서드를 추가하겠습니다. 컨트롤러 메서드에는 URL 매핑 애너테이션 @GetMapping, @PostMapping,  @PutMapping, @DeleteMapping 등을 사용 할 수 있습니다. 이름에서 볼 수 있듯이 각 메서드는 HTTP 메서드에 대응합니다. 여기에서는 api/articles에 POST 요청이 오면 @PostMapping을 이용해 요청을 매핑한 뒤 블로그에 글을 생성하는 BlogService의 save()메서드를 호출 한 뒤, 생성하는 BlogService의 save()메서드를 호출한 뒤, 생성된 블로그 글을 반환하는 작업을 할 addArticle()메서드를 작성하겠습니다.

@RequiredArgsConstructor
@RestController
public class BlogApiController {

    private final BlogService blogService;

    @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를 요청할 때 응답에 해당하는 값을 @RequestBody애너테이션이 붙은 대상 객체인 AddArticleRequest에 매핑합니다. ResponseEntity.status().body()은 응답코드로 201, 즉 Created를 응답하고 테이블에 저장된 객체를 반환합니다. 

 

출저:https://slidesplayer.org/slide/15757128/

 

 

 

5. 테스트


그럼 이제 저희가 구성한 저장 로직이 잘 작동되는지 테스트 해보겠습니다. 방금 만든 api인 BlogApiController에 테스트 파일을 만드겠습니다. 테스트 파일 생성 및 기본로직은 여기를 참고해주세요.

 

@SpringBootTest //테스트용 애프리케이션 컨텍스트
@AutoConfigureMockMvc // MockMvc 생성 및 자동 구성
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();
    }

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

        // 객체 JSON으로 직렬화
        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); //크기가 1인지 검증
        assertThat(articles.get(0).getTitle()).isEqualTo(title);
        assertThat(articles.get(0).getContent()).isEqualTo(content);
        }

    }

 

given-when-then 패턴을 이용하여 테스트 코드를 작성하였습니다. 

 

1.@BeforeEach테스트 실행 전 초기화

@BeforeEach
public void mockMvcSetUp() {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .build();
    blogRepository.deleteAll();
}

테스트 메서드가 실행되기 전에 실행시킬 메서드를 지정해줍니다. mockMvc를 context에 맞게 초기화하여 사용 가능하도록 설정해주고 deleteAll() 메서드를 통해서 이전 테스트의 데이터가 남아 있지않도록 모든 데이터를 삭제하여 테스트간 간섭을 방지하였습니다.

 

2.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);

 

 api의 엔드포인트와 새로 추가 할 블로그 글의 제목과 내용을 지정해 준 뒤 요청 본문을 담을 객체(addArticleRequest)를 생성해 줍니다. 

 

objectMapper.writeValueAsString(userRequest);를 통해서 Java 객체(AddArticleRequest)를 JSON 문자열로 변환해줍니다.

 

3. when: API 요청 실행

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

 

post 요청을 수행하여 실재 애플리케이션처럼 동작을 시뮬레이션하게 해줍니다.

요청의 Content-Type을 JSON형식으로 지정한 뒤 요청 본문에 JSON데이터를 포함해줍니다.

 

4. 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);

 

HTTP 응답 코드가 201(Created)인지 검증합니다. 

데이터베이스의 모든 Article 데이터를 가져 옵니다. 그 뒤에 저장된 데이터가 1개인지 확인 한 뒤 저장된 데이터의 제목과 내용이 요청한 값과 같은지 확인합니다.

 

테스트 결과를 확인해보면 아래 사진처럼 테스트 결과가 성공적으로 나오는 것을 확인 할 수 있습니다.

 

 

또한 아래 사진처럼 JSON형식으로 직렬화된 데이터도 확인 할 수 있습니다.