[개인프로젝트/말랑말랑] 2. 3 계층 스프링 부트 애플리케이션 1
2. 3 계층 스프링 부트 애플리케이션 1 by happyprogfrog · Pull Request #2 · happyprogfrog/mallang
작업 내용 도메인 설계, 비즈니스 로직 레이어, 프레젠테이션 레이어, 프론트엔드 개발 TIL @SpringBootTest를 남용하지 말 것 TDD 흐름 블로그 글 링크 [개인프로젝트/말랑말랑] 2. 3 계층 스프링 부트
github.com
다층 아키텍처
다층 아키텍처(multi-tier architecture)는 애플리케이션을 여러 계층으로 나눈 아키텍처이다. 운영 환경에 적합하기 때문에 대부분의 상용 애플리케이션이 다층 아키텍처로 설계되어 있다. 그중 계층을 3개로 나눈 3 계층 구조는 가장 인기 있는 아키텍처로서 웹 애플리케이션을 설계할 때 많이 사용된다.
- 클라이언트 계층: 사용자 인터페이스를 제공하는 계층. 프론트엔드
- 애플리케이션 계층: 비즈니스 로직, 상호작용을 위한 인터페이스, 데이터를 저장하는 인터페이스를 포함하는 계층. 백엔드
- 데이터 저장 계층: 애플리케이션의 데이터를 보관하는 계층. 데이터베이스, 파일 시스템 등
자세히 살펴보면 애플리케이션 계층은 다음 세 개의 레이어로 나뉜다.
- 비즈니스 레이어: 도메인과 비즈니스 명세를 모델링한 클래스가 있으며, 애플리케이션의 두뇌 역할을 한다! 보통 개체(Multiplication)와 비즈니스 로직을 제공하는 서비스(MultiplicationService)의 조합으로 이뤄진다. 해당 레이어를 도메인(개체)과 애플리케이션(서비스)으로 나누기도 한다.
- 프레젠테이션 레이어: 우리가 만들 애플리케이션에서는 웹 클라이언트에 기능을 제공하는 컨트롤러 클래스가 프레젠테이션 레이어에 해당. 이 컨트롤러에 REST API를 구현한다.
- 데이터 레이어: 개체들을 데이터 스토리지나 데이터베이스에 보관한다. 보통 데이터 액세스 객체(Data Access Object; DAO) 또는 저장소 클래스를 포함한다. DAO는 데이터베이스 모델을 다루고, 저장소 클래스는 도메인을 데이터베이스 레이어로 변환하는 클래스이다.
@SpringBootTest를 남용하지 말자!
SpringExtension(책에서는 SpringRunner)는 애플리케이션 컨텍스트를 초기화하고 필요한 객체를 주입한다. 다행히 컨텍스트는 캐시로 재사용할 수 있어서 테스트 당 한 번만 로딩된다.
그러나, 단순히 클래스 하나의 기능을 테스트하기 위해서라면 종속성을 주입하거나 애플리케이션 컨텍스트가 필요하지 않다. 이런 경우는 @SpringBootTest를 사용하지 않고 그냥 클래스의 구현체를 테스트하는 게 낫다. 컨텍스트를 재사용한다고 하더라도 @SpringBootTest를 사용하면 자원과 시간을 낭비하게 되고, 트랜잭션 롤백과 부작용이 발생하지 않도록 스프링 컨텍스트를 정리해야 하기 때문이다. 대신 여러 클래스 간의 상호작용을 확인하는 통합 테스트에서 @SpringBootTest를 사용하도록 한다.
수정 전
package me.progfrog.mallang.service;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class RandomGeneratorServiceTest {
@Autowired
private RandomGeneratorService randomGeneratorService;
@Test
@DisplayName("생성한 인수가 11 ~ 99 범위에 있는지 확인 버전 1")
void generateRandomFactorIsBetweenExpectedLimits() throws Exception {
// 무작위 숫자를 생성
List<Integer> randomFactors = IntStream.range(0, 1000)
.map(i -> randomGeneratorService.generateRandomFactor())
.boxed()
.toList();
// 적당히 어려운 계산을 만들기 위해
// 생성한 인수가 11 ~ 99 범위에 있는지 확인
assertThat(randomFactors)
.allMatch(factor -> factor >= 11 && factor <= 99, "생성한 인수가 11 ~ 99 범위에 있어야 함");
}
}
package me.progfrog.mallang.service;
import me.progfrog.mallang.domain.Multiplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@SpringBootTest
class MultiplicationServiceTest {
@MockBean
private RandomGeneratorService randomGeneratorService;
@Autowired
private MultiplicationService multiplicationService;
@Test
@DisplayName("랜덤한 인수에 대한 계산 결과가 잘 나오는 지 확인")
void createRandomMultiplication() {
// given (randomGeneratorService가 처음에 50, 나중에 30을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);
// when
Multiplication multiplication = multiplicationService.createRandomMultiplication();
// then
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}
수정 후
package me.progfrog.mallang.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
public class RandomGeneratorServiceImplTest {
private RandomGeneratorServiceImpl randomGeneratorServiceImpl;
@BeforeEach
void setUp() {
randomGeneratorServiceImpl = new RandomGeneratorServiceImpl();
}
@Test
@DisplayName("생성한 인수가 11 ~ 99 범위에 있는지 확인 버전 2")
public void generateRandomFactorIsBetweenExpectedLimits() {
// 무작위 숫자를 생성
List<Integer> randomFactors = IntStream.range(0, 1000)
.map(i -> randomGeneratorServiceImpl.generateRandomFactor())
.boxed()
.toList();
// 적당히 어려운 계산을 만들기 위해
// 생성한 인수가 11 ~ 99 범위에 있는지 확인
assertThat(randomFactors)
.allMatch(factor -> factor >= 11 && factor <= 99, "생성한 인수가 11 ~ 99 범위에 있어야 함");
}
}
package me.progfrog.mallang.service;
import me.progfrog.mallang.domain.Multiplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
class MultiplicationServiceImplTest {
@Mock
private RandomGeneratorService randomGeneratorService;
@InjectMocks
private MultiplicationServiceImpl multiplicationServiceImpl;
@Test
@DisplayName("랜덤한 인수에 대한 계산 결과가 잘 나오는 지 확인")
void createRandomMultiplication() {
// given (randomGeneratorService가 처음에 50, 나중에 30을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);
// when
Multiplication multiplication = multiplicationServiceImpl.createRandomMultiplication();
// then
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}
도메인 설계
개발 프로세스를 시작하기 전에 시스템에서 식별할 수 있는 다양한 개체와 개체 간의 관계를 명확하게 파악하는 것이 중요하다. 소프트웨어를 설계할 때 가장 핵심적인 부분이고, 따라서 나중에 수정하기 가장 어려운 부분이 된다.
비즈니스 로직에서 사용하는 객체를 요구사항에 따라 다음과 같이 정의할 수 있다.
- Multiplication: 곱셈의 인수와 연산을 포함
- User: 곱셈 문제를 푸는 사용자를 식별
- MultiplicationResultAttempt: Multiplication과 User의 참조를 포함하고, 사용자가 제출한 값과 채점 결과를 포함
불변성과 롬복 라이브러리
잠시 후에 Multiplication 클래스를 final로 만들 예정이다. 따라서 해당 클래스의 모든 필드는 getter로만 접근이 가능하고 해당 클래스는 불변 상태가 된다. 불변성(Immutability)은 여러 이점을 주는데, 가장 중요한 점은 다중 스레드에서 발생할 수 있는 여러 문제에서 안전하게 만들어준다는 점이다.
롬복은 컴파일러 동작 전에 애노테이션을 기반으로 코드를 생성한다. 이것의 이점은 롬복은 코드의 중복과 불필요한 부분을 제거해 간결하게 만든다(getter, 생성자, toString, hashCode, equals 메서드 등).
비즈니스 로직 레이어
- 제출한 답안의 정답 여부 확인
- 적당히 어려운 곱셈 만들어내기
프레젠테이션 레이어(REST API)
- 이제 웹 클라이언트와 다른 애플리케이션이 우리가 만든 기능과 상호작용할 수 있도록 REST API를 제공하자.
- REST는 HTTP를 기반으로 하는 기초적인 인터페이스로 간단하게 사용할 수 있다.
- 여기서 중요한 것은 우리가 애플리케이션을 만드는 데 엄밀히 따지면 REST 레이어가 꼭 필요한 건 아니라는 것인데, 스프링 MVC에서 화면 이름을 반환하면 HTML 같은 뷰 레이어 구현체로 렌더링 할 수 있기 때문이다.
- 하지만 그러면 코드 기반에 뷰를 설계할 필요가 있으며, 이 경우 UI 기술이 변경될 때마다 수정하기가 더 어려워진다(예를 들어, Angular로 마이그레이션 하거나 모바일 앱을 추가하는 등).
- REST를 사용하면 UI 필요 없이 HTTP 만을 사용해 인터페이스를 제공할 수 있다.
- 하나의 API로 여러 가지 백엔드 서비스에 기능을 제공할 수 있다(예를 들면, 다른 애플리케이션에서 무작위 곱셈을 필요로 하는 경우).
그러면 어떤 인터페이스를 제공해야 할까?
- GET /multiplications/random: 무작위로 생성한 곱셈을 반환
- POST /results: 결과를 전송하는 엔드포인트
- GET /results?user=[user_alias]: 특정 사용자의 계산 결과를 검색
보다시피 multiplication과 result라는 두 가지 컨텍스트로 API를 설계했다. 모든 것을 하나의 컨트롤러에 담지 말고 비즈니스 개체와 연관된 인터페이스에 분리해서 담는 것이 좋다. 따라서, 곱셈과 결과 컨트롤러를 따로 만들어보도록 하자!
Multiplication 컨트롤러
테스트 코드 작성
- @WebMVCTest는 스프링의 웹 애플리케이션 컨텍스트를 초기화한다. 하지만 @SpringBootTest처럼 모든 설정을 불러오는 것이 아니라, MVC 레이어(컨트롤러)와 관련된 설정만 불러온다. 이 애노테이션은 MockMvc 빈도 불러온다.
- @WebMVCTest는 컨트롤러를 테스트하는 애노테이션으로, HTTP 요청과 응답은 목을 이용해 가짜로 이뤄지고 실제 연결은 생성되지 않는다.
- 반면, @SpringBootTest는 웹 애플리케이션의 컨텍스트와 설정을 모두 불러와 실제 웹 서버에 연결을 시도한다. 이런 경우에는 MockMvc가 아니라 RestTemplate을 대신 사용하면 된다(또는 새로운 TestRestTemplate).
- 따라서, @WebMVCTest는 서버에서 컨트롤러만 테스트할 때 사용하고, @SpringBootTest는 클라이언트로부터 상호작용을 확인하는 통합 테스트에서 사용하는 것이 좋다.
- @SpringBootTest가 목을 사용할 수 없다는 뜻은 아니며, 통합 테스트에서도 목은 필요하지만 어쨌든 @SpringBootTest는 간단한 컨트롤러 단위 테스트에서 사용하지 않는 것이 좋다.
- @Mock 대신 @MockBean을 사용한다. 스프링이 진짜 빈(MultiplicationServiceImpl) 대신 목 객체를 주입해야 하기 때문이다. 목 객체는 given() 메서드에서 지정한 대로 값을 반환한다. 이 테스트는 컨트롤러만 테스트하는 것이지 서비스를 테스트하는 것은 아니다.
- JacksonTester 객체를 사용해 JSON의 내용을 쉽게 확인할 수 있다. JacksonTester 객체는 자동으로 설정할 수 있고, @JsonTest 애노테이션을 이용해 자동으로 주입할 수 있다. 예제에서는 @WebMVCTest를 사용하기 때문에 수동으로 설정하고 있다(@BeforeEach 메서드 안에서 설정).
package me.progfrog.mallang.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.service.MultiplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ExtendWith(SpringExtension.class)
@WebMvcTest(MultiplicationController.class)
class MultiplicationControllerTest {
@MockBean
private MultiplicationService multiplicationService;
@Autowired
private MockMvc mvc;
// 이 객체는 initFields() 메서드를 이용해 자동으로 초기화
private JacksonTester<Multiplication> json;
@BeforeEach
void setUp() {
JacksonTester.initFields(this, new ObjectMapper());
}
@Test
@DisplayName("무작위로 생성한 곱셈을 반환")
public void getRandomMultiplicationTest() throws Exception {
// given
given(multiplicationService.createRandomMultiplication()).willReturn(new Multiplication(70, 20));
// when
MockHttpServletResponse response = mvc.perform(
get("/multiplications/random")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(json.write(new Multiplication(70, 20)).getJson());
}
}
package me.progfrog.mallang.controller;
import lombok.RequiredArgsConstructor;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.service.MultiplicationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping("/multiplications")
final class MultiplicationController {
private final MultiplicationService multiplicationService;
@GetMapping("/random")
public Multiplication getRandomMultiplication() {
return multiplicationService.createRandomMultiplication();
}
}
TDD 흐름은 다음과 같다!
우선 컴파일하기 위한 컨트롤러 클래스 생성 -> 테스트 코드 작성 -> 테스트 실패 확인 -> MultiplicationController 구현 -> 테스트 통과 확인
컨트롤러는 이처럼 코드가 간단하다 보니 컨트롤러의 단위 테스트를 중요하게 생각하지 않는 사람도 있다. 하지만 여러 팀이 서로 다른 서비스를 관리하는 마이크로 서비스 환경에서 API를 변경하는 경우, 컨트롤러 단위 테스트가 있으면 각 팀에서 변경사항을 이중으로 확인할 필요가 없다. 만약 실수로 /multiplications/ranom을 /multiplication/ranom으로 수정한다면 테스트에서 오류를 확인할 수 있고, 따라서 API를 사용하는 쪽에서 알아채기 전에 미리 오류를 감지할 수 있다.
Result 컨트롤러
Result 컨트롤러는 사용자가 제출한 답안을 확인하고 채점 결과를 반환한다. 응답을 반환하는 방법에는 여러 가지가 있지만, 결과를 감싸는 클래스를 만드는 것이 좋다. 이 클래스는 우선은 correct라는 boolean 필드만 가지고 있다. 만약 boolean 값을 직접 반환한다면, 기본 JSON 직렬화 객체가 동작하지 않는다.
TDD 흐름은 동일하다.
package me.progfrog.mallang.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.mallang.controller.MultiplicationResultAttemptController.ResultResponse;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.domain.User;
import me.progfrog.mallang.service.MultiplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ExtendWith(SpringExtension.class)
@WebMvcTest(MultiplicationResultAttemptController.class)
class MultiplicationResultAttemptControllerTest {
@MockBean
private MultiplicationService multiplicationService;
@Autowired
private MockMvc mvc;
// 이 객체는 initFields() 메서드를 이용해 자동으로 초기화
private JacksonTester<MultiplicationResultAttempt> jsonResult;
private JacksonTester<ResultResponse> jsonResponse;
@BeforeEach
void setUp() {
JacksonTester.initFields(this, new ObjectMapper());
}
@Test
@DisplayName("사용자가 보낸 답안이 맞음")
void postResultReturnCorrect() throws Exception {
genericParameterizedTest(true);
}
@Test
@DisplayName("사용자가 보낸 답안이 틀림")
void postResultReturnNotCorrect() throws Exception {
genericParameterizedTest(false);
}
void genericParameterizedTest(final boolean correct) throws Exception {
// given (지금 서비스를 테스트하는 것이 아님!)
given(multiplicationService
.checkAttempt(any(MultiplicationResultAttempt.class)))
.willReturn(correct);
User user = new User("Frog");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3500);
// when
MockHttpServletResponse response = mvc.perform(post("/results")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonResult.write(attempt).getJson()))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(jsonResponse.write(new ResultResponse(correct)).getJson());
}
}
package me.progfrog.mallang.controller;
import lombok.RequiredArgsConstructor;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.service.MultiplicationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping("/results")
final class MultiplicationResultAttemptController {
private final MultiplicationService multiplicationService;
@PostMapping
ResponseEntity<ResultResponse> postResult(@RequestBody MultiplicationResultAttempt attempt) {
return ResponseEntity.ok(new ResultResponse(multiplicationService.checkAttempt(attempt)));
}
record ResultResponse(
boolean correct
) {
}
}
프론트엔드(웹 클라이언트)
이 모든 파일을 main/resources 경로에 static 폴더에 만든다.
- 기본적인 index.html
- 최소한의 스타일을 가진 style.css
- jQuery로 간단한 동작을 만들 multiplication-client.js
- 페이지가 로드된 후에 REST API로 무작위 곱셈 문제를 가져와 보여준다.
- 폼 제출(submit) 이벤트 리스너를 등록한다. 폼 제출의 기본 동작 대신 폼에서 데이터를 가져와 채점 결과를 확인하는 API를 호출하고 결과를 사용자에게 보여준다.
정리
첫 번째 사용자 스토리를 완성했다! TDD를 따라 만들었고, 3 계층 구조로 설계하고 REST API를 만들었다. 우리는 이 애플리케이션을 좋은 표준에 맞춰 만들었고, 확장할 준비가 되었다 :)