← Notes

Spring 계층형 아키텍처와 의존성 주입

Spring 백엔드 입문· 2 / 4
  1. 1 — Spring 기본 구조와 작동 방식
  2. 2 — Spring 계층형 아키텍처와 의존성 주입
  3. 3 — Spring 데이터베이스 접근 기술 비교 — JDBC · JPA · Spring Data JPA
  4. 4 — 객체 지향 프로그래밍(OOP)의 특징과 Spring

계층형 아키텍처

Spring 애플리케이션을 여러 계층으로 나누어 각 계층이 독립적인 책임을 갖도록 설계하는 계층형 아키텍처의 구조와, 그 예시가 되는 Member 관련 코드를 살펴본다.

계층형 아키텍처 구조

Spring의 대표적인 설계 패턴 중 하나는 계층형 아키텍처(Layered Architecture) 다. 애플리케이션을 여러 개의 독립적이고 구분된 계층으로 나누어 각 계층이 특정한 책임을 가지도록 설계한다.

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

  • Controller: 요청을 받고 응답을 처리하는 사용자와 상호작용하는 계층
  • Service: 비즈니스 로직을 수행하는 계층
  • Repository: 데이터베이스와 상호작용을 담담하는 계층
  • Domain: 비즈니스 객체의 모델을 정의하는 계층(데이터베이스 테이블과 매핑됨)

계층별 코드 예시

Member를 추가하고 조회하는 기능을 예시로 각 계층의 역할과 코드를 살펴보자.

계층별 디렉토리 구조

  • Domain

    Domain 계층은 비즈니스 객체를 정의하며, 정의된 후 다른 모든 계층에서 객체 생성 및 타입 정의 시 사용된다.

    package angari.dev.spring_start.domain;
    
    public class Member {
    
        // Field
        private Long id;
    
        private String name;
    
        // Method
        public Long getId() {
            return id;
        }
    
        public void setId(Long id) {
            this.id = id;
        }
    
        public String getName() {
            return this.name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    }

    여기서는 회원의 정보(Field)와 정보를 조회 및 설정(Method)하는 비즈니스 객체를 정의한다.

  • Repository

    Repository는 데이터 삽입, 조회, 수정, 삭제 등 실제로 데이터베이스를 다루는 역할을 수행하며, 데이터베이스와 직접적으로 접촉하기 때문에 그 사용 기술에 따라 내부 로직이 달라질 수 있다.

    따라서 기능적으로 필요한 메서드를 인터페이스로 먼저 정의한 후, 상황에 따라 적절한 구현 클래스를 구현하는 방식이 일반적이다.

    package angari.dev.spring_start.reposipory;
    
    import angari.dev.spring_start.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();
    }

    여기서는 기본적인 데이터 저장과 조회 메서드가 정의되었다.

    위 인터페이스를 토대로 구현된 클래스는 아래와 같다.

    package angari.dev.spring_start.reposipory;
    
    import angari.dev.spring_start.domain.Member;
    import org.springframework.stereotype.Repository;
    
    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));
        }
    
        @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<Member>(store.values());
        }
    }

    여기서는 인메모리 DB 방식으로 데이터를 다루도록 설정되었으며, MemberRepository 인터페이스를 구현하고 있다.

  • Service

    Service는 실질적으로 애플리케이션의 서비스 기능이 동작할 비즈니스 로직을 수행한다.

    이때, 데이터 저장 및 조회 등 데이터 조작이 필요할 경우 미리 정의된 Repository에 의존해 데이터를 처리한다.

    package angari.dev.spring_start.service;
    
    import angari.dev.spring_start.domain.Member;
    import angari.dev.spring_start.reposipory.MemberRepository;
    
    import java.util.*;
    
    public class MemberService {
    
        private final MemberRepository memberRepository = new MemoryMemberRepository();
        // final: 초기화 이후 변수 변경 불가
    
        /**
         * 회원 가입
         */
        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> findMember(Long memberId) {
            return memberRepository.findById(memberId);
        }
    }

    리포지토리는 MemoryMemberRepository 객체를 직접 생성해 사용하도록 처리해놓았다.

  • Controller

    Controller는 사용자와 상호작용하는 역할을 수행한다. HTML 페이지를 제공하는 역할뿐만 아니라, 사용자의 기능적인 요청을 받아 응답을 전달하는 창구와 같은 역할이라고 할 수 있다.

    사용자로부터 받은 요청을 수행하기 위해 미리 구현된 Service 계층에 요청을 전달하고 그 결과를 반환한다.

    package angari.dev.spring_start.controller;
    
    import angari.dev.spring_start.domain.Member;
    import angari.dev.spring_start.service.MemberService;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.List;
    
    @Controller
    public class MemberController {
    
        private final MemberService memberService;
    
        public MemberController(MemberService memberService) {
            this.memberService = memberService;
        }
    
        @GetMapping("/members/new")
        public String createForm() {
            return "members/createMemberForm";
        }
    
        @PostMapping("/members/new")
        public String create(MemberForm form) {
            Member member = new Member();
            member.setName(form.getName());
    
            memberService.join(member);
    
            return "redirect:/";
        }
    
        @GetMapping("/members")
        public String list(Model model) {
            List<Member> members = memberService.findMembers();
            model.addAttribute("members", members);
    
            return "members/memberList";
        }
    }

    여기서는 템플릿 엔진을 통한 HTML 페이지 전달 역할과 동시에 회원가입 및 회원 조회 등의 기능적 역할도 동시에 수행하도록 작성되었다.

    memberService를 외부로부터 주입받아 내부 메서드 중 적절한 메서드를 사용한다.

