웹 개발 101

웹 개발 101_Chapter02_백엔드 개발

강용민 2022. 11. 25. 19:56

Spring Boot

스프링 부트는 Stand-alone 프로덕션급의 스프링 기반 애플리케이션을 쉽게 구동할 수 있다. Stand-alone이란 이 애플리케이션을 실행하기 위해 여타 다른 애플리케이션이 필요하지 않다는 뜻이다. 예를 들어 내장형 서버, 시큐리티, 측정, 상태 점검, 외부 설정등이 있다.

또 스프링 부트는 개발자가 최소의 설정을 할 수 있도록 많은 부분을 자동으로 설정해준다.

 

스프링 프레임워크와 디스패쳐 서블릿

우리가 개발하는 자바 웹 애플리케이션은 대부분 자바 서블릿을 기반으로 한다. 서블릿 기반의 서버를 사용하기 위해 개발자는 Javax.servlet.http.HttpServlet을 상속받는 서브 클래스를 작성해야 한다. 그러면 서블릿 컨테이너가 서블릿 서브 클래스를 실행시킨다.

스프링 부트는 어노테이션과 서브클래스를 적절히 이용해 서블릿 클래스를 작성하지 않고, 우리의 비즈닉스 로직에 집중할 수 있게 한다.

 

메인 메서드와 @SpringBootApplication

DemoApplication.java

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

@SpringBootApplication은 해당 클래스가 스프링 부트를 설정하는 클래스임을 의미한다. 또한 해당 클래스가 있는 패키지를 베이스 패키지로 간주한다.

스프링은 베이스 패키지와 그 하위 패키지에서 자바 빈을 찾아 스프링의 의존성 주입 컨테이너 오브젝트, 즉 ApplicationContext에 등록한다. 그리고 애플리케이션 실행 중 어떤 오브젝트가 필요한 경우, 의존하는 다른 오브젝트를 찾아 연결해준다. 그러기 위해서는 @ComponentScan, @Bean, @Configuration이 필요한데 이를 @SpringBootApplication하나로 생략할 수 있다.

 

빌드 자동화 툴: Gradle과 라이브러리

Gradle은 빌드 자동화 툴이다. 빌드 자동화 툴은 컴파일, 라이브러리 다운로드, 패키징, 테스팅 등을 자동화할 수 있다. 라이브러리를 다운받는 대신 원하는 라이브러리와 버전을 코드로 작성한다. 오퍼레이터가 직접 컴파일, 빌드, 단위 테스트를 실행하는 대신 이 과정을 일련의 코드로 적는다. 그러면 빌드 자동화 툴이 이 코드를 해성해 프로젝트 빌드에 필요한 작업을 실행해준다.

그럼 프로젝트 내부의 build.gradle을 살펴보자.

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

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'
}

tasks.named('test') {
    useJUnitPlatform()
}
  • Group, Version, SourceCompatibility
    • 이들은 프로젝트의 메타데이터로 group은 애플리케이션를 배포하기 위해 사용되며, version은 이 프로젝트의 버전을, sourceCompatibility는 자바 버전을 표시한다. 자바 플러그인은 sourceCompatibilitity에 명시된 자바 버전을 이용해 소스를 컴파일한다.
  • Repository
    • gradle이 라이브러리를 다운로드 하는 곳을 의미한다. mavenCentral은 mvnrepository이다.
  • Dependency
    • 이 프로젝트에서 사용할 라이브러리를 명시하면 gradle이 repository에서 라이브러리를 다운 및 설치한다.
  • Test
    • 단위 테스트 시 어떤 프레임워크를 사용할지 명시한다. JUnitPlatform을 사용해 단위 테스트를 하도록 명시했다.

 

벡엔드 서비스 아키텍처

레이어드 아키텍처 패턴

이 패턴은 프로젝트 내부에서 어떻게 코드를 적절히 분리하고 관리할 것이냐에 대한 이야기이다.레이어드 아키텍처 패턴은 애플리케이션을 구성하는 요소들을 수평으로 나눠 관리하는 것이다. 이의 궁극적인 목표는 OCP(Open Close Principal)을 해결하기 위함과 적절한 분리를 통한 결합성하락, 가독성 향상을 위함이다.

각 Layer들에게 데이터를 전달하기 위한 데이터를 담기 위한 클래스들이 존재하는데, 이를 DTO, Model, Entity로 부른다.

Presentation Layer은 DTO로 사용자에게 표현해야할 데이터를 담는 역할을 하며, Business Layer는 Model를 사용해 비즈니스 데이터를 담는 역할을 한다. 또 Persistence Layer에서는 Entity를 이용해 데이터베이스의 테이블과 스키마를 표현하는 역할을 한다.

 

Model과 Entity

이 프로젝트에서는 모델과 엔티티를 한 클래스에 구현한다. 따라서 모델은 비즈니스 데이터를 담는 역할과 데이터베이스의 테이블과 스키마를 표현하는 두 역할을 한다.이제 TodoEntity를 만들어보자. 이는 Todo 리스트의 한 아이템에 해당한다.

 

TodoEntity.java

package com.example.demo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoEntity {
    private String id;
    private String userId;
    private String title;
    private boolean done;
}

 

DTO(Data Transition Object)

서비스가 요청을 처리하고 클라이언트로 반환할 때, Model 자체를 그대로 리턴하는 경우는 별로 없다. 보통은 데이터를 전달하기 위해 사용하는 오브젝트인 DTO로 변환한다. 변환이유는 다음과 같다.

  • 비즈니스 로직을 캡슐화하기 위함이다.
    • Model은 데이터베이스 테이블 구조와 매우 유사하여, Model이 가지고 있는 필드들은 테이블의 스키마와 비슷할 것이다. 이는 외부 사용자에게 데이터베이스 스키마를 보여주는 꼴이된다. 이를 방지할 수 있다.
  • 클라이언트가 필요한 정보를 모델이 전부 포함하지 않는 경우가 많다.
    • 에러가 나면 이 에러메시지를 어디에 포함해야 하는가? 모델은 서비스 로직과는 관련이 없기에 모델에 담기는 애매하다. 이런 경우 DTO에 에러 메시지 필드를 선언하고 DTO에 포함하면 된다.

이제 TodoDTO를 생성하자. 사용자는 이 클래스를 이용해 Todo 아이템을 생성, 수정, 삭제할 예정이다.

 

TodoDTO.java

package com.example.demo;

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는 애플리케이션과 데이터베이스에서 사용자를 구별하기 위한 고유 식별자로 사용하기 때문에 숨길 수 있다면 숨기는 것이 보안상 알맞다.

이제 HTTP 응답으로 사용할 DTO가 필요하다. ResponseDTO를 생성하자.

 

ResponseDTO.java

package com.example.demo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> {
    private String error;
    private List<T> data;
}

TodoDTO뿐 아니라 이후 다른 모델의 DTO도 ResponseDTO를 이용해 리턴 할 수 있도록 자바 Generic을 이용했다. 또한 Todo리스트를 반환하는 경우가 많아 데이터를 리스트로 반환하도록 짰다.

 

Rest 아키텍처 스타일

