스프링부트와 AWS로 혼자 구현하는 웹 서비스 3장

2022. 1. 18. 23:31잡다한 IT/Springboot와 AWS로 혼자 구연하는 웹 서비스

이 내용은 이동욱님의 '스프링부트와 AWS로 혼자 구연하는 웹 서비스' 책 내용을 정리한 것입니다.

http://www.yes24.com/Product/Goods/83849117

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

 

이전 시간에 배운것

TDD와 단위 테스트가 무엇인가?

스프링 부트 환경에서 테스트 코드를 작성하는 방법은 무엇인가?

자바의 유틸인 롬복은 어떻게 사용하는가?

 

 

 

 

웹 서비스를 개발하다 보면 반드시 필요한 것이 있는데 바로 DB(데이터베이스)이다. 이것저것 엄청나게 많고 다양하지만 JPA라는 자바 표준 ORM(object relation mapping)을 사용하도록 하자.

 

*Mybatis, iBatis는 ORM이 아니고 SQL Mapper이다. ORM은 '객체'를 매핑하고 SQL Mapper는 쿼리를 매핑한다.

 

자 그럼 JPA란 놈은 무엇일까?

현대의 웹 애플리케이션은 관계형 데이터베이스(RDB, Relationl Database)는 빠질 수 없는 요소이다. 대표적으로 Oracle, MySQL, MSSL이 있으며 이런것들을 사용하지 않는 애플리케이션이 드물 정도다. 그렇기에 객체를 관계형 데이터베이스에 관리하는것은 중요하다.

 

그렇기에 개발자가 자바 클래스를 어떻게 설계하더라도 SQL을 통해서만 DB에 저장하고 조화할 수 있게 된다. 즉 SQL을 피할 수 없게 되었다. 실 현업에서는 수십, 수백 개의 table이 있는데 이 태이블의 몇 배의 SQL을 만들고 유지, 보수해야한다. 또한 패러다임 불일치 문제를 해결하기 위해서도 노력해야한다.

어떻게 DB를 저장할지에 저장할 것인지 초점이 맞춰진 기술이 바로 관계형 데이터 베이스 이다.

 

반대로 객체지향 프로그래밍 언어는 메세지를 기반으로 기능과 속성을 한 곳에서 관리하느 ㄴ기술이다.

 

이 두가지는 시작점 자체가 다르기 때문에(언어의 패러다임이 서로 다르기에) 객체를 데이터 베이스에 저장하기 위해 많은 문제가 발생하고 이것이 패러다임 불일치 라고한다. 웹 어플리케이션 개발은 점점 더 데이터베이스 모델링에게만 집중하게 되기때문에 JPA로 이런 문제를 해결하고자 등장하게 되었다.

 

즉 객체지향적으로 프로그래밍을 하고 JAP는 관계형 데이터베이스에 맞게 SQL을 대신 생성해 실행한다. => 더이상은 SQL에 종속적인 개발을 하지 않아도 된다 라는 의미이다.

 

JAP는 인터페이스로서 자바 표준의 명세서이다. 대표적으로 Hibernate, Eclipse Link 등이 있다. 하지만 spring에서는 JPA를 사용할 때에는 이런것을 직접적으로 하지는 않는다.

 

이제 지겨운 이론은 접어두고 실기로 넘어가 보도록 하자. build.gradle에 다음과 같은 의존성을 추가하도록 하자.

//스프링 부트용 spring data jpa추상화 라이브러리다.
//스프링부트 버전에 맞춰 자동으로 jpa 관련 라이브러리들의 버전을 관리해 준다.
compile('org.springframework.boot:spring-boot-starter-data-jpa')
//인메모리 관계형 데이터베이스이다.
//별도의 설치가 필요없이 프로젝트 의존성만으로 관리할 수 있다.
//메모리에서 실행되기 때문에 애플리케이션을 재시할 때마다 초기화 된다는 점을 이용해 테스트용도로 많이 사용된다.
compile('com.h2database:h2')

이 후 domain이라는 패키지를 새롭게 생성하자.

여기에서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역이라고 생각하면 된다. 이곳에 Posts패키지와 posts클래스를 각각 생성하도록 하자. 코드는 다음과 같다.

package com.test.page.web.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

// Lombok의 어노테이션이다.
//클래스 안의 모든 필드의 Getter메소드를 자동생성해준다.
@Getter
//Lombok의 어노테이션이다.
//기본 생성자 자동 추가 기능이다.
//public posts(){}와 같은 효과를 가진다.
@NoArgsConstructor
//JPA의 어노테이션이다.
//테이블과 링크될 클래스임을 나타낸다.
//기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍으로 테이블 이름을 매칭한다.
//HelloWord=>hello_word
@Entity
public class Posts {

