밍쎄의 코딩공간
스프링 핵심 원리 기본편 정리 본문
스프링 부트란?
스프링을 편리하게 사용할 수 있도록 지원하는 프레임워크입니다. 이 툴을 사용하면서 제가 와닿는 강점은 아래와 같습니다. 그렇다면 스프링의 핵심 개념은 무엇일까요? 본질적으로 스프링은 자바 언어 기반의 프레임워크입니다. 따라서 객체 지향이라는 강력한 특징을 살려낼 수 있는, 즉 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 역할을 해줍니다.
객체 지향의 특징
이어서 객체 지향이 어떤 특징을 갖고 있는지 알아보면서, 이를 스프링에서는 어떻게 지원하는지 확인해보겠습니다.
객체 지향 프로그래밍의 의미는 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위인 객체들의 모임으로 파악하고자 하는 것입니다. 각각의 객체는 메세지를 주고받고, 데이터를 처리할 수 있습니다. (협력) 더불어 이는 프로그램을 마치 컴퓨터 부품을 갈아 끼우듯이, 유연하고 변경이 용이하게 만들어주기 때문에 대규모 소프트웨어 개발에 많이 사용됩니다.
이를 위한 중요한 특징으로, 네 가지를 꼽을 수 있습니다. 추상화, 캡슐화, 상속, 다형성. 이 중에서 가장 중요한 다형성에 대해 더 알아보도록 하겠습니다.
다형성을 설명하기 위해서, 역할과 구현으로 세상을 구분하여 예시를 들어보겠습니다.
- 자동차라는 역할이 있다면, 이것의 구현체로 K3, 아반떼, 테슬라 모델3 등이 있습니다.
- 로미오와 줄리엣의 역할이 있다면, 구현체로 장동건과 원빈, 그리고 김태희와 송혜교 등이 있습니다.
이렇게 역할과 구현으로 구분하면 세상이 단순해지고 유연해지며 변경도 편리해집니다.
- 클라이언트는 대상의 역할(인터페이스)만 알면 됩니다.
- 클라이언트는 구현 대상의 내부 구조를 몰라도 됩니다.
- 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않습니다.
- 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않습니다.
자바 언어에 대입을 하면 역할은 인터페이스, 구현은 클래스라고 할 수 있습니다. 즉, 객체 설계시 역할(인터페이스)를 먼저 부여하고, 그 역할을 수행하는 구현체(클래스)를 만들어야합니다.
그 과정에서 중요한 자바의 기본 문법이 바로 오버라이딩입니다. 실제 동작하는 로직은 구현체에 맞게 오버라이딩된 메서드이며, 클래스는 인터페이스를 구현한 것이므로 유연하게 필요에 따라 객체를 변경할 수 있습니다.
public class MemberService {
// private MemberRepository memberRepository = new MemoryMemberRepository();
private MemberRepository memberRepository = new JdbcMemberRepository();
}
즉 다형성의 본질은, 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다는 점입니다. 이를 이해하려면 협력이라는 객체사이의 관계에서 시작해야합니다. 한 문장으로 정리하면, 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경 가능하다는 점입니다.
좋은 객체 지향 설계의 원칙: SOLID
SOLID는 클린코드로 유명한 로버트 마틴이 정리한 5가지 원칙입니다.
- SRP (Single Responsibility Principle): 단일 책임 원칙
- 한 클래스는 하나의 책임만을 가져야 합니다. 이때 하나의 책임을 구분하는 기준은 변경입니다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것으로 볼 수 있습니다.
- OCP (Open / Closed Principle): 개방 / 폐쇄 원칙그러나 순수 자바 언어로는 위의 자바 예시처럼, 다형성을 사용했지만 OCP 원칙을 지킬 수는 없습니다. 이를 해결하기 위해서, 객체를 생성하고 연관관계를 맺어주는 별도의 조립 및 설정자가 필요합니다. 이 부분을 스프링에서 도맡아 줍니다.
- 소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀있어야 합니다. 확장을 하려면 당연히 기존 코드를 변경해야 한다고 생각할 수 있지만, 다형성을 활용한다면, 역할과 구현의 분리를 생각해보면 가능합니다.
- LSP (Liskov Substitution Principle): 리스코프 치환 원칙
- 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 합니다. 이는 인터페이스를 구현한 구현체를 믿고 사용하기 위한 원칙으로, 하위 클래스는 인터페이스 규약을 다 지켜야 함을 뜻합니다. 단순히 컴파일에 성공하는 것을 넘어서, 자동차 인터페이스의 엑셀은 앞으로 가라는 기능인데 이를 뒤로 가게 구현하면 LSP에 위반되는 것처럼, 인터페이스 규약을 따라야 합니다.
- ISP (Interface Segregation Principle): 인터페이스 분리 원칙
- 클라이언트를 위한 인터페이스 여러 개가 하나의 범용 인터페이스보다 제 역할을 다합니다. 자동차 인터페이스는 운전 인터페이스와 정비 인터페이스로 분리하는 것과 같이, 인터페이스가 명확해지고 대체 가능성이 높아집니다. 동시에 하나의 인터페이스가 변하더라도 운전자 클라이언트에 영향을 주지 않게 됩니다.
- DIP (Dependnecy Inversion Principle): 의존관계 역전 원칙그러나 위의 자바 예시처럼 순수 자바 언어로는, 클라이언트가 구현 클래스를 직접 선택하여 인터페이스와 구현 클래스를 동시에 의존합니다. 이 문제를 스프링에서 해결해줍니다.
- 프로그래머는 구체화에 의존하지 않고, 추상화에 의존해야 합니다. 즉, 역할에 의존해야 하는 것입니다.
객체 지향 설계와 스프링
자바 언어로는 OCP와 DIP를 지키기 힘듭니다. 이를 위해 스프링이 등장했고, 이외에도 객체 지향을 위해 추가적인 강점을 제공합니다.
- DI (Dependency Injection) 개념과 DI 컨테이너를 제공하여 다형성와 OCP, DIP를 가능하게 지원합니다.
- 클라이언트 코드의 변경없이 기능을 확장하도록 도와줍니다.
예제를 통한 스프링 핵심 원리 이해
주문 도메인 설계
구현하고자 하는 모델은 아래의 다이어그램입니다.
- 주문 도메인 협력, 역할, 책임
- 주문 도메인 전
- 주문 클래스 다이어그램
- 주문 객체 다이어그램
주문 도메인 개발
public class OrderServiceImpl implements OrderService {
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
위처럼 자바 코드를 작성하면, OCP와 DIP 원칙에 어긋납니다. 기능을 확장해려면 클라이언트 코드에 영향을 주기 때문에 OCP를 위반하며, 주문 서비스 클라이언트(OrderServiceImpl)은 DiscountPolicy 인터페이스를 의존하는 것과 동시에 구현 클래스인 FixDiscountPolicy 혹은 RateDiscountPolicy에도 의존하고 있기 때문에 DIP를 위반합니다.
구현하고자 했던 모델이 아래와 같다면,
현재 구현한 모델의 실제 모습은 아래와 같습니다.
즉, 위 코드를 인터페이스에만 의존하도록 설계를 변경해야 합니다.
public class OrderServiceImpl implements OrderService {
private final DiscountPolicy discountPolicy;
}
그러나 이 경우에는 구현체가 없기 때문에 null pointer exception이 발생하며 제대로 동작하지 않습니다. 따라서 누군가가 클라이언트인 OrdeServiceImpl에 DiscountPolicy의 구현 객체를 대신 생성하고 주입해주어야 합니다.
AppConfig의 등장
애플리케이션의 전체 동작 방식을 구성하기 위해 구현 객체를 생성하고, 연결하는 책임을 가지는 별도의 설정 클래스입니다. 이를 클라이언트인 OrderServiceImpl에 구현하지 않은 이유는, SRP 원칙을 지키기 위해서입니다. OrderServiceImpl은 DiscountPolicy의 구현 객체를 가지고 주문 로직을 수행하는 역할을 수행해야 합니다. 여기에 추가로 각 인터페이스에 어떤 구현 객체가 들어와야 하는지 정하는, 즉 또 다른 역할(책임)을 추가한다면 클라이언트는 점점 복잡해집니다. 따라서 각각의 책임을 확실히 분리하기 위해 AppConfig를 별도로 만드는 것입니다.
public class AppConfig {
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
ths.discountPolicy = discountPolicy;
}
}
public class OrderApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
OrderService orderService = appConfig.orderService();
Long memberId = 1L;
Member member = new Member(memberId, "memberA", Grade.VIP
Order order = orderService.createOrder(memberId, "itemA", 20000);
System.out.println("order = " + order);
}
}
- 더이상 OrderServiceImpl은 구현 클래스를 의존하지 않습니다.
- OrderServiceImpl 입장에서 생성자를 통해 어떤 구현 객체가 주입될지 알 수 없습니다. 이는 오직 외부(AppConfig)에서 결정합니다.
별도의 설정 클래스 AppConfig를 사용함으로써, OCP와 DIP 원칙을 지키며 기존에 하고자 했던 설계를 했습니다. 비즈니스 로직상 DiscountPolicy 인터페이스의 구현 객체로 다른 클래스가 추가되어도, 구성 영역인 AppConfig에서 수정하면 사용 영역의 어떠한 코드 변경없이 확장할 수 있습니다.
의존 관계 주입
IoC (Inversion of Control) 제어의 역전
- 기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했습니다. 한마디로 구현 객체가 프로그램의 제어 흐름을 스스로 조종했습니다.
- 반면에 AppConfig가 등장한 이후에 구현 객체는 자신의 로직을 실행하는 역할만 담당합니다. 프로그램의 제어 흐름은 이제 AppConfig가 가져갑니다.
- 이렇듯 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)이라고 합니다.
DI (Dependency Injection) 의존 관계 주입
OrderServiceImpl은 DiscountPolicy 인터페이스에만 의존합니다. 실제 어떤 구현 객체가 사용될지는 모릅니다. 이러한 의존관계는 정적인 클래스 의존 관계와 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계로 분리해서 생각해야 합니다.
- 정적인 클래스 의존 관계
- 클래스가 사용하는 import 코드만 보고 의존 관계를 쉽게 판단할 수 있습니다. 정적인 의존 관계는 애플리케이션을 실행하지 않아도 분석할 수 있습니다. OrderServiceImpl은 MemberRepository와 DiscountPolicy에 의존함을 알 수 있는 것처럼 말입니다.
- 동적인 객체(인스턴스) 의존 관계
- 애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계입니다.
DI 개념을 정리하면 아래와 같습니다.
- 애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존 관계가 연결되는 것을 의존 관계 주입이라 합니다.
- 의존 관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있습니다.
- 의존 관계 주입을 사용하면 클래스 의존 관계를 변경하지 않고, 동적인 객체 인스턴스 의존 관계를 쉽게 변경할 수 있습니다.
DI 컨테이너
AppConfig처럼 객체를 생성하고 관리하면서 의존 관계를 연결해주는 것을 IoC 컨테이너 혹은 DI 컨테이너라고 합니다.
스프링으로 전환
@Configuration
public class AppConfig {
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
public class OrderApp {
public static void main(String[] args) {
// AppConfig appConfig = new AppConfig();
// OrderService orderService = appConfig.orderService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Long memberId = 1L;
Order order = orderService.createOrder(memberId, "itemA", 20000);
System.out.println("order = " + order);
}
}
- ApplicationContext를 스프링 컨테이너라고 합니다.
- 기존에는 개발자 AppConfig를 사용해서 직접 객체를 생성하고 DI를 했지만, 이제부터는 스프링 컨테이너를 통해서 사용합니다.
- 스프링 컨테이너는 @Configuration이 붙은 AppConfig를 설정 정보로 사용합니다. 여기서 @Bean이라 적힌 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록합니다. 이렇게 스프링 컨테이너에 등록된 객체를 스프링빈이라고 합니다.
- 이전에는 개발자가 필요한 객체를 AppConfig를 사용해서 직접 조회했지만, 이제부터는 스프링 컨테이너를 통해서 필요한 스프링 빈(객체)를 찾아야 합니다.
- 기존에는 개발자가 직접 자바 코드로 모든 것을 했다면 이제부터는 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용하도록 변경되었습니다.
스프링 컨테이너와 스프링 빈
스프링 컨테이너 생성 과정
- 스프링 컨테이너 생성
- ApplicationContext를 스프링 컨테이너라 합니다.
- XML 기반 혹은 애노테이션 기반의 자바 설정 클래스, 두 방법으로 만들 수 있습니다.
- AnnotationConfigApplicationContext는 인터페이스 ApplicationContext의 구현체입니다.
- 스프링 빈 등록
- 스프링 컨테이너는 파라미터로 넘어온 설정 클래스 정보(AppConfig)를 사용해서 스프링 빈을 등록합니다.
- 빈 이름의 디폴트 값은 메서드 이름입니다. (@Bean(name="orderService") 으로 직접 설정 가능)
- 스프링 빈 의존 관계 설정
- 설정 정보를 참고해서 의존 관계를 주입(DI)합니다.
- 싱글톤 컨테이너로, 단순히 자바 코드를 호출하는 것과 차이가 있습니다.
스프링 빈 조회
- 기본
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
@Test
@DisplayName("빈 이름으로 조회")
void findBeanByName() {
OrderService orderService = ac.getBean("orderService", OrderService.class);
Assertions.assertThat(orderService).isInstanceOf(OrderServiceImpl.class);
}
@Test
@DisplayName("이름 없이 타입으로 조회")
void findBeanByType() {
OrderService orderService = ac.getBean(OrderService.class);
Assertions.assertThat(orderService).isInstanceOf(OrderServiceImpl.class);
}
@Test
@DisplayName("구체 타입으로 조회")
void findBeanByName2() {
OrderService orderService = ac.getBean("orderService", OrderServiceImpl.class);
Assertions.assertThat(orderService).isInstanceOf(OrderServiceImpl.class);
}
@Test
@DisplayName("빈 이름으로 조회X")
void findBeanByNameX() {
// Object xxxxxx = ac.getBean("XXXXXX");
org.junit.jupiter.api.Assertions.assertThrows(NoSuchBeanDefinitionException.class,
() -> ac.getBean("XXXXXX"));
}
- 스프링 컨테이너에서 스프링 빈을 찾는 가장 기본적인 조회 방법
- ac.getBean(빈이름, 타입)
- ac.getBean(타입)
- 동일한 타입이 둘 이상인 경우
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SameBeanConfig.class);
@Test
@DisplayName("타입으로 조회시 같은 타입이 둘 이상 있으면, 중복 오류가 발생한다")
void findBeanByTypeDuplicate() {
Assertions.assertThrows(NoUniqueBeanDefinitionException.class,
() -> ac.getBean(MemberRepository.class));
}
@Test
@DisplayName("타입으로 조회시 같은 타입이 둘 이상 있으면, 빈 이름을 지정하면 된다")
void findBeanByName() {
MemberRepository memberRepository1 = ac.getBean("memberRepository1", MemberRepository.class);
org.assertj.core.api.Assertions.assertThat(memberRepository1).isInstanceOf(MemberRepository.class);
}
@Test
@DisplayName("특정 타입을 모두 조회하기")
void findAllBeanByType() {
Map<String, MemberRepository> beansOfType = ac.getBeansOfType(MemberRepository.class);
for (String key : beansOfType.keySet()) {
System.out.println("key = " + key + " value = " + beansOfType.get(key));
}
org.assertj.core.api.Assertions.assertThat(beansOfType.size()).isEqualTo(2);
}
@Configuration
static class SameBeanConfig {
@Bean
public MemberRepository memberRepository1() {
return new MemoryMemberRepository();
}
@Bean
public MemberRepository memberRepository2() {
return new MemoryMemberRepository();
}
}
타입으로 조회시 같은 타입의 스프링 빈이 둘 이상이면 오류가 발생합니다. 이때는 빈 이름을 지정해야 합니다.
- 상속 관계
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
@Test
@DisplayName("부모 타입으로 조회시, 자식이 둘 이상 있으면, 중복 오류가 발생한다")
void findBeanByParentTypeDuplicate() {
assertThrows(NoUniqueBeanDefinitionException.class,
() -> ac.getBean(DiscountPolicy.class));
}
@Test
@DisplayName("부모 타입으로 조회시, 자식이 둘 이상 있으면, 빈 이름을 지정하면 된다")
void findBeanByParentTypeBeanName() {
DiscountPolicy rateDiscountPolicy = ac.getBean("rateDiscountPolicy", DiscountPolicy.class);
assertThat(rateDiscountPolicy).isInstanceOf(RateDiscountPolicy.class);
}
@Test
@DisplayName("부모 타입으로 모두 조회하기")
void findAllBeanByParentType() {
Map<String, DiscountPolicy> beansOfType = ac.getBeansOfType(DiscountPolicy.class);
assertThat(beansOfType.size()).isEqualTo(2);
}
@Configuration
static class TestConfig {
@Bean
public DiscountPolicy rateDiscountPolicy() {
return new RateDiscountPolicy();
}
@Bean
public DiscountPolicy fixDiscountPolicy() {
return new FixDiscountPolicy();
}
}
BeanFactory와 ApplicationContext
BeanFactory
- 스프링 컨테이너의 최상위 인터페이스
- 스프링 빈을 관리하고 조회하는 역할을 담당
- getBean()을 제공
- 위 테스트 코드에서 사용한 대부분 기능을 BeanFactory가 제공
ApplicationContext
- BeanFactory 기능을 모두 상속받아서 제공
- 빈 관리 및 조회 기능 뿐만이 아닌, 여러 부가 기능을 제공
- 메세지 소스를 활용한 국제화 기능 (한국에서 들어오면 한국어로, 영어권에서 들어오면 영어로 출력)
- 환경 변수: 로컬 / 개발 / 운영 등을 구분해서 처리
- 애플리케이션 이벤트: 이벤트를 발행하고 구독하는 모델을 편리하게 지원
- 편리한 리소스 조회: 파일, 클래스 패스, 외부 등에서 리소스를 편리하게 조회
다양한 설정 형식 지원
스프링 컨테이너는 다양한 형식의 설정 정보를 받아드릴 수 있게 유연하게 설계가 되어있습니다. (자바 코드, XML, Groovy 등등)
스프링 핵심 원리 기본편 정리
스프링 핵심 원리 기본편 강좌 정리본입니다.
velog.io
'개념정리' 카테고리의 다른 글
Service, ServiceImpl (0) | 2023.08.27 |
---|---|
스프링 핵심 원리 - 싱글톤 (2) | 2023.08.20 |
깊이 우선 탐색 ( DFS ) (0) | 2023.08.13 |
그리디(Greedy) (0) | 2023.08.13 |
배열 (0) | 2023.08.12 |