Post

[스프링 핵심 원리] 스프링의 핵심 원리

예제를 통한 스프링 핵심 원리(DI)의 이해

[스프링 핵심 원리] 스프링의 핵심 원리

본 포스트는 김영한 개발자님의 스프링 핵심 원리 강의를 듣고 정리한 것입니다.
※ 코드는 강의에서 사용된 것과 다릅니다.
<a href = https://github.com/jaewon-ju/Learning_Spring>jaewon-ju Github Address</a>

실제 프로젝트를 설계해보면서 왜 스프링이 필요한지, 스프링의 핵심 원리는 무엇인지 이해해보자.

✏️ 들어가기 전에..

JUnit이란, 테스트를 위한 자바용 프레임워크이다.

비즈니스 로직을 구현한 뒤에, JUnit 프레임워크를 사용하여 반드시 테스트를 해봐야 한다.

  • @Test 어노테이션을 통해 테스트 메소드임을 선언할 수 있다.
  • @BeforeEach 어노테이션을 통해 각 테스트 실행 전에 실행될 메소드를 선언할 수 있다.
  • Assertions.assertThat(A).isEqualTo(B) 를 통해 A와 B가 같은지 확인하고 test 성공 여부를 알 수 있다.

✏️ 비즈니스 요구사항

학원용 게시판 프로그램을 순수한 자바 코드로 설계한다.

■ 회원

  • 회원을 가입하고 조회할 수 있다.
  • 회원은 학생 회원과, 교사 회원이 있다.
  • 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)

■ 게시판 이용

  • 회원은 게시판을 이용할 수 있다.
  • 회원 자격에 따라 게시판에 글을 쓸 수 있다.
  • 학생 회원은 글을 읽는 기능만 가능하게 설계하고 싶다.
  • 하지만, 학생 회원의 게시판 이용 범위를 확실하게 결정하지 못했다. (미확정)

요구사항을 보면 회원 데이터, 이용 범위 등은 지금 결정하기 어렵다. 이러한 세부사항이 결정될 때까지 개발을 미룰 수는 없다! ➜ 다형성을 이용한다.




✏️ 회원 도메인 설계

회원 클래스 다이어그램

회원 객체 다이어그램

