본문 바로가기
Backend/Spring

[Spring] Spring 기초

by 볼링치는 개발자 2021. 7. 21.
반응형

이번 포스팅에서는 인턴을 하게 된 회사에서 진행해준 신입사원 교육 중, Spring의 기초에 대해 정리해보려 합니다.

Spring Boot로 프로젝트를 진행했던 경험이 있어서 Spring의 특징에 대해 어느 정도 알고 있다고 생각하고 있었는데, 이 교육을 듣고 Spring에 대해 한층 더 자세히 알게 되었습니다.


Spring의 등장

Spring이 등장하기 전, 자바 개발자들은 EJB(Enterprise Java Beans)를 주 프레임워크로 사용했습니다.

2002년도에 로드 존슨이 출판한 도서 "Expert One-on-One J2EE Design and Development"에 선보인 코드가 Spring의 근간이 되었습니다.

이 도서를 읽은 개발자들이 코드가 책으로만 존재하기에는 너무 아깝다며 로드 존슨의 허가를 받고 프레임워크로 발전시켰습니다.

2003년 6월 Spring이 Apache 2.0 라이선스로 공개되었습니다.

이때 당시 Spring의 가장 큰 장점은 생산성, 품질보증, 그리고 유지보수의 용이성이었습니다.


Spring Framework의 특징

경량 컨테이너로서 자바 객체를 직접 관리합니다.

- Spring은 모든 객체의 생성, 소멸과 같은 라이프 사이클을 관리하며 Spring으로부터 필요한 객체를 얻어올 수 있습니다.

 

Spring은 POJO(Plain Old Java Object) 방식의 프레임워크입니다.

- POJO라는 용어를 처음 보면 어색하고 어려워 보일 수 있는데, 무거운 객체가 아닌, 그냥 아무것도 안 붙인 자바 객체라고 생각하면 됩니다.

 

Spring은 IoC(Invesion of Control)를 지원합니다.

- Spring이 컨트롤권을 갖고 있어서 필요에 따라 Spring에서 사용자의 코드를 호출합니다.

 

Spring은 DI(Dependency Injection)를 지원합니다.
- 각각의 계층이나 서비스들 간에 의존성이 존재할 경우 프레임워크가 서로 연결시켜 줍니다.

 

Spring은 AOP(Aspect-Oriented Programming)을 지원합니다

- 트랜잭션, 로깅, 보안처럼 여러 모듈에서 공통적으로 사용되는 기능을 분리하여 관리할 수 있습니다.

 

Spring은 영속성과 관련된 다양한 서비스를 지원합니다.

- iBatis, Hibernate 등 이미 완성도가 높은 데이터베이스 처리 라이브러리와 연결할 수 있는 인터페이스를 제공합니다.

 

Spring은 확장성이 높습니다.

- Spring에서 통합하기 위해 기존 라이브러리를 간단하게 감싸는 정도로 Spring에서 사용이 가능하기 때문에 수많은 라이브러리가 지원되고, 별도로 분리하기도 용이합니다.

 

이렇게 Spring Framework의 특징으로 잘 알려져 있는 내용들을 한번 봤습니다.

처음 접하시는 분들은 이렇게 보면 무슨 내용인지도 잘 모르고, 와닿지도 않을 것 같습니다.

이제, Spring의 특징 중에서 중요하다고 생각하는 것을 좀 더 자세히 설명해 보려 합니다.


DI(Dependency Injection)

DI(Dependency Injection)는 우리말로 하면 "의존성 주입"입니다.

정말 와닿지 않죠?

일단 쉽게 말하자면, DI는 디자인 패턴입니다.

역시 말로 설명하는 것보다 코드를 통해 설명하는 것이 이해하기 쉬울 것 같습니다.

 

이 코드를 한번 봅시다.

//HAS-A 관계
public class Foo {
    private Bar bar;
    
    public Foo() {
    	//Foo가 bar를 만드는 중!
    	bar = new SubBar();
    }
}

Foo라는 클래스 안에 Bar이 있습니다.

이를 이렇게도 표현할 수 있습니다.

"Foo has-a Bar, Foo와 Bar는 has-a관계입니다"

추후, Bar클래스에 대해 수정할 거면 Foo클래스도 마찬가지로 수정해줘야 합니다.

 

쉬운 실생활 예시로 예를 들면, 컴퓨터가 하드디스크를 만든다고 가정합니다.