계층 간 의존성 주입

각 계층이 서로 어떻게 의존 관계를 맺고, 이를 스프링 컨테이너가 어떻게 관리하는지 살펴본다.

의존성

위에서 살펴본 바와 같이, 각 계층은 다른 계층의 기능(메서드)을 가져다 사용하는 의존 관계를 형성한다.

  • Controller는 사용자 요청을 처리하기 위해 Service의 비즈니스 로직을 사용한다.
  • Service는 데이터베이스를 다루기 위해 Repository의 DB 처리 로직을 사용한다.

이렇듯, A클래스 에서 다른 B클래스 의 메서드를 가져다 사용하는 것을 의존(Dependency) 이라고 한다.

A클래스B클래스 에 의존하려면, B클래스 의 객체(인스턴스)를 생성해서 사용해야 하는데(Static 제외), 이때, 두 가지 방법을 사용할 수 있다.

  1. A클래스 내부에서 직접 객체를 생성하는 방식
  2. A클래스 외부에서 객체를 생성해 A클래스에 주입하는 방식

첫 번째 방법은 A클래스B클래스 가 강하게 결합되는 방식이다. 이 경우 A클래스는 B클래스의 구체적인 구현법을 알고 있어야 하며, B객체의 생명주기가 A객체에 종속된다. 이 방식은 코드가 직관적이고 간단하지만, 유지보수테스트에서 어려움이 있을 수 있다.

두 번째 방법을 의존성 주입(Dependency Injection) 이라고 하며, A클래스B클래스 의 결합도를 낮추는 방식이다. 이렇게 하면 B객체가 독립적으로 생성되고 관리되며, A객체는 B객체를 외부에서 주입받아 사용할 수 있다. 이 방식은 유연성테스트 용이성을 높여준다.

스프링 컨테이너

계층이라고 하는 Controller와 Service, Repository도 결국 클래스이며, 이들은 객체를 생성해야만 그 기능을 수행할 수 있다.

각 계층이 필요한 객체(의존성)을 외부로부터 주입받음으로써, 독립적으로 동작할 수 있고, 다른 계층에 미치는 영향을 최소화할 수 있다. 이러한 방식이 계층 간 결합도를 낮춘다는 의미에서 계층 분리의 목적에 더 부합한다.

그렇다면, 이제 그 ‘외부’ 라는 존재가 필요하다. 즉, 각 클래스의 객체를 직접 생성해 관리하고, 필요한 의존성을 다른 객체에 주입해주는 의존성 주입 관리자가 필요하다.

스프링에서는 스프링 컨테이너라는 의존성 주입 관리자가 있다. 스프링 컨테이너는 애플리케이션이 시작될 때 등록된 클래스의 객체를 싱글톤(기본)으로 생성하고, 이들 객체의 의존성을 분석해 필요한 의존성을 자동으로 주입하는 역할을 한다.

빈 등록

스프링 컨테이너에 클래스를 등록하는 행위를 빈(Bean)으로 등록한다고 표현한다.

빈으로 등록하는 주된 방법으로는 1. 컴포넌트 스캔을 통해 자동으로 등록하는 방법2. @Configuration + @Bean을 이용해 명시적으로 등록하는 방법이 있다.

  • 컴포넌트 스캔을 통해 자동으로 등록

    Bean으로 등록하려는 클래스에 @Component 계열 애노테이션을 적용한다.

    • @Component: 일반적인 클래스

      @Component
      public class MyComponent {}
    • 하위 애노테이션

      • @Controller: 컨트롤러 클래스

        @Controller
        public class MemberController {}
      • @Service: 서비스 클래스

        @Service
        public class MemberService {}
      • @Repository: 리포지토리 클래스

        @Repository
        public class MemoryMemberRepository implements MemberRepository {}
  • Java 코드로 명시적으로 등록

    세밀한 설정이 필요하거나 추후 의존성이 변경되야할 때 유용하다.

    @Configuration@Bean 애노테이션을 이용해 별도의 컨피그 클래스를 작성한다.

    package angari.dev.spring_start;
    
    import angari.dev.spring_start.reposipory.*;
    import angari.dev.spring_start.service.*;
    import org.springframework.context.annotation.*;
    
    @Configuration
    public class SpringConfig {
    
        @Bean
        public MemberService memberService() {
            return new MemberService(memberRepository());
        }
    
        @Bean
        public MemberRepository memberRepository() {
            return new MemoryMemberRepository();
        }
    }

주입 방식

주입된 의존성을 객체 내부에서 사용하기 위해서는 의존성 객체를 필드에 초기화해야한다.

주로 아래 세 가지 방식 중 하나를 활용한다.

  • 필드 주입 방식

    : 간결한 코드 / 불변성 보장 x

    public class MemberController {
      @Autowired private MemberService memberService;
    }
  • 세터 주입 방식(메서드 직접 정의)

    : 의존성 변경 가능(불변성 보장 x) / 필수 의존성 보장 x

    public class MemberController {
      private MemberService memberService;
    
      @Autowired
      public void setMemberService(MemberService memberService) {
          this.memberService = memberService;
      }
    }
  • 생성자 주입 방식 권장 방식

    : 필수 의존성 보장, 불변성 보장 / 복잡한 코드

    public class MemberController {
      private final MemberService memberService;
    
      @Autowired
      public MemberController(MemberService memberService) {
          this.memberService = memberService;
      }
    }

참고 자료

  • [1] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술
목차