의존성 주입은 한 클래스가 의존하는 다른 클래스를 주입하는 것이다. 생성자나 setter를 만들고, 인자로 객체를 전달한다. 유닛 테스트(클레스의 메서드 등의 동작 테스트)를 위해 Mock 클래스(테스트를 위한 기능만 가진)를 만들어 사용할 수 있다는 장점도 있다.
의존성 주입의 구현은 간단하지만, 주입할 클래스가 많아지면 객체를 생성할 때 코드가 복잡해질 수 있다. 이를 해결하기 위해 의존성 주입 컨테이너(Dependency Inject Container)를 사용할 수 있고, 컨테이너 중 하나가 바로 스프링 프레임워크이다.
스프링 프레임워크를 사용하면 어노테이션, XML, Java 코드를 이용해 오브젝트(bean이라고 부름) 간의 의존성을 명시할 수 있다. 그렇게 하면 앱 시작 시 스프링 프레임워크의 IoC 컨테이너가 자바 오브젝트를 이용해 오브젝트를 생성 및 관리해준다. 스프링 부트를 이용하면 stand-alone 프로덕션급의 스프링 기반 앱을 쉽게 구동할 수 있다. stand-alone 앱이란 앱을 실행 시 다른 앱이 필요하지 않는 것을 의미한다. stand-alone이 아닌 앱은 Apache Tomcat 같이 웹 서버/서블릿 컨테이너가 필요한 경우이다. 이런 경우 자바 앱을 컴파일해서 톰캣이 이해할 수 있는 구조인 WAR 파일로 압축해서 이 WAR 파일을 톰캣에 배포해야한다. 이는 stand-alone이 아니지만, 스프링 부트는 임베디드 톰캣, 제티와 같은 웹 서버를 앱 실행 시 함께 실행 및 설정해주어서 스프링 부트 앱을 실행하는 것 자체가 웹 서버를 실행하는 것이다.
스프링 부트는 많은 부분을 자동으로 설정해준다. 이 책은 스프링 부트가 제공하는 자동 설정에 의존한다.
Java 웹 애플리케이션은 대부분 Java Servlet을 기반으로 한다. Java Servlet 기반 서버에서는 서블릿 컨테이너가 javax.servlet.http.HttpServlet을 상속받은 서브 클래스만 실행 가능하다.
[HttpServlet 서브 클래스의 예시]
package com.example.Demo;
import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
public class Hello extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws
ServletException, IOException{
// parameter 해석
String name = request.getParameter("name");
// business logic 실행
process(name);
// response 구축
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.print("<html>");
// UI 부분
out.print("</html>");
}
}
private void process(String name){
// business logic
}
서브 클래스는 HttpServlet을 상속 받는 것 외에도 doGet() 메서드 구현해서 매개변수로 넘어오는 HttpServletRequest에서 원하는 정보를 추출하고 비지니스 로직인 process를 실행한 후 반환할 정보를 HttpServletResponse에 담는다.
스프링 부트는 어노테이션, 서브 클래스를 최소한으로 이용해 반복 작업을 최소화해준다. DispatcherServlet이라는 서블릿 서브 클래스를 이미 구현해서 따로 위와 같은 서블릿 클래스를 작성하지 않고, 아래와 같이 비즈니스 로직 클래스만 구현하면 된다.
[비즈니스 로직 클래스의 예시]
@RestController // JSON을 리턴하는 웹 서비스임을 명시
public class HelloController {
@GetMapping("/test") // path 설정, GET 메서드 사용
public String process(@RequestParam String name){
// 비즈니스 로직
return "test" + name;
}
}
스프링 부트를 사용하면 반복적으로 서블릿 클래스를 작성하지 않아도 된다. [HttpServlet 서브 클래스의 예시]와 비교했을 때 장점은 아래와 같다.
- HttpServlet을 상속받지 않아도 된다.
- doGet을 오버라이드하지 않아도 된다.
- HttpServletRequest를 직접 파싱하지 않아도 된다.
- HttpServletResponse를 직접 작성하지 않아도 된다.
@SpringBootApplication의 어노테이션이 달린 클래스의 패키지(비슷한 클래스를 모아놓은 디렉토리)는 베이스 패키지가 된다. 앞서 스프링의 의존성 주입 컨테이너는 중요한 기능임을 언급했다. @SpringBootApplication은 베이스 패키지와 그 하위 패키지에서 @Component의 어노테이션이 달린 자바 빈을 찾아 ApplicationContext(의존성 주입 컨테이너)에 등록한다.
@ComponentScan 어노테이션이 어떤 클래스에 있어야 @Component를 스캐닝한다. @SpringBootApplication의 어노테이션에 이미 @ComponentScan이 포함되어 있다.
[@SpringBootApplication 어노테이션의 내부]
// .. 다른 어노테이션들
@ComponentScan // 매개변수 생략
public @interface SpringBootApplication {
// ...
}
스프링으로 관리하고 싶은 빈의 클래스 상단에 @Component를 추가하고 @Autowired와 함께 이용하면, 등록한 오브젝트가 필요할 때 자동으로 이 오브젝트를 생성해준다.
@Component 대신 @Bean 어노테이션으로 직접 스프링 빈으로 등록할 수 있다. 엔터프라이즈 에플리케이션의 경우 엔지니어가 오브젝트를 어떻게 생성하고 어느 클래스에서 사용하는지 알아야해서 @Bean을 사용한다.
@Configuration
public class ConfigClass{
@Bean
public Controller getController() {
if(env == 'local'){
return new LocalController(...);
}
return new Controller(...);
}
}
@Bean은 메서드를 불러 오브젝트를 생성해 빈으로 넘겨준다. 이 오브젝트를 정확히 어떻게 생성하고 매개변수를 어떻게 넣어줘야 하는지 알려줄 수 있다. 이 책에선 @Bean을 사용하지 않고, @Component를 내부에서 사용하는 @Controller, @Service, @Repository 등 다양한 스테레오 타입(의존성 주입에 기본으로 사용되는) 어노테이션을 사용한다.
아래 명령어를 프로젝트 디렉토리에서 실행하면 애플리케이션을 실행할 수 있다
./gradlew bootRun
/*
Started DemoApplication in 19.006 seconds (JVM running for 21.206)가보이면
localhost:8080으로 접근한다.
*/
Gradle은 빌드 자동화 툴이다. 빌드(소스 코드를 실행 가능한 파일로 만드는 과정) 자동화 툴은 컴파일, 라이브러리 다운, 패키징, 테스팅 등을 자동화할 수 있다. 빌드 → 유닛 테스트 실행 작업을 자동화하도록 코드를 작성해준다. Gradle JVM(컴파일러가 java → 자바 바이트 코드로 번역한 코드를 실행하는 실행기)에서 빌드 자동화를 위해 사용된다. Gradle은 groovy 언어로 작성된다.
[build.gradle]
// 위치: /workspace/ToDoList/Todolist/demo/demo/build.gradle
// plugins를 통해 gradle의 compile 범위 확장.
plugins {
id 'org.springframework.boot' version '2.6.4'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java' // 빌드를 위해 자바 플러그인을 사용함을 명시
}
// group, version, sourceCompatability는 프로젝트 메타데이터이다.
group = 'com.example' // artifact(애플리케이션) 배포에 사용
version = '0.0.1-SNAPSHOT' // 프로젝트의 버전, 배포 마다 버전이 올라간다.
sourceCompatibility = '1.8' // plugins에 추가한 java의 버전 정보를 명시
// Lombok & annotationProcessor 아래 추가 설명
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
...
annotationProcessor 'org.projectlombok:lombok'
...
}
// end
// 레포지토리(gradle이 라이브러리를 다운로드하는 곳)를 명시
repositories {
// = https://mvnrepository.com/repos/central
mavenCentral()
// spring 관련 레포지토리
maven {url 'https://repo.spring.io/milestone'}
maven {url 'https://repo.spring.io/snapshot'}
}
// gradle은 build 뿐 아니라 unit test도 실행 가능함
tasks.named('test') {
useJUnitPlatform() // 테스트 관련 설정, JunitPlatform으로 unit test를 하도록 명시
}
[Lombok과 annotationProcessor]
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
...
annotationProcessor 'org.projectlombok:lombok'
...
}
Lombok은 어노테이션을 추가한 것을 컴파일할 때 그에 맞는 코드를 만들어주는 라이브러리이다. Lombok이 코드를 작성할 때 사용하는 게 어노테이션 프로세서이다. 위 코드의 configuration 부분에서 annotationProcessor를 컴파일 당시에 사용하라고 gradle에게 알려준다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
dependencies에 이 프로젝트에서 필요한 라이브러리를 명시한다. gradle은 여기에서 필요한 라이브러리들을 보고 repositories에서 주소를 받아서 설치한다. 앞에 있는 implemetation, runtimeOnly 등은 라이브러리의 scope에 관한 내용인데, 자세한 내용은 그래들 자바 라이브러리 컨피규레이션을 참고한다.
dependencies에 라이브러리를 추가해보겠다. 우리는 메이븐 센트럴을 사용하므로 메이븐 레포지터리를 사용해 라이브러리를 추가한다. google guava를 메이븐 센트럴에서 검색한다. guava를 검색후 버전을 선택하고 gradle 코드 스니펫(재사용이 가능한 소스코드, 텍스트의 작은 부분)을 dependencies에 복사한다.
[gradle 코드 스니펫]
// https://mvnrepository.com/artifact/com.google.guava/guava
implementation group: 'com.google.guava', name: 'guava', version: '28.1-jre'
메이븐 센트럴에서 lombok의 버전을 선택하고 jar파일을 다운받은 후 프로젝트 디렉터리로 옮기고 아래 코드를 실행한다.
[롬복 설치]
java -jar lombok-1.18.6.jar
[롬복 테스팅]
package com.example.demo;
import lombok.Builder;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@Builder
@RequiredArgsConstructor
public class DemoModel{
@NonNull
private String id;
}
codespace, goorm에서 테스트가 잘 안되는데, spring은 잘 돌아가니 일단 해보겠다. POSTMAN을 이용하면 RESTful API를 GUI 환경에서 테스트할 수 있다. 또한 테스트를 저장해 API 스모크 테스트(빌드 후 프로그램이 잘 돌아가는지 테스트하는 것)에 사용할 수 있다. 나는 codespace를 사용하므로 cURL을 이용해 테스트하겠다.
curl GET www.google.com
// html 코드
[localhost:8080의 결과]
curl GET localhost:8080
// 결과
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx/1.17.7</center>
</body>
</html>
레이어드 아키텍처 패턴은 프로젝트 내부 코드를 적절히 분리하고 관리하는 것이다. REST 아키텍처 스타일은 어떤 형식으로 요청을 보내고 응답을 받는지에 대한 것이다. REST 아키텍처 스타일을 따라 구현된 서비스를 RESTful 서비스라고 한다.
위 그림에서 한 레이어는 프로젝트 내부 코드를 분리해놓은 하나의 클레스 또는 하나의 메서드이다. 예를 들어 아래와 같이 레이어가 없는 웹서비스가 있다고 하자.
[레이어가 없는 웹 서비스]
public String getTodo(Request request){
// 요청 검사
List<Todo> todos = new ArrayList<>();
// DB 콜
try( .. ){
while (rs.next()){
long id = rs.getLong("ID");
String title = rs.getString("TITLE");
Boolean isDone = rs.getBoolean("IS_DONE");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
// 응답 생성
JSONObject json = new JSONObject();
JSONArray array = new JSONArray();
for(Todo todo : todos) {
JSONObject todoJson = new JSONObject();
...
array.put(json);
}
json.put("data", array);
return json.toString();
}
위 서비스의 getTodo의 내부 코드의 일부를 getTodoFromPersistence, getResponse의 작은 메서드들로 나눠 분리하면 훨씬 보기 깔끔합니다.
[메서드로 분리한 웹 서비스]
public String getTodo(Request request){
// 요청 검사
List<Todo> todos = this.getTodoFromPersistence(request);
return this.getResponse(todos);
}
private List<Todo> getTodoFromPersistence(Request request) {
List<Todo> todos = new ArrayList<>();
// DB 콜
try( .. ){
while (rs.next()){
long id = rs.getLong("ID");
String title = rs.getString("TITLE");
Boolean isDone = rs.getBoolean("IS_DONE");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
return todos;
}
private String getResponse(List<Todo> todos) {
// 응답 생성
JSONObject json = new JSONObject();
JSONArray array = new JSONArray();
for(Todo todo : todos) {
JSONObject todoJson = new JSONObject();
...
array.put(json);
}
json.put("data", array);
return json.toString();
}
메서드로 만드는 것의 문제는 다른 클래스에서 같은 메서드를 사용하면 같은 메서드를 복사-붙여넣기해서 만들어줘야하는 것이다. 그래서 이 메서드는 따로 빼서 클래스로 만든다. 이 클래스를 국소적으로 봤을 때 레이어로 나눈것이라고 할 수 있다. 레이어로 나눈다는 건 결국 메서드 → 클래스 or 인터페이스(강제로 형식을 지정하게끔 하는 클래스)로 쪼개는 것이다. 레이어의 또 다른 특징은 레이어는 자기보다 한 단계 하위의 레이어만 사용한다는 것이다.
Tip
이는 전통적인 회사 운영 방식과 비슷하다. 부장 → 차장 → 대리 → 사원 순으로 일처리를 제촉하고, 결과물을 사원 → 대리 → 차장 → 부장으로 보고한다. 이를 컴퓨터로 치환하면 컨트롤러 → 서비스 → 퍼시스턴스로 요청하고, 요청한 데이터를 퍼시스턴스 → 서비스 → 컨트롤러에게 반환한다. 컨트롤러는 사용자에게 데이터를 가공한 후 응답을 반환한다.
[레이어드 아키텍처를 적용해 클래스/인터페이스로 분리한 웹 서비스]
public class TodoService {
public List<Todo> getTodos(String userId) {
List<Todo> todos = new ArrayList<>();
// .. 비즈니스 로직
return todos;
}
}
public class WebController {
private TodoService service; // 책에서는 todoService 였음
public String getTodo(Request request){
// request validation
if(request.userId == null){
JSONObject json = new JSONObejct();
json.put("error", "missing user id");
return json.toString();
}
}
// 서비스 레이어
List<Todo> todos = service.getTodos(request.userId);
return this.getResponse(todos);
}
}
클래스로 분리하면 유지/보수가 편리하다. 다른 클래스에서 getTodos 메서드를 사용하고 싶으면, 서비스 클래스의 객체를 만들어 사용하면 된다. 비즈니스 로직이 변경될 경우 서비스 클래스의 getTodos 메서드만 바꿔주면 된다.
보통 자바 비즈니스 애플리케이션에서 사용하는 클래스는 2개로 분류할 수 있다.
- 기능을 수행하는 클래스, Controller, Service, Persistence처럼 로직을 수행한다.
- 데이터를 담는 클래스, model, entity, DTO처럼 데이터만 갖고 있다.
model은 비즈니스 데이터를 담는 역할을 하고 entity는 DB의 테이블, 스키마(DB의 생성 규칙)를 표현하는 역할을 하는데, 이 책에서는 간단하게 model, entity를 하나로 구현한다.
[TodoEntity.java]
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoEntity {
private String id; // 이 오브젝트의 id
private String userId; // 이 오브젝트를 생성한 사용자의 id
private String title; // Todo 타이트 (ex, 운동하기)
private boolean done; // true - todo를 완료한 경우(checked)
}
@Builder 어노테이션은 Builder 클래스를 개발하지 않아도 Builder 패턴(객체를 만드는 로직)을 사용해 오브젝트를 생성할 수 있다. 아래와 같은 형태로 오브젝트를 생성한다.
TodoEntity todo = TodoEntity.Builder()
.id("t-10328373")
.userId("developer")
.title("Implement Model")
.build();
@NoArgsConstructor 어노테이션은 매개변수가 없는 생성자를 구현해준다.
public TodoEntity() {
}
@AllArgsConstructor 어노테이션은 클래스의 모든 멤버 변수를 매겨변수로 받는 생성자를 구현해준다.
public TodoEntity(String id, String userId, String title, boolean done) {
super();
this.id = id;
this.userId = userId;
this.title = title;
this.done = done;
}
@Data 어노테이션은 클래스 멤버 변수의 Getter/Setter 메서드를 구현해준다.
public String getId(){
return id;
}
public void setId(String id){
this.id=id;
}
...
public boolean isDone(){
return done;
}
public void setDone(boolean done){
this.done=done;
}
Service → Client로 반환하기 전에 Model을 DTO(Data Transfer Object)로 변환해 리턴한다. Model을 DTO로 변환하는 이유는 다음과 같다.
- 비즈니스 로직을 캡슐화하고 DB의 스키마를 감추기 위해서이다. 모델의 Field는 테이블의 스키마와 비슷하다.
- Cilent가 필요한 정보를 Model이 전부 갖지 않는다. 서비스 실행 중 사용자 에러가 발생하면 서비스 로직과 거리가 먼 Model보다 DTO에 에러 메시지 Field를 담는 것이 낫다.
com.example.demo 아래에 dto 패키지를 생성하고 TodoDTO.java를 생성하자. 이 클래스를 이용해 Todo 아이템을 생성, 수정, 삭제한다.
[TodoDTO.java]
package com.example.demo.dto;
import com.example.demo.model.TodoEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoDTO{
private String id;
private String title;
private boolean done;
public TodoDTO(final TodoEntity entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.done = entity.isDone();
}
}
TodoDTO 생성자에 userID가 없는데, 이후 스프링 시큐리티를 이용해 인증을 구현한다. 따라서 사용자가 자신의 userId를 넘기지 않아도 인증이 가능하다. 이것은 DB에서 사용자를 구별하는 고유 식별자로 사용가능해서 숨기는 것이 보안상 맞다.
이제 HTTP 응답으로 사용할 DTO를 만들어야 한다. DTO 패키지 아래 ResponseDTO를 만든다.
[ResponseDTO.java]
package com.example.demo.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> { //<T>는 객체 생성시 Type을 Reference type으로 지정할 수 있다.
private String error;
private List<T> data;
}
TodoDTO 뿐만 아니라 다른 모델의 DTO도 이 ResponseDTO를 이용하므로 데이터를 리스트로 반환하도록 작성했다.
REST는 아키텍처 스타일이다. 이는 반복되는 아키텍처 디자인(프로그램 구조)을 의미한다. REST 아키텍처 스타일의 6가지 제약조건을 따르는 API를 RESTful API라고 한다.
- 클라이언트 - 서버 Cilent - Server : 클라이언트 - 서버 구조가 그 예이다.
- 상태가 없는 stateless
- 캐시되는 데이터 Cacheable
- 일관적인 인터페이스 Uniform Interface
- 레이어 시스템 Layered System
- 코드-온-디맨드 Code-On-Demand (선택사항)
클라이언트 - 서버에는 웹과 같은 브라우저 - 서버 구조가 있다. 다수의 클라이언트가 서버에 접근해 리소스(HTML, JSON, 이미지 등)를 소비한다.
상태가 없음은 Client → Server로 요청을 보낼 때, 이전 요청의 영향을 받지 않음을 의미한다.
가령 로그인을 할 때, 이전에 로그인한 사실을 서버가 알고 있어야 하는 이슈가 발생한다. 이는 요청에 로그인 정보와 같은 리소스를 받기 위한 모든 정보를 포함해야 함을 의미한다. 만약 사용자가 리소스를 수정한 상태를 유지해야 할 때는 서버가 아닌 DB 같은 퍼시스턴스에 상태를 저장한다. HTTP는 기본적으로 상태가 없는 프로토콜이다. 따라서 웹 애플리케이션은 상태가 없는 구조를 따른다.
캐시되는 데이터는 서버에서 리소스를 리턴할 때 헤더에 캐시 여부를 명시할 수 있음을 의미한다.
일관적인 인터페이스는 리소스에 접근할 때 URI, 요청·응답의 형태가 애플리케이션 전반에 걸쳐 일관적이어야 함을 의미한다.
가령 Todo 아이템을 가져올 때 http://example.com/todo를 사용하고, Todo 아이템을 업데이트할 때 http://example2.com/todo를 사용한다면 URI 일관성이 없는 것이다. http://example.com/todo는 JSON 형식으로 리소스를 리턴하고, http://example.com/account는 HTML을 리턴한다면 응답의 리턴 타입에 일관성이 없는 것이다.
레이어 시스템은 Client → Server로 요청을 보낼 때 여러 개의 레이어로 된 서버를 거칠 수 있다.
가령 인증 서버 → 캐싱 서버 → 로드 밸런서를 거쳐 최종 애플리케이션에 도달할 수 있다. 그런데 이 레이어들의 존재는 Client가 알지 못한다. 이 레이어들은 응답과 요청에 어떤 영향을 미치지 않기 때문이다.
코드-온-디맨드는 Client는 서버에 코드를 요청할 수 있고, 서버가 리턴한 코드를 실행할 수 있음을 의미한다. 이는 선택사항이다.
HTTP는 REST 아키텍처를 구현할 때 사용하면 쉬운 프로토콜이다.
컨트롤러 레이어: 스프링 REST API 컨트롤러
HTTP는 GET/POST/PUT/DELETE/OPTIONS 등과 같은 메서드와 URI를 이용해 서버에 HTTP 요청을 보낼 수 있다.
[예제 2-32. HTTP 요청]
GET /test HTTP/1.1
Host: localhost:8000
Content-Type: application/json
Content-Length: 17
{
"id": 123
}
서버는 리소스와 요청에서 사용한 HTTP 메서드를 알아야합니다. 그 후 해당 리소스의 HTTP 메서드에 연결된 메서드를 실행해야 한다. spring-boot-starter-web의 어노테이션은 이 연결을 쉽게 해준다. build.gradle에 이미 dependencies 부분에 설정되어 있다.
implementation 'org.springframework.boot:spring-boot-starter-web'
com.example.demo.controller라는 패키지를 생성한다. 이 패키지는 컨트롤러와 관련된 클래스만 생성할 것이다. @RestController 어노테이션은 HTTP와 관련된 코드 및 요청·응답 매핑을 자동으로 해준다. @GetMapping 어노테이션은 HTTP 메서드를 지정한다. @RequestMapping으로 URI 경로를 지정할 수 있다.
[TestController.java]
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("test") // 주소
public class TestController {
@GetMapping
public String testController() {
return "Hello World!";
}
}
브라우저에 URI를 입력해 접근하는 것은 GET 요청이다. GET 요청으로 localhost:8080/test에 접근한다.
사실 @GetMapping에서도 URI 경로를 지정할 수 있다. 아래 메서드를 TestController 클래스에 구현한 후 localhost:8080/test/testGetMapping으로 GET 요청을 보내면 아래 사진의 응답을 얻을 수 있다.
@GetMapping("/testGetMapping")
public String testControllerWithPath(){
return "Hello World! testGetMapping";
}
@GetMapping의 매개변수로 경로를 지정하면 URI를 어떻게 매핑하는지 알 수 있다. @RequestMapping("test")와 @GetMapping("/testGetMapping")을 통해 /test/testGetMapping이 GET 메서드에 연결되어야 함을 알 수 있다.
@GetMapping과 비슷한 어노테이션으로 @PostMapping, @PutMapping, @DeleteMapping이 있고, 각각 HTTP 메서드는 POST, PUT, DELETE를 의미한다.
매개 변수
URI에서 매개변수를 넘겨받으려면 /test/{id}와 같이 PathVariable이나 /test?id=123처럼 요청 매개변수를 받아야 한다.
[PathVariable을 이용한 매개변수 전달]
import org.springframework.web.bind.annotation.PathVariable; // import는 맨 위에 추가
@GetMapping("/{id}")
public String testControllerWithPathVariables(@PathVariable(required = false) int id)
{
//required = false는 꼭 필요한 것은 아니라는 뜻
return "Hello World! ID " + id;
}
위 코드를 TestController 클래스에 구현한 후 실행해보자. http://localhost:8080/test/123과 같은 요청을 할 수 있다.
@PathVairable과 @GetMapping("/{id}")을 사용해 /{id}는 경로로 들어오는 숫자를 변수 id에 매핑한다.
[RequestParam을 이용한 매개변수 전달]
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/testRequestParam")
public String testControllerRequestParam(@RequestParam(required = false) int id){
return "Hello World! ID " + id;
}
위 코드를 TestController에 추가하면 localhost:8080/test/testRequestParam?id=123 처럼 ?id={id}와 같은 요청 매개변수로 넘어오는 값을 변수로 받을 수 있다.
@RequestBody를 사용하면 요청에 오브젝트를 보낼 수 있다. dto 패키지에 TestRequestBodyDTO를 생성한다.
[ReqesetBody 실습을 위한 TestRequestBodyDTO]
package com.example.demo.dto;
import lombok.Data;
@Data
public class TestRequestBodyDTO {
private int id;
private String message;
}
이번엔 TestRequestBodyDTO를 요청 바디로 받는 testControllerRequestBody() 메서드를 추가해보자.
[TestController에 RequestBody 매개변수 추가]
import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.TestRequestBodyDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.ArrayList;
import java.util.List;
@GetMapping("/test/testRequestBody")
public String testControllerRequestBody(@RequestBody TestRequestBodyDTO testRequestBodyDTO){
return "Hello World! ID " + testRequestBodyDTO.getId() + "Message : " + testRequestBodyDTO.getMessage();
}
@RequestBody TestRequestBodyDTO testRequestBodyDTO는 RequestBody로 보내오는 JSON을 TestRequestBodyDTO 오브젝트로 변환해 가져오라는 뜻이다. JSON은 TestRequestBodyDTO에 필요한 정보를 갖고 있어야한다. 예제에서 JSON은 요청 바디로 int형의 id, String형의 message를 넘겨줘야한다.
{
"id" : 123,
"message" : "Hello ?"
}
포스트멘에서 Body → raw → JSON을 선택해 위 코드를 요청 바디를 넣고 실행하면 결과가 잘나온다. (군대라서 실행 못해봄)
@ResponseBody 어노테이션을 사용해 오브젝트를 리턴할 수 있다. @ResponseBody는 이미 @RestController 어노테이션에 포함되어 있다.
[@RestController]
@Controller
@ResponseBody
public @interface RestController {
...
}
@Controller는 @Component와 의미가 같다. 이 클레스의 오브젝트를 스프링이 알아서 생성하고 다른 오브젝트의 의존성을 연결한다는 뜻이다. @ResponseBody는 이 클래스의 메서드가 오브젝트를 반환한다는 뜻이다. 리턴된 오브젝트를 JSON의 형태로 바꾸고 HttpResponse에 담아 반환한다.
Tip
오브젝트를 JSON으로 바꾸는 것처럼 네트워크를 통해 전달할 수 있도록 변환하는 것을 직렬화(serialization)이라 하고, 반대는 역직렬화(deserialization)라 한다.
ResponseDTO를 리턴하는 컨트롤러를 구현하겠다.
@GetMapping("/testResponseBody")
public ResponseDTO<String> testControllerResponseBody(){
List<String> list = new ArrayList<>();
list.add("Hello World! I'm ResponseDTO");
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return response;
}
우리가 작성할 컨트롤러는 모두 ResponseEntity를 반환할 예정이다. ResponseEntity와 ResponseDTO를 리턴하는 것의 차이는 헤더와 HTTP status(400, 500 등)를 조작할 수 있다는 점만 다르고 body에는 아무 차이가 없다.
[ResponseEntity를 반환하는 컨트롤러 메서드]
import org.springframework.http.ResponseEntity;
@GetMapping("/testResponseEntity")
// <?>는 wildcard, 어떤 자료형도 받겠다.
public ResponseEntity<?> testControllerResponseEntity(){
List<String> list = new ArrayList<>();
list.add("Hello World! I'm ResponseEntity. And you got 400!");
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
// http status를 400으로 설정
return ResponseEntity.badRequest().body(response);
}
400 Bad Request가 반환된 것을 확인할 수 있다.
test는 할만큼 했으니 TodoController 클래스를 만들고 ResponseEntity를 리턴하는 HTTP GET testTodo() 메서드를 작성한다.
[TodoController.java]
package com.example.demo.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
import java.util.ArrayList;
import com.example.demo.dto.ResponseDTO;
import org.springframework.http.ResponseEntity;
@RestController
@RequestMapping("todo")
public class TodoController {
@GetMapping("/test")
public ResponseEntity<?> testTodo(){
List<String> list = new ArrayList<>();
list.add("Hello World! I'm ResponseEntity! you got 200!");
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return ResponseEntity.ok().body(response);
}
}
서비스 레이어: 비즈니스 로직
서비스 레이어에서는 우리가 개발하고자 하는 로직에만 집중할 수 있다. 서비스 레이어는 HTTP와 긴밀히 연관된 컨트롤러에서 분리되어 있고, DB와 긴밀히 연관된 퍼시스턴스와도 분리되어 있기 때문이다.
비즈니스 로직 구현을 위해 com.example.demo.service 패키지 아래 TodoService.java를 생성한다.
[TodoService.java]
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class TodoService {
public String testService(){
return "Test Service";
}
}
@Service 어노테이션은 스테레오타입 어노테이션이다. 내부에 @Component를 포함하고 있지만 @Component와 차이가 없다. 단지, @Service는 이 클래스가 서비스 레이어임을 알려주는 어노테이션으로 사용된다.
TodoService를 사용해보겠다. 이 서비스를 사용한 TodoController.java의 코드는 아래와 같다.
[TodoController.java]
// 실습코드 2-22 TodoService 사용
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo.service.TodoService;
@RestController
@RequestMapping("todo")
public class TodoController {
@Autowired
private TodoService service;
@GetMapping("/test")
public ResponseEntity<?> testTodo(){
String str = service.testService(); // 테스트 서비스 사용
List<String> list = new ArrayList<>();
list.add(str);
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return ResponseEntity.ok().body(response);
}
}
@Service, @RestController 모두 자바 빈이고 스프링이 관리한다. TodoController 오브젝트를 생성할 때(초기화할 때) TodoController 내부의 TodoService를 @Autowired가 알아서 필요한 빈(TodoService)을 찾아서 이 인스턴스 멤버 변수에 연결해준다.(초기화해준다.)
"Test Service"를 담은 ResponseEntity가 리턴됨을 알 수 있다.
퍼시스턴스 레이어: 스프링 데이터 JPA
앱과 DB를 연결하려면 데이터베이스 클라이언트를 설치해야한다. JDBC 드라이버는 자바와 데이터베이스의 연결을 도와주는 라이브러리다. 쉽게 말하면 MySQL 클라이언트같은 것이다. MySQL 클라이언트도 내부에서 JDBC/ODBC 등의 드라이버를 사용한다.
JDBC를 설치한 후 데이터베이스 콜 부분의 코드 스니펫(코드의 짧은 부분)을 살펴보겠다.
[데이터베이스 콜 스니펫]
// DB 콜
String sqlSelectAllTodos = "SELECT * FROM Todo where USER_ID = " + request.getUserId();
String connectionUrl = "Jdbc:mysql://mydb:3306/todo";
try{
/* (1) 데이터베이스에 연결 */
Connection conn = DriverManager.getConnection(connectionUrl, "username", "password");
/* (2) SQL 쿼리 준비 */
PreparedStatement ps = conn.prepareStatement(sqlSelectAllTodos);
/* (3) 쿼리 실행 */
ResultSet rs = ps.executeQuery();
/* (4) 결과를 오브젝트로 파싱 */
while (rs.next()){
long id = rs.getString("id");
String title = rs.getString("title");
Boolean isDone = rs.getBoolean("done");
todos.add(new Todo(id, title, isDone));
}
} catch (SQLException e) {
// handle the exception
}
JDBC 커넥션인 Connetcion을 이용해 DB에 연결하고 작성한 SQL을 실행한 후 ResultSet이라는 클래스에 결과를 담고 while 문 안에서 ResultSet → Todo 오브젝트로 바꿔준다. DB 테이블을 오브젝트로 변환하는 작업을 ORM(Object-Relation Mapping)이라 한다.
보통 DB Table 하나마다 그에 상응하는 Entity 클레스가 존재한다. ORM 작업을 모든 Entity 클래스마다 반복하는 것을 예방하려면, 집중적으로 해주는 DAO(Data Access Object) 클래스를 작성해야 한다. CRUD같은 기본적인 작업을 엔티티마다 작성해야한다. 이미 Hibernate 같은 ORM 프레임워크나 JPA, 스프링 데이터 JPA 같은 도구들이 개발되어 있다.
JPA는 위 예제 코드에서 ResultSet을 파싱하는 노고를 덜어준다. JPA는 자바에서 DB에 접근, 저장, 관리에 필요한 스펙(구현 지침이 되는 문서)이다. 이 스펙을 구현하는 구현자를 JPA Provider라 하고, 대표적인 JPA Provider가 Hibernate다.
스프링 데이터 JPA는 JPA를 더 사용하기 쉽게 JPA를 추상화(Abstraction)했다. 추상화했다는 것은 사용하기 쉬운 인터페이스를 제공한다는 것이다. 우리 프로젝트에서는 그런 인터페이스 중 하나인 JpaRepository를 사용한다.
데이터베이스와 스프링 데이터 JPA 설정
DB를 연결하려면 먼저 DB가 있어야 한다. Spring initializer에서 프로젝트를 생성할 때 H2 디펜던시 라이브러리를 추가했었다. H2는 In-Memory DB로 로컬 환경에서 메모리 상에 DB를 구축한다. @SpringBootApplication 어노테이션이 앱과 H2 DB를 자동으로 연결해준다. H2를 기본 설정인 In-Memory로 실행하면, 앱 실행 시 Table이 생성되고 종료 시 Table이 소멸된다.
runtimeOnly 'com.h2database:h2'
스프링 데이터 JPA는 spring-boot-starter-jpa 라이브러리가 필요한데, Spring intializer에서 프로젝트 생성 시 이미 포함했다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
앱 실행 시 출력되는 로그를 보면 JPA가 생성됐고, JPA Provider로 Hibernate ORM을 사용한다는 것, H2 DB를 사용한다는 사실을 알 수 있다.
Bootstrapping Spring Data JPA repositories in DEFAULT mode.
Finished Spring Data repository scanning in 9 ms. Found 0 JPA repository interfaces.
Hibernate ORM core version 5.6.5.Final
Using dialect: org.hibernate.dialect.H2Dialect
DB는 Table마다 그에 상응하는 Entity 클래스가 존재하는데, Entity 인스턴스는 DB Table의 한 행에 해당한다.
따라서 Entity 클래스는 그 자체가 테이블을 정의해아한다. 테이블 스키마에 관한 정보는 javax.persistence가 제공하는 JPA 관련 어노테이션을 이용해 정의한다.
자바 클래스를 Entity로 정의할 때 주의할 점은 3가지가 있다.
- 클래스에는 매개변수가 없는 생성자(@NoArgsContructor)가 필요하다.
- Getter/Setter(@Data)가 필요하다.
- 기본 키(Primary Key)(@Id)를 지정해줘야 한다.
TodoEntity.java에 DB관련 어노테이션을 추가해보겠다.
[TodoEntity.java]
import javax.persistence.Entity; // import한 라이브러리 추가
import javax.persistence.Table; // import한 라이브러리 추가
import javax.persistence.Id; // import한 라이브러리 추가
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name="Todo") // DB의 Todo Table에 매핑
public class TodoEntity {
@Id
private String id;
private String userId;
private String title;
private boolean done;
}
DB Table 이름 선택
@Entity로 자바 클래스를 엔티티로 지정할 수 있다. @Entity("TodoEntity")처럼 매개변수를 넣어서 엔티티에 이름을 부여할 수 있다. @Table은 DB의 Table과 entity를 매핑시킨다. @Table을 추가하지 않거나 name을 명시하지 않으면 @Entity의 name을, @Entity에 이름이 없으면 클래스의 이름을 Table의 이름으로 간주한다.
@Id는 기본키가 될 필드를 지정한다. @GeneratedValue 어노테이션을 사용해 오브젝트를 DB에 생성할 때 id 필드 값을 자동으로 부여할 수도 있다.
import javax.persistence.GeneratedValue; // import 추가한 라이브러리
import org.hibernate.annotations.GenericGenerator; // import 추가한 라이브러리
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name="Todo") // DB의 Todo Table에 매핑
public class TodoEntity {
@Id
@GeneratedValue(generator="system-uuid")
@GenericGenerator(name="system-uuid", strategy="uuid")
private String id;
private String userId;
private String title;
private boolean done;
}
@GeneratedValue는 ID를 자동으로 생성하겠다는 뜻이다. generator 매개변수는 어떤 방식으로 ID를 생성할지 지정할 수 있다. 이는 @GenericGenerator에 정의된 generator의 이름으로 기본 Generator가 아닌 custom Generator를 사용할 수 있게 한다. 기본 Generator에는 INCREMENTAL, SEQUENCE, IDENTITY 등이 있다. 문자열 형태의 UUID(고유한 id를 만드는 규약)의 사용을 위해 strategy 매개변수에 uuid를 넘겼다.
com.example.demo.persistence 패키지 아래에 TodoRepository 인터페이스를 구현하겠다.
package com.example.demo.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.model.TodoEntity;
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String>{
}
JpaRepository는 인터페이스이므로 사용하려면 새 인터페이스를 생성해 JpaRepository를 확장(extend)해야 한다. JpaRepository<T, ID>에서 알 수 있듯이 T는 table에 매핑될 Entity 클래스(TodoEntity)이고, ID는 이 Entity의 기본 키 타입(String)이다.
@Repository 어노테이션은 @Service와 같이 @Component에 의미상 기능(이 interface가 Repository라는 의미)을 부여한 것이다. 따라서 자바 빈에 등록된다.
[TodoService.java]
package com.example.demo.service;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo.model.TodoEntity;
import com.example.demo.persistence.TodoRepository;
@Service
public class TodoService {
@Autowired
private TodoRepository repository;
public String testService(){
// TodoEntity 생성
TodoEntity entity = TodoEntity.builder().title("My first todo item").build();
// TodoEntity 저장
repository.save(entity);
// TodoEntity 검색
TodoEntity savedEntity = repository.findById(entity.getId()).get();
return savedEntity.getTitle();
}
}
repository에 저장한 entity를 @GeneratedValue로 인해 자동 생성된 UUID로 entity를 조회했을 때, 부여한 title이 잘 불러와지는 것을 보니 퍼시스턴스 레이어가 잘 작동함을 알 수 있다.
JpaRepository 인터페이스 내부는 아래와 같이 구성되어 있다. JpaRepository는 기본적인 데이터베이스 오퍼레이션 인터페이스를 제공한다. save, findById, findAll 등이 기본으로 제공되는 인터페이스에 해당한다. 구현은 스프링 데이터 JPA가 실행 시 알아서 해주어서 save 메서드를 구현하려고 "Insert into .."와 같은 sql 쿼리를 작성할 필요가 없다.
@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>,
QueryByExampleExecutor<T> {
List<T> findAll();
List<T> findAll(Sort var1);
List<T> findAllById(Iterable<ID> var1);
<S extends T> List<S> saveAll(Iterable<S> var1);
void flush();
<S extends T> S saveAndFlush(S var1);
void deleteInBatch(Iterable<T> var1);
void deleteAllInBatch();
T getOne(ID var1);
<S extends T> List<S> findAll(Example<S> var1);
<S extends T> List<S> findAll(Example<S> var1, Sort var2);
}
Tip
추상 클래스나 인터페이스는 반드시 구현하는 클래스가 있어야 한다고 알고 있는데, JpaRepository는 그 법칙을 무시하는 것 같다. JpaRepository를 실행하는 과정을 알려면 AOP(Aspect Oriented programming)을 알아야 한다. 스프링은 MethodInterceptor라는 AOP 인터페이스를 사용하는데, MethodInterceptor는 우리가 JpaRepository의 메서드를 부를 때마다 이 메서드 콜을 가로채 간다. 가로챈 메서드의 이름을 확인하고 메서드 이름을 기반으로 쿼리를 작성한다.
[TodoRepository.java]
import java.util.List;
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String>{
List<TodoEntity> findByUserId(String userId);
}
findByUserId 메서드를 위 코드처럼 작성하면, 스프링 데이터 JPA가 메서드 이름을 파싱해서 SELECT * FROM TodoRepository WHERE userId = '{userId}'와 같은 쿼리를 작성해 실행한다. 메서드 이름은 쿼리, 매개변수는 where문에 들어갈 값을 의미한다. 더 복잡한 쿼리는 @Query 어노테이션을 사용해야한다.
import org.springframework.data.jpa.repository.Query;
@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String>{
// ?1은 메서드의 매개변수의 순서 위치다.
@Query("select * from Todo t where t.userId = ?1")
List<TodoEntity> findByUserId(String userId);
}
메서드 이름 작성 방법과 예제는 공식 사이트 레퍼런스를 통해 확인할 수 있다.
지금까지 배웠던 JPA 어노테이션을 기반으로 진짜 Todo 서비스를 작성할 것이다. 작성할 서비스는 CRUD로 총 4가지 API를 만들 것이다. 퍼시스턴스 → 서비스 → 컨트롤러 순으로 구현할 것이다.
서비스 구현에 앞서 디버깅을 위한 로그 설정을 할 것이다. 로그 정보는 용도에 따라 info, debug, warn, error 용으로 나뉜다. 이를 로그 레벨이라고 한다. System.out.println으로 이를 모두 구현할 수도 있겠지만, 이미 이런 기능을 제공하는 Slf4j(Simple Logging Facade for Java) 라이브러리가 존재한다. 로깅은 웹 서비스에 반드시 필요한 존재이다. Slf4j는 로그계의 JPA이다. Slf4j를 사용하려면 구현부를 연결해줘야 하는데, 이 또한 스프링이 알아서 작업해준다.(스프링은 기본적으로 Logback 로그 라이브러리를 사용한다.)
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class TodoService {
...
}
다른 클래스에서도 로깅할 생각이라면 해당 클래스에도 @Slf4j를 클래스에 추가하라.
Create Todo 구현
Todo 아이템을 생성하는 기능을 구현하겠다.
퍼시스턴스 구현
이미 작성한 TodoRepository를 사용한다. 이 클래스는 JpaRepository를 상속하므로 엔티티 저장에는 save 메서드를, 새로 만든 Todo 리스트 반환에는 findByUserId() 메서드를 사용한다.
서비스 구현
TodoService에 create 메서드를 작성하겠다. create 메서드는 크게 3 단계로 구성되어 있다.
- 검증(Validation) : 넘어온 엔터티가 유효한지 검사한다. 이 부분은 코드가 더 커지면 TodoValidator.java로 분리시킬 수 있다.
- save() : 엔티티를 DB에 저장하고 로그를 남긴다.
- findByUserId() : 저장된 엔티티를 포함하는 새 리스트를 리턴한다.
[TodoService.java]
public List<TodoEntity> create(final TodoEntity entity){
// Validations
validate(entity);
repository.save(entity);
log.info("Entity Id : {} is saved", entity.getId());
return repository.findByUserId(entity.getUserId());
}
private void validate(final TodoEntity entity){
if(entity == null){
log.warn("Entity cannot be null.");
throw new RuntimeException("Entity cannot be null");
}
if(entity.getUserId() == null){
log.warn("Unknown user.");
throw new RuntimeException("Unknown user.");
}
}
컨트롤러 구현
컨트롤러는 TodoDTO를 요청 바디로 받아서 TodoDTO → TodoEntity로 변환해 저장해야 한다. 또, TodoService의 create()가 리턴값 TodoEntity → TodoDTO로 변환해 리턴해야한다.
DTO → Entity로 변환하는 toEntity 메서드를 작성하겠다.
[TodoDTO.java]
public static TodoEntity toEntity(final TodoDTO dto){
return TodoEntity.builder()
.id(dto.getId())
.title(dto.getTitle())
.done(dto.isDone())
.build();
}
toEntity를 이용해 컨트롤러에 createTodo 메서드를 구현하겠다.
[TodoController.java]
import org.springframework.web.bind.annotation.PostMapping;
import com.example.demo.model.TodoEntity;
import com.example.demo.dto.TodoDTO;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.stream.Collectors;
@PostMapping
public ResponseEntity<?> createTodo(@RequestBody TodoDTO dto){
try{
String temporaryUserId = "temporary-user"; // temporary user id.
// (1) TodoEntity로 변환
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) id를 null로 초기화한다. 생성 당시에는 id가 없어야 하기 때문
entity.setId(null);
// (3) 임시 사용자 아이디를 설정한다. 인증 기능은 4장에서 구현
entity.setUserId(temporaryUserId);
// (4) 서비스를 이용해 Todo 엔티티를 생성
List<TodoEntity> entities = service.create(entity);
// (5) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환 ?
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (6) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (7) ResponseDTO를 리턴한다.
return ResponseEntity.ok().body(response);
} catch (Exception e){
// (8) 혹시 예외가 있는 경우 dto 대신 error에 메시지를 넣어 리턴
String error = e.getMessage();
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
curl -d '{"title":"새포스트1"}' -H "Content-Type: application/json" -X POST localhost:8080/todo
// 리턴 결과
{"error":null,"data":[{"id":"ff8080817f8816c2017f881ecb880000","title":"새포스트1","done":false}]}
Retrieve Todo 구현
Todo 아이템을 검색하는 기능을 구현하겠다.
퍼시스턴스 구현
이미 작성한 TodoRepository를 사용한다. 이 클래스는 JpaRepository를 상속하므로 Todo 리스트 반환에 findByUserId() 메서드를 사용한다.
서비스 구현
TodoService에 retrieve 메서드를 작성해보자.
[TodoService.java]
public List<TodoEntity> retrieve(final String userId){
return repository.findByUserId(userId);
}
컨트롤러 구현
TodoController에 GET 메서드를 만들어준다.
[TodoController.java]
@GetMapping
public ResponseEntity<?> retrieveTodoList(){
String temporaryUserId = "temporary-user"; // temporary user id.
// (1) 서비스 메서드의 retrieve() 메서드를 사용해 Todo 리스트를 가져온다.
List<TodoEntity> entities = service.retrieve(temporaryUserId);
// (2) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환한다.
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (3) 변환된 TodoDTO 리스트를 활용해 ResponseDTO를 초기화한다.
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (4) ResponseDTO를 리턴한다.
return ResponseEntity.ok().body(response);
}
POST method로 새 Todo 아이템을 만들고 HTTP GET 메서드로 리스트를 받아보자.
curl -d '{"title":"새 포스트2"}' -H "Content-Type: application/json" -X POST localhost:8080/todo
// 결과
{"error":null,"data":[{"id":"ff8080817f883a5e017f883aadd00000","title":"새 포스트2","done":false}]}
결과는 마찬가지로 JSON 형태의 HTTP 응답이 리턴되었다.
Update Todo 구현
Todo 아이템을 업데이트하는 기능을 구현하겠다.
퍼시스턴스 구현
퍼시스턴스로 TodoRepository를 사용한다. 업데이트를 위해 save(), findByUserId() 메서드를 사용한다.
서비스 구현
TodoService에 update 메서드를 구현하겠다.
[TodoService.java]
import java.util.Optional;
public List<TodoEntity> update(final TodoEntity entity){
// (1) 저장할 엔티티가 유효한지 확인한다.
validate(entity);
// (2) 넘겨 받을 엔티티 id를 이용해 TodoEntity를 가져온다.
final Optional<TodoEntity> original = repository.findById(entity.getId());
original.ifPresent( todo -> {
// (3) 반환된 TodoEntity가 존재하면 값을 새 entity 값으로 덮어 씌운다.
todo.setTitle(entity.getTitle());
todo.setDone(entity.isDone());
// (4) DB에 새 값을 저장한다. id가 Table에 이미 존재하면 update로 간주
repository.save(todo);
});
// 사용자의 모든 Todo 리스트를 리턴한다.
return retrieve(entity.getUserId());
}
컨트롤러 구현
TodoController에 PUT 메서드인 updateTodo를 만들겠다.
[TodoController.java]
import org.springframework.web.bind.annotation.PutMapping;
@PutMapping
public ResponseEntity<?> updateTodo(@RequestBody TodoDTO dto){
String temporaryUserId = "temporary-user"; // temporary user id.
// (1) dto를 entity로 변환한다.
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) id를 temporaryUserId로 초기화한다. 4장에서 수정할 예정
entity.setUserId(temporaryUserId);
// (3) 서비스를 이용해 entity를 업데이트한다.
List<TodoEntity> entities = service.update(entity);
// (4) 자바 스트림으로 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (5) TodoDTO 리스트로 ResponseDTO를 초기화한다.
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (6) ResponseDTO를 리턴한다.
return ResponseEntity.ok().body(response);
}
테스팅
POST로 Todo Item을 먼저 만든다.
curl -d '{"title":"새 포스트 1"}'
-H 'Content-Type:application/json'
-X POST localhost:8080/todo
// 결과
{"error":null,
"data":[{"id":"ff8080817f8b4b90017f8b4cb7d00000","title":"새 포스트 1","done":false}]}
PUT으로 수정한다.
curl -d '{"id":"ff8080817f8b4b90017f8b4cb7d00000","title":"새 포스트 1-수정","done":true}'
-H 'Content-Type:application/json'
-X PUT localhost:8080/todo
// 결과
{"error":null,
"data":[{"id":"ff8080817f8b4b90017f8b4cb7d00000","title":"새 포스트 1-수정","done":true}]}
Delete Todo 구현
Todo 아이템을 삭제하는 기능을 구현하겠다.
퍼시스턴스 구현
퍼시스턴스로 TodoRepository를 사용한다. 업데이트를 위해 delete(), findByUserId() 메서드를 사용한다.
서비스 구현
TodoService에 delete 메서드를 구현하겠다.
[TodoService.java]
public List<TodoEntity> delete(final TodoEntity entity){
// (1) 엔터티가 유용한지 확인한다.
validate(entity);
try{
// (2) 엔티티를 삭제한다.
repository.delete(entity);
} catch(Exception e){
// (3) exception 발생 시 id와 exception을 로깅한다.
log.error("error deleting entity ", entity.getId(), e);
// (4) 컨트롤러 exception을 보낸다. DB 내부 로직을 캡슐화하려면 e를 리턴하지 않고
// 새로운 exception 오브젝트를 리턴한다.
throw new RuntimeException("error deleting entry " + entity.getId());
}
// (5) 새 Todo 리스트를 가져와 리턴한다.
return retrieve(entity.getUserId());
}
컨트롤러 구현
TodoController에 DELETE 메서드인 deleteTodo 메서드를 만들겠다.
[TodoController.java]
import org.springframework.web.bind.annotation.DeleteMapping;
@DeleteMapping
public ResponseEntity<?> deleteDTO(@RequestBody TodoDTO dto){
try{
String temporaryUserId = "temporary-user"; // temporary user id.
// (1) TodoEntity로 변환한다.
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) 임시 사용자 아이디를 설정한다. 4장에서 구현한다.
entity.setUserId(temporaryUserId);
// (3) 서비스를 이용해 entity를 삭제한다.
List<TodoEntity> entities = service.delete(entity);
// (4) 자바 스트림으로 엔터티 리스트를 TodoDTO 리스트로 변환.
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (5) TodoDTO 리스트로 ResponseDTO를 초기화
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (6) ResponseDTO를 리턴
return ResponseEntity.ok().body(response);
} catch (Exception e){
// (7) 혹시 예외가 있는 경우 dto 대신 error에 메시지를 넣어 리턴한다.
String error = e.getMessage();
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
테스팅
Todo Item을 생성한다.
curl -d '{"title":"새 포스트 1"}'
-H 'Content-Type:application/json'
-X POST localhost:8080/todo
// 결과
{"error":null,
"data":[{"id":"ff8080817f8b71e5017f8b720c9f0000","title":"새 포스트 1","done":false}]}
DELETE 요청을 보낸다.
curl -d '{"id":"ff8080817f8b71e5017f8b720c9f0000"}'
-H 'Content-Type:application/json'
-X DELETE localhost:8080/todo
// 결과
{"error":null,"data":[]}
정리
CRUD 오퍼레이션을 구현했다. 3장에서는 여기서 만든 백엔드 REST API를 자바스크립트에서 사용 방법을 알아보겠다.
출처: React.js, 스프링 부트, AWS로 배우는 웹 개발 101
깨달은 점
- TodoDTO.toEntity는 static으로 함수를 선언해 객체 없이 함수를 사용할 수 있다.
- Optional은 NPE(NullPointerException)을 방지하도록 도와준다. Optional은 null이 올 수 있는 값을 감싸는 Wrapper 클래스이다. 클래스이기 때문에 각종 메서드를 제공한다. (https://mangkyu.tistory.com/70) Optional.ifPresent(메서드)로 작성해서 참일 때만 메서드를 실행
- Lambda 표현식을 사용해, (모두 같은 type이면 생략 가능)매개변수 -> {메서드} 실행 가능.
- JpaRepository.save() 메서드는 id가 중복되면 자동으로 update로 sql query를 생성한다.
질문
- Java Generic을 공부한 후 java stream의 map 함수 이해하기
- JpaRepository.save() 메서드는 id가 중복되면 자동으로 update로 sql query를 생성한다. 실행 과정 공부해서 정리하기.
- Optional 글 따로 쓰기, Optional을 쓰면 참조해도 NPE가 왜 발생하지 않는지?
- Lambda 표현식 정리
- log.error 메서드 원형 보기
'React, 스프링, AWS로 배우는 웹개발 101' 카테고리의 다른 글
인증 프론트엔드 통합 (0) | 2022.04.08 |
---|---|
프로덕션 배포 (0) | 2022.04.07 |
인증 백엔드 통합 (0) | 2022.03.28 |
프론트엔드 개발 (0) | 2022.03.16 |
ToDoList APP을 개발하기 전 (0) | 2022.02.26 |