본문 바로가기

WINK-(Web & App)/Spring Boot 스터디

[2025 1학기 스프링부트 스터디] 오세웅 #3주차

반응형

비즈니스 요구사항 정리

회원ID와 이름을 데이터에 저장, 회원 등록 및 조회 기능을 구현하는데

아직 데이터 저장소가 선정되지 않았다는 가상의 시나리오가 있습니다.

데이터 저장소가 선정되지 않았다는게 모르겠지만 일단 넘어가겠습니다.

 

컨트롤러에서 브라우저의 요청을 받아서 서비스에서 핵심 비즈니스 로직을 거친 후

리포지토리를 통해 데이터베이스에 접근하여 도메인 객체를 DB에 저장하고 관리합니다.

도메인은 비즈니스 도메인 객체로 주로 데이터베이스에 저장하고 관리를 한다는데 무슨 말인지 이해를 못했습니다..

그래서 조금 더 찾아봤는데 서비스에서는 "언제, 어떻게"를 정하는 거고

도메인에서 "무엇을 해야 하는가"를 정하는 거라고 합니다.

 

이게 클래스 의존 관계인데 데이터 저장소가 선정되지 않아서 interface를 먼저 만듭니다.

interface는 형태만 정의하고 구현은 하지 않는 틀이라서 나중에 저장소가 바뀌면 interface에 따라 구현체만 바꾸면 됩니다.

지금은 메모리 기반의 데이터 저장소를 사용합니다.

 

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

 

MemberRepository(interface)
public interface MemberRepository {
    Member save (Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}​

위 코드가 인터페이스 코드이고 우리가 구현할 기능들을 먼저 정해준 겁니다.

여기서 Optional은 리턴할 값이 null일 경우에 그 값을 명시적으로 표현할 수 있게 해주는 기능을 합니다.

 

MemoryMemberRepository
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;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

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

저장하고 Id나 이름을 통해 DB에서 값을 가져오고, DB에 있는 값을 모두 가져오는 기능을 구현했습니다.

 

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

JUnit이라는 프레임워크를 통해 구현한 기능이 잘 잘동하는지 테스트해볼 수 있습니다.

ㅇㅇ
public class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(member).isEqualTo(result);
    }

    @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("spring2").get();

        assertThat(result).isEqualTo(member2);
    }

    @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);

    }​

테스트 케이스에선 검증할 클래스의 객체를 만들어서 기능이 정상적으로 동작하는 지를 확인합니다.

이때 출력을 통해 확인하는 것보다는 Assertions를 사용해 테스트합니다.

클래스를 전체를 실행하면 기능을 한번에 테스트할 수 있습니다.

 

클래스 전체를 실행하면 실행 순서에 따라서 데이터가 달라지기 때문에 다른 테스트에서 오류가 발생할 수 있습니다.

이를 방지하기 위해서는 하나의 테스트가 끝날 때 마다 데이터를 비워야합니다.

MemoryMemberRepository의 clearStore를 정의하고 @AfterEach 애너테이션을 통해 테스트가 끝날 때 마다

clearStore를 호출해서 데이터를 비워줍니다.

 

이러한 테스트 코드를 먼저 작성하고 그에 따라 실제 구현 코드를 작성하는 방법을 TDD(Test-Driven Development)라고 합니다.

 

회원 서비스 개발

ㅇㅇ
package hello.hello_spring.service;

import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;

import java.util.List;
import java.util.Optional;

public class MemberService {

    private final MemberRepository memberRepository;

    //DI
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    //회원가입
    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 id) {
        return memberRepository.findById(id);
    }
}

서비스에서는 실질적인 기능을 구현합니다.

회원가입 기능, 전체 회원 조회 기능을 구현하는데 회원가입 기능에는 중복 이름이 불가능하다는 조건이 있습니다.

회원가입 기능은 중복이 아니면 저장을 하도록 구현하고, 전체 회원 조회나 한명의 회원 조회는 repository의 기능을 그래도 사용했습니다.

 

회원 서비스 테스트

MemberSeviceTest
class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @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());
    }

    @Test
    public void 중복_회원_예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        //when
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

/*
        assertThrows(IllegalStateException.class, () -> memberService.join(member2));

        try {
            memberService.join(member2);
            fail();
        }catch (IllegalStateException e) {
            Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }
*/

        //then

    }

    @Test
    void findMembers() {

    }

    @Test
    void findOne() {

    }

}​

테스트 코드에서는 한글을 사용하는 것도 괜찮다고 합니다.

테스트를 할 때는 given - when - then으로 나눠서 하는 것을 권장합니다.

given에서 뭐가 주어졌을 때, when에서 이것을 언제 실행하고, then에서 그래서 어떻게 되었는지를 나눠줍니다.

 

테스트에선 정상 플로우보다 예외 플로우가 훨씬 더 중요합니다.

중복 회원 예외 로직이 잘 작동하는지 확인하기 위해 "중복_회원_예외" 메서드를 통해 확인할 수 있습니다.

 

try-catch을 사용해도 되지만 assertThrows를 사용하는 것이 편리합니다.

 

이 테스트 코드에서도 clear를 해줘야하기 때문에 MemoryMemberRepository 객체를 이 코드에 선언하면

MemberService에 있는 MemoryMemberRepository 객체와는 다른 객체이기 때문에 오류가 발생할 여지가 생깁니다.

그러므로 MemberService의 객체를 사용하기 위해서 MemberService의 객체를 외부에서 선언하도록 바꿔줘야합니다.

 

이러한 형태가 스프링부트에서 중요한 DI(Dependency Injection), 의존성 주입입니다.

반응형