데이터: 회원ID, 이름
기능: 회원 등록, 조회
아직 데이터 저장소가 선정되지 않음(가상의 시나리오)
[클래스 의존관계]


Member(회원 객체)
package hello.hellospring.domain;
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;
}
}
MemberRepository(회원 리포지토리 인터페이스)
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id); //Optional이 사용되는 이유? null을 처리할 때 null을 그대로 반환하는 방법 대신 optional로 감싸서 반환하는 방법을 많이 선호함
Optional<Member> findByName(String name);
List<Member> findAll();
}
MemoryMemberRepository(회원 리포지토리 메모리 구현체)
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); // sequence에 1을 더해 id를 세팅한 멤버를
store.put(member.getId(), member); // 스토어에 저장
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id)); // 스토어에서 꺼내기(결과가 없으면 null일 것, 이 경우를 위해 Optional로 감싸서 반환해 클라이언트에서 작업을 더 쉽게 하도록 도와줌)
}
@Override
public Optional<Member> findByName(String name) {
// 맵에서 루프를 돌며 하나를 찾으면 반환해주기, 없으면 Optional에 null이 포함되어 반환됨
return store.values().stream() //람다식 이용, 루프로 돌리기
.filter(member -> member.getName().equals(name)) // member의 name이 파라미터의 name과 같은지 확인
.findAny(); // 같은 경우 필터링되어 반환됨
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values()); // 스토어에 있는 멤버들이 반환됨
}
}
자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 테스트를 하면 시간이 오래 걸리고, 반복 실행하기 어렵고, 여러 테스트를 한번 에 실행하기 어렵다는 단점이 있다.
자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.
※참고
test파일에 패키지 이름은 똑같이 작성한다.
클래스 이름은 뒤에 test를 붙임.

