이 글은 김영한 님의 Infrean 강의를 학습한 내용을 정리하여 작성합니다.
- 비지니스 요구사항 정리
- 회원 도메인과 리포지토리 만들기
- 회원 리포지토리 테스트 케이스 작성
- 회원 서비스 개발
- 회원 서비스 테스트
비지니스 요구사항 정리
- 데이터: 회원ID, 이름
- 기능: 회원 등록, 조회
- 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)
일반적인 웹 애플리케이션 계층 구조
- 컨트롤러: 웹 MVC 컨트롤러 역할
- 서비스: 핵심 비지니스 로직 구현
- 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인: 비지니스 도메인 객체, ex) 회원 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨
클래스 의존관계
- 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
- 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민 중인 상황으로 가정
- 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
-> 회원을 저장하는 MemberRepository는 interface로 구현한다.
(∵ 아직 데이터 저장소가 선정되지 않았기 떄문)
회원 도메인과 리포지토리 만들기
회원 객체
이때, id는 임의의 값(데이터 구분을 위해 시스템이 정해주는 값)
회원 리포지토리 인터페이스
- save : 회원을 저장소에 저장
- findById : id를 이용해 저장소로부터 회원 검색
- findByName : name을 이용해 저장소로부터 회원 검색
- findAll : 저장된 모든 회원 리스트 반환
주의!!
위 클래스 의존관계에서 설명했듯이 MemberRepository는 interface로 구현한다.
Optoinal 클래스
코드를 작성하고 실행하다 보면 NullPointerException 예외를 접할 수 있다.
따라서 이에 대한 처리를 고려하고 코드를 작성해야 하는데, 이는 어렵지 않지만 매우 번거로운 일이다.
그래서 이러한 일을 단순히 처리할 수 있도록 자바 8에서 Optional 클래스가 소개되었다.
Optional은 멤버 value에 인스턴스를 저장하는 일종의 래퍼(Wrapper) 클래스다.
import java.util.Optional; class ContInfo { Optional<String> phone; Optional<String> adrs; public ContInfo(Optional<String> ph, Optional<String> ad){ phone = ph; adrs = ad; } public Optional<String> getPhone() { return phone;} public Optional<String> getAdrs() {return adrs;} } public class Main { public static void main(String[] args){ Optional<ContInfo> ci = Optional.of(new ContInfo(Optional.ofNullable(null), Optional.of("Republic of Korea"))); String phone = ci.flatMap(c -> c.getPhone()).orElse("There is no phone number."); String addr = ci.flatMap(c -> c.getAdrs()).orElse("There is no address."); System.out.println(phone); System.out.println(addr); } }
회원 리포지토리 구현체
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@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());
}
}
- store : 회원 객체를 저장할 저장소 (실제 실무에서 동시성 문제가 발생할 수 있으므로 ConcurentHashMap 사용)
- sequence : 회원 객체의 id 생성 (실제 실무에서 동시성 문제가 발생할 수 있으므로 AtomicLong 사용)
더보기stream()
stream() 메서드는 java.util.Collection<E>에서 디폴트로 제공하는 메서드다.
하지만, Map은 보다시피 Collection<E>를 상속받고 있지 않으므로 사용할 수 없다.values() 메서드는 Collection<V>를 반환한다.
이러한 이유로 findByName() 메서드에서 store.values().stream()과 같이 사용한다.
java.util.HashMap.values()는 HashMap의 value 값을 Collection 형태로 반환해준다.
List<Member>가 정상적으로 반환된다.
현재 작성한 코드가 정상적으로 작동하는지 동작시켜 보아야 한다.
이를 위해 사용하는 방법이 테스트 케이스를 작성하는 것이다.
회원 리포지토리 테스트 케이스 작성
개발한 기능을 실행해서 테스트할 때 자바의 main 메서드를 통해 실행하거나, 웹 애플리케이션의 컨트롤러를 통해 해당 기능을 실행한다.
이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한 번에 실행하기 어렵다는 단점이 있다.
자바는 JUnit이라는 프레임워크로 테스트를 실행해 이러한 문제를 해결한다.
회원 리포지토리 메모리 구현체 테스트
테스트할 클래스 이름 뒤에 "Test"를 추가해 파일을 생성하는 것이 관례이다.
테스트를 수행하기 위해 @Test 어노테이션을 작성해야 하며 org.junit.jupiter.api를 import 해야 한다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@Test
public void save(){
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();
System.out.println("result = " + (result == member));
}
}
테스트 케이스를 위와 같이 작성하였다.
하지만, 우리가 테스트를 할 때마다 계속 글자로 확인할 수는 없다.
그래서 assert 기능을 사용한다.
Assertions.assertEquals 사용 예제
assert 기능을 사용하기 위해 우선, org.junit.jupiter.api를 import 한다.
- assertEquals
- 첫 번째 인자(기댓값)가 두 번째 인자(실제값)와 동등한 지 검사한다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@Test
public void save(){
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();
Assertions.assertEquals(member, result);
}
}
보다시피, 실행 결과로 출력되는 내용은 아무것도 없다.
하지만, 왼쪽을 확인해보면 녹색으로 체크 표시가 존재하는 것이 보일 것이다.
이번엔 일부로 두 번째 인자에 잘못된 값을 대입해 본다.
실패 결과 왼쪽 화면에 X 표시가 위치하며
빨간 글씨로 기댓값과 실제값이 다르다고 출력된다.
또한, <Click to see difference>를 클릭한 결과는 다음과 같다.
Assertions.assertThat 사용 예제
위 예제와 달리 요즘 많이 사용하는 방법이 존재한다.
이전 Assertions와는 달리 org.assertj.core.api를 import 해야 한다.
assertEquals와 달리 가독성이 좋다는 장점이 존재한다.
단축키 "Alt + Enter"
이미지의 Add on-demand static import for 'org.assertj.core.api.Assertions'를 클릭하면 다음처럼 사용 가능하다.
이 예제 또한 위 예제와 실행결과는 동일하게 나타난다.
findByName() Test
테스트 케이스의 장점은 생성한 테스트 메서드들을 동시에 수행할 수 있다.
방법은 두 가지가 존재한다.
1. 클래스 레벨로 실행
2. 파일 레벨로 실행
단축키 "Alt + F6"
위 코드에서 동일한 member1 세 개를 한꺼번에 변경시키려면 "Alt + F6"
물론 "spring1"은 따로 변경해야 한다.
findAll() Test
생성한 모든 테스트 메서드들을 실행시킨 결과 이전에 문제가 없던 findByName()에서 오류가 발생한다.
실행 결과를 보면 findAll() 다음 findByName()이 실행된다.
하지만, 실제로 실행 순서는 보장되지 않는다.
모든 테스트들은 순서와 관계없이 따로 동작하도록 설계해야 한다.
전체 실행 결과에서 오류가 발생한 이유는 간단하다.
findAll() 메서드에서 이미 "spring1"과 "spring2"가 Map에 저장되어 있기 때문이다.
이러한 문제를 해결하기 위해 하나의 테스트가 끝날 때마다 저장소나 공용 데이터를 깔끔히 정리해줘야 한다.
깔끔히 정리해주는 메서드를 생성해보자.
- @AfterEach
- 현재 클래스의 각 테스트 메서드를 실행한 후에 실행되어야 하는 메서드임을 나타낸다.
@AfterEach를 사용하면 각 테스트가 종료될 때마다 이 기능을 실행한다.
여기서는 메모리 DB에 저장된 데이터를 삭제한다.
보다시피 이전처럼 오류가 발생하지 않는 것을 확인할 수 있다.
테스트 주도 개발(TDD)
테스트 주도 개발(TDD)은 테스트 클래스를 먼저 작성한 뒤 개발을 수행한다.
하지만, 현재 우리는 MemoryMemberRepository를 먼저 개발하고 테스트를 작성하였으므로 TDD에 해당하지 않는다.
테스트 케이스에 대해 더 자세히 알고 싶다면 다음 블로그를 참조한다.
회원 서비스 개발
회원 서비스 클래스를 생성해본다.
회원 서비스는 회원 리포지토리와 도메인을 활용해 실제 비지니스 로직을 작성한다.
위 메서드에서 동일한 이름을 가진 회원이 있는지 검사하는 코드가 난잡하므로 캡슐화를 수행한다.(메서드 생성)
더보기단축키 "^ + Alt + Shift + T"단축키를 입력하면 위 이미지처럼 Refactor This 창이 뜨는데 리팩터링과 관련된 것이다.
Extract Method... 를 클릭한 결과는 다음과 같다.난잡하던 코드가 새로운 메서드로 추출되었다.
더보기
단축키 "^ + Alt + V"
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
/*
* 회원 가입
* */
public Long join(Member member){
// 동일한 이름이 있는 중복 회원X
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName()).ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
/*
* 전체 회원 조회
* */
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
서비스 VS. 리포지토리
리포지토리(Repository)
리포지토리는 데이터베이스에 접근해 도메인 객체를 DB에 저장하고 관리한다고 하였다.
그래서 메서드 이름만 보아도 단순히 저장소에 저장하고 찾는 기능을 수행한다는 것을 알 수 있다.
서비스(Service)
서비스는 핵심 비지니스 로직을 구현한다 하였다.
그래서 메서드 이름도 비지니스에 가까운 것을 확인할 수 있다.
서비스 클래스는 비지니스 용어를 사용해 구현해야 한다.
서비스는 비지니스에 의존해 설계하고 리포지토리는 단순히 역할에 맞도록 설계한다.
회원 서비스 테스트
생성한 회원 서비스 클래스를 테스트해 볼 필요가 있다.
단축키 "^ + Shift + T"
테스트 클래스를 생성하는 창이 나타난다.
이때, Testing library로 JUnit5를 선택한다.
테스트 케이스 작성 패턴(given, when, then)
//given, //when, //then 세 부분으로 나눈다.
//given : 무언가 주어졌을 때
//when : 실행했을 때
//then : 이러한 결과가 생성되어야 한다.
join() Test
테스트 메서드에서 "회원가입"이라는 이름으로 메서드를 작성하였다.
테스트의 경우 한글로 메서드 이름을 작성해도 무방하다.
그런데 위 테스트 메서드는 너무 단순하다.
테스트는 예외인 경우를 테스트하는 것이 훨씬 중요하다.
현재, join() 메서드에서 발생할 수 있는 예외는 중복 회원이 존재하는 경우다.
정상적으로 catch()문으로 이동해 예외가 정상적으로 작동하는 것을 확인할 수 있다.
try catch문을 사용해 예외처리를 할 수 있지만, 사용하기 애매한 경우들이 많다.
그래서 다음과 같은 기능을 제공해준다.
- assertThrows
- assertThrows(Class, Executable)
- 제공된 Excutable을 실행했을 때, 첫번째 인자로 명시한 타입의 예외가 발생하는지 검사한다.
문제점
문제가 하나 존재한다.
MemberService 클래스와 MemberServiceTest 클래스에서 정보를 저장하는 리포지터리가 다르다는 것이다.
현재 MemoryMemberRepository 클래스에서 저장소가 static이라서 별 문제가 발생하고 있지 않지만,
엄연히 저장하는 곳이 다르다.
해결 방법
- @BeforeEach
- 현재 클래스의 각 테스트 메서드를 실행하기 전에 실행되어야 하는 메서드임을 나타낸다.
해결 방법을 자세히 살펴보면 우리가 의존 대상을 직접 생성하지 않고 생성자를 이용해 의존성을 주입하였다.
이러한 방식을 DI(Dependency Injection)라 한다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
void 회원가입() {
//given
Member member = new Member();
member.setName("spring");
//when
Long saveId = memberService.join(member);
//then
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
public void 중복_회원_예외() {
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
/*
try{
memberService.join(member2);
fail();
} catch(IllegalStateException e){
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.123123");
}
*/
//then
}
}
'스프링 > 스프링 입문' 카테고리의 다른 글
회원 관리 예제 - 웹 MVC 개발 (0) | 2022.03.01 |
---|---|
스프링 빈과 의존관계 (0) | 2022.03.01 |
스프링 웹 개발 기초 (0) | 2022.02.27 |
빌드하고 실행하기 (0) | 2022.02.27 |
View 환경설정 (0) | 2022.02.26 |