..

[Spring Basic] 3. Member Management Example - Backend

Table of contents

Business Requirement

Requirement

  • Data : userId, name
  • Functions : Member Registration, Search
  • Data storage is not decided yet..

Web Application Layer Structure

  • Controller : Web MVC Controller
  • Service : Business logic
  • Repository : Store and manage Domain object in DB
  • Domain : Business domain object.
    • e.g., Member, Order, Coupon, etc..
    • they are mainly stored and managed in DB

Class Dependency

  • MemberRepository is designed as interfece since DB is not selected yet.
    • We can easily change concrete class(구현 클래스)
    • DB로는 RDB, JDBC, NoSQL 등등 고민중
  • 초기 개발 단계에서는 구현체(e.g., MemoryMemberRepository)로 가벼운 메모리 기반의 데이터 저장소 사용한다

Member Domain and Repository

Package Setting

Domain

  • Member 클래스 (회원 객체)

domain/Member.java

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;  
	}  
}

Repository

  • Member(회원 객체)를 저장하는 저장소

repository/MemberRepository.java (interface)

public interface MemberRepository {  
	Member save(Member member);  
	Optional<Member> findById(Long id);  
	Optional<Member> findByName(String name);  
	List<Member> findAll();  
  
}

repository/MemoryMemberRepository.java (구현체)

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);  // id는 시스템이 결정 한다
		store.put(member.getId(), member);  
		return member;  
	}  
	  
	@Override  
	public Optional<Member> findById(Long id) {  
		// return store.get(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();  
	}
}
  • Null이 발생할 가능성이 있으면 Optional로 감싸준다
    • Optional.ofNullable(store.get(id))
  • 람다를 사용
    • member의 이름이 파라미터로 넘어온 이름과 같은지 비교
    • findAny()는 하나라도 찾는것을 반환, 없으면 Null을 반환

Java8 Optional

  • Optional class (java.util.Optional< T >)
    • Java 8부터 지원되는 클래스
    • null이 될 수도 있는 객체를 감싸는 Wrapper 클래스
    • 없으면 NULL로 반환, Optional 로 감싸서 반환하는걸 선호
    • Optional.ofNullable
  • Optional의 장점
    • NPE을 유발할 수 있는 null을 직접 다루지 않아도 된다
    • 매번 null을 체크할 필요 없다
    • 명시적으로 해당 변수가 null일 수도 있다는 가능성을 표현 가능

실무에서는 Map 대신 ConcurrentHashMap을 사용 실무에서는 Long 대신 AutomicLong을 사용

Member Repository Test Case

Junit Test

  • Class레벨에서 각 메소드 테스트를 한번에 돌릴 수 있다
  • 각 메소드의 테스트 순서는 보장이 안된다
  • 테스트는 서로(method간에) 순서 및 의존관계가 없이 될 수 있도록 설계가 되어야 한다

  • Test libraries
    • org.junit.jupiter.api.Assertions
      • e.g., Assertions.assertEquals(member, result);
    • org.assertj.core.api.Assertions
      • e.g., assertThat(member).isEqualTo(result);

repository/MemoryMemberRepositoryTest.java

import org.junit.jupiter.api.Assertions;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

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();  
		// System.out.println("result = " + (result == member));  
		Assertions.assertEquals(member, result); // way1, (expected, actual)  
		assertThat(member).isEqualTo(result); // way2  
		  
	}  
	  
	@Test  
	public void findByName(){  
		Member member1 = new Member();  
		member1.setName("spring1");  
		repository.save(member1);  
		  
		Member member2 = new Member();  
		member2.setName("spring2");  
		repository.save(member2);  
		  
		// Optional<Member> result = repository.findByName("spring1");  
		Member result = repository.findByName("spring1").get();  
		  
		assertThat(member1).isEqualTo(result);  
		  
	}  
	  
	@Test  
	public void findAll(){  
		Member member1 = new Member();  
		member1.setName("spring1");  
		repository.save(member1);  
		  
		Member member2 = new Member();  
		member2.setName("spring2");  
		repository.save(member2);  
		  
		List<Member> result = repository.findAll();  
		  
		assertThat(result.size()).isEqualTo(2);  
	}  
}

Test시 주의점

  • 모든 테스트들은 순서가 보장이 안된다
  • Repository에 메모리를 지워주는 코드를 넣어야 한다
    • 이전에 저장했던 객체를 초기화 안해주면 다른 메소드에서 같은 이름의 객체 테스트시 에러 발생
    • 그래서 테스트가 끝나면 data를 clear해주어야 한다
	@AfterEach  
	public void afterEach(){  
		repository.clearStore();  
	}  
  • @AfterEach
    • => 테스트가 끝날때마다 clear함수 (e.g., clearStore)를 수행해서 data를 지운다

Member Service

Package Setting

Service

  • MemberService 클래스 service/MemberService.java