MemoryMemberRepository에서 추가한 부분
public class MemoryMemberRepository implements MemberRepository{
...
public void clearStore(){
store.clear(); // store 비우기
}
}
MemoryMemberRepositoryTest(회원 리포지토리 메모리 구현체 테스트)
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class MemoryMemberRepositoryTest { // 다른 데에서 이용할게 아니기 때문에 public 쓸 필요 없다.
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach // 메소드가 실행이 끝날때마다 동작하는 것, (예. save 끝나고 호출됨, findbyname 끝나고 호출됨 ...)
public void afterEach(){
repository.clearStore(); // 테스트가 실행되고 끝날때마다 저장소를 clear함.
}
@Test
public void save(){
Member member = new Member();
// 멤버 저장
member.setName("spring"); // spring으로 이름 설정
repository.save(member); // repository에 멤버 저장
// 값 꺼내기
Member result = repository.findById(member.getId()).get(); // (반환타입 Optional이므로 Optional에서 값 꺼낼땐 get 이용할 수 있다.)
// 검증 방법1
//Assertions.assertEquals(member, result); // 내가 저장한 멤버와 DB에서 꺼낸 멤버가 똑같은지 확인
// 검증 방법2 (더 많이 사용됨)
assertThat(result).isEqualTo(member); // member가 result와 똑같은지 확인
}
@Test
public void findByName(){
// 멤버 저장
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
// 값 꺼내기
Member result = repository.findByName("spring1").get(); // (get으로 Optional을 까서 거낼 수 있다.)
// 검증
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll(){
// 멤버 저장
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring1");
repository.save(member2);
// 값 꺼내기
List<Member> result = repository.findAll();
// 검증
assertThat(result.size()).isEqualTo(2);
}
}
테스트를 돌릴 때 원하는 레벨에서 돌릴 수 있다.(함수 레벨 / 클래스 레벨 / 테스트 전체 레벨)
실무에서는 build툴과 엮어서 build할 때 오류 테스트 케이스를 통과하지 않으면 다음 단계로 못 넘어가게 막는 방식을 사용한다.
save()를 함수레벨에서 테스트해보면 다음과 같은 결과가 나온다.
[assertThat(result).isEqualTo(member);인 경우 결과 - 정상 작동]

[assertThat(result).isEqualTo(null);인 경우 결과 - 오류 발생]

afterEach() 함수를 작성하지 않으면 아래와 같이 전체 레벨에서 테스트를 돌릴 때 오류가 발생한다.

그 이유는 findAll에서 spring1, spring2가 이미 메모리DB에 저장되었는데 또 spring1, spring2로 멤버 저장을 하려고 하니 오류가 생긴 것이다.
이러한 경우를 방지하기 위해 모든 테스트는 메소드별로 따로 동작하게 설계되어야 한다. 즉 테스트가 끝날때마다@AfterEach를 이용해 repository를 지워줘야 한다. @AfterEach를 이용해 각 테스트가 종료될 때마다 이 기능을 실행한다. 여기에선 메모리 DB에 저장된 데이터를 삭제한다.
※ 위와 같은 방식으로 구현 클래스를 만들고 이를 테스트하는 방법도 있지만 TDD(테스트 주도 개발) 방법도 있다.
(TDD(테스트 주도 개발): 테스트를 먼저 만들고 구현클래스를 만듦)
회원 서비스 - 회원 repository, 도메인을 활용해 실제 비즈니스 로직 작성하기
package hello.hellospring.service;
//import 생략
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
// 회원가입
public long join(Member member){
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member); // 검증 통과시 저장
return member.getId();
}
private void validateDuplicateMember(Member member) {
// 방법1
// Optional<Member> result = memberRepository.findByName(member.getName());// 이름으로 먼저 찾아봄
// result.ifPresent(m->{
// throw new IllegalStateException("이미 존재하는 회원입니다.")
// });
// 방법2 위와 같이 Optional을 바로 반환하는건 권장하지 않음. 다음과 같이 짜도록 권장한다.
memberRepository.findByName(member.getName())
// null이 아니라 이름이 있으면 다음과 같이 동작
.ifPresent(m->{
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
// 전체 회원 조회
public List<Member> findMembers(){
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId){
return memberRepository.findById(memberId);
}
}
join()함수에서 Optional이기 때문에 위와 같이 ifPresent를 사용할 수 있다. Optional로 감싸면 Optional안에 맴버 객체가 있는 것이다. 그러므로 Optional을 통해 여러 메서드를 사용할 수 있다.
(Optional이 아닌 경우엔 ifnull과 같은 방식으로 동작)
바로 꺼내고 싶으면 get, orelseget을 사용해도 된다. 그렇지만 권장되지는 않는 방법이다.
※ service는 비즈니스에 가깝게, repository는 개발에 가깝게 용어를 선택해야한다.
MemberServiceTest
package hello.hellospring.service;
//import 생략
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository; //clear를 해주기 위해 repository 변수 선언
@BeforeEach
public void beforeEach(){
memberRepository = new MemoryMemberRepository(); //MemoryMemberRepository만들고
memberService = new MemberService(memberRepository); //MemberService에 넣어주기
}
@AfterEach
public void afterEach(){
memberRepository.clearStore();
}
@Test
void 회원가입() { // test는 한글로 작성해도 된다. 빌드될 때 테스트코드는 실제 코드에 포함되지 않는다.
//test를 짤 땐 given/when/then을 이용하자
//given - 주어진 상황(데이터)
Member member = new Member(); //hello 이름을 가진 멤버 만들기
member.setName("spring");
//when - 이것을 실행했을 때(검증할 것)
Long saveId = memberService.join(member); //회원가입하기
//then - 이 결과가 나와야한다(검증부)
Member findMember = memberService.findOne(saveId).get(); //saveId를 가진 멤버 찾기
assertThat(member.getName()).isEqualTo(findMember.getName()); //member의 이름이 findMember의 이름과 같은지 확인
}
//테스트는 예외를 확인하는게 더 중요하다. 중복 회원가입을 잘 감지하는지 확인해보자.
@Test
public void 중복_회원_예외(){
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1); //member1 가입
/***** try catch를 이용한 방법 *****/
// try {
// memberService.join(member2); //member2 가입
// fail(); //예외가 발생하지 않는 경우 fail
// } catch (IllegalStateException e) { //IllegalStateException이 터진 경우
// assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); //메시지 확인(""안에 다른 문장을 넣을시 오류 발생)
// }
/***** assertThrows를 이용한 방법(권장) *****/
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2)); //예외 확인 방법
//() -> memberService.join(member2) 로직을 실행 할 때, IllegalStateException.class예외가 터져야 함
//e에 메시지가 반환됨
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); //메시지 검증하는 방법
}
}
test를 할 때 MemberService와 MemberServiceTest에서 모두 repository를 new로 선언한 경우 서로 다른 인스턴스를 사용하는 것으로 내용물이 달라질 우려가 있다. 그러므로 아래와 같이 DI(Dependency Injection)방법을 사용한다.
※ DI(Dependency Injection): 외부에서 메모리 멤버 리포지토리를 넣어줌
<<DI사용❌ 경우>>
MemberService
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
...
}
MemberServiceTest
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memoryMemberRepository = new MemoryMemberRepository();
...
}
<<DI사용⭕️ 경우>>
MemberService
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository; //외부에서 넣어주게 바꾸기
}
...
}
MemberServiceTest
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach(){
memberRepository = new MemoryMemberRepository(); //MemoryMemberRepository만들고
memberService = new MemberService(memberRepository); //MemberService에 넣어주기
}
...
}
이때까지 Optional이 자주 나왔는데 Optional이 정확히 뭔지 알아보자.
NPE(NullPointerException)예외를 피하기 위해 null 여부를 검사해야 한다. 하지만 매번 null 여부를 검사하는 것은 귀찮은 일이다.
Java8에서는 Optional 클래스를 사용해 NPE를 방지할 수 있도록 도와준다. Optional는 null이 올 수 있는 값을 감싸는 Wrapper 클래스로, 참조하더라도 NPE가 발생하지 않도록 도와준다. Optional 클래스는 아래와 같은 value에 값을 저장하기 때문에 값이 null이더라도 바로 NPE가 발생하지 않으며, 클래스이기 때문에 각종 메소드를 제공해준다.
public final class Optional<T> {
// If non-null, the value; if null, indicates no value is present
private final T value;
...
}
[참고 자료]
https://mangkyu.tistory.com/70