클라이언트가 우리 서비스를 이용하려면 어떤 형식으로 요청을 보내고 응답을 받는지에 대한 이야기이다. Rest 아키텍처 스타일은 6가지 제약 조건으로 구성된다. 이 가이드라인을 따르는 API를 RESTful API라 한다.

  • 클라이언트-서버
    • 리소스를 관리하는 서버가 존재하며, 다수의 클라이언트가 리소스를 소비하기 위해 서버에 접근하는 구조
  • 상태가 없는(Stateless)
    • 클라이언트가 서버에 요청을 보낼 때, 이전 요청의 영향을 받지 않음을 의미한다. 그러기 위해서는 클라이언트가 서버에 요청을 날릴 때마다 요청에 리소스를 받기 위한 모든 정보를 포함해야 한다.
  • 캐시 가능한 데이터(Cacheable)
    • 서버에서 리소스를 리턴할 때 캐시가 가능한지 명시할 수 있어야 한다. HTTP에서는 cache-control이라는 헤더에 리소스의 캐시 여부를 명시할 수 있다.
  • 일관적인 인터페이스(Uniform Inteface)
    • 시스템 또는 애플리케이션의 리소스에 접근하기 위한 인터페이스가 일관적이어야 한다.
  • 레이어 시스템(Layered System)
    • 클라이언트가 서버에 요청을 날릴 때, 여러 개의 레이어로 된 서버를 거칠 수 있다. 이 사이의 레이어들은 요청과 응답에 어떤 영향을 미치지 않으며 클라이언트는 서버의 레이어 존재 유무를 알지 못한다.

 

컨트롤러 레이어 : 스프링 REST API 컨트롤러

TodoController.java

package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;

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

 

서비스 레이어 : 비즈니스 로직

서비스 레이어는 Controller와 persistence 사이에서 비즈니스 로직을 수행하는 역할을 한다.

 

TodoService.java

package com.example.demo.service;

import com.example.demo.model.TodoEntity;
import com.example.demo.persistence.TodoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

 

퍼시스턴스 레이어 : 스프링 데이터 JPA

우리 애플리케이션은 Todo 아이템을 데이터베이스에 저장하고 추출해야 한다. 근데 추출된 데이터는 자바 애플리케이션 내에서 사용해야 한다. 어떻게 해야 할까? JDBC를 이용하여 데이터베이스와 연결하고, ORM프레임워크를 이용하여 데이터베이스에서 추출된 데이터를 Java에 맞게 매핑해줘야 한다.

JPA는 스펙이다. 스펙이라는 것은 'JPA를 구현하기 위해서 이런 이런 기능을 작성하라'라고 말해주는 지침서이다.우리는 그 중 JpaRepository를 사용한다.

 

TodoEntity.java

package com.example.demo.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name = "Todo")
public class TodoEntity {
    //primary key 지정
    @Id
    //id 자동생성
    @GeneratedValue(generator = "system-uuid")
    //커스텀 Generator
    @GenericGenerator(name="system-uuid",strategy="uuid")
    private String id;
    private String userId;
    private String title;
    private boolean done;
}

 

TodoRepository.java

package com.example.demo.persistence;

import com.example.demo.model.TodoEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TodoRepository extends JpaRepository<TodoEntity,String> {
    @Query("select * from TodoEntity t where t.userId = ?1")
    List<TodoEntity> findZByUserIdQuery(String userId);
}

 

서비스 개발 및 실습

Todo서비스를 작성해보자. 이 장에서 작성할 서비스는 생성, 검색, 수정, 삭제 네가지 API이다.

퍼시스턴스 -> 서비스 -> 컨트롤러 순으로 구현한다.

Create Todo 구현

persistence 구현

위에서 구현했던 TodoRepository를 사용한다.

 

Service 구현

서비스 추가를 위해 다음과 같이 작성하자. 메서드는 크게 세 단계로 구성되어 있다.

  • 검증 : 넘어온 엔티티가 유효한지 검사하는 로직
  • save() : 엔티티를 데이터베이스에 저장한다.
  • findByUserId() : 저장된 엔티티를 포함하는 새 리스트를 리턴한다.

TodoService.java

package com.example.demo.service;

import com.example.demo.model.TodoEntity;
import com.example.demo.persistence.TodoRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Slf4j
@Service
public class TodoService {
    @Autowired
    private TodoRepository repository;

    public List<TodoEntity> create(final TodoEntity entity){
        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("Unkonwn user");
            throw new RuntimeException("Unknown user.");
        }
    }
}

 

컨트롤러 구현