    //해당 테이블의 PK필드를 나타낸다.
    @Id
    //PK의 생성 규칙을 나타낸다
    //스프링부트2.0에서는 generationType.IDENTITY를 옵션에 추가해야지 auto_increment가 된다.
    @GeneratedValue(strategy =  GenerationType.IDENTITY)
    private Long id;
    //테이블의 칼럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이된다.
    //사용을 하는 이유는 기본값 이외 추가로 변경이 필요한 옵션이 있을 경우 사용한다.
    //문자열은 기본적으로 255가 기본값인데 사이즈를 늘리고 싶을때나 타입을 변경하기 위해 사용한다.
    @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;
    }

}

*Entity의 PK는 Long타입의 auto_increment를 추천한다.

 

이 클래스에는 한 가지 특징이 있는데 Setter 메소드가 없다는 점이다. 자바빈 규약을 생각하며 getter/setter를 무작정 생성하는 경우가 있는데 이렇게 되면 해당 클래스의 인스턴스 값들이 언제, 어디서 변화하는지 코드상으로 구분할 수 없기에 차후 변경시 복잡해진다.

그렇기에 Entity 클래스에서는 setter매소드를 절때 만들지 않는다. 그렇다면 setter가 없는 상황에 어찌 값을 채워서 DB에 insert할 수 있을까? 

 

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는것 이며 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.

 

'이 책에서는 @builder를 통해 제공되는 빌더 클레스를 기본으로 한다.'

 

Posts 클래스 생성이 끝났다면 Posts클래스로 DB를 접근하게 해 줄 JpaRepository를 생성한다.

package com.test.page.web.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts,Long> {
}

 

반드시 인터페이스 로 생성해야한다. 인터페이스 생성 후 JpaRepository<Entitiy 클래스, PK타입>의 형식으로 상속하게 되면 기본적인 CRUD메소드가 자동으로 생성된다.

*이 두가지는 반드시 같은 위치에 있어야한다.

 

이제 테스트 코드를 작성해 보자.

package com.test.page.web.domain.posts;


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.test.context.junit4.SpringRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

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

    @Autowired
    PostsRepository postsRepository;

    //junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
    //보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용한다.
    //여러 테스트가 동시에 수행되면 테스트용 DB인 H2에 데이터가 그대로 남아있어 다음 테스트 실행 시 테스트가 실패할 수 있다.
    @After
    public void cleanup() {
        postsRepository.deleteAll();
    }

    @Test
    public void 게시글저장_불러오기() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        //테이블에 posts에 insert/update쿼리를 실행한다.
        //Id값이 있다면 update, 없다면 insert쿼리가 실행된다.
        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("test@gmail.com")
                .build());

        //when
        //테이블 posts에 있는 모든 데이터를 조회해오는 메소드이다.
        List<Posts> postsList = postsRepository.findAll();

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

 

별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 DB를 자동으로 실행해 준다. 테스트를 실행하면 잘 실해행되는것을 확인할 수 있다.

실제 실행된 쿼리를 스프링부트에서 눈으로 확인하기 위해서는 application.properties, application.yml등의 파일로 한줄의 코드로 설정할 수 있다. resources라는 패키지에  application.properties라는 파일을 생성한다. 코드는 짧게 이렇게 작성하면된다.

//실제 쿼리를 눈으로 확인하기 위한 코드다.
spring.jpa.show-sql=true
//쿼리 로그를 MYSQL버전으로 변경해 눈으로 확인할 수 있게 해 준다.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

 

이 후 실행을 하게되면 쿼리 로그를 눈으로 확인할 수 있게 된다.

 

이제 API를 만들어 보도록 하자. 주요 기능은 등록/수정/조회 기능을 가지고 있다. 이를 위해서는 3가지의 클래스가 필요한데 request데이터를 받을 dto, api요청을 받을 controller, 트랜잭션, 도메인 기능 간의 순서를 보장하는 service가 있다.

*여기서 주의할 점은 비즈니스 로직은 service에서 처리하는것이 아닌 domain에서 처리한다.

 

자 이제

web 페키지에 PostsApiController를

web.dto에 PostsSaveRequestDto

service에 PostsService를 각각 생성하자.

PostsApiController

package com.test.page.web;

import com.test.page.service.PostsService;
import com.test.page.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);
    }
}

PostsSaveRequestDto

package com.test.page.web.Dto;

import com.test.page.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();
    }

}

PostsService

package com.test.page.service;

import com.test.page.domain.posts.PostsRepository;
import com.test.page.web.Dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

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

 

기본적으로 스프링에서는 Bean을 주입하는 방식은 3가지가 있는데 흔히 알고있는 @Autowired, setter, 그리고 생성자가 있다. 이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다. 이 생성자는 @RequiredArgsConstructor에서 해결해 준다. final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiraedArgsConstructor가 대신 생성해 주기 때문이다.

 

