[Spring Boot] 입문 - 백엔드 개발 흐름_1 익힉기(도메인/리포지토리/서비스/테스트 케이스)
스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 김영환 인프런 강의 참고
간단한 회원 관리 예제를 통해 스프링의 백엔드 개발 흐름을 익혀보겠다.
순서는 아래와 같다.
- 비즈니스 요구사항 정리
- 회원 도메인과 리포지토리 만들기
- 회원 리포지토리 테스트 케이스 작성
- 회원 서비스 개발
- 회원 서비스 테스트
1. 비즈니스 요구사항 정리
비즈니스 요구사항을 최대한 간단하게 정리했다.
- 데이터 : 회원 ID(pk용), 이름
- 기능 : 회원 등록, 조회
- 데이터 저장소 : 아직 선정되지 않음
이제 위 요구사항에 맞게 클래스를 설계하면 된다. 일반적인 웹 어플리케이션 계층 구조는 아래와 같다.
- 컨트롤러 : 웹 MVC의 컨트롤러로써 앱의 사용자로부터의 입력에 대한 응답으로 모델(도메인)이나 뷰를 업데이트하는 로직을 포함한다.
- 서비스 : 핵심 비즈니스 로직 구현
- 리포지토리 : 데이터베이스에 접근하는 로직을 포함하며, 도메인 객체를 DB에 저장 및 관리
- 도메인 : 웹 MVC의 모델로써 비즈니스 객체(회원, 주문 등)
위 계층 구조에 맞게 클래스 의존관계를 최대한 간단하게 설계해 보자.
아직 데이터 저장소가 선정되지 않았지만, 개발을 진행하기 위해서 초기에는 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용하기로 한다.
또한 데이터 저장소가 선정되면 그에 맞게 구현 클래스를 변경할 수 있도록, 지금은 MemberRepository
인터페이스를 MemoryMemberRepository
가 구현해 사용할 수 있도록 했다.
이는 객체 지향 설계 5원칙 중 OCP(Open Closed Principle)와 DIP(Dependency Inversion Principle)를 활용한 설계다. (참고 : 2023.02.07 - [프로그래밍 언어 공부/Java] - [Java] 객체 지향 설계 5원칙 - SOLID)
2. 회원 도메인과 리포지토리 만들기
/domain/Member.java
package com.example.practice.domain;
public class Member {
private Long id; // 회원 아이디(pk용)
private String name; // 회원 이름
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
비즈니스 요구사항에 맞게 회원 아이디와 이름만 인스턴스 변수로 가지게 했고, 각각의 getter
, setter
만 구혔했다.
/repository/MemberRepository.java
package com.example.practice.repository;
import java.util.List;
import java.util.Optional;
import com.example.practice.domain.Member;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
리포지토리 역시 회원 등록과 3가지 방법으로 조회하는 기능들만 추상 메서드로 선언했다. 이때 findById
와 findByName
을 Optional
래퍼 클래스로 반환하는 이유는 null
처리를 효율적으로 하기 위해서다. 지금 중요한 것은 백엔드 개발의 흐름을 익히는 것이기 때문에 자세한 내용은 뒤에 남겨두자.
repository/MemoryMemberRepository.java
package com.example.practice.repository;
import java.util.*;
import com.example.practice.domain.Member;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>(); // 저장소
private static long sequence = 0L; // 회원 ID
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
MemberRepository
를 구현하는 클래스로 store
라는 Map
타입의 클래스 변수가 저장소 역할을 한다. sequence
는 회원 ID를 의미하며 자동으로 증가하는 PK 역할이다.
clearStore
메서드는 저장소를 비우는 기능인데, 추후 테스트 시 용이하게 사용하기 때문에 구현해 뒀다.
코드 상에서 생각해 볼 부분은 findByName
메서드에서 매개변수 값으로 받은 name
과 같은 이름을 가진 회원을 찾는 부분이다. 다양한 방법이 있겠지만, 위 예제에서는 스트림을 적극 사용했다. 중간 연산으로는 filter
함수를 사용하여 저장소에서 찾고 싶은 이름을 가진 회원만을 걸러낸 뒤, 최종 연산은 findAny
로 1명의 회원만을 반환했다. 실제로는 이름이 같은 회원이 있을 수 있겠지만 이 역시 지금은 넘어가기로 했다.
또한 위 코드에서는 동시성 문제가 고려되어 있지 않다. 실무에서는 ConcurrentHashMap
, AtomicLong
사용을 고려한다고 하는데, 이 역시 지금은 중요한 것이 아니기 때문에 넘어간다.
3. 회원 리포지토리 테스트 케이스 작성
이제 위에 구현한 회원 리포지토리 기능들을 테스트하기 위한 테스트 케이스를 작성해야 한다.
테스트는 혼자서 진행하는 프로젝트는 괜찮을 수 있지만, 코드의 양이 점점 많아지거나 다수의 개발자가 함께 진행하는 프로젝트라면 필수가 된다.
개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행한다. 이러한 방법은 테스트 세팅과 실행이 생각보다 오래 걸리고, 반복이 어려우며, 여러 테스트를 한번에 실행하기 어렵다는 단점이 있다. 자바는 JUnit
이라는 테스트 프레임워크를 사용하여 앞서 언급한 문제점들을 모두 해결할 수 있다. 프레임워크의 도움도 받을 수 있으니, 평소에 테스트 코드를 조금만 신경써서 작성하는 습관을 들이면 추후 유지보수하는데 쏟는 시간을 배로 줄일 수 있다.
테스트 코드 작성 시 테스트는 각각 독립적으로 실행되어야 한다는 원칙을 지켜주는 것이 좋다. 테스트 순서에 의존 관계가 있는 것은 테스트 실행 방법이나 환경에 따라 테스트 결과가 달라질 수도 있고, 테스트 대상이 불분명해지기 때문에 좋은 테스트가 아니다.
/src/test/java/…/repository/MemoryMemberRepositoryTest.java
package com.example.practice.repository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import com.example.practice.domain.Member;
import static org.assertj.core.api.Assertions.*;
import java.util.List;
public class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
repository.clearStore();
}
@Test
public void save() {
// given
Member member = new Member();
member.setName("spring");
//when
repository.save(member);
//then
Member result = repository.findById(member.getId()).get();
assertThat(result).isEqualTo(member);
}
@Test
public void findByName() {
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
Member result = repository.findByName(member1.getName()).get();
// Member result = repository.findByName(member2.getName()).get();
//then
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll(){
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
List<Member> result = repository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
}
}
각 기능에 맞는 메서드 선언문을 그대로 적어주고 어노테이션 @Test
를 붙여주면 된다.
테스트 코드를 처음 작성할 때 어떻게 작성해야 할 지 몰라 막막할 수 있다. 이때 좋은 방법이 있는데 아래와 같이 주석을 작성하고, 그에 맞는 코드를 채워 넣으면, 좀 더 쉽고 정확한 테스트 코드를 작성할 수 있다.
//given
: 준비 - 테스트를 위해 준비를 하는 과정으로 테스트에 사용하는 변수, 입력 값 등을 정의//when
: 실행 - 실제로 액션을 하는 테스트를 실행하는 과정//then
: 검증 - 테스트를 검증하는 과정으로 예상한 값, 실제 실행을 통해서 나온 값을 비교
위 예제에서는 메모리를 사용하고 있기 때문에 한 번에 여러 테스트를 진행하게 되면 면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 때문에 @AfterEach
에서 repository.clearStore()
메서드를 호출해 테스트 1개가 끝날 때마다 메모리 DB에 저장된 데이터를 삭제한다.
@AfterEach
: 각 테스트가 종료될 때 마다 이 메서드를 실행
예제를 보면 assertThat
을 활용한 코드들이 보이는데, 이는 AssertJ 라이브러리를 사용했기 때문이다. AssertJ 라이브러리는 메소드 체이닝을 사용해 깔끔한 테스트 코드 작성을 할 수 있다.
참고 : https://assertj.github.io/doc/
이 외에도 테스트를 지원하는 다양한 기능들이 assertj와 junit 라이브러리에 포함되어 있는데, 양이 꽤 방대하기 때문에 필요할 때 조금씩 공식 문서를 참고하면서 사용해 보면 좋을 듯 하다.
작성한 테스트는 @Test
가 붙은 메서드를 개별적으로 테스트할 수도 있고, 클래스 단위로 테스트할 수도 있다.
아래는 VSCode 기준으로 Testing 탭을 활용해 테스트하는 방법이다. 테스트를 성공하면 초록색 체크표시가 옆에 붙는다.
만약 테스트가 실패하면 빨간색 X 표시가 붙고, 아래와 같이 기대값(Expected) 실제값(Actual)이 어떻게 다른지 표시해 준다.
테스트는 JUnit5를 사용하기 때문에 큰 틀이나 로그 메세지는 똑같겠지만, IDE에 따라 인터페이스가 조금씩 다를 수는 있다.
서비스는 다음 포스팅에서 다루겠다.
References
- 인프런 강의 - https://www.inflearn.com/course/스프링-입문-스프링부트
- Given-When-Then Pattern - https://brunch.co.kr/@springboot/292
- AssertJ 라이브러리 - *https://assertj.github.io/doc/*간단한 회원 관리 예제를 통해 스프링의 백엔드 개발 흐름을 익혀보겠다.