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

컨트롤러 : 웹 MVC의 컨트롤러 역할
서비스 : 핵심 비즈니스 로직 구현
리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
도메인 : 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨
클래스 의존관계

아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

package study.hello_spring.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;
}
}
domain이라는 Package를 만들고, Member라는 Class를 생성한다.

package study.hello_spring.Repository;
import study.hello_spring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member); // 저장
Optional<Member> findById(Long id); // id로 찾기
Optional<Member> findByName(String name); // 이름으로 찾기
List<Member> findAll(); // 저장된 모든 회원 리스트를 반환
}
Repository Package를 생성하고 MemberRepository Interface를 생성한다.

package study.hello_spring.Repository;
import study.hello_spring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
// 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
@Override
public Member save(Member member) {
member.setId(++sequence); // sequence 값 증가
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id)); // null 가능
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream() //컬렉션(List, Set 등)에서 호출하는 메서드
.filter(member -> member.getName().equals(name)) // 스트림 안의 요소들을 조건에 맞는 것만 걸러내기
.findAny(); // 스트림에서 조건에 맞는 요소 중 아무거나 하나를 꺼냄
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values()); // ArrayList : 자바스크립트 배열처럼 가변적인 리스트
}
}
설명은 코드에 주석으로 작성
MemoryMemberRepository 클래스를 생성하여 리포지토리를 만들었다.
이제 테스트를 할 차례이다.
개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해
서 해당 기능을 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번
에 실행하기 어렵다는 단점이 있다. 자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.

test에 main과 같이 repository 패키지를 생성하고 MemoryMemberRepositoryTest 클래스를 생성한다.
package study.hello_spring.repository;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository repository = new MemoryMemberRepository();
@Test
public void save(){
}
}

그 후에 좌측 실행 버튼을 클릭하면

위 사진과 같이 Run 탭이 실행된다면 성공이다.
@Test
public void save(){
Member member = new Member(); // member 생성
member.setName("spring"); // 'spring' 이라는 이름을 설정
repository.save(member); // repository에 member 저장
Member result = repository.findById(member.getId()).get(); // member.getId()로 Id를 불러오고, findbyId로 repository에서 member를 찾은 후 get()으로 가져옴
System.out.println("reuslt = " + (result == member));
}

true가 출력된다.
Assertions.assertEquals(member, result); // 기대값과 실제값을 비교
System.out.print 대신, 값을 비교하는 매서드인 Assertion.assertEquals를 사용해보자.

완료 마크가 보인다.
member와 result가 같다는 뜻이다.
그럼 result 대신 null을 넣으면 어떻게 될까?

위 사진과 같이 오류가 발생한다.
Assertions.assertThat(member).isEqualTo(result);
또한 비교하는 매서드이다.

Assertions에 커서를 두고 option + enter를 누르면 static import를 할 수 있다.

import static이 추가되고, Assertions. 없이 assertThat을 바로 사용할 수 있다.
@Test
public void findByName(){
Member member1 = new Member(); // member1 생성
member1.setName("spring1"); // 'spring1' 지명
repository.save(member1); // member1에 저장
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member result = repository.findByName("spring1").get(); // result에 "spring1" 으로 findByName
assertThat(result).isEqualTo(member1);
}
이번엔 findByName 테스트해보자.

member1에 setName("spring1")을 했으니 성공한 모습.
repository.findByName("spring2").get(); 으로 수정하고 실행하면

예상대로 오류가 발생한다.


테스트를 동시에 실행하려면 위 사진과 같이 class에서 실행하면 해당 클래스에 있는 모든 테스트를 실행할 수 있다.


shift + F6을 누르면 위 사진과 같이 rename이 가능하다.
@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);
}
이번에도 member1, member2를 만든 후 테스트해보자.

result.size가 2이므로 정상적으로 작동한다.
isEqualTo(3)으로 수정하면

예상대로 오류가 발생한다.

정상적으로 작동하도록 코드를 수정하고 class단에서 테스트를 모두 실행했더니 위 사진처럼 오류가 발생한다.
같은 저장소를 사용하고 있어 테스트 간 간섭이 발생하여 오류가 발생하는 것이다.
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
repository.clearStore(); // 저장소 클리어
}
...
MemoryRepository를 MemoryMemberRepository로 변경 후
MemoryMemberRepository.java 파일에
public void clearStore(){
store.clear();
}
위 코드를 추가해준다.
@AfterEach는 각 테스트가 끝난 후 실행된다는 뜻이다.

각 테스트 후 clearStore()를 실행해주면서 테스트 간 간섭이 일어나지 않는다.