하드디스크를 수정하고 싶으면, 컴퓨터를 뜯어서 수정해야 합니다.

 

즉 Foo클래스가 Bar를 생성하고, Bar를 수정하고 싶으면 Foo를 뜯어서 수정해야 합니다.

 

이 문제를 Dependency Injection, 즉 의존성 주입으로 해결할 수 있습니다.

//DI 사용
public class Foo {
    private Bar bar;
    
    public void setBar(Bar bar) {
    	this.bar = bar;
    }
}

첫 번째 코드와 확실히 달라진 점은, new 키워드를 사용하지 않습니다.

즉, Foo 클래스가 bar를 사용하긴 하는데 내가 직접 만들어 사용하진 않고 외부에서 생성된 bar객체를 주입받는 것입니다.

이게 바로 DI(Dependency Injection) 의존성 주입입니다.

 

하나의 예시만 보고 100% 이해된다면 그건 거짓말입니다.

이해를 돕기 위해서 예시 하나를 더 들어보겠습니다.

class Coffee {...}

class Cappuccino extends Coffee {...}
class Americano extends Coffee {...}

class Programmer {
    private Coffee coffee;
    
    public Programmer() {
    	this.coffee = new Cappuccino();
        //또는
        this.coffee = new Americano();
    }
}

이번에는 조금 더 복잡해진 코드입니다.

Coffee 클래스가 있고, Coffee를 상속받는 Cappuccino, Americano 클래스 2개가 있습니다.

Programmer 클래스를 보면 생성자에서 직접 Cappuccino 혹은 Americano 클래스를 직접 수정, 생성하는 것을 볼 수 있습니다.

만약 Cappuccino, Americano가 아닌 Mocha를 먹고 싶으면 Programer 클래스 코드를 수정해야 합니다.

 

그럼 Programmer 클래스의 코드를 다음과 같이 수정해 보겠습니다.

class Programmer {
    private Coffee coffee;
    
    public Programmer(Coffee coffee) {
    	this.coffee = coffee;
    }
    
    public startProgramming() { 
    	this.coffee.drink();
        ...
    }
}

Programmer의 생성자를 보면 매개변수로 Coffee 클래스의 객체가 들어오면서, Programmer클래스의 coffee를 설정합니다.

이렇게 coffee는 programmer가 직접 만들지 않고, 누가 넣어줍니다.

Programmer는 단순히 그 커피를 마시기만 하면 됩니다!

 

이렇게 DI에 대해 살펴보았습니다.

Spring의 모든 컴포넌트는 이렇게 DI로 구성됩니다! 누가 넣어주는 것처럼 말이죠.


그럼 이제 Spring에서의 DI문법 3가지에 대해 알아보겠습니다.

방법 1 - 생성자 주입

public class Foo {
    private final Bar bar;
    
    @Autowired
    public Foo(Bar bar) {
    	this.bar = bar;
    }
}

이렇게 코드를 작성하면 Foo객체를 생성할 때 bar 객체를 매개변수로 전달해 주면 의존성 주입이 됩니다.

장점으로는 단위 테스트가 쉽고, 주입받는 객체의 final 선언이 가능합니다.

또한 객체의 생성자는 1번 호출되는 것이 보장되기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요할 때 사용할 수 있습니다.

Spring Framework에서도 생성자 주입 방식을 적극 권장하기 때문에, 생성자가 1개인 경우 @Autowired 어노테이션을 생략할 수 도 있습니다.

방법 2 - Setter 주입

public class Foo {
    private Bar bar;
    
    @Autowired
    publid void setBar(Bar bar) {
    	this.bar = bar;
    }
}

교육해주시는 분은 "Setter주입 사용할 바에 그냥 생성자 주입 사용하자"라고 하셨습니다.

이 방법은 주입받는 객체가 변할 수 있을 때에 사용합니다. 하지만 변경되는 경우는 극히 드물기 때문에 생성자 주입을 사용합시다!

@Autowired 어노테이션은 필수입니다!

방법 3 - 필드 주입

public class Foo {
    @Autowired
    pirvate Bar bar;
}

앞에서 보던 코드들과는 달리 한 줄로 엄청나게 줄어들었습니다!

이렇게 코드를 작성하면 Spring이 외부에서 bar를 찾고 Foo에 넣어줍니다.

코드가 간결해졌지만, 유닛 테스트도 힘들어질뿐더러 서로 참조를 하다가 순환 참조가 생길 수 있는데, 이때 이를 찾기가 힘들어집니다.

