본문 바로가기

Dev/Spring Boot

[코드로 배우는 스프링 부트] 회원 관리 예제(1)

본 포스팅은 인프런의 '스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술' 강의를 수강하며 정리한 내용입니다.

 

백엔드 개발 순서

1. 비지니스 요구사항 정리 

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

3. 회원 리포지토리 테스트 케이스 작성 

4. 회원 서비스 개발

5. 회원 서비스 테스트 ( JUnit 사용 )

 

1. 비지니스 요구사항 정리

(예제이므로, 단순하게 비지니스를 설정합니다.)

- 데이터 : 회원 id(db 시스템), 회원 이름

- 기능 : 회원 등록, 조회

- 데이터 저장소는 추후 선택 예정이라 가정

 

 

일반적인 웹 어플리케이션의 구조

- 컨트롤러 : 웹 MVC의 컨트롤러 역할

- 서비스 : 핵심 비즈니스 로직 구현 (ex. 중복 가입 불가)

- 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리

- 도메인 : 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

 

 

서비스, 리포지토리 클래스 의존관계

 리포지토리는 인터페이스로 생성합니다. 왜냐하면, 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계하는 것입니다. 데이터 저장소는 RDB, NoSQL, JPA, JDBC, Mybatis 등등 다양한 저장소를 고민중인 상황으로 가정 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소(Memory MemeberRepository)를 사용한고 나중에 데이터 저장소가 결정되면 쉽게 변경할 수 있도록 합니다.

 

 

 

2-1. 회원 도메인 만들기

hello.hellospring 패키지 밑에 domain 패키지를 생성하고,  Member 클래스를 생성합니다.

비지니스 요구사항 정리했던 것에서 처럼  id, name 멤버 변수와 getter/setter 메소드를 생성합니다.

package hello.hellospring.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;
    }
}

 

 

 

2-2. 회원 리포지토리 만들기

hello.hellospring 패키지 밑에 repository 패키지(회원 객체를 저장하는 저장소)를 생성하고 MemberRepository 인터페이스를 생성합니다.

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    // 회원을 저장하면, 저장된 회원이 반환됩니다.
    Member save(Member member);
    // 회원의 db id로 회원을 조회
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
    // Optional을 java8에 있는 기능입니다. findBy~ 로 가져올 때 없으면,
    // NULL을 반환해야 하는데 이때, 반환형을 Optional을 사용합니다.
}

 

이제 구현체를 작성합니다. repository 패키지 밑에 MemoryMemberRepository 클래스를 생성합니다.

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{

    // 실무에서는, 동시성 문제가 발생할 수 있어서 공유되는 변수일 때는 ConcurrentHashMap 사용 고려
    private static Map<Long, Member> store = new HashMap<>();
    // 시퀀스는 자동으로 0, 1, 2 로 key 값을 만들어주는 용도입니다.

    // 이때도 마찬가지로 동시성 문제 발생을 예방해 실무에서는 long 보다 AtomicLong 사용 고려
    private static long sequence = 0L;

    @Override
    public Member save(Member member) {
        // 메소드가 호출될 때, 이미 회원명은 정해진 상태입니다.
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        // Null 일 경우 대비
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        // java 8 람다 사용 (반복문)
        return store.values().stream()
              .filter(member -> member.getName().equals((name)))
              .findAny();
    }

    @Override
    public List<Member> findAll() {
        // 실무에서 루프 돌리기가 좋아 리스트를 많이 사용합니다.
        return new ArrayList<>(store.values());
    }
}

 

 

3. 회원 리포지토리 테스트 케이스 작성 

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

 

test 패키지 밑에 (main패키지 밑에 만들었던 것처럼)  hello.hellospring 패키지 밑에 repository 라는 패키지를 생성합니다.

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;


// 테스트 케이스의 장점 : 클래스 단위(레벨)에서 실행가능,
// hello.hellospring 패키지에서 실행하면 전체 클래스 테스트 가능
// test는 외부에서 접근할 필요가 없으므로, public 아니어도 괜찮습니다.
class MemoryMemberRepositoryTest {
 
    MemberRepository repository = new MemoryMemberRepository();
 
    @Test
    public void save(){
        // 멤버 저장이 되는 지 테스트
        Member member = new Member();
        member.setName("baek jiyeon");
 
        repository.save(member);

        Member result = repository.findById(member.getId()).get()

        // findById 메소드의 반환형이 Optional 이라 get()
 
        // 객체 비교로 검증
        System.out.println("result = " + (result == member));
        // 이렇게 출력으로 확인하는 것보다 assert 사용하면, 빌드 툴에서 오류 여부로 확인가능
        // Assertions.assertEquals(result, member);
        // Assertions(junit) 보다 더 편하게 쓸 수 있는 org.assertj.core.api.Assertions
        // add static import
        assertThat(member).isEqualTo(result);
    }
 
    @Test
    public void findByNmae(){
        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);
    }


    @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);
    }
 
}
 
 테스트는 클래스 레벨에서, 패키지 레벨에서 모두 가능합니다. 아래는 findByName까지 코드를 작성하고 클래스단위로, 패키지 전체로 테스트한 결과인데 오류 없이 동작합니다.

 

 하지만 각 @Test의 순서가 보장 되지 않아 위 코드에서 패키지 레벨에서 테스트를 진행하면, 이전 테스트에서 쌓인 데이터 때문에 다음 테스트가 실패할 가능성이 있습니다다. 아래 사진에서 findAll() 메소드가 가장 먼저 동작해서 spring1, spring2 이름으로 객체 2개가 만들어진 상황이고, 이 뒤에 findByName()이 동작할때, 한번 더 spring1, spring2를 저장하므로써 중복된 객체가 저장되게 됩니다. 이 과정에서 findByName으로 객체를 조회할 때 오류가 발생할 수 있습니다.

 이런 문제를 예방하기 위해, 테스트 하나가 끝나고 나면, 데이터를 clear  해주어야 합니다.

 

MemoryMemberRepositoryTest.java 코드에 @AfterEach 메소드 추가

class MemoryMemberRepositoryTest {

    // MemoryMemberRepository에 있는 메소드만 테스트할 것이므로 타입 수정

   // Factory 디자인 패턴인가?
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    // 동작이 끝날때마다 어떤 처리를 해줍니다. (콜백 메소드)
    public void afterEach() {

        repository.clearStore();
    }

 

MemoryMemberRepository.java 코드에 clearStore() 메소드 추가

public void clearStore() {
    store.clear();
}

 이러면 각 메소드가 실행되고 나서, 해쉬맵에 있는 데이터가  clear되므로 전체 메소드를 한번에 테스트해도 오류가 발생하지 않습니다.

 @AfterEach : 각 테스트가 종료될 때 마다 이 기능을 실행합니다. 여기서는 메모리 DB에 저장된 데이터를 삭제하게 됩니다. 테스트는 각각 독립적으로 실행되어야 하고, 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아닙니다.

 

 지금까지는 리포지토리 개발 후 테스트 작성하였는데, 테스트 클래스 작성 후, 리포지토리 작성하는 순서로 하는 방식 (미리 검증틀을 만드는 것) 을 테스트 주도 개발 'TDD' 라고 합니다. 테스트가 만약 수십개라면 test 패키지 밑에 있는 hello.hellospring 패키지를 실행하면 됩니다. 테스트가 없는 개발은 협업을 할 때 필수적이기에 테스트 관련 공부를 하는 것 또한 필요합니다.