스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술을 들으며 정리하는 POST입니다.
전체적인 흐름
- Spring Project 생성
- Spring boot로 웹 서버 실행
- 회원 도메인 개발
- 웹 MVC 개발
- DB 연동 - JDBC, JPA, Spring data JPA
- 테스트 케이스 작성
interface
로 구현 클래스를 변경할 수 있도록 설계src/main/java/hello.hellospring/domain
라는 새로운 Package 안에,
Member
라는 새로운 Class를 생성한다.
Member
class에 요구사항에서 명시했던 회원과 관련된 Data를 처리하기 위해
아래와 같이 변수들을 선언한다. 그리고 각 변수에 대한 Getter & Setter 도 생성한다.
Getter & Setter의 필요성: https://ecsimsw.tistory.com/387
public class Member {
private Long id;
private String name;
private int pwd;
private String phone;
}
그리고, src/main/java/hello.hellospring/repository
라는 Package로 회원 객체 (= Member Class)를 저장하고 이 안에 MemberRepository
라는 Interface 를 생성하여 회원과 관련된 기능들을 선언한다.
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<Member> findByName(String name);
Optional<Member> findByPhone(String phone);
Optional<Member> findByPwd(int pwd);
List<Member> findAll();
Member drop();
}
Optional<>은 뒤에 명시된 API가 가져온 객체가 null일 수도, 아닐 수도 있는데 null인 경우를 대비해 이를 감싸주는 Wrapper 역할을 수행한다. (JAVA 8에 내장된 기능)
그래서get()
method 사용 시,isPresent()
를 사용하여 null 여부를 판단한다.
save
로 저장소에 회원 정보가 저장findAll
은 모든 회원정보를 가져오는 기능drop
은 회원 정보 삭제용으로 만들었는데, 따라하면서 만들어보려는 기능이제 구현체를 만들기 위해, repository
안에 MemoryMemberRepository
라는 Class를 생성한다.
public class MemoryMemberRepository implements MemberRepository
한 후, option + enter(return)
하여 implements method -> Ok
한다.
그럼 생성한 interface의 method들을 Override 할 수 있다.
import java.util.Map;
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
id
를 Key로서 사용하기 위해 Long
으로 지정해주고, '0,1,2'와 같이 Key 값을 생성해주는 sequence
를 선언한다.Map collection class는 Key와 value를 하나의 쌍으로 저장하는 방식을 사용한다. 이때 Key는 실질적인 value를 찾기 위한 역할을 한다.
- entry의 저장 순서를 고려하지 않고,
- Key는 중복이 허용되지 않는다.
HashMap
은 Key와 value를 묶어 하나의 entry로 저장한다. 이름 그대로Hash
알고리즘을 사용하기에 데이터의 검색 속도가 빠르다.
- 역시나 Key는 중복이 불가능하고,
- value는 null이 가능하다.
실무에서는 위와 같은 공유되는 변수에 대한 동시성 문제때문에
ConcurrentHashMap
&AtomicLong
등을 사용해야 한다. 동시성 제어와 관련된 참고: https://devlog-wjdrbs96.tistory.com/269
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
Member
객체의 id
에 sequence + 1
한 값을 settingid
는 중복이 허용되지 않는 고유한 값이어야 하므로 위와 같이 지정한다.store
에 해당 member
의 id
& member
를 Key & value 쌍으로 저장public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
store.get(id)
로 지정한 id
를 가지는 member
객체를 가져올 수 있다.id
를 가지는 member
가 없는 경우, 즉 Null
이 반환될 수 있는데, 이 또한 클라이언트에서 처리할 수 있도록 하기 위한 작업이 필요하다.Optional.ofNullable()
를 사용하여 반환되는 객체를 감싸준다.public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
store
에서 파라미터로 전달받은 name
과 동일한 name
을 가지는 member
객체를 하나라도 찾으면 (= findAny()
) return 하도록 한다.public List<Member> findAll() {
return new ArrayList<>(store.values());
}
store
에 있는 모든 값들을 가져와야 한다.store.values()
로 store
에 있는 모든 Member
들을 가져온다.List
이므로 store
에 있는 값들을 ArrayList
에 담아 반환한다.위에서 생성한 회원 정보 API의 기능을 테스트하기 위한 Test case를 작성
src/test/java/hello.hellospring/repository/MemoryMemberRepositoryTest
라는 새로운 class를 생성한다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository 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));
}
}
@Test
를 통해 TEST 코드임을 명시하고, 새로운 Member
객체를 만든다.save()
를 수행한다.save
한 member
의 id
정보를 findById
로 찾아내어 result
라는 Member
객체에 저장한다.result = true
가 출력된다면, save
기능의 테스트는 정상적으로 완료된 것이다.하지만, 매번 두 결과가 같은 비교하는 구문으로는 불편함을 느낄 수 있다.
Assertions.assertEquals(member, result);
org.junit.jupiter.api
에서 제공하는 Assertions
로 expected, 즉 기대하는 result
값이, actual, member
와 동일한지를 확인하는 코드로 테스트의 결과를 확인할 수 있다.또 다른 방법으로는,
Assertions.assertThat(member).isEqualTo(result);
org.assertj.core.api
에서 제공하는 Assertions
를 사용하여 위와 같이 결과를 확인하는 방법이 있다.Assertions
에서 option + enter(return)
→ static import
하여 이후에는 Assertions
를 명시하지 않고 바로 assertThat
를 사용할 수 있다.@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();
assertThat(result).isEqualTo(member1);
}
save()
에 대한 Test와 동일하게 수행한다.Test Class 자체에 대한 실행으로 전체
@Test
method들을 한 번에 실행할 수 있다.
@Test
public void findAll() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member member3 = new Member();
member3.setName("spring3");
repository.save(member3);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(3);
}
findAll
이므로 반환형이 List
이기 때문에 result
의 size()
에 대해 비교를 수행한다.이제 Class 전체 Test를 실행하면 에러가 발생하고 아래와 같이 명시한 것과 실행 순서가 달라진 것을 확인할 수 있다.
findAll
에서 이미 member1
& member2
객체를 생성하여 findByName
에서 에러가 발생하는 것이다.@Test
가 끝날 때마다 repository
를 비워줘야 한다.Test는 서로 의존관계 없이 설계되어야 한다!
MemoryMemberRepository
에 store를 비워주는 method를 선언한다.
@Override
public void clearStore() {
store.clear();
}
이후, Test에서 각 @Test
가 끝난 이후 위 method를 수행하기 위해 @AfterEach
annotation을 이용하는 method를 선언한다.
@AfterEach
public void afterEach() {
repository.clearStore();
}
테스트 주도 개발 = TDD
위와 같은 테스팅 코드들을 먼저 선언한 후, 개발을 진행하는 방식
실제 비즈니스 로직을 작성하는 부분,
서비스 부분에서는 비즈니스적인 용어들로 Method를 구현해야 기능 유지 보수 시, 의미와 매칭이 잘 된다. (서비스는 비즈니스를 처리하는 역할!)
src/main/java/hello.hellospring/service/MemberService
private final MemberRepository memberRepository = new MemoryMemberRepository();
// 회원 가입
public Long join(Member member) {
// 같은 이름인 중복회원을 불가능해야 함
Optional<Member> result = memberRepository.findByName(member.getName());
result.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
memberRepository.save(member);
return member.getId();
}
findByName
method로 이름의 여부를 확인한다.save
method로 회원가입이 가능하게 하고, 해당 회원의 id
를 반환한다.위의 코드를 Control + T
단축키를 이용해 Extract Method
하여 아래와 같은 형태로 변환할 수 있다.
public Long join(Member member) {
// 같은 이름인 중복회원을 불가능해야 함
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);
}
위에서 구현한 회원 서비스에 대한 테스트를 수행
command + shift + T
단축키를 이용해 손쉽게 Test를 작성할 수 있다.
@Test
void 회원가입() {
// given
// when
// then
}
@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());
}
then
part 에서, 위에서 join
하고 반환된 id
를 가지고 findOne
method를 실행member
객체의 Name
과 findOne
의 반환 객체인 findMember
의 Name
이 동일할 것이다.위의 Test code는 사실상 반쪽짜리 Test code 이다.
왜냐하면, Test는 예외에 대해서도 수행되어야 하기 때문이다. (그리고 service
에 중복회원체크 method도 있기 때문에 해당 Test도 필요하다.)
@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("이미 존재하는 회원입니다.");
}
// then
}
join
을 수행하려 한다.try - catch
구문으로, 발생한 Exception의 message를 비교하여 Test를 수행한다.하지만, 다른 문법으로 더 깔끔하게 Test를 수행할 수 있다.
assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThrows
를 이용해 () -> memberService.join(member2)
가 수행될 때, IllegalStateException
예외가 발생해야 함을 명시한다.IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
asserThrows
는 반환이 가능(command + option + V
로 반환받을 객체 선언)하기에, Exception message에 대한 검증도 가능하다.이전의 Test와 동일하게, 실행 순서가 보장되지 않아 에러가 발생할 수 있다.
이를 위해 이전에는 @AfterEach
로 MemoryMemberRepository
에서 생성한 clearStore
method를 사용했었다.
MemberService memberService = new MemberService();
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
MemberService
에서도 MemoryMemberRepository
객체를 선언하여 사용하는데, 위 코드에서는 한 번 더 해당 객체를 생성하여 사용하고 있다.
즉, MemberService
에서 사용하는 MemoryMemberRepository
와 MemberServiceTest
에서 사용하는 MemoryMemberRepository
는 서로 다른 객체를 생성하여 사용하기에, MemoryMemberRepository
에서 Map<> store
가 Class에 종속적인 static
이 아니라면 객체 생성마다 다른 DB가 되어버려 원하는 결과를 얻을 수 없게 된다.
MemberService.java
private final MemberRepository memberRepository = new MemoryMemberRepository();
↓
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
new
로 생성하지 않고, 외부에서 전달해주도록 변경MemberServiceTest.java
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
BeforeEach
annotation으로, 각 Test Method가 수행되기 전에 MemoryMemberRepsotiory
를 생성하고 이를 MemberService
에 전달하여 사용함으로써, 같은 Repository를 사용할 수 있게 한다.이를 Dependency Injection (의존성 주입) 이라고 한다.
Optional, Test, Dependency Injection