우리 모두 생성자 주입을 사용합시다!


Spring에서 DI의 원리

Spring에서 DI가 작동하는 원리는 명세서에 따라 자동으로 부품을 조립하는 것과 비슷합니다.

 

먼저 프로그래머는 부품을 만듭니다. 클래스를 구현하는 것이죠.

그리고 해당 부품들, 즉 클래스의 명세서를 정의합니다. 생성자 주입 방식을 추천합니다.

 

Spring은 이제 부품들을 조립합니다. 자동으로 DI를(생성자/Setter 방식) 하면서 쓸 줄 아는 제품은 알아서 사용합니다.

예를 들어 특정 URL에 대한 request가 도착하면 해당 request에 대한 처리 로직(Controller)을 자동으로 호출해줍니다.


IoC (Inversion of Control)

IoC를 설명하기 전에 Spring Bean에 대해 먼저 얘기해보려 합니다.


Spring Bean

Spring Bean은 스프링이 관리하는 인스턴스들입니다.

일반 Java 객체와 다를 것이 없고, 그냥 스프링 프레임워크가 관리하는 것들입니다.

 

쉽게 말하자면, 어떤 작업을 수행할 수 있는 객체들이고, 해당 객체는 스프링이 관리하면서 언제든지 DI에 사용될 수 있습니다.

 

기본적으로 Bean을 생성하면 Singleton으로 스프링 컨테이너에 하나만 존재하게 됩니다.

즉, 스프링이 DI를 할 때 항상 같은 객체가 사용됩니다.

이 객체는 스프링 Bean의 Scope로 수정 가능하긴 합니다.

Spring Bean Scope

Spring Bean은 별 다른 설정이 없으면 기본적으로 Singleton Scope으로 생성됩니다.

이는 원하는 bean을 하나 만들어 놓고 공유하기 위함입니다.

이렇게 bean을 공유하다 보면 서로 bean을 사용하고 수정하려고 해서 가끔 race condition이 발생하기도 합니다.

가끔 상황에 따라 Singleton이 아닌 bean을 생성해야 할 때가 있는데, Spring에서 제공하는 다른 scope를 사용하면 됩니다.

Singleton (Default)

기본적으로 사용되는 scope로 스프링 컨테이너에 단 하나가 존재합니다. DI시 항상 같은 객체가 사용됩니다.

Prototype

다수의 객체가 컨테이너에 존재할 수 있어서 DI시 매번 새로운 객체가 생성됩니다.

Request

하나의 HTTP request동안 하나의 객체만 존재합니다. 즉 request가 들어올 때마다 생성됩니다.

Session

하나의 HTTP session동안 하나의 객체만 존재합니다.

멀티스레드 환경에서의 Bean

멀티스레드 환경에서 공유자원에 각각의 스레드들이 write 하려면 race condition이 발생하는 것을 다들 알고 계실 겁니다.

Spring Bean도 마찬가지입니다.

 

Bean은 기본적으로 singleton으로 생성되기 때문에, 생성한 bean에 멤버 변수가 있다면, 멀티스레드 환경에서 여러 사용자가 많이 접속하여 공유를 하게 되기 때문에 고민을 해봐야 합니다.

 

그래서 보통 bean을 생성할 때 공유 변수가 없습니다. 즉, 멤버 변수를 잘 사용하지 않습니다.

코드는 공유해도 되지만, 멤버 변수는 공유하면 race condition이 발생하기 때문입니다.

Spring Bean 생성 방법

@Bean

외부 라이브러리에서 정의한 클래스를 스프링 Bean으로 등록하고자 하는 경우에 사용합니다.

@Component

개발자가 직접 작성한 클래스를 스프링 Bean으로 등록할 경우 사용합니다.

@Component어노테이션은 아래 세 가지 중에 정확히 어디에 속하는지 모르는 경우에 사용합니다.

  • @Controller - 기본적으로 @Component와 같으나 presentation layer의 component라는 것 표기
  • @Service - 기본적으로 @Component와 같으나 business layer의 component라는 것 표기
  • @Repository - 기본적으로 @Component와 같으나 persistence layer의 component라는 것 표기

@Bean과 @Component의 차이는 외부 라이브러리에서 정의한 거냐, 개발자가 직접 정의하는 거냐의 차이입니다.

@Controller, @Service, @Repository는 Component 간에 구분할 때 사용하는 어노테이션입니다.

 

