Showing

[스프링부트] Spring Data JPA 적용(3) Spring 웹 계층에 대한 이해와 게시글 등록 기능 본문

JAVA, SPRING/스프링 부트와 AWS로 혼자 구현하는 웹서비스

[스프링부트] Spring Data JPA 적용(3) Spring 웹 계층에 대한 이해와 게시글 등록 기능

RabbitCode 2023. 6. 3. 00:16

*이동욱 저, 스프링부트와 aws로 혼자 구현하는 웹서비스를 학습하면서 작성한 포스팅입니다.

 

1. Spring 웹 계층

API를 만들기 위해 총 3개의 클래스가 필요하다. 

- Request 데이터를 받을 Dto

- API 요청을 받을 Controller

- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

(트랜잭션은 데이터베이스에서 데이터 조작 작업의 논리적인 단위를 나타내는 개념)

 


DTO란...

DTO는 "Data Transfer Object"의 약자, 데이터 전송 객체. 스프링 부트에서는 주로 DTO 패턴을 사용하여 데이터를 전달하고 관리하는 데 사용. DTO는 비즈니스 로직이나 도메인 로직을 포함하지 않고, 단순히 데이터를 전송하기 위한 목적으로 사용. 주로 데이터베이스로부터 데이터를 검색하거나 클라이언트로 데이터를 전송할 때 사용. DTO는 데이터의 특정 부분만 포함하거나 데이터를 변환하고 가공할 수도 있다.

  1. 데이터 전송: 클라이언트와 서버 간의 데이터 전송에 사용. DTO를 사용하여 필요한 데이터만 전송하고, 필요한 경우 데이터 변환을 수행.
  2. 불필요한 정보 필터링: DTO는 데이터의 일부분만을 포함시킬 수 있으므로, 클라이언트에 불필요한 정보를 제외시키고 필요한 정보만 전송할 수 있다. 이를 통해 네트워크 대역폭을 절약하고 응답 시간을 단축.
  3. 데이터 변환: DTO를 사용하여 서로 다른 데이터 구조 간의 변환을 수행할 수 있습니다. 예를 들어, 엔티티 객체에서 DTO로 데이터를 변환하거나, DTO에서 엔티티 객체로 데이터를 변환할 수 있다.

스프링 부트에서는 주로 DTO 사용하여 컨트롤러와 서비스 간에 데이터를 전달하거나, 데이터베이스와의 상호작용에서 데이터 변환에 사용. DTO 정의하고 사용하는 방법은 개발자의 패턴과 상황에 따라 다를 있으며, 프로젝트의 요구 사항에 맞게 설계하면 된다. 


트랜잭션이란...

트랜잭션은 테이블이 아니라 데이터베이스의 개념. 트랜잭션은 데이터베이스 내에서 데이터 조작 작업(INSERT, UPDATE, DELETE 등)을 논리적인 단위로 묶는 개념입니다.

데이터베이스는 여러 개의 테이블로 구성되어 있고, 트랜잭션은 이러한 테이블에서 수행되는 작업을 관리합니다. 트랜잭션은 한 번의 실행으로 여러 개의 테이블에 대한 조작을 묶어서 처리할 수 있습니다. 트랜잭션이 성공적으로 완료되면, 테이블에 대한 변경 내용이 커밋되어 영구적으로 적용됩니다. 반대로, 트랜잭션 실행 중에 오류가 발생하거나 롤백이 요청되면 이전 상태로 되돌려집니다. 트랜잭션은 데이터의 일관성, 안정성, 독립성을 보장하여 데이터베이스의 무결성을 유지하는 역할을 한다.


도메인이란...

 

도메인(Domain)은 소프트웨어 개발에서 사용되는 개념으로, 주요한 비즈니스 영역 또는 문제 영역을 나타낸다. 도메인은 비즈니스 요구사항과 개발 대상 영역을 의미하며, 해당 도메인에 관련된 데이터와 로직을 포함한다.

