개발 공부/백엔드

스프링 부트 회원 관리 예제 만들기: 설계부터 테스트, DI까지

baby-t 2025. 9. 16. 22:19

https://www.inflearn.com/

 

인프런 - 라이프타임 커리어 플랫폼

프로그래밍, 인공지능, 데이터, 마케팅, 디자인등 입문부터 실전까지 업계 최고 선배들에게 배울 수 있는 곳.

www.inflearn.com

 

인프런 사이트의 김영한님의 강의를 보면서 작성한 글입니다.

 

간단한 회원 관리 애플리케이션을 직접 개발하는 과정을 통해, 스프링 부트의 기본적인 설계 방식과 실용적인 테스트 방법을 알아보겠습니다. 

비즈니스 요구사항

  • 데이터: 회원 ID, 이름
  • 기능: 회원 등록, 회원 조회
  • 제약사항: 데이터 저장소가 아직 선정되지 않음

 

 

이 요구사항을 바탕으로, 일반적인 웹 애플리케이션의 **계층형 구조(Layered Architecture)**에 따라 개발을 진행하겠습니다.


## 1. Domain 과 Repository 설계 및 구현

java 파일 안에 각 domain과 repository 패키지를 생성해줍니다.

먼저 데이터를 저장할 도메인 계층을 개발하겠습니다.

Member 클래스를 생성해주고 id(Long)와 name을 생성해 줍니다. getter와 setter도 생성해줍니다.

 

그다음 데이터를 저장하고 관리하는 리포지토리 계층 개발을 시작하겠습니다.

### 유연한 설계를 위한 인터페이스 도입

아직 데이터베이스가 정해지지 않았다는 제약사항이 있으므로, 먼저 데이터 저장소의 기능 명세(역할)를 정의하는 MemberRepository 인터페이스를 만듭니다.

Java
 
public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}
  • Optional<T>란? findById나 findByName처럼 결과가 null일 수도 있는 경우, null을 직접 반환하는 대신 Optional이라는 객체로 감싸서 반환하는 방식입니다. 이를 통해 개발자는 NullPointerException(NPE)을 방지하고, 값이 없을 때의 처리를 명시적으로 코딩할 수 있습니다.

참고: MemoryMemberRepository의 store와 sequence 변수는 간단한 예제라 동시성 문제가 고려되지 않았습니다. 실무에서는 ConcurrentHashMap과 AtomicLong 등을 사용하여 동시성을 제어해야 합니다.

 

그런 다음, 우선 빠른 개발과 테스트를 위해 DB 대신 메모리를 사용하는 MemoryMemberRepository 구현체를 만듭니다. 이렇게 인터페이스와 구현체를 분리하면, 나중에 DB를 변경하더라도 MemberRepository를 구현한 클래스만 새로 만들어 교체하면 되므로 매우 유연한 설계가 됩니다.

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

 


## 2. JUnit을 이용한 Repository 단위 테스트

이제 MemoryMemberRepository가 제대로 동작하는지 검증하기 위해 단위 테스트를 작성합니다. Java에서는 JUnit이라는 프레임워크를 사용하여 테스트를 작성하며, 이는 main 메소드를 만들어 직접 실행하는 것보다 훨씬 체계적이고 효율적입니다.

test 폴더에 MemoryMemberRepositoryTest 클래스를 만들고, 다음과 같이 테스트 코드를 작성합니다.

Java
 
public class MemoryMemberRepositoryTest {

    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

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

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

        memberRepository.save(member);

        Member result = memberRepository.findById(member.getId()).get();

        //Assertions.assertEquals(result, member);

        assertThat(member).isEqualTo(result);
    }

    @Test
    public void findByName(){
        Member member1 = new Member();
        member1.setName("spring1");
        memberRepository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        memberRepository.save(member2);

        Member result = memberRepository.findByName("spring2").get();
        assertThat(member2).isEqualTo(result);
    }

    @Test
    public void findAll(){
        Member member3 = new Member();
        member3.setName("spring1");
        memberRepository.save(member3);

        Member member4 = new Member();
        member4.setName("spring2");
        memberRepository.save(member4);

        List<Member> result = memberRepository.findAll();
        assertThat(result.size()).isEqualTo(2);
    }
}
  • @Test: 각 메소드가 개별적인 테스트 케이스임을 나타냅니다.
  • @AfterEach: 하나의 테스트 메소드가 끝날 때마다 실행되는 콜백 메소드입니다. 여기서는 각 테스트가 서로에게 영향을 주지 않고 독립적으로 동작하도록 메모리 저장소(store)를 깨끗이 비우는 역할을 합니다. 이를 사용하여 각 테스트 함수들을 독립적으로 실행할 수 있게 해줍니다.
  • TDD (Test-Driven Development): 실무에서는 종종 이렇게 구현 코드보다 테스트 코드를 먼저 작성하는 테스트 주도 개발(TDD) 방식을 사용하기도 합니다.
  • Assertions는 테스트에서 사용하는 함수로 두가지 방식이 있습니다. 위의 예제에서 equals와 assertThat은 다른 라이브러리 입니다.

