도서 - 스프링 부트와 AWS로 혼자 구현하는 웹서비스 1
by choising
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 (~ 124p)
- https://github.com/Oraindrop/book-practice
- 위 repository에 실습을 진행 중
Gradle 설정
- build.gradle, basic spring boot
buildscript {
ext {
springBootVersion = '2.1.7.RELEASE'
}
repositories {
mavenCentral()
jcenter()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group 'io.github.oraindrop'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
jcenter()
}
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
ext
keyword- build.gradle 의 전역변수를 선언
- repositories 의 mavenCentral vs jcenter
- mavenCentral : 이전부터 많이 사용하는 저장소, 개발자들의 커스텀 라이브러리 업로드가 힘들어 공유가 되지 않는 상황이 발생
- jcenter : 위 문제를 해소하기 위해 라이브러리 업로드를 간단하게! + 여기 업로드 하면 mavenCetral 에도 업로드 되도록 자동화
- 그러다 보니 개발자들의 라이브러리가 점점 jcenter 로 이동하고 있다.
- 가장 아래의 dependencies 의 compile 과 testCompile 의 라이브러리는 의도적으로 version 을 명시하지 않았다.
- 명시하지 않아야만 맨 위에 작성한 springBootVersion 을 따라가게 된다.
Spring Boot Main Class
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@SpringBootApplication
- 스프링 부트의 자동 설정, 스프링 Bean 읽기 및 생성
- 프로젝트의 최상단 에 위치 해야한다.
- 해당 어노테이션이 있는 위치부터 설정을 읽어가기 때문
SpringApplication.run(Application.class, args);
- 요 문장으로 인해 내장 WAS 실행
- 서버에 톰캣을 설치할 필요가 없어짐
- 스프링 부트로 만들어진 Jar 파일로 실행하면 되게 된다.
- 내장 WAS 의 장점
-
언제 어디서나 같은 환경에서 스프링 부트를 배포 할 수 있다.
- 외장 WAS 의 경우, 종류와 버전 설정을 일치시켜야 함
- 새로운 서버가 추가될 경우 같은 환경을 구축해야 한다
- ex) 30 대의 서버 WAS 버전을 올릴 경우, 실수할 여지도 많고 큰 작업이 될 수 있다.
-
Spring Boot JUnit Test
@WebMvcTest
@RunWith(SpringRunner.class)
public class HelloControllerTest {
@Autowired
private MockMvc mvc;
@Test
public void hello가_리턴된다() throws Exception {
String hello = "hello";
mvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string(hello));
}
}
@WebMvcTest
- 여러 스프링 테스트 어노테이션 중, Web(Spring MVC) 에 집중할 수 있는 어노테이션
- 선언할 경우 @Controller, @ControllerAdvice 등을 사용할 수 있다.
- 거의 컨트롤러 테스트를 위한 어노테이션이라고 봐도 무방할 듯
- 별도의 HTTP 서버 없이 Controller 테스트 진행 가능
- 단, @Service, @Component, @Repository 등은 사용할 수 없다. (Scan 대상이 아니다.)
- 그렇기 때문에 전체 응용프로그램 컨텍스트를 스캔하는
@SpringBootTest
에 비해 가볍고 빠르게 테스트가 가능하다.- 대신 필요한 나머지 bean 을 직접 세팅해줘야 한다.
@RunWith(SpringRunner.class)
- 테스트 진행 시 @RunWith에 Runner클래스를 설정하면 JUnit에 내장된 Runner대신 그 클래스를 실행한다.
- JUnit 프레임워크의 테스트 실행방법을 확장할 때 사용하는 것.
- 테스트 진행 중 테스트가 사용할 애플리케이션 컨텍스트를 만들고 관리하는 작업을 진행해준다.
- 보통 함께 사용되는
@ContextConfiguration
은 자동으로 만들어줄 애플리케이션 컨텍스트의 설정파일 위치를 지정한 것 @RunWith
애노테이션은 각각의 테스트 별로 오브젝트가 생성 되더라도 싱글톤의 ApplicationContext를 보장한다.- 각 메서드 마다 애플리케이션 컨텍스트를 띄우는 게 아니라, 최초 1번만 띄워서 한 번 이후 테스트는 빠르다.
- SpringBoot 테스트와 JUnit 사이의 연결자 역할
- 테스트 진행 시 @RunWith에 Runner클래스를 설정하면 JUnit에 내장된 Runner대신 그 클래스를 실행한다.
Lombok 설정
-
build.gradle 에 dependency 추가.
... dependencies { compile('org.springframework.boot:spring-boot-starter-web') compile('org.projectlombok:lombok') testCompile('org.springframework.boot:spring-boot-starter-test') } ...
-
Gradle Refresh
-
Intellij Lombok Plugin 설치
- Command + Shift + A -> Plugins
- Marketplace 탭 -> lombok 검색
- 가장 상단의 Lombok 플러그인 Install, 다되면 Install 버튼 자리가 Restart IDE 로 바뀌고, Restart ㄱㄱ
- 리스타트 후 “Setting -> Build -> Compiler -> Annotatioin Processors”
- 여기서 [x] Enable annotation processing Check.
롬복 사용해보기
@Getter
@RequiredArgsConstructor
public class HelloResponseDto {
private final String name;
private final int amount;
}
@Getter
- 선언된 모든 필드의 get Method 생성
@RequiredArgsConstructor
- 선언된 모든 final 필드가 포함된 생성자 생성
- final이 없는 필드는 생성자에 포함하지 않는다.
public class HelloResponseDtoTest {
@Test
public void 롬복_기능_테스트() {
// given
String name = "TEST";
int amount = 1000;
// when
HelloResponseDto dto = new HelloResponseDto(name, amount);
// then
assertThat(dto.getName()).isEqualTo(name);
assertThat(dto.getAmount()).isEqualTo(amount);
}
}
- 해당 테스트 run 시 gradle version check
- Mac 기준
./gradlew --version
으로 자신의 버전 체크 가능- 5.2.1 이었다.
- 이 책은 gradle 4 기준으로 예제들이 적혀있다.
- gradle 5 부터 lombok, Querydsl 등의 플러그인의 설정 방법이 조금 달라졌다. 이 책의 예제를 원활히 수행하기 위해 4버전으로 다운그레이드.
./gradlew wrapper --gradle-version 4.10.2
- Mac 기준
실제 롬복을 적용한 컨트롤러 만들어보기
@GetMapping("/hello/dto")
public HelloResponseDto helloDto(@RequestParam("name") String name, @RequestParam("amount") int amount) {
return new HelloResponseDto(name, amount);
}
-
Test
@Test public void helloDto가_리턴된다() throws Exception { String name = "hello"; int amount = 1000; mvc.perform((get("/hello/dto") .param("name", name) .param("amount", String.valueOf(amount)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.name", is(name))) .andExpect(jsonPath("$.amount", is(amount))); }
- get()
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
- param()
- API TEST 의 Request Parameter 추가.
param(String s1, String s2)
- jsonPath
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
- JSON 응답값을 Filed 별로 TEST
- $을 기준으로 필드명을 명시 ($.name, $.amount)
- is
import static org.hamcrest.Matchers.is
.andExpect(jsonPath("$.name", is(name)))
이 문장을.andExpect(jsonPath("$.name").value(name))
이렇게도 표현 가능하다.
JPA
- SQL Mapper 를 사용하다보면, 애플리케이션 코드보다 SQL 이 더 가득가득해진다.
- 이유는?
RDB 와 객체지향의 패러다임 불일치
- 즉 사상부터 다른 시작점에서 출발하였다.
- 이유는?
-
객체지향에서 부모-자식 관계 조회
User user = findUser(); Group group = user.getGroup();
- User 와 Group 의 부모-자식 관계가 명확하다.
-
JPA 를 사용하지 않은 DB 가 결합된 User, Group 조회
User user = userDao.findUser(); Group group = groupDao.findGroup(user.getGroupId());
- User 따로, group 따로 조회 해야하므로, USer 와 Group 의 관계가 모호하다.
- 패러다임 불일치를 해결하기 위해 JPA 등장
- 개발자는 객체지향으로 프로그래밍을 하고,
- JPA가 이를 RDB에 맞게 SQL을 대신 생성/실행
- 개발자는 객체지향 코드로 전부 표현할 수 있으므로, SQL 종속적인 개발을 하지 않아도 됨
Spring Data JPA
- JPA 는 인터페이스
- 대표적인 구현체는
Hibernate
- 이 구현체를 직접 다루는가?
- ㄴㄴ 한단계 더 추상화 된
Spring Data JPA
모듈을 사용. - 즉, JPA <- Hibernate <- Spring Data JPA
- 대표적인 구현체는
- Why
Spring Data JPA
?- 사실 Hibernate 를 직접쓰는거랑 큰 차이가 없긴함.
- 다만, 구현체를 Hibernate 에서 다른걸로 바꾸고 싶다면?
구현체 교체의 용이성
Slf4j
생각해보면 될 듯
- 또, mySql 쓰다가 MongoDB 로 교체가 필요하다면?
저장소 교체의 용이성
- build.gradle
dependencies { ... compile('org.springframework.boot:spring-boot-starter-data-jpa') compile('com.h2database:h2') ... }
- package name
domain
이란- 소프트웨어에 대한 요구사항 / 문제 영역
Posts
Class@Entity @Getter @NoArgsConstructor public class Posts { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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; } }
@Entity
- 테이블과 링크될 클래스임을 나타냄
- 카멜케이스 -> 스네이크케이스로 자동으로 변경하여 table 생성
@Id
- 해당 테이블의 PK
@GeneratedValue
- PK 생성 규칙
- 스프링부트 2.0 이상에서는 GenerationType.IDENTITY 옵션을 추가하면 auto increment 됨
@Column
- 테이블의 컬럼을 나타냄
- 굳이 선언하지 않더라도, 해당 클래스의 필드는 모두 컬럼이 됨
- size, type 등을 변경하고 싶은 경우에 사용합니다.
- 생각해볼 것들
- 어노테이션 순서
- 저자는 어노테이션 순서를 주요 어노테이션 을 클래스에 가깝게 두고 있다.
- 이를테면
@Getter
,@NoArgsConstructor
,@Entity
순서인 것이다. - 이렇게 하면, 새언어로의 전환 등의 이유로 롬복이 필요없어질 경우 위에 것만 지우면 되기 때문
- 이런 것에도 마땅한 근거가 있을 줄은 몰랐다. 역시 갓갓.
- 이를테면
- 개인적으로 내 어노테이션 순서는 저자의 반대에 가깝고, 내 기준은 글자수가 늘어나는 대로 이다.
- 이를테면
@Entity
,@Getter
,@NoArgsConstructor
가 된다. - 이유는 가독성이 좋아진다고 생각해서.
- 삐뚤빼뚤한 것 보다 가독성이 좋다고 생각했고, 같은 길이라면 더 중요하다고 생각되는 것(ex.
@Entity
)을 더 잘 보이는 위쪽에 위치시키는 습관이 있다.
- 이를테면
- 저자는 어노테이션 순서를 주요 어노테이션 을 클래스에 가깝게 두고 있다.
- PK 에 Obejct Type Long을 사용한 이유가 있을까?
- not null 이므로 primitive type long 을 사용하는 것이 더 좋지 않나?
- 어노테이션 순서
JPA
Posts
Class@Entity @Getter @NoArgsConstructor public class Posts { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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; } }
- 의견 충돌 부분
- Class 이름을 복수형으로 한 이유는 뭘까
- 특별한 이유가 없다면 안티패턴으로 알고있는데..
- 특이점
Setter
메소드가 없다.getter/setter
를 무작정 생성하는 것을 지양하자.
- Entity 클래스에서
절대
Setter 메소드를 만들지 않는다.- 해당 필드 값 변경이 필요하다면, 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가하자.
- 이 문장 너무 좋네 세상 똑똑.
- Builder 장점
- 생성자는 채워야 하는 필드가 무엇인지 정확히 알 수 없다.
-
같은 타입의 파라미터가 2개라면, 순서가 변경되어도 Syntax error 가 없으니 디버깅이 매우 어렵다.
new Example(b, a);
-
반면 빌더는 어느 필드에 어떤 값을 채워야 할 지 명확하다.
Example.builder() .a(a) .b(b) .build();
-
PostsRepository
Classpublic interface PostsRepository extends JpaRepository<Posts, Long> {}
- like Dao
-
Repository test
@SpringBootTest @RunWith(SpringRunner.class) public class PostsRepositoryTest { @Autowired PostsRepository postsRepository; @After public void cleanup() { postsRepository.deleteAll(); } @Test public void 게시글저장_불러오기() { // given String title = "테스트 게시글"; String content = "테스트 본문"; postsRepository.save(Posts.builder() .title(title) .content(content) .author("choising") .build()); // when List<Posts> postsList = postsRepository.findAll(); // then Posts posts = postsList.get(0); assertThat(posts.getTitle()).isEqualTo(title); assertThat(posts.getContent()).isEqualTo(content); } }
@After
- 단위 테스트가 끝날 때 마다 수행되는 메소드
@SpringBootTest
@SpringBootApplication
을 찾아 테스트를 위한 빈을 다 생성한다.webEnvironment
라는 값을 통해 테스트 시 Mock 으로 테스트 할 것인지, 실제 서블릿 컨테이너를 구동해서 테스트 할 것인지를 정할 수 있다.@SpringBootTest(webEnvironment = MOCK) // Default 값이 Mock 이긴 하다.
- 애플리케이션 컨텍스트를 올려, 설정을 모두 로드하기 때문에 애플리케이션 규모가 클 수록 느려진다.
- H2도 자동으로 실행됨.
application.properties
- 설정파일
- src/main/resources 아래에 위치한다.
spring.jpa.show_sql=true
- 이 한 줄의 옵션을 추가하면 실제 쿼리 로그를 확인 가능하다.
- 단 H2 쿼리 문법으로 출력되었다.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
- 이 한줄을 추가함으로, mySql 문법의 쿼리로그를 확인할 수 있다.
- 설정파일
API 만들기
- 통상적으로 api 는 3개의 클래스가 필요하다.
- Dto / Controller / Service
Service Layer
- Service Layer 에서 비즈니스 로직을 처리하지 않도록 하는 게 좋다.
- 트랜잭션, 도메인 간 순서 보장 의 역할만!
- 모든 로직이 서비스 클래스 내부에서 처리되는 것은 서비스 계층이 무의미하며, 객체가 단순히 데이터 덩어리 역할만 하게되는 안티패턴
PostsController
Class@RestController @RequiredArgsConstructor public class PostsApiController { private final PostsService postsService; @PostMapping("/api/v1/posts") public Long save(@RequestBody PostsSaveRequestDto requestDto) { return postsService.save(requestDto); } }
PostsService
Class@Service @RequiredArgsConstructor public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
PostsSaveRequestDto
Class@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(); } }
- 스프링의 Bean 주입 방식
- @Autowired
- setter
-
constructor
- 이 중 저자는 생성자를 통해 주입하는 방식을 강력히 권한다.
- Lombok 의
@RequiredArgsConstructor
어노테이션으로 쉽게 구현 가능하다. final
키워드로 선언된 모든 필드를 인자값으로 하는 생성자
- Lombok 의
- 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안된다.
- Entity 클래스는 DB와 맞닿은 핵심 클래스이므로
- 화면 변경 처럼 사소하고 잦은 변경의 인자로 쓰이는 것은 위험하다.
- View Layer 와 DB Layer 의 분리를 철저히 하는 것이 좋다.
-
PostsApiControllerTest
Class@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); } }
- 컨트롤러 테스트지만
@WebMvcTest
를 사용하지 않고@SpringBootTest
를 사용하였다.- @WebMvcTest 의 경우 JPA 기능이 동작하지 않기 때문
- 컨트롤러 테스트지만
PostsService
Classupdate
method@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; }
- postsRepository.save(posts) 를 하지 않았다.
- JPA 영속성 컨텍스트 때문
- JPA 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스의 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태.
- 이 때 해당 데이터의 값을 변경하게 되면, 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
- 이 개념은
더티체킹
이라는 개념!
- 더티체킹
Dirty
: 상태의 변화가 생김Dirty Checking
: 상태변경 검사- JPA 는 트랜잭션이 끝나는 시점에 최초 조회 상태를 기준으로 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영
- 기본적으로 전체 필드를 업데이트 친다.
- 요 문장만 보면 엄청 구릴 것 같지만 그렇지가 않다.
- 생성되는 쿼리가 동일하므로, 부트 실행시점에 미리 만들어 재사용한다.
- DB 입장에서는 동일한 쿼리를 받으면 이전에 파싱된 쿼리를 재사용하므로, 재사용 가능하다.
- 요 문장만 보면 엄청 구릴 것 같지만 그렇지가 않다.
- 변경 필드만 반영되도록 할 수도 있다.
@Entity
객체에@DynamicUpdate
어노테이션을 추가하여- 변경분만 업데이트 치므로 쿼리 자체는 가벼워졌지만
- 쿼리 재사용이 불가하다는 측면에서, 필드가 몇 개 없는 테이블이라면 오히려 쿼리가 재사용되는 전체 업데이트가 더 효율적일수도 있다.
- 기본적으로 전체 필드를 업데이트 친다.
- postsRepository.save(posts) 를 하지 않았다.
PostsApiControllerTest
Class Posts 수정 테스트 케이스@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, 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); }
- create table, insert, update query 를 log 로 확인도 가능하다.
- 테스트 코드가 아닌 실제로 확인하기
application.properties
에 한 줄 추가spring.h2.console.enabled=true
- run application
http://localhost:8080/h2-console
로 접속- JDBC URL 을
jdbc:h2:mem:testdb
로 변경 ->Connect
버튼 클릭!
- JDBC URL 을
- query 를 날려 볼 수 있는 콘솔로 접속 완료.
JPA Audit
-
BaseTimeEntity
Class@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { @CreatedDate private LocalDateTime createdDate; @LastModifiedDate private LocalDateTime modifiedDate; }
@MappedSuperclass
- JPA Entity 클래스들이 해당 어노테이션이 붙은 클래스를 상속할 경우 해당 클래스의 field 들을 column 으로 인식
@EntityListeners(AuditingEntityListener.class)
- Auditing 기능을 포함시키는 어노테이션
-
Posts
Class... public class Posts extends BaseTimeEntity { ...
-
Application
Class... @EnableJpaAuditing ...
@EnableJpaAuditing
- JPA Auditing 활성화
-
PostsRepositoryTest
class@Test public void BaseTimeEntity_등록() { // given LocalDateTime now = LocalDate.now().atStartOfDay(); 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(">>>>>> createDate = " +posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate()); assertThat(posts.getCreatedDate()).isAfter(now); assertThat(posts.getModifiedDate()).isAfter(now); }
Subscribe via RSS