도메인은 보통 현실 세계에서 사용되는 용어와 개념을 반영한다. 예를 들어, 전자상거래 도메인에서는 주문(Order), 상품(Product), 고객(Customer) 등과 관련된 데이터와 기능을 도메인으로 정의할 있다


Service에서 비즈니스 로직을 처리해야한다는 것은 오해이다. Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다. 

Spring 웹 계층

 

Web Layer

흔히 사용하는 컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역이다. 이외에도 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역

Service Layer

Service에 사용되는 영역. 일반적으로 controller와 dao의 중간 영역에서 사용된다. transactional이 사용되어야 하는 영역

Repository Layer

database와 같이 데이터 저장소에 접근하는 영역. Dao(Data Access Object) 영역으로 이해하면 쉬울 것

Dtos

Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역. 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기 함

Domain Model

도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 한다. 이를테면 택시 앱이면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다. Entity를 사용한 사람은 Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다. 다만 무조건 데이타베이스의 테이블과 관계가 있어야만 하는 것은 아니다 .VO처럼 값 객체들도 이 영역에 해당하기 때문이다.

 

Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비즈니스 처리를 담당할 곳은 바로 Domain이다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다. 반면 주문 취소 로직을 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있다.

@Transactional
public Order cancelOrder(int orderId){
    //1)
    Orders order = ordersRepository.findById(orderId);
    Biling billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findBtOrderId(orderId);
    
    //2-3)
    delivery.cancel();
    
    //4)
    order.cancel();
    billing.cancel();
    
    return order;
}

위와 같이 order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다. 이 책에서는 계속 이렇게 도메인 모델을 다루고 코드를 작성한다. 

 

2. 등록 기능 제작

본격적으로 등록, 수정, 삭제 기능을 만들어보도록 한다. PostsApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service 패키지에 생성한다. 

(1) PostsApiController

package com.jojo.book.springbootwebservice.web;
import com.jojo.book.springbootwebservice.service.posts.PostsService;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class PostsApiController {
    private final PostsService postsService;
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
}

(2) PostsService

package com.jojo.book.springbootwebservice.service.posts;
import com.jojo.book.springbootwebservice.domain.posts.PostsRepository;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

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

스프링을 어느 정도 썼던 사람이라면 Controller와 Service에서 @Autowired가 없는 것이 어색하게 느껴진다. 스프링에서 Bean을 주입하는 방식들이 다음과 같다. 1)Autowired(권장하지 않음) 2)setter 3)생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다. 즉 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다. 생성자는 @RequiredArgsConstructor에서 해결해준다. final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해 준 것이다.

