회원 관리 예제 - 백엔드개발
📋 비즈니스 요구사항 정리
🏗️ 회원 도메인과 리포지토리 만들기
🧪 회원 리포지토리 테스트 케이스 작성
💻 회원 서비스 개발
✅ 회원 서비스 테스트 (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를 자동으로 생성하기 위한 변수입니다.
- private static Map<Long, Member> store = new HashMap<>()
- 인터페이스의 메서드를 모두 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 접근 기술
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%9E%85%EB%AC%B8-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8
[지금 무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의 | 김영한 - 인프
김영한 | 스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., 스프링 학습 첫 길잡이! 개발 공부의 길을 잃지 않도록 도와드립니다. 📣 확
www.inflearn.com
반응형