repository 아래에 service 패키지를 만들고 MemberService 클래스를 생성한다.
package study.hello_spring.repository.service;
import study.hello_spring.domain.Member;
import study.hello_spring.repository.MemberRepository;
import study.hello_spring.repository.MemoryMemberRepository;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
/*
* 회원 가입
*/
public Long join(Member member) {
// 같은 이름이 있는 중복 회원 x
Optional<Member> result = memberRepository.findByName(member.getName()); // Optional : 값이 있을 수도 있고 없을 수도 있는 상황을 표현하는 "상자" 같은 객체
result.ifPresent(m -> { // ifPresent : 값이 있으면
throw new IllegalStateException("이미 존재하는 회원입니다."); // throw : 예외(에러)를 던짐, IllegalStateException : 자바 런타임 예외 클래스
});
// result.orElseGet 도 있음
memberRepository.save(member);
return member.getId();
}
}
이름이 중복되지 않게 설계했으니
이미 중복된 이름이면 throw, 중복되지 않은 이름이면 save 후 getId()
Optional<Member> result = memberRepository.findByName(member.getName());
result.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
부분을
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
이렇게 표현할 수 있다.
위 코드를 선택하고 control + t 를 누르면

위 사진과 같이 refactor 할 수 있다.
Extract Method를 사용하면

위 사진과 같이 메서드가 생성된다.
/*
* 전체 회원 조회
*/
public List<Member> findMembers(){
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId){
return memberRepository.findById(memberId);
}
회원 조회하는 메서드도 작성하고 테스트해보자.
지난 테스트에서는 test에 패키지를 만들고 직접 작성했는데 간단한 방법이 있다.

클래스 내부에 커서를 잡고 command + shift + t 단축키를 입력하면 위 사진처럼 Create 할 수 있다.



간단하게 Test용 껍데기 파일이 생성된다.

Build 될 때 Test 코드는 실제 코드에 포함되지 않는다.
그래서 직관적으로 알아볼 수 있도록 과감하게 한글로 바꿔도 무방하다.
MemberService memberService = new MemberService();
memberService를 새로 생성하고
@Test
void 회원가입() {
// given 상황
Member member = new Member();
member.setName("hello");
// when 실행했을 때
Long saveId = memberService.join(member);
// then 결과
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
given, when, then 주법으로 테스트하는 걸 추천한다
member를 생성하여 setName("hello")
memberService에 방금 생성한 member를 join
결과 확인

정상적으로 테스트 성공
하지만 테스트는 성공보단 예외(실패)가 더 중요하다.
중복 가입을 테스트해보자.
@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){ // 예외처리
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다. 123"); // 실패 메세지와 작성한 메세지가 같은지 확인
}
// then
}
member1, member2에 둘 다 "spring" 이라고 setName을 한 후
memberService에 join을 해보았다.

실패 메세지는 "이미 존재하는 회원입니다." 이지만 뒤에 123을 붙여서 두 메세지가 같지 않으니 위 사진처럼 오류가 발생한다.
근데 이렇게 try catch를 사용하려니 뭔가 애매하다.
assertThrows를 사용해보자.
// when
memberService.join(member1);
assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThrows(예외클래스, 실행할코드);
- 예외클래스 → 어떤 예외가 발생해야 하는지 지정
- 실행할코드 → 예외가 발생할 것으로 기대하는 코드(람다로 작성)
assertThrows는 "이 코드 실행했을 때 정말로 IllegalStateException이 던져지는지 확인"하는 역할을 한다.

- memberService.join(member2) 실행 도중 IllegalStateException이 발생하면
→ 예상한 대로 동작했으므로 테스트 성공- 만약 예외가 발생하지 않거나, 다른 종류의 예외가 발생하면
→ 예상과 다르므로 테스트 실패
IllegalStateException이 발생하므로 테스트 결과는 통과인 것이다.
만약 회원 가입의 setName에 "hello" 가 아닌 "spring"을 넣고 전체 실행하면 오류가 발생한다.
왜? 테스트하면서 "spring"이라는 member는 이미 memberRepository에 저장되어있기 때문이다.
그럴 땐 아까 배운
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
memberRepository.clearStore();
}
를 추가해주면 clear되기 때문에 정상적으로 작동한다.
테스트 할 때
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
를 작성해서 하였다.
하지만 new로 새로 생성하기 때문에 memberService에서 사용하는 repository와 테스트 케이스에서 사용하는 repository는 서로 다르다.
이런 경우 테스트하면서 문제가 발생할 수 있다.
그럴 땐
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
...
외부에서 넣어주도록 바꿔준다.
테스트 케이스에서도
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach // 각 테스트 실행 전
public void beforeEach(){
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
위처럼 수정한다.
new MemberRepository로 생성 후 memberRepository에 넣고,
MemberService에 넣어준다.
이러면 같은 repository를 사용하게 된다.
위와 같은 구조를 DI는 Dependency Injection (의존성 주입) 라고 한다.