생성자를 직접 안 쓰고 롬복 어노테이션을 사용한 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함이다. (롬복 어노테이션이 있으면 해당 컨트롤러에 새로운 서비스를 추가하건, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.

 

순수 생성자 로직
생성자만들 때 @RequiredArgsConstructor 덕분에 final 변수들 대신 초기화


빈(Bean)

스프링에서 "빈(Bean)"은 스프링 컨테이너가 관리하는 객체를 의미한다. 빈은 스프링에서 애플리케이션의 핵심 구성 요소로 간주되며, 스프링 프레임워크에서의 객체 인스턴스를 나타낸다.

스프링의 빈은 일반적으로 스프링 컨테이너에 의해 생성, 관리 및 제어된다. 이를 통해 개발자는 객체의 생명주기, 의존성 주입(Dependency Injection), 스코프(Bean Scope) 등을 스프링 컨테이너에게 위임할 수 있다.

빈은 스프링 애플리케이션의 구성 요소로서 다음과 같은 특징을 가지고 있습니다:

  1. 인스턴스화: 스프링은 빈의 인스턴스화를 담당하며, 개발자가 직접 객체를 생성하지 않고 스프링 컨테이너에 의해 생성된다.
  2. 의존성 주입: 스프링은 빈 사이의 의존성을 자동으로 주입해준다. 객체 간의 의존성을 직접 관리하지 않고 스프링에게 의존성을 관리하도록 위임할 수 있다.
  3. 스코프: 빈은 특정한 스코프에 속할 수 있으며, 스코프에 따라 빈의 생명주기와 사용 범위가 결정된다. 예를 들어, 싱글톤 스코프의 빈은 애플리케이션 전체에서 공유되고, 프로토타입 스코프의 빈은 요청 시마다 새로운 인스턴스가 생성된다.
  4. 설정 정보: 빈은 스프링 설정 파일(XML 또는 Java Config)이나 어노테이션을 통해 정의된다. 개발자는 빈의 속성, 의존성 및 특징을 설정 정보에 명시하여 스프링 컨테이너가 해당 빈을 생성하고 구성할 수 있다.

 


롬복(Lombok)

자바 개발을 더욱 편리하게 만들어주는 오픈 소스 라이브러리. 롬복은 반복적이고 번거로운 작업을 줄여주는 기능을 제공하여 개발자의 생산성을 향상시킨다.

주로 다음과 같은 기능을 제공한다:

  1. Getter/Setter 자동 생성: 롬복을 사용하면 필드에 대한 Getter와 Setter 메서드를 자동으로 생성할 수 있다. 개발자는 Getter와 Setter 메서드를 직접 작성하지 않고도 해당 필드에 접근할 수 있다.
  2. 생성자 자동 생성: 롬복을 사용하면 필드를 초기화하는 생성자를 자동으로 생성할 수 있다. @NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor 등의 어노테이션을 사용하여 필요한 생성자를 자동으로 생성할 수 있다.
  3. toString(), equals(), hashCode() 자동 생성: 롬복은 toString(), equals(), hashCode() 메서드를 자동으로 생성해주는 기능을 제공한다. 이를 통해 객체의 문자열 표현, 동등성 비교, 해시 코드 생성 등을 간단하게 처리할 수 있다.
  4. 불변(Immutable) 클래스 생성: 롬복은 @Value 어노테이션을 사용하여 불변 클래스를 생성할 수 있다. 불변 클래스는 한 번 생성된 후에는 내부 상태가 변경되지 않는 클래스로, 객체의 안정성과 스레드 안전성을 보장한다.
  5. 자동 리소스 관리: 롬복은 @Cleanup 어노테이션을 사용하여 자동으로 리소스를 관리할 수 있다. 예를 들어, 파일이나 스트림 등의 자원을 사용한 후 자동으로 닫아주는 코드를 생성할 수 있다.

롬복은 자바 컴파일 Annotation Processor 사용하여 소스 코드를 변경하거나 생성하는 방식으로 동작한다. 이를 통해 개발자는 간편한 어노테이션 사용으로 코드를 간소화하고, 잦은 작업 반복을 피할 있다.

 


(3) PostsSaveRequestDto 

package com.jojo.book.springbootwebservice.web.dto;
import com.jojo.book.springbootwebservice.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

}

Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했다. 하지만, 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안된다. Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다. Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다. 화면 변경은 아주 사소한 기능 변경인데, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경이다.

 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다. Entity클래스가 변경되면 여러 클래스에 영향을 기치지만, Request나 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.

 View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋다. 실제로 controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스 만으로는 표현하기 어려운 경우가 많다.

그러므로 꼭 Entity 클래스와 controller에서 쓸 Dto는 분리해서 사용해야 한다. 

최종

(4) 테스트 코드로 검증 

테스트 패키지 중 web 패키지에 PostsApiControllerTest를 생성한다.

package com.jojo.book.springbootwebservice.web;
import com.jojo.book.springbootwebservice.domain.posts.Posts;
import com.jojo.book.springbootwebservice.domain.posts.PostsRepository;
import com.jojo.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }
    @Test
    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
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

랜덤 포트 실행과 insert 쿼리가 실행된 것 모두 확인하였다.