회원가입

회원가입시 같은 이름 중복을 막는 로직 추가

public Long join(Member member){  
// 이름 중복 제거, 이름을 찾아봐서 이름이 있으면  
	validateDuplicateMember(member);  
	memberRepository.save(member);  
	return member.getId();  
}

private void validateDuplicateMember(Member member) {  
	Optional<Member> result = memberRepository.findByName(member.getName());  
	result.ifPresent(m -> {  
	throw new IllegalStateException("이미 존재하는 회원입니다");  
	});  
}

아래와 같이 편의성을 위해 변경 가능

memberRepository.findByName(member.getName())
.ifPresent(m -> {
	throw new IllegalStateException("이미 존재하는 회원입니다");
})

전체 회원 조회

public List<Member> findMembers(){  
	return memberRepository.findAll();  
}

단일 회원 조회 (id)

public Optional<Member> findOne(Long memberId){  
	return memberRepository.findById(memberId);  
}

Member Repository and Implementation 구현체 test

Test Convention

  • Given (무언가가 주어졌을때)
    • 해당 데이터를 기반으로 테스트
  • When (이걸 실행 했을때)
    • memberService에서 join할때 검증
  • Then (결과가 이렇게 나와야해)
    • 검증부

회원가입 테스트

MemberService memberService = new MemberService();  
  
@Test  
void join() {  
	// given  
	Member member = new Member();  
	member.setName("hello");  
	  
	// when  
	Long saveId = memberService.join(member);  
	  
	// then  
	Member findMember = memberService.findOne(saveId).get();  
	Assertions.assertThat(member.getName()).isEqualTo(findMember.getName());  
}
  • join테스트시 Service에서 findOne으로 찾아도 되지만 우리가 저장을 한것이 Repository에 있는게 맞음을 확인하고 싶은것

중복회원 예외 테스트

  • 테스트는 정상 flow도 중요하지만 예외flow도 중요하다
  • 중복_회원_예외 호출시 예외가 터져야되
@Test  
	public void 중복_회원_예외(){  
	//given  
	Member member1 = new Member();  
	member1.setName("spring");  
	  
	Member member2 = new Member();  
	member2.setName("spring");  
	  
	//when  
	memberService.join(member1);  
	try {  
		memberService.join(member2);  
		fail();  
	} catch (IllegalStateException e){  
		Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다");  
	}  

	// 위의 try-catch문과 똑같이 동작한다
	IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));  
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다");

}
  • Try Catch도 좋지만 juniit에서 문법을 제공해준다
    • assertThrows()
      • 오른쪽이 실행되면 왼쪽의 예외가 발생해야 한다

주의 사항

  • 회원가입 태스트중복회원예외 테스트시 같은 이름을 member에 지정하면 테스트가 실패한다. 왜냐하면 중복이름을 허용하지 않기 때문이다.

문제점

  • memberService에서의 memoryMemberRepositoryMemberServiceTest에서의 memoryMemeberRepository가 다른 instance이다! 이렇게 따로 2개를 쓸 이유가 없고 같은 걸 사용해야 한다.

MemberService repository

public class MemberService {  
  
	private final MemberRepository memberRepository = new MemoryMemberRepository();
	...
}

MemberServiceTest repository

class MemberServiceTest {  
  
	MemberService memberService = new MemberService();  
	MemoryMemberRepository memberRepository = new MemoryMemberRepository();
	...
}

MemberService 의 데이터의 경우 현재는 static이라 괜찮지만, static이 아닌 경우에는 다른 DB가 되면서 문제가 생길 수 있다.

public class MemoryMemberRepository implements MemberRepository{  
	private static Map<Long, Member> store = new HashMap<>();  
	private static long sequence = 0L;
	...
}

문제 해결 방법

Dependency Injection

기존 MemberService Constructor를 통해 외부에서 MemberRepository를 넣어준다 (DI)

public class MemberService {  
	// private final MemoryMemberRepository memberRepository = new MemoryMemberRepository();  
	private final MemberRepository memberRepository;  
	  
	public MemberService(MemberRepository memberRepository){  
		this.memberRepository = memberRepository;  
}
  • MemberService의 입장에서 memberRepository를 외부에서 넣어준다. 이걸 Dependency Injection (DI)이라고 한다

MemberServiceTest 에서는 동작하기 전마다 넣어주면 된다

class MemberServiceTest {  
	  
	// MemberService memberService = new MemberService();  
	MemberService memberService;  
	MemoryMemberRepository memoryMemberRepository;  
	  
	@BeforeEach  
	public void beforeEach(){  
		memoryMemberRepository = new MemoryMemberRepository();  
		memberService = new MemberService(memoryMemberRepository);  
	}
  • 이렇게 하면 매번 같은 MemberRepository가 사용이 된다

위의 테스트 코드들은 Java JVM안에서 끝나게 된다