1
2
3
// Member는 id, name, position 필드와 Getter/Setter를 가지는 클래스
// Position은 열거형으로 STUDENT, TEACHER 2가지 종류가 있다.
Member 클래스 구현 생략
1
2
3
// MemberRepository 인터페이스는 save, findById를 추상메소드로 갖고있다.
// MemoryMemberRepository 구현 객체는 save, findById 메소드를 오버라이딩했다.
MemoryRepository 인터페이스, MemoryMemberRepository 클래스 구현 생략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//MemberServiceImpl 구현 객체
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();
    
    @Override
    public void join(Member member) {
        memberRepository.add(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

■ 설계의 문제점

MemberServiceImpl 객체가 MemberRepository 뿐만 아니라, MemoryMemberRepository에도 의존하고 있음

즉, 구현객체가 인터페이스뿐만 아니라 구현객체에도 의존하고 있음 ➜ DIP 위반

메모리 저장소에서 DB 저장소로 바꾸려면, 클라이언트인 MemberServiceImpl을 변경해야함 ➜ OCP 위반



✏️ 게시판 도메인 설계

클래스 다이어그램 객체 다이어그램

게시판에서 게시글을 작성하는 과정

| 순서| 실행 주체 | 기능 | | - | - | - | | 1 | BoardApp | member.join() 으로 회원 가입 -> 학생 회원이라 가정| | 2 | BoardApp | boardService.posting() 호출 | | 3 | BoardServiceImpl | 저장소에서 해당 member 정보 가져옴 | | 4 | BoardServiceImpl | boardPolicy.returnAuthority() 호출| | 5 | ReadOnly | 학생 회원이므로 false 반환 | | 6 | BoardServiceImpl | authority가 false이므로 제목과 내용 인수는 unavailable로 설정| | 6 | BoardServiceImpl | Post 타입으로 게시글 반환 | | 7 | BoardApp | 2번의 호출로 받은 게시글 출력 |

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 게시판 도메인 BoardApp
public class BoardApp {
    public static void main(String[] args) {
        MemberService memberService = new MemberServiceImpl();
        BoardService boardService = new BoardServiceImpl();

        Member member = new Member(1L, "studentA", Position.STUDENT);
        memberService.join(member);

        Post newPost = boardService.posting(member.getId(), member.getPosition(), "NEW POST", "hi");

        System.out.println("post = " + newPost);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 게시판 서비스 객체 BoardServiceImpl
public class BoardServiceImpl implements BoardService{
    private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final BoardPolicy boardPolicy = new ReadOnly();

    @Override
    public Post posting(Long id, Position position, String title, String content) {
        Member member = memberRepository.findById(id);
        boolean authority = boardPolicy.returnAuthority(member);

        if(authority) {
            return new Post(id, position, title, content);
        } else {
        	// 권한이 없는 경우, 제목과 내용은 unavailable로 고정
            return new Post(id, position, "unavailable", "unavailable");
        }

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 게시판 접근 권한 객체 ReadOnly
public class ReadOnly implements BoardPolicy {
    @Override
    public boolean returnAuthority(Member member) {
        if(member.getPosition() == STUDENT){
            // 학생인 경우 권한 없음
            return false;
        } else {
        	// 교사인 경우 권한 있음
            return true;
        }
    }
}

■ 설계의 문제점

BoardServiceImpl 객체가 MemoryMemberRepository, ReadOnly 객체에 의존하고 있음

즉, 구현객체가 인터페이스뿐만 아니라 구현객체에도 의존하고 있음 ➜ DIP 위반

읽기만 가능한 권한인 ReadOnly 에서 ReadWrite로 바꾸려면, 클라이언트인 BoardServiceImpl을 변경해야함 ➜ OCP 위반




✏️ 문제점 해결

앞선 두 설계 과정에서 공통적으로 OCP, DIP를 위반하는 문제점이 발생했다.

어떻게 해결할 수 있을까? ➜ 클라이언트에서는 인터페이스만 결정하고, 사용할 구현 객체는 외부에서 주입받는다.

■ AppConfig의 등장

AppConfig는 구현객체를 생성하고 연결하는 책임을 가지는 별도의 설정 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
// MemberServiceImpl 수정
public class MemberServiceImpl implements MemberService {
	// private final MemberRepository memberRepository = new MemoryMemberRepository;
    
    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
    // MemberServiceImpl이 어떤 저장소를 사용할지는 AppConfig에서 주입받음
    ...
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BoardServiceImpl 수정
public class BoardServiceImpl implements BoardService{

    private final MemberRepository memberRepository;
    private final BoardPolicy boardPolicy;

    public BoardServiceImpl(MemberRepository memberRepository, BoardPolicy boardPolicy) {
        this.memberRepository = memberRepository;
        this.boardPolicy = boardPolicy;
        }
        
        // BoardServiceImpl이 어떤 정책을 적용할지는 AppConfig에서 주입받음
    	...
 } 
1
2
3
4
5
6
7
8
9
10
11
12
 // AppConfig 클래스 생성
 public class AppConfig {
    public MemberService memberService(){
        return new MemberServiceImpl(new MemoryMemberRepository());
        // memberService() 호출 시, 사용할 저장소를 결정해서 MemberServiceImpl에 주입한 뒤에 MemberServiceImpl 객체 리턴
    }

    public BoardService boardService(){
        return new BoardServiceImpl(new MemoryMemberRepository(), new ReadOnly());
        // BoardService() 호출 시, 사용할 저장소, 정책을 결정해서 BoardServiceImpl에 주입한 뒤에 BoardServiceImpl 객체 리턴
    }
}

이제, MemberServiceImpl과 BoardServiceImpl은 인터페이스만 의존한다. 구현 객체는 생성자로 주입받는다. ➜ DIP 문제 해결

저장소를 바꾸거나, 권한 정책을 바꾸고 싶다면 AppConfig 클래스만 수정하면된다. ➜ OCP 문제 해결




✏️ AppConfig 리팩터링

현재 AppConfig에는 중복이 존재하고, 역할에 따른 구현이 눈에 잘 들어오지 않는다. 따라서, refactoring이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
public class AppConfig {
	/* 이전코드
    	public MemberService memberService(){
        	return new MemberServiceImpl(new MemoryMemberRepository());   
	    }
        public BoardService boardService(){
        	return new BoardServiceImpl(new MemoryMemberRepository(), new ReadOnly());
        }
        
  		new MemoryMemberRepository()가 중복, new ReadOnly()는 역할이 눈에 잘 들어오지 않음
	*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 // 변경 후
 
    public MemberRepository memberRepository(){
    	return new MemoryMemberRepository();
    	// memberRepository() 호출 시, 현재 설계에서 사용하고자 하는 저장소 반환
    }
    
    public MemberService memberService(){
       	return new MemberServiceImpl(memberRepository());   
        // memberService() 호출 시, 현재 설계에서 사용하고자 하는 서비스 객체 반환
	}
    
    public BoardPolicy boardPolicy(){
        return new ReadOnly();
    }
    
    public BoardService boardService(){
        return new BoardServiceImpl(memberRepository(), boardPolicy());
    }
}
    




✏️ 수정사항 발생

이제, 설계 당시에 미확정 상태였던 요구사항들이 확정되었다고 가정해보자. 학생 회원은 글을 쓸 수 없도록 설계 했지만, 요구사항이 변경되어 글쓰기가 가능하게 바꾸고 싶다.

클라이언트인 BoardServiceImpl을 수정하지 않고, AppConfig만 변경하면 된다. ➜ 확장에는 열려있고, 변경에는 닫혀있다.

1
2
3
4
// AppConfig 내에서
public BoardPolicy boardPolicy(){
     return new ReadWrite();
}




✏️ IOC, DI, 컨테이너

■ IOC

IOC(Inversion of Control): 제어의 역전

IOC란, 프로그램의 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것이다.

  • AppConfig 사용 이전에는, 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다.
  • AppConfig 사용 후에는, 구현 객체는 자신의 로직만 실행하고 제어의 흐름은 AppConfig가 담당했다.

이러한 상황을 IOC라 부른다.

■ DI

DI(Dependency Injection): 의존관계 주입

애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고, 클라이언트에 전달하여 클라이언트-서버간 의존관계가 연결되는 것을 의존관계 주입이라 한다.

  • 실행 이전에, 클래스를 보고 의존관계가 판단되는 것은 정적 의존관계이다.
  • DI를 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 사용하는 객체를 바꿀 수 있다.

■ 컨테이너

AppConfig 처럼 객체를 생성/관리하고 의존관계를 연결해 주는 것을 DI 컨테이너라 한다. (IOC 컨테이너라 부르기도 한다. 하지만, 의존관계 주입에 초점을 맞추어 DI 컨테이너로 자주 부른다.)




✏️ Spring으로 전환

위의 예제는 오로지 “순수한 자바”로만 작성되었다. AppConfig를 Spring의 기능을 사용해서 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class AppConfig {

    @Bean
    public MemberRepository memberRepository(){
        return new MemoryMemberRepository();
    }
    @Bean
    public MemberService memberService(){
        return new MemberServiceImpl(memberRepository());
    }
    @Bean
    public BoardPolicy boardPolicy(){
        return new ReadOnly();
    }
    @Bean
    public BoardService boardService(){
        return new BoardServiceImpl(memberRepository(), boardPolicy());
    }
}

// annotation을 추가했다.


BoardApp 클래스 또한 스프링의 기능 중 하나인 ApplicationContext를 사용하도록 변경한다.

1
2
3
4
5
6
7
8
public class BoardApp{
	ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);

    MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
    BoardService boardService = applicationContext.getBean("boardService", BoardService.class);

	...
}


ApplicationContext를 스프링 컨테이너라고 한다.

  • 기존에는 AppConfig를 사용하여 DI를 했지만, 이제부터는 스프링 컨테이너를 활용한다.
  • 스프링 컨테이너는 @Configuration이 붙은 AppConfig를 설정 정보로 사용한다.
  • 스프링 컨테이너는 @Bean이 붙은 메소드를 모두 호출해서 반환된 객체를 등록한다.
  • 메소드 명을 스프링 빈의 이름으로 사용한다.

코드가 오히려 더 복잡해진 것 같은데… 스프링을 쓰는게 무엇이 좋은걸까??? 다음 포스트에서 알아보자.


REFERENCE

스프링 핵심 원리 - 김영한 개발자님

This post is licensed under CC BY 4.0 by the author.