자 이제 코드가 완성되었으니 테스트 코드로 검증을 해 보자. 테스트 패키지 중 web패키지에 PostsApiControllerTest를 생성하자.

PostsApiControllerTest

package com.test.page.web;

import com.test.page.domain.posts.Posts;
import com.test.page.domain.posts.PostsRepository;
import com.test.page.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쿼리가 잘 실행되는것을 확인할 수 있다. 이제 수정/조회를 등록해 보도록 하자.

 

PostsAPIController

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

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

PostsResponseDto (com/test/page/web/Dto/PostsResponseDto)

package com.test.page.web.Dto;

import com.test.page.domain.posts.Posts;
import lombok.Getter;

@Getter
public class PostsResponseDot {
    
    private Long id;
    private String title;
    private String content;
    private String author;
    
    public PostsResponseDot(Posts entity){
        this.id= entity.getId();
        this.title=entity.getTitle();
        this.content=entity.getContent();
        this.author=entity.getAuthor();
    }
}

PostsUpdateRequestDto(com/test/page/web/Dto/PostsUpdateRequestDto)

==> 책에는 이 위치가 기록되지 않아서 한참 해맸다.

package com.test.page.web.Dto;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

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

Posts

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

PostsService

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

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

자 여기서 하나 눈여겨 봐야할 점은 update기능에서 쿼리를 날리는 부분이 없다. 바로 JPA의 영속성 컨텍스트 때문인데, 영속성 컨텍스트란 엔티티를 영구 저장하는 환경을 의미한다. 논리적인 개념이며 JPA의 핵심 개념은 엔티티가 여기에 포함되느냐 아니냐로 갈린다. JPA의 EntityManager가 활성화 된 상태로 트랜잭션 안에서 DB를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다. 이 상태에서 해당 데이터의 값을 변경하면 트랜젝션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉 entity 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없다는 것이다. 이것을 보고 dirty checking이라고 한다.

 

이제 PostsApiControllerTest에 아래의 내용을 추가하도록 하자. update 쿼리가 수행되는것을 확인할 수 있다.

@Test
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
    ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, 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(expectedTitle);
    assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}

이제 H2를 사용해 보도록 하자. application.properties에 아래의 명령어를 추가하자.

spring.h2.console.enabled=true

이 후 application 클레스의 main 메소드를 실행하자. 이 후 인터넷으로 아래의 url로 접근하도록 하자. 

http://localhost:8080/h2-console 

아래와 같은 창이 나타날 것인데 JDBC URL을 다음과 같이 바꾼 후 connect를 하도록 하자.

jdbc:h2:mem:testdb

명령어를 실행하면 아무것도 없음을 확인할 수 있으며 insert쿼리를 실행해 API로 내용을 조회해 보도록 하자.

SELECT * FROM posts;

 

insert into posts (author, content, title) values ('author', 'content', 'title');

잘 들어간 것을 확인할 수 있다. 이제 이 기나긴 여정도 슬슬 마지막에 다가간다.

이제 JPA를 통해서 생성시간/수정시간을 자동화 하는 방법에 대해 알아보도록 하겠다.

일반적으로 entity에는 해당 데이터의 생성시간과 수정시간이 기록되 있도록 코딩이 포함되어있다. 하지만 이런 코드가 이곳저곳에 있으면 상당히 정신사납고 복잡할 것이다. 이것을 해결하기 위해 JPA Auditing을 사용한다. 

 

BaseTimeEntity(domain/BaseTimeEntity)

package com.test.page.domain;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
//JPA Entity클레스들이 baseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 한다.
@MappedSuperclass
//baseTimeEntity 클래스에 auditing기능을 포함시킵니다.
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    //entity가 생성되어 저장될 때 시간이 자동으로 저장 됩니다.
    @CreatedDate
    private LocalDateTime createdDate;

    //조회한 entity의 값을 변경할 때 시간이 자동으로 저장 됩니다.
    @LastModifiedDate
    private LocalDateTime modifiedDate;

}

 

마지막으로 Posts클레스가 BaseTimeEntity를 상속받도록 변경한 후 application 클래스에 활성화 어노테이션을 추가하도록 하자.

Posts(domain/posts/Posts)

public class Posts extends BaseTimeEntity

 

Application(com/test/page/Application)

// JPA Auditing 활성화
@EnableJpaAuditing 
@SpringBootApplication
public class Application {
    public static void main(String[] args)
    {SpringApplication.run(Application.class, args);
    }
}

이상으로 3장을 마치도록 하겠다.

 

 

 

 

 

 

 

 

 

==============================================================================

후기

기본기 없이 시작하면 겁나 힘들다. wa 센즈 보다 더...