회원 ID와 이름을 데이터로 받고, 회원 등록과 조회의 기능만을 구현하도록 아주 단순하게 구성한다. DB가 선정되지 않았다는 가상의 시나리오를 따른다.
일반적인 웹 애플리케이션은 컨트롤러, 서비스, 레포지토리, 데이터베이스, 그리고 도메인으로 구성되어 있다.
- 컨트롤러: 웹 MVC의 컨트롤러
- 서비스: 도메인을 가지고 핵심 비즈니스 로직을 구현한 객체
- 리포지토리: 데이터베이스에 접근, 도메인 객체를 데이터베이스에 저장 및 관리
- 도메인: 비즈니스 도메인 객체. 데이터베이스에 저장되어 관리됨
어떤 DB를 사용할지가 정해지지 않았기 때문에 인터페이스로 MemberRepository를 만들어 놓고 구현 클래스를 변경할 수 있도록 설계한다.
Member 클래스는 간단하게 Id와 이름을 받는다. 이때 Id는 시스템이 부여하는 수이다.
public class Member {
private Long id;
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;
}
}
DB가 없는 관계로 인터페이스 MembreRepository를 만든다.
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
회원저장, Id로 회원 찾기, 이름으로 회원찾기, 회원 리스트 반환 메서드를 만들었다.
인터페이스를 implement한 MemoryMemberRepository클래스를 만들어 메서드를 재정의한다.
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());
}
public void clearStore(){
store.clear();
}
}
- Id를 key로 받고 Member 객체를 값으로 저장하는 해시맵 store를 생성한다.
- Id로 사용할 숫자 sequence를 생성한다.
- save(member):
저장할 회원을 받으면, sequence에 1을 추가하고 그 숫자를 Id로 부여한다. 이 Id와 Member 객체를 store에 저장하고 member를 반환한다.- findById(id):
id를 전달받아 store에 해당 id를 키로 가지는 값을 받는다. Optional<Member>을 반환하는 것으로 나와있는데, 해당 Id를 가진 회원이 있으면 Member 객체를, 없으면 null을 반환한다.- findByName(name):
name을 전달받아 store의 값들을 stream으로 열어서(=루프문을 돌려서) 같은 이름을 가진 회원이 있는지 필터링한다. 일치하는 객체를 하나라도 찾으면 반환하며, 역시 Optional로 받으므로 객체가 없다고 하면 null을 반환한다.- findAll():
store에 저장된 모든 회원들을 리스트로 반환한다.
이 클래스가 정상적으로 동작하는지 검증하기 위해서 테스트 케이스를 작성할 수 있다. 메인 클래스를 사용해서 서버를 구동해 직접 하나씩 실행해도 되지만, 프로젝트 규모가 커질 수록 테스트를 돌려보는 것이 훨씬 효율적이다. 테스트케이스는 JUnit이라는 프레임워크를 사용하며, src가 아닌 test 폴더 밑에서 패키지 및 파일을 만들어 진행한다.
import static org.assertj.core.api.Assertions.*;
public class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
repository.clearStore();
}
@Test
public void save(){
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();
assertThat(member).isEqualTo(result);
}
@Test
public void findByName(){
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member result = repository.findByName("spring1").get();
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll(){
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member1.setName("spring2");
repository.save(member2);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
- save 테스트:
Member 객체를 생성하여 이름을 저장한 후, save 메서드를 사용해 store에 저장한다. AssertJ를 사용해서 실제로 저장된 객체와 결과가 일치하는지 확인한다.- findByName 테스트:
Member 객체를 생성하여 이름을 저장한 후, findByName 메서드를 사용해 Member를 검색한다. 메서드가 반환한 Member 객체와 실제로 저장한 Member가 일치하는지 확인한다.- findAll 테스트:
Member 객체를 2개 이상 저장한 후 findAll 메서드를 사용해 반환받은 리스트의 size와 실제로 저장한 Member 객체 수가 일치하는지 확인한다.
- afterEach:
이 테스트 3개를 한 번에 돌리면 오류가 난다. 테스트는 정해진 순서대로 돌아가지 않고 임의의 순서로 돌아가기 때문에, 한 번 테스트를 실행한 다음 레포지토리를 비워주지 않으면 저장한 내용이 그대로 다음 테스트로 넘어가 정확한 테스트가 불가능하다. afterEach는 레포지토리를 비워주는 메서드로, @AfterEach 애너테이션은 매 테스트 후 애너테이션이 붙은 메서드를 실행하게 한다.
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);
}
}
- join(member):
회원 가입 기능으로, MemberRepository의 save 기능을 사용해 저장한다.
저장하기 전에 같은 이름을 가진 회원의 가입을 막기 위해 findByName을 써서, 그 결과가 null이 아니면(=isPresent) 예외를 던지도록 만들고, 이를 validateDuplicateMember 메서드로 분리하였다.
중복된 이름이 없으면 성공적으로 저장된 member 객체를 반환한다.- findOne(memberId)
memberId를 받아 findById를 사용해 Optional member 객체를 반환한다. 해당 id를 가진 회원이 존재하면 member 객체를, 없으면 null을 반환한다.
테스트 할 클래스에서 Ctrl+Shift+T를 누르면 자동으로 Test 클래스를 생성해준다. 테스트 할 메서드들을 선택 후 ok를 누르면, 테스트 폴더 하에 패키지와 테스트 파일이 자동으로 생성된다.
테스트 코드를 작성한다. 보통 테스트코드를 작성할 때는 주어진 입력, 실행할 명령, 결과 확인을 given, when, then 3단계로 나누어 작성하는 것이 좋다.
먼저 join()을 테스트한다. join의 플로우에는 정상 플로우 1개, 중복된 이름을 가진 예외 플로우 1개가 있다.
@Test
void join() {
// given
Member member = new Member();
member.setName("hello");
// when
Long saveId = memberService.join(member);
// then
Member m1 = memberService.findOne(saveId).get();
Assertions.assertThat(member.getName()).isEqualTo(m1.getName());
}
정상 플로우는 제대로 작동한다.
예외플로우를 하나 더 만든다.
@Test
public void duplicateMemberJoin(){
// given
Member member = new Member();
member.setName("aaah");
Member member2 = new Member();
member2.setName("aaah");
// when
memberService.join(member);
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// try {
// memberService.join(member2);
// fail();
// } catch (IllegalStateException e){
// assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// }
}
중복된 이름을 가진 회원이 가입할 경우, IllegalStateException을 반환한다. 에러 메시지가 동일한지를 파악하기 때문에 뒤에 다른 숫자를 더 붙이거나 하면 테스트에 빨간 불이 들어온다. try-catch 문으로 예외를 받을 수도 있겠으나, 그보다는 assertThrows를 사용하는 것이 훨씬 편하다.
이번에도 마찬가지로 레포지토리에 저장된 값을 비워줄 필요가 있다. MemoryMemberRepository에 clearStore()를 사용한다.
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
repository.clearStore();
}
그런데 MemberServiceTest에서 사용하는 리포지토리와 MemberService에서 사용하는 인스턴스는 new를 가지고 새로 생성한 서로 다른 인스턴스이다. 테스트와 실제 소스코드가 동일한 레포지토리를 사용해야 하기 때문에, MemberService의 생성자 메서드를 다음과 같이 바꿀 수 있다.
private final MemberRepository memberRepository;
public MemberService(MemoryMemberRepository memberRepository){
this.memberRepository=memberRepository;
}
memberRepository는 프라이빗 파이널로 그대로 두되, 회원 리포지토리를 외부에서 넣도록 바꾼다. 그리고 Test 파일에서 @AfterEach처럼 @BeforeEach를 사용해 회원 서비스를 생성할 때 직접 레포지토리를 넣어주도록 바꾸었다.
MemberService memberService = new MemberService();
MemoryMemberRepository repository;
@BeforeEach
public void beforeEach(){
repository = new MemoryMemberRepository();
memberService = new MemberService(repository);
}
MemberService 입장에서, 직접 new로 생성하지 않고 외부에서 리포지토리를 넣어주기 때문에 이를 의존성 주입이라고 한다.