3 분 소요

이 포스트는 인프런 김영한님의 강의 및 자료를 사용합니다

1. 웹 애플리케이션의 구조

logic

Controller

컨트롤러는 클라이언트의 요청을 받아 처리하는 역할을 담당한다. 사용자의 입력을 받아들이고, 해당 요청에 맞는 서비스 로직을 호출하여 처리한 후, 그 결과를 사용자에게 다시 전달한다.

예를 들어 버튼을 누르면, 클라이언트로부터 회원가입 요청을 받고, 정보를 서비스 계층에 넘겨준다.

Service

서비스 계층은 비즈니스 로직을 처리한다. 컨트롤러로부터 받은 요청에 대한 비즈니스 로직을 실행하는 것이 주된 역할이다.

컨트롤러로부터 받은 데이터를 처리하는 비즈니스 로직을 수행한다. 예를 들면 정보들을 도메인 객체로 변환하거나 비밀번호 해싱과 같은 로직이 있다.

Repository

레포지토리는 데이터베이스와의 통신을 담당하는 계층이다. 즉, 데이터베이스로부터 데이터를 조회하거나, 데이터를 저장, 수정, 삭제하는 등의 작업을 진행한다.

사용자 도메인 객체를 데이터베이스에 저장한다.

Domain

도메인은 비즈니스 로직을 구현하는 핵심적인 엔티티를 포함한다. 이는 실제 비즈니스 문제를 해결하기 위한 모델로, 애플리케이션에서 사용하는 데이터와 그 데이터에 대한 연산을 정의한다.

‘User’ 도메인 모델은 사용자의 정보(사용자 이름, 이메일, 비밀번호 등)를 포함한다.

DB

데이터베이스는 애플리케이션의 데이터를 영구적으로 저장하는 시스템이다.

2. 회원 도메인과 레포지토리 만들기

이번 챕터에서는 회원 정보 저장과 회원 조회 기능을 다루기 때문에, 먼저 회원 도메인을 만든다.

데이터는 간단하게 ID와 이름만 포함한다.

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

위의 사진을 보면 MemberRepository 인터페이스가 있고, MemoryMemberRepository 클래스가 인터페이스를 구현하고 실질적으로 기능을 제공한다.

아래 코드에서 구현하는 회원저장이나 회원정보를 불러오는 등의 데이터베이스와 직접적으로 연관이 있는 기능들을 통해서 레포지토리의 중요성을 알 수 있다.

public interface MemberRepository {
  Member save(Member member);
  Optional<Member> findById(Long id);
  Optional<Member> findByName(String name);
  List<Member> findAll();
}
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);
    store.put(member.getId(), member);
    return member;
  }
  //ID를 통해 회원조회
  @Override
  public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id));
  }
  //회원 전체 조회
  @Override
  public List<Member> findAll() {
    return new ArrayList<>(store.values());
  }
  //이름을 통해 회원조회
  @Override
  public Optional<Member> findByName(String name) {
    return store.values().stream()
      .filter(member -> member.getName().equals(name))
      .findAny();
  }
  //스토어 클리어
  public void clearStore() {
    store.clear();
  }
}

3. 레포지토리 테스트코드

개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번에 실행하기 어렵다는 단점이 있기 때문에 JUnit이라는 프레임워크로 테스트실행해서 이러한 문제를 해결한다.

테스트코드를 사용하는 이유는?

김영한님

  • 내가 구현한 코드가 내가 원하는데로 작동하는지 코드를 코드로 확인하는 것. 혼자 개발을 한다면 테스트코드 없이 어찌저찌 개발을 할 수는 있지만, 여러명이서 개발을 하거나 소스코드가 길어지게 된다면, 테스트코드를 사용하지 않으면 문제가 많아지기 때문에 거의 불가능에 가깝다.

ChatGPT

  • 버그 조기 발견이 가능하다.

  • 테스트를 작성하는 과정에서 개발자는 코드의 구조와 설계에 대해 더 깊이 생각하게 된다. 이는 결과적으로 더 깨끗하고 유지 관리하기 쉬운 코드로 이어진다.

  • 팀 프로젝트에서 테스트 코드는 팀원 간의 신뢰를 구축하고, 코드에 대한 공통의 이해를 돕는다. 새로운 팀원이 프로젝트에 합류했을 때, 테스트 코드는 프로젝트의 기능과 요구 사항을 빠르게 파악하는 데 도움이 된다.

테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
  @AfterEach
  public void afterEach() {
    repository.clearStore();
  }
  @Test
  public void save() {
    //given
    Member member = new Member();
    member.setName("spring");
    //when
    repository.save(member);
    //then
    Member result = repository.findById(member.getId()).get();
    assertThat(result).isEqualTo(member);
  }
  @Test
  public void findByName() {
    //given
    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);
  }
  @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
    assertThat(result.size()).isEqualTo(2);
  }
}

테스트코드를 만약 처음 작성해본다면 주어진 상황(given)에서 특정 메소드를 호출했을 때(when) 예상한 결과(then)가 나오는지 확인하는 방식으로 구성하는 것이 좋다.

예를 들어 save 테스트를 설명을 해보자면.

@Test
public void save() {
  //given
  //Member객체의 이름을 spring으로 설정하고
  Member member = new Member();
  member.setName("spring");
  //when
  //Member 객체를 DB에 저장할 때
  repository.save(member);
  //then
  //Member 객체를 id를 사용하여 다시 조회하고, 조회된 객체가 원래의 객체와 동일한지 검증. 
  Member result = repository.findById(member.getId()).get();
  assertThat(result).isEqualTo(member);

@AfterEach : 테스트는 순서가 랜덤이기 때문에 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. @AfterEach 를 사용하면 각 테스트가 종료 될 때 마다 이 기능을 실행한다. 여기서는 메모리 DB에 저장된 데이터를 삭제한다.

4. 회원 서비스 개발

실제로 코드를 보면서 서비스 클래스는 어떤 역할을 하는지 살펴보자.

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

카테고리:

업데이트: