웹 애플리케이션 계층 구조




복습 ) 만약 View에 모든 코드를 다 짜서 100만줄이 되었다면? 유지보수가 힘들어 질 것이다.
그래서 분리하여 개발하는 것을 MVC 패턴이라고 한다.
Model : 데이터를 저장, 데이터베이스와 연결되어 데이터 로직 처리
View : 화면에 웹 페이지를 보여주는 역할
Controller : 요청 엔드포인트 관리
회원 관리 예제(데이터 처리 부분) 구현
다음과 같은 순서로 구현한다.
domain(Model 중 VO) -> repository(Model 중 DAO) -> service
1. Getter/Setter을 통해 회원 정보의 틀(클래스)을 만든다.
- Member 클래스는 고유의 아이디와 이름을 갖는다.
// Member 클래스
package hello.hellospring.domain;
public class Member {
private Long id;
private String name;
// setter, getter
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
}
2. 인터페이스를 설계한다. = 기능을 큰 틀에서 결정한다. (선수지식 -> JAVA 인터페이스)
데이터를 메모리를 통해 저장할지, DB를 통해 저장할지 결정되지 않았다.
- 회원 정보 저장
- 아이디로 유저 정보 조회
- 이름으로 유저 정보 조회
- 모든 유저 정보 조회
- 저장된 회원 정보 모두 제거
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);
List<Member> findAll();
void clearStore();
}
3-1. 인터페이스를 받아와서 데이터와 관련된 세부 기능들을 결정하고 구현한다.
데이터를 메모리를 통해 관리하기로 결정!
-> 데이터를 담을 HashMap을 하나 만들어 담는다.
- 회원 정보를 "메모리에" 저장하는 기능 (id 값은 auto_increment 되도록)
- 특정 아이디가 있는 유저의 정보를 "메모리에서" 확인하고 조회
- 특정 이름이 있는 유저의 정보를 "메모리에서" 확인하고 1개 조회
- 모든 "메모리에 저장된" 유저 정보 조회
- "메모리에 저장된" 회원 정보를 모두 제거하는 기능
*단, 여기서 "메모리"는 아래 코드의 store로 담긴 HashMap 안에서를 의미함
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static Long sequence = 0L;
// 멤버의 정보를 저장한다. (id는 auto_increment)
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
// 특정 아이디가 있는 유저의 정보를 1개 리턴
@Override
public Optional<Member> findById(Long id) {
Optional<Member> result = Optional.ofNullable(store.get(id));
return result;
}
// 특정 이름이 있는 모든 유저의 정보를 1개 리턴
@Override
public Optional<Member> findByName(String name) {
Optional<Member> result = store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
return result;
}
// 모든 유저를 조회
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
// store을 모두 없애주는 로직
@Override
public void clearStore() {
store.clear();
}
}
3-2. 만약에 데이터를 DB를 통해 관리하기로 결정했다면 마찬가지로 인터페이스에서 받아와서 세부 데이터 관리를 DB를 통해 관리하면 된다. (이 포스팅에서는 생략)
4. 비즈니스 로직과 관련된 Service 단을 구현한다. (repository(데이터 관리) 부분 메서드보다 변수명을 직관적으로 짓는다.)
[회원 가입]
- 같은 회원이 있는 중복 회원은 안된다. -> 중복 회원 확인 로직 필요
- 회원 정보를 저장한다.
[전체 회원 조회]
- 전체 회원을 조회한다.
[특정 ID를 가지는 회원 조회]
- 특정 ID를 가지는 회원을 조회한다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository; // MemberRepository 중에서 MemoryMemberRepository
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 회원 가입
public Long join(Member member) {
// 같은 이름이 있는 중복 회원 X
Optional<Member> result = memberRepository.findByName(member.getName());
validateDuplicateMember(result);
memberRepository.save(member);
return member.getId();
}
// 중복 회원 검증
private static void validateDuplicateMember(Optional<Member> result) {
result.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
// 전체 회원 조회
public List<Member> findMembers() {
return memberRepository.findAll();
}
// ID에 맞는 1명 조회
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
테스트 코드 작성

1. 왜 테스트 코드를 작성하는가? Why?
개발한 기능이 잘 되는지 확인하기 위해서 main 메서드를 통해 실행해보거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행하려면 테스트 하는데 번거롭고 오래 걸린다.
그리고 여러 테스트를 한번에 실행하기 어렵다.
그래서 자바에서는 JUnit 프레임워크로 테스트를 실행하여 이 문제를 해결한다.
2. 그렇다면 테스트 코드를 만들어보자.
만약, repository/MemoryMemberRepository 클래스의 있는 메서드들을 테스트 해보고 싶으면 해당 소스코드에서 윈도우 기준 [Ctrl+Shift+T] 를 누른다.
그러면 테스트 껍데기들이 만들어질 것이다.
먼저 save() 부터 검증 코드를 알아보자.
// MemoryMemberRepository 클래스 안에 있는 save() 메서드 테스트
// 멤버 이름이 "spring"인 사람이 잘 저장되는지 테스트
MemoryMemberRepository repository = new MemoryMemberRepository(); // MemoryMemberRepository 클래스의 메서드를 사용하므로 객체 생성
@Test // JUnit이 "이 메서드 테스트니까 실행해줘~" 한다.
public void save() {
// case : 데이터
// member의 이름이 spring 이라고 가정하고 테스트
Member member = new Member();
member.setName("spring");
// when : 테스트
repository.save(member);
// then : 검증
Member result = repository.findById(member.getId()).get(); // .get 을 사용하면 Optional 껍데기가 벗겨져서 나옴
Assertions.assertThat(result).isEqualTo(member);
}
@Test : Test 어노테이션 아래에 있는 메서드(save())는 테스트 용도니까 테스트 할 때 실행해줘~ 라는 의미이다.
멤버 이름이 "spring"인 사람이 잘 저장되는지 테스트를 해보기 위해서 member 객체를 만들고 이름을 "spring" 으로 설정하고 save 함수를 호출하여 저장해본다.
그리고 나서 이름이 "spring" 인 테스트 멤버가 잘 저장을 되었는지 확인하기 위해서 member 객체가 같은지 확인해본다.
Assertions.assertThat 구문이 만약에 참이면 테스트를 돌렸을 때 초록색 불과 함께 아무 오류 메시지가 뜨지 않을 것이고,
result의 Member 객체와 member의 Member 객체가 같지 않으면 빨간색 불과 함께 오류 메시지가 뜰 것이다.


findByName() 메서드와 findAll() 메서드도 마찬가지로 구현하면 된다.
// MemoryMemberRepository 클래스 안에 있는 findById() 메서드 테스트
// repository에 이름이 "spring1"과 "spring2"인 Member을 만들어 저장하고 "spring1"인 객체를 잘 가져오는지(findByName 메서드) 검증
@Test
public void findByName() {
// given (testcase)
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
// when
Member result = repository.findByName("spring1").get();
// then
assertThat(result).isEqualTo(member1);
}
// repository에 이름이 "spring1"과 "spring2"인 Member을 만들어 저장하고(given) 모든 객체가 잘 담겼는지(when) 검증(then)
@Test
public void findAll() {
// given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
// when
List<Member> result = repository.findAll();
// then
Assertions.assertThat(result.size()).isEqualTo(2);
}
모든 코드를 다 합치면 현재 아래와 같다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
public class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
// MemoryMemberRepository 클래스 안에 있는 save() 메서드 테스트
// 멤버 이름이 "spring"인 사람이 잘 저장되는지 테스트
@Test // JUnit이 "이 메서드 테스트니까 실행해줘~" 한다.
public void save() {
// member의 이름이 spring 이라고 가정하고 테스트
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get(); // .get 을 사용하면 Optional 껍데기가 벗겨져서 나옴
assertThat(result).isEqualTo(member);
}
// MemoryMemberRepository 클래스 안에 있는 findById() 메서드 테스트
// repository에 이름이 "spring1"과 "spring2"인 Member을 만들어 저장하고 "spring1"인 객체를 잘 가져오는지(findByName 메서드) 검증
@Test
public void findByName() {
// given (testcase)
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
// when
Member result = repository.findByName("spring1").get();
// then
assertThat(result).isEqualTo(member1);
}
// repository에 이름이 "spring1"과 "spring2"인 Member을 만들어 저장하고(given) 모든 객체가 잘 담겼는지(when) 검증(then)
@Test
public void findAll() {
// given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
// when
List<Member> result = repository.findAll();
// then
Assertions.assertThat(result.size()).isEqualTo(2);
}
}
3. @AfterEach의 필요성