Presentaion Layer는 주로 클라이언트에서 보낸 요청 간의 json 형태로의 변환 등의 작업을 수행합니다.

Business Layer는 Presentation Layer에서 보내온 데이터를 가지고 비즈니스 로직에 따른 계산, 처리를 수행합니다.

Persistence Layer는 주로 데이터베이스와의 연결을 유지하고 데이터를 송, 수신합니다.


스프링 컨테이너

이제 본격적으로 IoC에 대해 얘기하기 위해 또 다른 개념 하나를 소개하겠습니다.

스프링 컨테이너는 IoC/Bean/DI 컨테이너라고 불리기도 합니다.

 

일반적으로 컨테이너는 인스턴스의 생명주기를 관리하고, 생성된 인스턴스들에게 추가적인 기능을 제공하도록 합니다.

프로그래머가 작성한 코드를 스스로 참조하고 알아서 객체의 생성과 소멸을 컨트롤해줍니다.

즉, 스프링 컨테이너에서 Bean들을 다 담아놓고, Bean의 생성, 소멸, 명세까지 전부 관리합니다.

 

오브젝트 생성을 개발자가 new 키워드를 통해 하지 않고 컨테이너가 호출하기 때문에 DI가 되는 것입니다.

DI가 되기 때문에 스프링 컨테이너가 스스로 제어할 수 있습니다.

 

이게 바로 IoC(Inversion of Control), 제어의 역전입니다.

 

개발자가 제어하던 프로그램의 흐름, 객체의 생성, 소멸 등을 컨테이너가 제어하기 때문에 제어의 역전이라고 불립니다.

사람이 하던 일을 스프링 프레임워크가 대신해주고, 개발자는 코드만 짜 놓고 실행만 하면 스프링이 알아서 해줍니다.


AOP (Aspect Oriented Programming)

AOP를 우리나라 말로 하면 "관점 지향 프로그래밍"입니다.

즉, 문제를 바라보는 관점을 기준으로 프로그래밍하는 기법을 말합니다.

(교육해주시는 분께서 이걸 보고 뭔 개소린지 모르겠죠?라고 하셔서 속으로 네라고 했다)

 

쉽게 말하자면, 공통된 부분을 좀 더 쉽게 프로그래밍하는 기법입니다.

예시를 몇 개 들어보겠습니다.

 

클래스에 메서드를 여러 개 구현하고 있는데, 각각 메서드마다 걸리는 실행시간을 알고 싶으면 어떻게 할까요?

모든 메서드마다 시작 지점의 시간과, 끝나는 지점의 시간을 체크해서 확인할 수 있습니다.

 

혹은 각각 메서드마다 어떤 일을 하는지 로그에 출력하고 싶으면 모든 메서드에 로그를 출력하는 코드를 작성하면 됩니다.

 

각각의 메서드가 다른 역할을 하지만, 이렇게 시간을 측정하는 기능은 모두 동일하고, 코드의 중복이 발생합니다.

또한 개발자가 모든 메서드에 공통 코드를 작성하다가 까먹고 안 넣는 실수를 할 수도 있습니다.

 

이를 AOP로 개선이 가능합니다.

AOP를 통해 OOP를 더욱 OOP 답게 만들어 줍니다.

 

다음의 예시를 한번 봅시다.

public class Hello {
    public String sayHello(String name) {
    	//로그 남기는 공통 코드
        System.out.println("log: " + new java.util.Date());
        
        String msg = "hello~ " + name;
        return msg;
    }
}

위 코드는 메서드가 실행된 시간을 보여주는 로그를 남기는 코드를 갖고 있는데, 이를 Hello 클래스로부터 분리해 보겠습니다.

public class HelloLog {
    public static void log() {
    	System.out.println("log: " + new java.util.Date());
    }
}

이렇게 클래스를 분리하면 첫 번째 작성했던 Hello 클래스 코드는 다음과 같이 달라집니다.

public class Hello {
    public String sayHello(String name) {
    	HelloLog.log(); //공통코드를 호출하는 코드가 포함됨
        
        String msg = "hello~" + name;
        return msg;
    }
}

이렇게 한 줄짜리 코드로 줄어들긴 했지만 이 또한 없애고 싶을 때 AOP를 사용합니다.

 

AOP에서는 핵심 로직들을 구현한 코드에서 공통 기능을 직접 호출하지 않습니다.