## 3. Service 개발 및 테스트

이제 회원 가입, 조회 등 핵심 비즈니스 로직을 처리하는 서비스 계층을 개발할 차례입니다.

### 비즈니스 로직 구현

네이밍 컨벤션: 보통 리포지토리의 메소드 이름은 save, findById처럼 데이터 중심의 기계적인 용어를 사용하는 반면, 서비스의 메소드 이름은 join, findMembers처럼 실제 비즈니스에 가까운 용어를 사용하는 경향이 있습니다. 

Java
 
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("이미 존재하는 회원입니다.");
                        });
    }
    // ... findMembers, findOne 등 메소드 구현 ...
}

### Service 테스트와 DI의 필요성

Ctrl + Shift + T 단축키로 MemberService의 테스트 클래스를 생성하고, 중복 회원 검증 로직이 잘 동작하는지 테스트합니다.

Java
 
class MemberServiceTest {
    MemberService memberService = new MemberService();
    // ...
    @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)); // 예외가 발생해야 함

        // then: 이런 결과가 나와야 한다
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}

 

  • Given-When-Then 패턴: 테스트 코드를 작성할 때 given(준비)-when(실행)-then(검증)의 세 단계로 나누어 작성하면 의도가 명확해져 가독성이 높아집니다.
  • assertThrows(): 특정 예외가 발생하는지를 검증하는 JUnit 5의 기능입니다. 중복 회원 가입 시 IllegalStateException이 터져야 테스트가 성공합니다.

 

그런데 여기서 문제가 발생합니다. MemberService가 내부에서 new MemoryMemberRepository()로 직접 리포지토리를 생성하고, 테스트 클래스에서도 new MemoryMemberRepository()로 리포지토리를 생성합니다. 이 둘은 서로 다른 인스턴스(객체) 이므로, 테스트가 의도대로 동작하지 않습니다.


## 4. 의존성 주입(DI)으로 문제 해결

이 문제를 해결하기 위해 의존성 주입(Dependency Injection, DI) 이라는 중요한 개념을 사용합니다. MemberService가 리포지토리를 직접 생성하는 것이 아니라, 외부에서 생성된 리포지토리를 주입받아 사용하도록 코드를 변경하는 것입니다.

 

MemberService 수정 (생성자 주입)

Java
public class MemberService {
    private final MemberRepository memberRepository;

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

이제 MemberService는 생성자를 통해 외부에서 MemberRepository의 구현체를 받습니다.

 

MemberServiceTest 수정

Java
class MemberServiceTest {
    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void beforeEach() {
        // 각 테스트 실행 전에,
        memberRepository = new MemoryMemberRepository(); // 같은 리포지토리를 만들고
        memberService = new MemberService(memberRepository); // 서비스에 주입해준다.
    }
    // ...
}
  • @BeforeEach: 각 테스트가 실행되기 전에 먼저 실행되는 코드입니다. 여기서 동일한 MemoryMemberRepository 인스턴스를 생성하여 MemberService에 주입해주면, 서비스와 테스트는 이제 같은 저장소를 공유하게 되어 문제가 해결됩니다.

이처럼 DI를 사용하면 각 컴포넌트 간의 의존성을 낮추어(느슨한 결합), 유연하고 테스트하기 쉬운 코드를 작성할 수 있습니다.

## 5. 종합 코드

Java

public class MemberService {

    private final MemberRepository memberRepository;

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

    /// 회원 가입
    public Long join(Member member) {
        ///  같은 이름이 있는 중복 회원 x
        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);
    }
}

 

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("hello");

        //when
        Long saveId = memberService.join(member);

        //then
        Member findMem = memberService.findOne(saveId).get();
        assertThat(findMem.getName()).isEqualTo(member.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));

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

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

        //then

    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}