본문 바로가기

Java/spring

[Java/스프링 입문] 스프링 웹 개발 기초 - 회원 관리 예제 (비즈니스 요구사항 정리, 회원 도메인과 리포지토리 만들기, 회원 리포지토리 테스트 케이스 작성, 회원 서비스 개발, 회원 서비스 테스트 (JUnit 테스트 프레임워크))

회원 관리 예제 - 백엔드개발

📋 비즈니스 요구사항 정리
🏗️ 회원 도메인과 리포지토리 만들기
🧪 회원 리포지토리 테스트 케이스 작성
💻 회원 서비스 개발
✅ 회원 서비스 테스트 (JUnit 테스트 프레임워크)

 

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

  • 데이터: 회원 ID, 이름
  • 기능: 회원 등록, 조회
  • 가상의 시나리오: 아직 데이터 저장소가 선정되지 않음

 

  • 컨트롤러: 웹 MVC의 컨트롤러 역할
  • 서비스: 핵심 비즈니스 로직 구현
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

 

클래스 의존관계

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
    • 회원저장소: MemberRepository (interface)
    • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
    • 구현체: MemoryMemberRepository

 


 

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

  • main > java > hello > hello.hello_spring > domain  패키지 생성 > Member 클래스
    • 요구사항 - 데이터: 회원 ID, 이름
package hello.hello_spring.domain;

public class Member {

    private Long id; // 데이터 구분을 위해 시스템이 정하는 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;
    }
}

 

  • main > java > hello > hello.hello_spring > respository 패키지 생성 > MemberRespository 인터페이스, MemoryMemberRepository 구현체 생성
    • 요구사항 - 기능: 회원 등록, 조회  
    • MemberResitory 인터페이스
      • Java Optional 문법: 어떤 메서드가 null을 반환할지 확신할 수 없거나, null 처리를 놓쳐서 발생하는 예외를 피할 수 있습니다.
      • Member save(Member member): Member 객체를 저장합니다. 새로운 회원 정보를 저장하거나,  기존 회원 정보를 업데이트할 때 사용할 수 있습니다.
      • Optional<Member> findById(Long id): 주어진 ID를 사용해 멤버를 검색합니다.
      • Optional<Member> findByName(String name): 주어진 이름을 사용해 멤버를 검색합니다.
      • List<Member> findAll(): 저장소에 저장된 모든 멤버를 반환합니다.
package hello.hello_spring.repository;

import hello.hello_spring.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();
}

 

  • MemoryMemberRepository 구현체
    • 필드
      • private static Map<Long, Member> store = new HashMap<>()
        : store는 Member 객체를 저장하기 위한 Map입니다. key는 Long 타입의 id이고, value는 Member 객체입니다.
      • private static long sequece = 0L
        : 멤버 ID를 자동으로 생성하기 위한 변수입니다.
    • 인터페이스의 메서드를 모두 Override하여 구현합니다.
      • save: Member 객체를 저장합니다. 새로운 멤버가 저장될 때 sequence값을 증가 시키고, 이를 member의 ID로 설정합니다.
      • findById: 주어진 ID를 사용하여 store에서 멤버를 찾습니다.
        • Java ofNullable 문법: 대응하는 값이 없을 경우 Optional.empty()를 반환하여 null 처리의 위험성을 줄입니다.
      • findByName: store 맵의 값을(Member 객체)를 스트림으로 변환한 후, filter를 통해 이름이 일치하는 멤버를 찾습니다. 조건에 만족하는 멤버가 없으면 Optional.empty()를 반환합니다.
      • findAll: 저장소에 있는 모든 멤버를 리스트로 반환합니다. stroe의 값들을 ArrayList로 변환하여 반환합니다. 
package hello.hello_spring.repository;

import hello.hello_spring.domain.Member;

import java.util.*;

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)); // id가 Null일 경우를 대비
    }

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

 



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

  • 위에서 작업한 도메인과 리포지토리가 제대로 동작하는 지 확인하기 위해 테스트 케이스를 작성합니다.
    • 참고사항
      : 테스트 케이스부터 작성하고 개발하는 방법을 테스트 주도 개발(TDD)라고 합니다.
      테스트를 먼저 만들고 이후에 구현 클래스를 만듭니다.
  • test > java > hello > hello.hello_spring > repository 패키지 생성 > MemoryMemeberRepositoryTest 클래스 생성
    (main의 구조와 동일하게 테스트 케이스 패키지 생성합니다.)
  • @Test
    • Test 수행을 위한 어노테이션입니다.
    •  (AfterEach가 없는 상태에서) save → findByName → findAll을 순차적으로 실행했을 때는 문제 없지만, MemboryMemberRepositoryTest 전체로 테스트를 수행하면 오류가 뜹니다.
      • 그 이유는? 테스트를 순차적으로 설계했기 때문입니다.

 

📢 유의사항: Test는 순서가 보장되지 않기 때문에, 순서 의존적으로 테스트를 설계해선 안 됩니다

    • 서로 의존관계 없이 생성되어야 하는데, 이를 위해선 하나의 테스트가 실행될 때마다 공용 데이터가 삭제되어야 합니다.
  • @AfterEach
    • 하나의 Test가 끝날 때마다 afterEach가 수행됩니다.
package hello.hello_spring.repository;