HTTP 응답을 반환할 때, 비즈니스 로직을 캡슐화하거나 추가적인 정보를 함께 반환하기 위해 DTO를 사용한다고 했다.DTO를 Entity로 변환하는 메서드를 작성하자.

 

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

    public static TodoEntity toEntity(final TodoDTO dto){
        return TodoEntity.builder().
                id(dto.getId()).
                title(dto.getTitle()).
                done(dto.isDone()).
                build();
    }
}

 

TodoController.java

package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.TodoDTO;
import com.example.demo.model.TodoEntity;
import com.example.demo.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("todo")
public class TodoController {
    @Autowired
    private TodoService service;

    @PostMapping
    public ResponseEntity<?> createTodo(@RequestBody TodoDTO dto){
        try{
            String temporaryUserId = "temporary-user";
            TodoEntity entity = TodoDTO.toEntity(dto);
            
            entity.setId(null);
            entity.setUserId(temporaryUserId);
            
            List<TodoEntity> entities = service.create(entity);
            
            List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
            
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
            
            return ResponseEntity.ok().body(response);
        }catch (Exception e){
            String error = e.getMessage();
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
            return ResponseEntity.badRequest().body(response);
        }
    }
}

 

Retrieve Todo 구현

이 절에서는 Todo 리스트를 검색하기 위한 리포지터리, 서비스, 컨트롤러를 구현한다.

 

persistence 구현

여전히 기존에 구현했던 TodoRepository를 사용한다.

 

service 구현

리포지터리의 findByUserId()를 이용해 retrieve 메서드를 작성하자.

 

TodoService.java 일부

public List<TodoEntity> retrieve(final String userId){
    return repository.findByUserId(userId);
}

 

컨트롤러 구현

TodoController.java 일부

@GetMapping
public ResponseEntity<?> retrieveTodoList(){
    String temporaryUserId = "temporary-user";

    List<TodoEntity> entities = service.retrieve(temporaryUserId);
    List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());

    ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
    return ResponseEntity.ok().body(response);
}

 

Update Todo 구현

이 절에서는 Todo를 업데이트하기 위한 리포지터리, 서비스, 컨트롤러를 구현한다.

 

service 구현

TodoService.java 일부

public List<TodoEntity> update(final TodoEntity entity){
    validate(entity);

    final Optional<TodoEntity> original = repository.findById(entity.getId());
    original.ifPresent(todo -> {
        todo.setTitle(entity.getTitle());
        todo.setDone(entity.isDone());

        repository.save(todo);
    });
    return retrieve(entity.getUserId());
}

 

controller 구현

TodoController.java 일부

@PutMapping
public ResponseEntity<?> updateTodo(@RequestBody TodoDTO dto){
    String temporaryUserId = "temporary-user";

    TodoEntity entity = TodoDTO.toEntity(dto);
    entity.setUserId(temporaryUserId);
    List<TodoEntity> entities = service.update(entity);

    List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
    ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
    return ResponseEntity.ok().body(response);
}

 

Delete Todo 구현

마지막 절에서는 Todo를 삭제하기 위한 리포지터리, 서비스, 컨트롤러를 구현한다.

 

service 구현

TodoService.java 일부

public List<TodoEntity> delete(final TodoEntity entity){
    validate(entity);

    try{
        repository.delete(entity);
    }catch (Exception e){
        log.error("error deleting entity",entity.getId(),e);
        throw new RuntimeException("error deleting entity"+entity.getId());
    }
    return retrieve(entity.getUserId());
}

 

컨트롤러 구현

TodoController.java 일부

@DeleteMapping
public ResponseEntity<?> deleteTodo(@RequestBody TodoDTO dto){
    try{
        String temporaryUserId = "temporary-user";
        TodoEntity entity = TodoDTO.toEntity(dto);
        entity.setUserId(temporaryUserId);

        List<TodoEntity> entities = service.delete(entity);

        List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
        ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
        return ResponseEntity.ok().body(response);
    }catch (Exception e){
        String error = e.getMessage();
        ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
        return ResponseEntity.badRequest().body(response);
    }
}