Hello It's good to be back ^_^

[ECC-백엔드 5팀] 8주차 스터디 - 도메인, 레포지토리, 컨트롤러, 서비스, 테스트, 의존성 주입, 스프링 빈, 컴포넌트 스캔 본문

Study/Spring Boot

[ECC-백엔드 5팀] 8주차 스터디 - 도메인, 레포지토리, 컨트롤러, 서비스, 테스트, 의존성 주입, 스프링 빈, 컴포넌트 스캔

HongyeonLee 2026. 5. 7. 17:52
강의: 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 

범위: 섹션 4-5

실습: https://github.com/HongYeonLee/ECC-Backend-Study

 

GitHub - HongYeonLee/ECC-Backend-Study: ECC 51기 SS 백엔드 5팀 스터디 내용을 기록하는 레포지토리입니다

ECC 51기 SS 백엔드 5팀 스터디 내용을 기록하는 레포지토리입니다. Contribute to HongYeonLee/ECC-Backend-Study development by creating an account on GitHub.

github.com


섹션 4. 회원 관리 예제 - 백엔드 개발

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

1.1 비즈니스 요구사항 정리

 

데이터: 회원ID, 이름

기능: 회원 등록, 조회

DB: 아직 선정되지 않음

 

일반적인 웹 애플리케이션 계층 구조

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

 

클래스 의존관계

  • 아직 DB가 선정되지 않아서 우선 인터페이스로 구현하고 나중에 선정되면 구현 클래스를 변경할 수 있게(바꿔끼움) 설계
  • DB는 RDB, NoSQL, JDBC 등등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 DB를 사용하고 나중에 DB가 선정되면 인터페이스를 바꿈

 

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

src/main/java/hongyeon_spring/domain 패키지 생성

Member 클래스 (회원을 구현하기 위한 클래스)

package hongyeon_spring.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;
    }
}
  • 고객의 필드로는 id(시스템이 정함)와 name(고객이 정함)이 있음

 

src/main/java/hongyeon_spring/repository 패키지 생성

MemberRepository 인터페이스

package hongyeon_spring.repository;

import hongyeon_spring.domain.Member;

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

public interface MemberRepository {
    Member save(Member member); //회원을 저장하면 저장된 회원이 리턴
    Optional<Member> findById(Long id); //리턴값이 null인 경우(회원이 null) optional로 감싸서 리턴
    Optional<Member> findByName(String name);
    List<Member> findAll();
}
  • 저장소의 인터페이스를 정함
  • 고객 객체 저장, 고객을 id/name으로 찾음, 모든 저장된 고객을 찾는 메소드가 있음
  • Optional 클래스는 리턴 값이 null인 경우 Optional로 감싸서 리턴할 수 있게 해주는 것임

 

MemoryMemberRepository 클래스 (고객을 저장하는 저장소를 구현하는 클래스)

package hongyeon_spring.repository;

import hongyeon_spring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>(); //멤버 아이디와 멤버 객체 저장, 실무에서는 공유 변수일 때는 동시성 문제로 Concurrent HashMap을 씀
    private static long sequence = 0L; //012..키 값을 생성해줌, 실무에서는 동시성 문제로 atomic long 사용

    @Override
    public Member save(Member member) {
        member.setId(++sequence); //저장소에 넣기 전에 시스템이 고객아이디 결정, name이 고객이 이미 입력해서 저장함
        store.put(member.getId(), member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id)); //null 리턴될 경우를 위해 optional로 감쌈
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name)) //고객의 이름과 매개변수로 받은 이름이 같은지 확인, 같은 경우만 필터링됨
                .findAny(); //돌면서 하나라도 찾으면 바로 리턴, 끝까지 돌렸는데 없으면 optional에 null이 포함되서 리턴
    }

    @Override
    public List<Member> findAll() {
        //맵을 리스트로 변환해서 리턴
        return new ArrayList<>(store.values());
    }
}

 

저장소 변수 store

  • 고객 객체는 HashMap으로 저장, 키로는 id, 값으로는 고객 객체를 저장함
  • 실무에서는 고객과 같은 공유 변수를 이용할 때는 동시성 문제 때문에 Concurrent HashMap을 사용함

고객의 id 변수 sequence

  • id는 시스템이 정하는 변수값으로 long 타입의 sequence라는 변수를 사용함
  • 마찬가지로 동시성 문제로 실무에서는 AtomicLong 사용

고객 객체를 저장하는 메소드 save

  • 고객 객체를 저장하는 save 메소드는 매개변수로 고객 객체를 받고, 고객의 id(sequence)를 정함
  • 해시맵 store에 키로 id, 값으로 고객 변수를 저장함
  • 저장한 고객 객체를 리턴함

고객을 id로 찾는 메소드 findById

  • 매개변수로 고객의 아이디를 받음
  • store에서 키 값에 전달받은 id를 넣어서 찾음
  • 만약 null값이 나온다면 optional로 감싸서 리턴

고객을 name으로 찾는 메소드 findByIName

  • 매개변수로 고객의 이름을 받음
  • store.values().stream().filter(member -> member.getName().equals(name)) - store에서 값을 찾는데, 매개변수로 받은 name과 같은 값을 가진 데이터만 필터링함
  • .findAny() - 필터링된 데이터 중 하나라도 찾으면 바로 리턴, 전부 다 돌았는데도 없으면 optional에 null을 넣어서 리턴

모든 고객을 찾는 메소드 findAll

  • 저장소 변수인 store는 HashMap 타입임. 그러나 실무에서는 편리함 때문에 리스트를 많이쓰기에 해시맵을 리스트로 변환해줘야함
  • new ArrayList<>(store.values()) - store에 저장된 값(데이터)들을 새 리스트에 만들어서 넣어주고 리턴

 

 

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

src/test/java/hongyeon_spring/repository에 MemoryMemberRepositoryTest 클래스 생성

package hongyeon_spring.repository;

import hongyeon_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;

import static org.assertj.core.api.Assertions.*;

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(); //optional에서 값을 꺼내야 하므로 get() 이용
        // System.out.println("result = " + (result == member));
        // Assertions.assertEquals(member, result); //매개변수로 기대값, 실제값 전달. 둘이 동일한지 검사해줌
        // Assertions.assertEquals(member, null);
        // Assertions.assertThat(member).isEqualTo(result);
        assertThat(member).isEqualTo(result); //static으로 클래스 넣어서 더 편하게 작성
    }

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

        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);
    }
}
  • @AfterEach - 각 테스트 메소드들이 끝난 후에 실행되는 콜백 함수로, 테스트 메소드간의 의존성이 생기는 걸 방지하기 위해 테스트 데이터를 지우는 역할의 메소드를 작성한다.
  • @Test - 메소드앞에 붙이면 바로 해당 메소드를 실행할 수 있게 해줌
  • get() - Optional로 감싸진 데이터를 꺼낼 때 사용하는 메소드
  • Assertions.assertEquals(기대값, 실제값) - 테스트를 위해 실제로 데이터 값들이 같은지 확인할 때 사용
  • Assertions.assertThat(기대값).isEqualtTo(실제값) 
import static org.assertj.core.api.Assertions.*;
  • static으로 선언하면 앞에 Assertions 안붙이고 바로 assertThat 메소드 사용 가능

 

테스트 케이스

  • 클래스 단위로 실행이 가능해서 여러 테스트를 동시에 할 수 있음
  • TDD(Test Driven Development)- 테스트 주도 개발로, 테스트를 먼저 만들고 그 다음에 구현 클래스를 만들어서 테스트를 돌리는 것
  • 테스트가 수십, 수백개로 엄청 많아질 경우, 테스트 패키지 자체를 빌드해서 돌리면 편하다

 

1.4 회원 서비스 개발

src/main/java/service에 MemberService 클래스 작성

package hongyeon_spring.service;

import hongyeon_spring.domain.Member;
import hongyeon_spring.repository.MemberRepository;
import hongyeon_spring.repository.MemoryMemberRepository;

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

public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    // 회원가입
    public Long join(Member member) {
        //같은 이름의 중복 회원x
//        Optional<Member> result = memberRepository.findByName(member.getName());
//        result.ifPresent(m -> { //null이 아니라 값이 있으면 동작(optional 때문에 사용), 들어온 값(멤버) m, 과거엔 result == null 이런식으로 썼는데 optional 덕분에 ifPresent로 작성
//            throw new IllegalStateException("이미 존재하는 회원입니다");
//        });
        //만약 optional로 감싸진 멤버 객체를 바로 꺼내고 싶으면 Member member = result.get()해서 꺼내면 됨, 권장x
        // result.orElseGet(), 값이 있으면 optional에서 꺼내고 없으면 특정 메소드를 실행하거나 디폴트 값을 넣어서 꺼내거나

        //바로 ifPresent를 사용할 수도 있음 어차피 optional로 리턴하니까
        //이렇게 멤버를 가져오는 덩어리를 메소드화 시키는 것이 좋음
//        memberRepository.findByName(member.getName())
//                .ifPresent(m -> {
//                    throw new IllegalStateException("이미 존재하는 회원입니다.");
//                });
        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 memberId){
        return memberRepository.findById(memberId);
    }
}

 

회원가입 기능 join 메소드

  • 리포지토리에 고객을 저장하면 됨
  • 그 전에 중복된 이름을 가진 고객은 없다고 했을 때, 중복 고객을 검사하는 로직이 필요
  • 이름으로 고객을 가져올 경우 optional에 감싸져서 들어오므로 optional에서 한번 꺼내는 과정이 필요함
  • .ifPresent(m -> {}) - 꺼내온 값이 null이 아니라 값이 있을 경우(동일한 이름을 가진 고객이 이미 있음) 실행시킬 함수를 작성
  • 중복 고객 검사 로직은 따로 메소드로 빼는 것이 좋음

 

* 리팩토링 하고 싶은 코드 부분을 선택하고 ctrl + alt + shift + t

 

서비스 기능은 메소드 이름을 비즈니스 의존적으로 작성해야 한다

리포지토리는 메소드 이름을 이름을 단순하고 기계적이게 작성한다

 

1.5 회원 서비스 테스트

ctrl + shift + t

  • 해당 클래스에 대한 테스트 클래스를 만들어줌

 

  • 라이브러리로 JUnit5 선택
  • 테스트 할 메소드 선택

 

src/test/java/hongyeon_spring/service에 MemberServiceTest 클래스 구현

테스트 코드는 실제로 빌드에 포함되는 코드가 아니기에 직관적으로 한글로 작성해도 괜찮다 

package hongyeon_spring.service;

import hongyeon_spring.domain.Member;
import hongyeon_spring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.fail;

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    //각 테스트 실행 전 마다
    @BeforeEach
    public void beforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository); //DI - Dependency Injection
    }

    @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();
        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);
//        try{
//            memberService.join(member2);
//            fail();
//        } catch (IllegalStateException e){
//            //정상적으로 예외를 잡아낸 것
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));//해당 메소드를 실행했을 때 예외가 발생해야 함
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

        //then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}
  • @BeforeEach - 각 테스트 실행 전마다 실행시키는 메소드 어노테이션
  • given, when, then 문법 - 테스트를 할 때 주어진 값을 언제 어떻게 하면 어떤 결과가 나와야하는지 한 눈에 파악할 수 있도록 함
  • try-catch는 예외가 발생하면 이를 복구하거나 우회하기 위해 설계된 문법으로써, 이 상황에서 예외가 발생하는지 검증하는 테스트 코드의 의도와는 적합하지 않음. 또 fail()을 빼먹을 수도 있음
  • JUnit5의 철학은 테스트 코드를 테스트답게 작성하는 것
  • 따라서 assertThrows를 이용해서 이 로직을 실행하면 반드시 이 예외가 터져야한다는 의도를 명확히 보여주는 것이 좋음
assertThrows(IllegalStateException.class, () -> memberService.join(member2))
  • memberService.join(meber2)를 실행했을 때 IllegalStateException 예외가 발생한다는 의미
  • assertThrow는 발생한 예외 객체를 리턴함
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
  • 발생한 에외 객체를 변수 e에 담고 메세지를 가져와 검증

 

 

MemberService 클래스에 의존성 주입

    private final MemberRepository memberRepository;

    //멤버 서비스에서 사용하는 리포지토리와 테스트에서 사용하는 리포지토리가 다른 객체이므로 발생하는 문제를 방지하기 위해 생성자를 이용
    // DI - Dependency Injection로 외부에서 넣어주는 거, 멤버 서비스 입장에선 외부에서 리포지토리를 넣어줌
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
  • 서비스 클래스에서 리포지토리를 new해서 생성하고, 테스트 클래스에서 리포지토리를 new해서 사용하면 서로 다른 객체를 사용하기 때문에 테스트가 어긋날 수가 있다
  • 따라서 서비스 클래스에서 사용하는 리포지토리는 외부에서 생성자를 통해 넣어질 수 있도록 구현하는게 좋다.
  • 이를 의존성 주입이라고 한다.

의존성 주입 (Dependency Injection, DI)

: 서비스 객체가 자신이 의존하는 리포지토리 객체의 구현체를 직접 생성하지 않고 외부(설정 등)에서 전달받아(생성자의 매개변수로 줌) 사용하는 설계 방식


섹션5. 스프링 빈과 의존관계

2.1 컴포넌트 스캔과 자동 의존관계 설정

스프링 빈

: 스프링이 열릴 때 스프링 컨테이너라는 것이 생긴다. @Component라는 어노테이션이 있으면 그 어노테이션이 달린 클래스의 타입으로 객체를 만들어 컨테이너에 넣고 관리한다. 이렇게 생성된 객체를 스프링 빈이라고 한다.

 

@Component

: 해당 어노테이션이 있으면 컴포턴트 스캔을 통해 자동으로 스프링 빈으로 등록된다. @Service, @Repository, @Controller 어노테이션은 @Component 어노테이션을 자동으로 포함하고 있다. 이러한 어노테이션이 없으면 순수 자바 코드로 스프링이 인식하지 못하고 컨테이너에 포함되지 않는다.

 

컨트롤러 - 외부 요청 받음

서비스 - 비즈니스 로직

리포지토리 - 데이터 저장

 

package hongyeon_spring.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;

import hongyeon_spring.service.MemberService;

@Controller
public class MemberController {
	//service는 new해서 새로운 객체를 만들지 않고 하나만 생성해놓고 공용으로 쓰면 됨
	private final MemberService memberService;
	
	@Autowired
	public MemberController(MemberService memberService) {
		super();
		this.memberService = memberService;
	}
	
	
}
  • Service는 new로 여러 객체(인스턴스)를 만들어서 사용하지 않고 하나만 만들어서 공용으로 사용하도록 한다 → 클래스에 생성자로 Service 객체를 받는다
  • @Autowired : 생성자에 붙이는 어노테이션으로 스프링 컨테이너에 해당 컨트롤러(서비스)가 생성될 때 프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다.  (스프링 빈에 등록된 매칭되는 서비스(리포지토리)의 객체를 찾아 연결해준다. ) → 의존성 주입

 

컴포넌트 스캔의 대상

  • SpringApplication이 위치한 곳에서 하위 계층만 스캔한다. 단, 같은 패키지내여만 한다

 

스프링은 스프링 컨테이너에 스프링 빈을 등록할 때 기본으로 싱글턴으로 등록한다(유일하게 하나만 등록해서 공유한다) 따라서 같은 스프링 빈이면 모두 같은 객체(인스턴스)이다. 웬만해선 싱글턴으로 구현한다

 

 

2.2 자바 코드로 직접 스프링 빈 등록하기

@Service, @Repository, @Autowired와 같은 어노태이션 없이 객체를 스프링 빈에 등록하는 법

 

SpringApplication과 같은 위치에 SpringConfig 클래스를 만든다

package hongyeon_spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import hongyeon_spring.repository.MemberRepository;
import hongyeon_spring.repository.MemoryMemberRepository;
import hongyeon_spring.service.MemberService;

@Configuration
public class SpringConfig {
	//스프링이 스프링 빈에 등록함
	@Bean
	public MemberService memberService() {
		return new MemberService(memberRepository());
	}
	
	@Bean
	public MemberRepository memberRepository() {
		return new MemoryMemberRepository();
	}
}

 

  • config 파일에 스프링 빈으로 등록할 클래스의 객체를 생성하는 메소드를 작성하고 앞에 @Bean 어노테이션을 붙인다.
  • MemberService의 객체와 MemberRepository는 스프링 컨테이너에 스프링 빈으로 등록된다
  • 특히 Repository는 향후 다른 리포지토리로 변경할 예정이므로 컴포넌트 스캔 방식 대신 자바 코드로 스프링 빈을 설정한다.
  • 그렇게 하면 memberRepository 메소드의 return 문의 호출 함수의 이름만 나중에 바꿔주면 되기 때문이다

참고사항

  • 예전에는 XML 방식으로 스프링 빈을 직접 등록했으나 지금은 사용x
  • 의존성 주입(DI)에는 필드 주입, setter 주입, 생성자 주입 3가지가 있다. 그러나 의존 관계가 실행 중에 동적으로 변하는 경우는 아예 없으므로 생성자 주입을 권장 
    • 필드 주입: @Autowired private MemberService memberserive; // final 사용x
    • setter 주입: 이렇게 할 경우 public하게 노출되어서 안 좋음, 중간에 memberService를 바꿀 일이 없는데 굳이 사용x
@Autowired
public void setMemberService(MemberService memberService){
	this.memberService = memberService;
}

 

  • 생성자 주입
@Autowired
public MemberController(MemberService memberService) {
	super();
	this.memberService = memberService;
}

 

  • 실무에서는 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드에는 컴포넌트 스캔을 이용한다
  • 정형화 되지 않거나 상황에 따라 구현 클래스를 변경해야 하면(ex.DB 변경) 설정을 통해 스프링 빈으로 등록한다
  • 스프링 빈으로 등록하지 않은 객체에 대해서는 @Autowired가 작동하지 않는다

 

 

인텔리제이 단축키

* shift + F10 : 이전에 실행했던 걸 그대로 실행함

* ctrl + shift +enter : 바로 다음 줄로 이동

* alt + enter : 코드 추천 사항 보여줌

* shift + F6 : 한번에 변수명 중복되는 것끼리 바꾸기

* ctrl + shift + F10 : 실행

* ctrl + alt + v : 호출하는 함수의 리턴 값에 맞게 자동 작성