import hello.hello_spring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

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); // 저장할 때 ID가 세팅됨
        Member result = repository.findById(member.getId()).get(); // Optional에서 값을 꺼낼 때는 get으로 꺼낼 수 있음(좋은 코드는 아니지만 Test case에서는 괜찮음
        Assertions.assertThat(member).isEqualTo(result); // isEqualTo(null) -> 오류 발생
    }

    @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();
        Assertions.assertThat(result).isEqualTo(member1);
    }

    @Test // 순서는 보장이 안 되기 때문에, 순서 의존적으로 테스트 케이스를 설계하면 안 됨 -> test 하나가 끝나고 나면 data를 clear해야 함
    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();
        Assertions.assertThat(result).hasSize(3);
    }
}

 

  • main > java > hello > hello.hello_spring > respository > MemoryMemberRepository
    • 원활한 테스트 진행을 위해 clearStore 메서드 - store.clear()를 수행하면 store의 데이터가 모두 삭제됩니다.
package hello.hello_spring.repository;

import hello.hello_spring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

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

테스트가 완료된 결과 화면

 



4. 회원 서비스 개발

  • main > java > hello > hello_spring > service 패키지 생성 > MemberService 클래스 생성
  • join: 회원가입, validateDuplicateMember: 중복회원 검증

 

👀 회원 서비스 코드가 DI 가능하게 변경한다는 무슨 말일까? (new → this.

  • 원래 MemberService 클래스는 MemberRepository를 직접 생성(new)하여 사용하는 방식으로 구현되었습니다. 하지만 이렇게 하면 MemberService 클래스가 MemberRepository에 강하게 결합(의존)하게 되면서 테스트나 확장이 어렵습니다.
  • 그러나 지금 코드에서는 MemberService 클래스의 생성자에게 MemberRepository 매개변수로 받아들이도록 변경했습니다. 이로 인해 MemberService 클래스는 MemberRepository의 구체적인 구현체에 의존하지 않고, 생성자를 통해 외부에서 주입된 객체를 사용하게 됩니다.
    => 의존성 주입 (Dependency Injection, DI)
    • DI의 장점: 테스트 용이성, 유연성 증가(MemberRepository의 구체적인 구현을 바꾸고 싶을 때 MemberService 코드를 수정할 필요가 없음), 재사용성
package hello.hello_spring.service;

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

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로 오류를 날리도록 한다
                    throw new IllegalArgumentException("이미 존재하는 회원입니다.");
                });
    }

    /**
     * 전체 회원 조회
     */
    private List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 



5. 회원 서비스 테스트

  • test > java > hello > hello_spring > service > MemberServiceTest
    • @beforeEach: 테스트가 실행되기 전에 각 테스트마다 새로운 MemoryMemberRepository 인스턴스를 생성하고, 이를 MemberService에 주입합니다.
    • @AfterEach: 테스트가 완료된 후 MemoryMemberRepository의 데이터를 초기화합니다.
    • Test의 기본 구조: Givien ~ When ~ Then
  • 메서드별 상세내용
    • beforeEach 메서드: 각 테스트 실행 전에 새로운 MemoryMemberRepository 인스턴스를 생성하고, 이를 MemberService 생성자에 주입하여 memberService 객체를 초기화합니다. (테스트 독립성 보장을 위함)
    • afterEach 메서드: 각 테스트 실행 후 호출되며, MemoryMemberRepository에 저장된 데이터를 초기화합니다. (각 테스트가 서로 영향을 미치지 않도록)
  • 테스트별 상세내용
    • 회원가입() 테스트
      • Given: 테스트에 필요한 초기 데이터를 설정합니다. 이름이 hello인 Member 객체를 생성합니다.
      • When: memberService.join(member)를 호출하여 회원가입합니다. 이때 회원의 ID가 반환됩니다.
      • Then: MemoryMemberRepository에서 해당 ID로 회원을 조회합니다. 가입한 회원의 이름과 저장된 회원의 이름이 일치하는지 검증합니다. (assertEquals)
    • 중복_회원_예외() 테스트
      • 예외가 발생한 상황에서 제대로 예외 처리가 되는지 확인하는 것도 테스트의 중요한 기능 중 하나입니다.
      • Given: 이름이 "spring"인 두 Member 객체(member1, member2)를 생성합니다.
      • When: 먼저 member1을 가입시킨 후 member2를 가입시킵니다. 이때 중복된 이름으로 인해 예외가 발생해야 합니다. assertThrows를 사용하여 IllegalStateException 예외가 발생하는지 확인합니다.
      • Then: 발생한 예외의 메시지가 "이미 존재하는 회원입니다."와 일치하는지 assertThat으로 검증합니다.
package hello.hello_spring.service;

import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.assertj.core.api.Assertions.*;


class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void beforeEach() {
        // DI
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }

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

    @Test
    public void 회원가입() throws Exception {
        //Given
        Member member = new Member();
        member.setName("hello");
        //When
        Long saveId = memberService.join(member);
        //Then
        Member findMember = memberRepository.findById(saveId).get();
        assertEquals(member.getName(), findMember.getName());
    }

    @Test
    public void 중복_회원_예외() throws Exception {
        //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));//예외가 발생해야 한다.
        // Then
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        try {
//            memberService.join(member2); // validateDuplicateMember에서 걸려서 예외가 터져야 함
//            fail();
//        } catch (IllegalStateException e) {
//            Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }
    }
}

 

 


 

[지금 무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의 | 김영한 - 인프

김영한 | 스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., 스프링 학습 첫 길잡이! 개발 공부의 길을 잃지 않도록 도와드립니다. 📣 확

www.inflearn.com

 

반응형