AOP에서는 분리한 공통 기능의 호출까지도 관점으로 다루며 이런 모듈로 산재한 관점을 "횡단 관점"이라고 부릅니다.

횡단 관점이란, 아래 사진처럼 파란색 박스는 각각 다른 로직을 지닌 메서드들이고, 빨간색 박스가 로그 등 공통 코드입니다.

빨간색 박스파란색 박스를 횡단한다는 개념으로 횡단 관점이라고 부릅니다.

AOP는 이런 회당 관점까지 분리하면서 각 모듈로부터 관점에 관한 코드를 완전히 제거하는 것을 목표로 합니다.


프록시를 이용한 AOP 구현

아까 봤던 예시에서는 클래스를 단순히 분리해서 공통 코드인 로그 남기는것을 구현했습니다.

지금 예시를 한번 보시죠.

public class HelloProxy extends Hello {
    @override
    publid String sayHello(String name) {
    	HelloLog.log(); //공통 코드 실행
        return super.sayHello(name); //핵심 코드 실행
    }
}

HelloProxy클래스가 Hello클래스를 상속하고 오버 로딩을 구현했습니다.

다른 데서 Hello 클래스를 호출하면 HelloProxy가 먼저 호출될 것이고, HelloProxy는 로그를 찍고, Hello 클래스의 핵심 코드를 실행합니다.

이렇게 구현한 것이 프록시를 이용한 AOP입니다.

 

이 방법엔 단점이 있습니다.

밖에 있는 누군가가 Hello의 A메서드를 호출해야 프록시를 거쳐서 해당 메서드가 호출됩니다.

하지만 Hello클래스 내의 다른 메서드가 A메서드를 호출하면 프록시를 거치지 않고 바로 호출되기 때문에 공통 코드의 실행이 생략됩니다.

 

다음 예시를 한번 봅시다

void aaa() {
    bbb();
}

@Transactional
void bbb() {
    ccc();
}

@Transactional
void ccc() {
    ...
}

@Transactional 어노테이션은 해당 메서드를 트랜잭션처럼 동작하게 합니다.

외부에서 ccc 메서드를 호출하면 정상적으로 트랜잭션이 걸립니다.

하지만 외부에서 bbb를 호출하면, bbb가 내부적으로 ccc를 호출하기 때문에 에러가 나지는 않지만 ccc에 트랜잭션이 정상 작동하지 않습니다. 

내부적으로 메서드를 호출하기 때문에 프록시를 거치지 않아서 @Transactional 어노테이션을 거치치 않게 되는 것입니다.


위빙 - Weaving

위빙(Weaving)은 AOP프레임워크가 공통 코드를 핵심코드에 삽입하는 것입니다.

위빙에는 3가지 방법이 있습니다.

- 컴파일 시 위빙

별도의 컴파일러를 통해 핵심 모듈 사이사이에 관점 형태로 만들어진 공통 관심 코드가 삽입됩니다.

예를 들면 AspectJ가 있습니다.

AspectJ를 사용하면 프록시를 이용한 AOP구현에서의 단점 같은 것은 일어나지 않지만 사용하기가 귀찮습니다.

- 클래스 로딩 시 위빙

별도의 agent를 이용해서 JVM이 클래스를 로딩할 때 해당 클래스의 바이너리 정보를 변경합니다.

즉, Agent가 횡단 관심사 코드가 삽입된 바이너리 코드를 제공하면서 AOP를 지원합니다.

ex) AspectWerkz

- 런타임 시 위빙

위빙의 99%는 런타임 시 위빙으로 진행됩니다.

소스 코드나 바이너리 파일의 변경 없이 프록시를 이용하여 AOP를 지원합니다.

프록시를 통해 핵심 코드를 구현한 객체에 접근하며 AOP를 지원합니다.

ex) Spring AOP


스프링에서의 AOP

스프링에서는 자체적으로 런타임 때 위빙 하는 프록시 기반의 AOP를 지원하고 있습니다.

 

프록시 기반이기 때문에 외부에서 메서드 호출할 때만 적용이 가능합니다.

 

스프링은 완전한 AOP 기능을 제공하는 것이 목적이 아니라, 애플리케이션을 구현할 때 필요한 기능을 제공하는 것을 목적으로 하고 있습니다.

 

spring-boot-starter-aop 라이브러리를 사용하고, @Aspect 같은 어노테이션을 통해 쉽게 AOP를 구현할 수 있습니다.

반응형

댓글