하나씩 실행할 때는 잘 되지만, 지금 작성한 테스트 코드를 한꺼번에 실행하면 잘 안될 것이다. (AssertionFailedError 오류)
"어라라..? 왜 안돼..?"
아래 코드를 추가하고 실행해보자.
// 각각의 test 메서드가 하나 끝나고 Member 객체가 중복되지 않도록 하기 위해 afterEach 메서드 사용
@AfterEach // test를 하나 끝난 후에 자동으로 실행되는 메서드에 붙이는 어노테이션
public void afterEach() {
repository.clearStore();
}
이제 잘 작동한다.
아까 오류가 났던 이유는 findAll 메서드와 findByName 메서드가 둘 다 given 부분에 member1, member2 라는 Member 객체를 생성해주었다. 그런데, findAll 이 실행되면 Member 객체는 member1, member2 가 있는 것인데 findByName에서도 member1, member2 객체가 만들어져서 충돌되어 버린다.

그리고 테스트 코드는 작성한대로 실행되지 않는다 (순서를 보장하지 않는다.) 는 것을 알 수 있다.
그래서 테스트가 하나 끝나고 나면 데이터를 비워주어야(clear) 한다.
그 구문이 아까 위에 있던 afterEach() 구문인 것이다.
아래 코드는 지금까지 배웠던 이론을 바탕으로 MemberService에 대해서도 테스트 코드를 작성하였다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
// 각 하나하나 테스트 전마다 실행
// 매번 새로운 MemoryMemberRepository와 MemberService 객체를 만들어서 "테스트 간에 서로 영향을 주지 않도록 테스트 격리"
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
// 테스트 후에 매번 실행
// 테스트가 끝난 뒤 저장소를 비워주는 역할
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
void 회원가입() { // 테스트 코드는 한글로 작성 가능하다!
// given : 이 데이터를 기반으로~
Member member = new Member();
member.setName("spring");
// when : ~ 메서드를 실행했을 때 잘 실행되는지 본다.
Long saveId = memberService.join(member);
// then : 다시 조회하여 잘 저장이 되었는지 체크한다.
Member findMember = memberService.findOne(saveId).get();
Assertions.assertThat(member.getName()).isEqualTo(findMember.getName()); // 저장된 멤버에 "hello" 라는 사람이 있는지 찾는다.
}
@Test
public void 중복_회원_예외() {
// given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
// when
memberService.join(member1);
// memberService.join(member2) 구문을 실행할 때 IllegalStateException 오류가 터지면 e에 메시지가 담긴다.
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); // 오류 메시지 검증
// try {
// memberService.join(member2);
// fail();
// } catch (IllegalStateException e){
// Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// }
// then
}
// 전체 회원 조회 체크 (2개 정도 넣어보고 2개가 나오는지 테스트)
@Test
void findMembers() {
// given
Member member1 = new Member();
member1.setName("spring1");
memberRepository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
memberRepository.save(member2);
// when
List<Member> result = memberService.findMembers();
// then
Assertions.assertThat(result.size()).isEqualTo(2);
}
// ID에 맞는 1명 조회 (2개 정도 넣어보고 그 특정 ID에 맞는 사람이 조회되는지 검증)
@Test
void findOne() {
// given
Member member1 = new Member();
member1.setName("spring1");
memberRepository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
memberRepository.save(member2);
// when
Member result = memberService.findOne(memberRepository.findByName("spring1").get().getId()).get();
// then
Assertions.assertThat(result).isEqualTo(member1);
}
}
4. 테스트 코드 작성에 기타 알고 있으면 좋을 사항들
1) [Ctrl+Shift+T] 단축키를 통해 테스트 코드 형태를 자동으로 만들고 시작하자
2) 메서드에 한글 이름을 적는 것이 현업에서 오히려 일반적이라고 한다. (테스트 코드는 실제 코드가 실행할 때 빌드가 되지 않으니 걱정하지 않아도 된다.)
3) assertThrows 함수를 통해 어떤 메서드를 실행했을 때 특정 예외가 터지는지 확인할 수 있다.
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); // 오류 메시지 검증
4) given, when, then 구문을 기억하자.
- given : 테스트에서 사용할 데이터
- when : 확인하고 싶은 기능(메서드)
- then : 예상되는 결과가 나오는지 체크 - Assertions.assertThat(A).isEqualTo(B); (A가 B와 같은지 체크)
5) 각 테스트마다 순서에 의존하면 안된다. 분리되어야 한다. -> @BeforeEach, @AfterEach 을 통해 관리할 수 있다.
6) 테스트를 할 때 기능마다 봐주는 것이 좋다. 예를 들어 회원가입이라면 회원가입이 잘 되어 추가가 되는지 하나랑, 중복 회원을 잘 예외처리 하는지를 봐야 한다.
관련 용어
TDD(Test Driven Development, 테스트 주도 개발) : 우리는 지금까지 기능을 구현하는 코드를 작성하고 그 이후에 테스트 코드를 작성했다. 이런 방식과 정반대로 테스트 코드를 먼저 작성해두고 기능 개발을 하는 방식을 테스트 주도 개발이라고 한다.