[묘공단-스프링부트3-2장] IoC, DI, AOP, PSA 뽀개기

힐링코더·2023년 9월 16일
1
post-thumbnail

안녕하세요!
골든래빗에서 주관하는 스프링부트3 묘공단(공부하는 토끼들...)장 힐링코더입니다.
(앞으로는 이렇게 인사 안 할 거임)

본 페이지에서는 도서 2장의 핵심 키워드인
IoC, DI, AOP, PSA를 아주 뽀개고 가겠습니다.
노베가 공부하면서 정리하는 내용이니 부족한 부분이 있어도 예쁘게 봐 주시고 틀린 부분이 있다면 댓글로 알려 주시면 정말로 감사하겠습니다!

IoC란 무엇인가?

Inversion of Control, 제어의 역전.

제어의 역전의 기본 아이디어는 '누가 누구를 제어하는가?'에 있다.
일반적인 코드에서는 1) 메인 프로그램이 객체를 만들고 2) 그 객체가 어떤 일을 할지를 지시한다.
제어의 역전에서는 이런 '제어' 자체를 객체나 프레임워크에게 넘긴다.

일반적인 접근 방식 (Non-IoC)

class Dog {
    void bark() {
        System.out.println("Woof!");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog myDog = new Dog();
        myDog.bark();
    }
}

위 코드에서는 main 메서드가 개 객체(Dog)를 만들고 "짖어라"(bark())라고 지시한다.
여기서 '제어'는 main 메서드에 있다.

여기서 main 메서드가 1) 객체를 생성하고 2) 그 객체의 메서드를 불러오게 하지 않으려면?
IoC가 필요하다.

interface Animal {
    void makeSound();
}

class Dog implements Animal {
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class AnimalCare {
    void takeCare(Animal animal) {
        animal.makeSound();
    }
}

public class Main {
    public static void main(String[] args) {
        AnimalCare care = new AnimalCare();
        care.takeCare(new Dog());
    }
}

이번에는 AnimalCare라는 클래스가 있고, takeCare 메서드가 동물이 내는 소리를 제어한다.
main 메서드가 동물이 소리 내게 하지 않는다.

제어의 역전이 일어나면, 메인 프로그램은 더 이상 어떻게 동물이 소리를 내야 하는지 알 필요가 없다.
그냥 AnimalCare에게 요청하면 끝이다.
이렇게 '제어'가 AnimalCare로 '역전'된다.



여전히 잘 모르겠는가?

보다 확실한 예를 들어 드리겠다.

아이스크림 서빙 프로그램을 만든다 생각해 보자.
IoC를 적용하지 않으면
사람이 직접 아이스크림을 선택하고, 어떻게 서빙할지를 결정하고, 결제까지 하는 형태가 나온다.

public class NoIoCExample {
    public static void main(String[] args) {
        IceCream chocolateIceCream = new IceCream("Chocolate");
        chocolateIceCream.serveIn("Cup");
        chocolateIceCream.pay();
    }
}

class IceCream {
    String flavor;

    IceCream(String flavor) {
        this.flavor = flavor;
    }

    void serveIn(String type) {
        System.out.println("Serving " + flavor + " ice cream in a " + type);
    }

    void pay() {
        System.out.println("Paid for " + flavor + " ice cream");
    }
}

이 경우, 1) 아이스크림을 원하는 사람이 가게에 들어가서 2) 원하는 맛과 3) 컵/콘 등을 직접 선택하고 4) 결제한다.

OMG.
It's like 무인가게에서 손님이 셀프서빙 하는 그림.
불.편.해.보.여.

나는 이렇게 아날로그적인 방식으로 아이스크림을 팔고 싶지 않아.
'그냥 자판기 만들면 되는 거 아냐?'

이렇게 생각하면 이제 IoC를 쓰게 된다.

이 경우, 아이스크림을 원하는 사람이 자판기 앞에 가서 원하는 맛의 버튼을 누르기만 하면 된다.
나머지는 자판기가 알아서 해 준다.

public class IoCExample {
    public static void main(String[] args) {
        IceCreamMachine machine = new IceCreamMachine();
        machine.serveIceCream("Chocolate", "Cup");
    }
}

interface IceCreamService {
    void serveIn(String type);
    void pay();
}

class IceCream implements IceCreamService {
    String flavor;

    IceCream(String flavor) {
        this.flavor = flavor;
    }

    public void serveIn(String type) {
        System.out.println("Serving " + flavor + " ice cream in a " + type);
    }

    public void pay() {
        System.out.println("Paid for " + flavor + " ice cream");
    }
}

class IceCreamMachine {
    public void serveIceCream(String flavor, String type) {
        IceCream iceCream = new IceCream(flavor);
        iceCream.serveIn(type);
        iceCream.pay();
    }
}

이렇게 해 두면 사람은 원하는 아이스크림 맛과 컵/콘 옵션만 선택하면 된다.
서빙하고 결제하는 건 자판기가 알아서 한다.

IoC가 적용되면, 주요한 결정(서빙하고 결제하는 등)을 하나의 중앙 집중된 곳(여기서는 자판기, 즉 IceCreamMachine 클래스)에서 처리하게 되어, 사용자(또는 main 함수)는 그저 원하는 것을 선택하기만 하면 된다.
이러면 1) 코드가 간단해지고 2) 유지보수가 쉬워진다.



그래도 '너낌'이 잘 오지 않는가?

IoC가 필요한 상황을 간단한 예로 설명해 보겠다.
지금 웹앱을 만드는데 사용자의 HTTP 요청(request)에 대한 응답(response)를 처리해야 한다고 생각해 보자.

IoC를 사용 안 하면 어떻게 될까?
1) 각 HTTP 요청을 수동으로 감지하고
2) 해당 요청의 종류(HTTP의 GET, POST 등)에 따라 적절한 메서드를 호출해야 한다.
즉 코드 작성 시, 서버가 어떤 요청을 받고, 그에 따라 어떤 함수를 호출해야 하는지, 데이터는 어떤 걸 사용할지 등을 내가 모두 제어해야 한다.

그럼 IoC를 사용하면 어떻게 되나?
나는 단순히 어떤 URL 경로가 어떤 함수에 매핑되는지만 설정하면 된다.
이렇게 하면 HTTP 요청이 들어왔을 때 프레임워크가 이를 감지해서 설정에 따라 적절한 함수를 자동으로 호출한다.

예를 들어, 자바의 기본 라이브러리만을 사용해 간단한 HTTP 서버를 만든다고 가정해 보자.

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

public class NoIoCExample {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);

        while (true) {
            Socket clientSocket = serverSocket.accept();
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            String request = in.readLine();

            if (request.startsWith("GET")) {
                // Handle GET request
                handleGetRequest(clientSocket);
            } else if (request.startsWith("POST")) {
                // Handle POST request
                handlePostRequest(clientSocket);
            }

            clientSocket.close();
        }
    }

    private static void handleGetRequest(Socket clientSocket) throws IOException {
        // Handling GET request logic here
    }

    private static void handlePostRequest(Socket clientSocket) throws IOException {
        // Handling POST request logic here
    }
}

여기서는 서버 소켓을 열고, 들어오는 클라이언트의 요청을 수동으로 체크하여 GET 요청이면 handleGetRequest 메서드를, POST 요청이면 handlePostRequest 메서드를 호출하고 있다.

IoC를 기본으로 사용하는 스프링(스프링부트)에서는 이를 아래와 같이 처리한다.

import org.springframework.web.bind.annotation.*;

@RestController
public class IoCExample {
    
    @GetMapping("/hello")
    public String handleGetRequest() {
        return "Hello, GET request!";
    }

    @PostMapping("/hello")
    public String handlePostRequest() {
        return "Hello, POST request!";
    }
}

이 경우, @GetMapping과 @PostMapping 어노테이션을 사용해 URL 경로와 메서드를 매핑만 해주면, Spring Framework가 나머지를 알아서 해준다.
클라이언트가 "/hello" URL로 GET 요청을 하면 handleGetRequest() 메서드가, POST 요청을 하면 handlePostRequest() 메서드가 자동으로 호출된다.

우리가 맨날 보고 쓰는 '그 코드'다.
IoC에 대해 너무 쫄지 말자!
나중에 시간 지나면 더욱 잘 알게 되겠지!!

DI란 무엇인가?

Dependency Inejction.
'의존성' '주입'.

(무슨 말인지 모르겠다... 개발 포기할까...)

쉽게 설명해 보자.

한 화가가 있다.
이 예술가는 지금 연필만 가지고 있다.
그래서 연필로만 그림을 그릴 수 있다.
만약 볼펜으로 그림을 그리고 싶으면...
'볼펜을 만들어야 한다!'.
사인펜으로 그림을 그리고 싶으면...
'사인펜을 만들어야 한다!'.

class Artist {
    Pencil pencil = new Pencil();
    
    void draw() {
        pencil.drawSomething();
    }
}

이게 DI가 없는 클래스다.
아니, 화가가 그림만 그리면 됐지, 그림 도구를 직접 만들어야 하나?

여기서 Artist 클래스는 Pencil 클래스에 의존하고 있다.
이런식으로 직접 Pencil을 만들면, 나중에 연필이 아니라 다른 도구로 그림을 그리고 싶을 때 코드를 바꿔야 한다.
복잡하고 어렵다.

DI를 적용하면 코드가 이렇게 바뀐다.
'나는 더 이상 도구 안 만들래!'.
'문구점에서 파는 도구 그냥 사서 쓸래!'.
생각해 보니 이런 내용으로도 설명할 수 있겠다.

직접 밀가루 반죽부터 해서 도우를 만들고, 직접 토마토를 데치고 껍질을 벗기는 것부터 해서 소스를 만들고, 직접 소젖을 짜는 것부터 해서 치즈를 만드는 피자집 VS
모든 걸 프랜차이즈 본사에서 사 와서 조립만 하는 피자집

모든 걸 사장이 직접 한다고 제일 맛있지 않다는 거... 다 아시쥬?

class Artist {
    Tool tool;
    
    Artist(Tool tool) {
        this.tool = tool;
    }

    void draw() {
        tool.drawSomething();
    }
}

interface Tool {
    void drawSomething();
}

class Pencil implements Tool {
    public void drawSomething() {
        System.out.println("Drawing with pencil!");
    }
}

class Chalk implements Tool {
    public void drawSomething() {
        System.out.println("Drawing with chalk!");
    }
}

여기서 Tool tool;은 DI 대상이다.
Tool 객체를 외부에서 주입받겠다는 것이다.
'의존성' '주입'을 하겠다는 부분은

Artist(Tool tool) {
        this.tool = tool;
    }

여기서 나타난다.
이렇게 Artist 객체가 생성될 때 Tool 객체를 생성자(Artist(Tool tool))를 통해 받는 것을 "의존성을 주입한다"라고 한다.

Artist artistWithPencil = new Artist(new Pencil());  // Pencil 객체를 주입!
Artist artistWithChalk = new Artist(new Chalk());  // Chalk 객체를 주입!

이렇게 DI를 적용하면, Artist가 어떤 도구로 그림을 그릴지 주문자가 결정해서 주기만 하면 된다.
연필이 필요하면 연필을, 분필이 필요하면 분필을 주면 끝이다.

public class Main {
    public static void main(String[] args) {
        Artist artistWithPencil = new Artist(new Pencil());
        artistWithPencil.draw();  // Output: Drawing with pencil!

        Artist artistWithChalk = new Artist(new Chalk());
        artistWithChalk.draw();  // Output: Drawing with chalk!
    }
}

확인해 볼 부분
1. @Autowired를 안 붙이면 컨테이너가 빈 객체로 관리 안 하나?(도서 p.52)

내가 알기로...

@Service
public class MyService {
    // @Autowired가 없으면 이 필드에는 자동으로 빈 객체가 주입되지 않습니다.
    private MyRepository repository;
}

코드가 딱 이렇게만 있으면 repository 객체가 빈이 되지 않는다.

하지만 생성자에 @Autowired를 사용하면,

@Service
public class MyService {
    private MyRepository repository;

    @Autowired
    public MyService(MyRepository repository) {
        this.repository = repository;
    }
}

이 경우에는 MyRepository 타입의 빈 객체가 자동으로 MyService 클래스의 생성자를 통해 주입된다.

그런데,
스프링 최신 버전에서는 생성자 주입의 경우 @Autowired 어노테이션을 생략할 수도 있는 걸로 알고 있다.
특히, 하나의 생성자만 있는 경우에는 IDE가 자동으로 그 생성자를 사용하여 의존성을 주입하기 때문이다.
롬복이랑도 연관이 있었던 거 같은데 아직 본인은 스프링부트 찐 뉴비기 때문에 이 말을 100% 신뢰하지 마시고 눈에만 잠시 바르고 넘어가 달라(!).

AOP란 무엇인가?

AOP는 이해하기 훨씬 쉬운 것 같다.
Aspect-Oriented Programming.
관점 지향 프로그래밍.
이는 프로그램의 여러 부분에 걸쳐 반복되는 코드(로깅, 보안 체크, 트랜잭션 관리 등)를 한 곳에 모아서 관리하는 방법이다.
AOP를 쓰면 같은 일을 반복하지 않아도 되기 때문에 코드가 깔끔해지고, 수정이나 유지보수가 쉬워진다.

IoC나 DI, AOP 설명에서 계속 같은 말이 반복되고 있다.
이를 적용하면 <수정이나 유지보수가 쉬워진다는 것>!

우리가 공부하는 모습을 상상해 보자.
만약 공부할 때
'책상 정리 > 필기구 준비 > 공부' 이 루틴을 과목마다 반복한다면 어떨까?
어억.

public class Study {
    public void math() {
        // 로깅
        System.out.println("로그: 수학 공부 시작");

        System.out.println("수학 공부");

        // 로깅
        System.out.println("로그: 수학 공부 끝");
    }

    public void english() {
        // 로깅
        System.out.println("로그: 영어 공부 시작");

        System.out.println("영어 공부");

        // 로깅
        System.out.println("로그: 영어 공부 끝");
    }
}

인생을 이렇게 살면 곤란하다.

좀 더 스마트하게 AOP가 적용되면?

// Aspect: 로깅 처리를 모아 둔 클래스
public class LoggingAspect {
    public void beforeStudy() {
        System.out.println("로그: 공부 시작");
    }

    public void afterStudy() {
        System.out.println("로그: 공부 끝");
    }
}

// Target: 실제 공부하는 클래스
public class Study {
    public void math() {
        System.out.println("수학 공부");
    }

    public void english() {
        System.out.println("영어 공부");
    }
}

TA-DA!

근데 이런 식의 코드가 스프링부트에서 실제 적용되면 어떨지 궁금하지 않은가?
복잡하니까 눈에만 바르고 넘어가자.


Aspect: 공부 시작/종료에 로깅을 하는 클래스

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.After;

@Aspect
public class StudyLoggingAspect {

    @Before("execution(* Study.*(..))")
    public void beforeStudy() {
        System.out.println("로그: 공부 시작!");
    }

    @After("execution(* Study.*(..))")
    public void afterStudy() {
        System.out.println("로그: 공부 끝!");
    }
}

Target: 실제 공부하는 학생 클래스

public class Study {

    public void math() {
        System.out.println("수학 공부 중...");
    }

    public void english() {
        System.out.println("영어 공부 중...");
    }
}

Main: 실행하는 메인 클래스

import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class MainApp {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        Study study = context.getBean(Study.class);

        study.math();
        study.english();
    }
}

AppConfig: 설정 클래스

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

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

    @Bean
    public Study study() {
        return new Study();
    }

    @Bean
    public StudyLoggingAspect studyLoggingAspect() {
        return new StudyLoggingAspect();
    }
}

이렇게 하면, 학생이 math() 메서드나 english() 메서드를 실행할 때마다 StudyLoggingAspect 클래스의 beforeStudy()와 afterStudy() 메서드가 자동으로 실행된다.
이것을 통해 "로그: 공부 시작!"과 "로그: 공부 끝!"이 각 과목 공부 시작과 끝에 출력된다.

AOP 설정에서 @Before, @After 부분이 설정되어 있기에 가능한 얘기.
(더 이상은 나도 모루요...)

이런 식으로 AOP를 적용하면, 공통된 작업(여기서는 로깅)을 별도의 코드로 분리하여 중복을 제거하고, 코드의 유지보수성을 높일 수 있습니다.

PSA란 무엇인가?

Portable Service Abstraction.
(얘는 제어의 역전이나 의존성 주입 같은 번역투로 안 불리네)
PSA는!
복잡한 기술이나 라이브러리를 쉽게 사용할 수 있도록 감싸주는 일종의 "포장지"라고 생각하면 된다.
이 포장지 덕분에 프로그래머는 내부의 복잡한 부분을 신경 쓰지 않고도 기능을 쉽게 사용할 수 있다.
(이거 어디서 많이 들어 본 내용인데...)

예를 들어, 파일을 읽고 쓰는 작업이 있다고 해보자.
일반적인 자바 코드로 이를 구현하면 아래와 같을 것이다.

import java.io.*;

public class FileOperationsWithoutPSA {

    public void readFile(String filename) {
        try {
            FileReader fileReader = new FileReader(filename);
            BufferedReader bufferedReader = new BufferedReader(fileReader);
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                System.out.println(line);
            }
            bufferedReader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void writeFile(String filename, String content) {
        try {
            FileWriter fileWriter = new FileWriter(filename);
            BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
            bufferedWriter.write(content);
            bufferedWriter.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

이 코드는 파일을 읽고 쓰기 위해 FileReader, BufferedReader, FileWriter, BufferedWriter 등을 직접 다룬다.
정말 raw하다.

PSA를 쓰면 이런 복잡한 부분을 감출 수 있다.
예를 들어, 스프링의 Resource 인터페이스를 사용한다면 아래처럼 작성할 수 있다.

import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;

public class FileOperationsWithPSA {

    private final ResourceLoader resourceLoader;

    public FileOperationsWithPSA(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    public void readFile(String filename) {
        try {
            Resource resource = resourceLoader.getResource("file:" + filename);
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(resource.getInputStream()));
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

이렇게 하면, 어떤 식으로 파일을 읽을지, 어떤 라이브러리를 사용할지 등의 세부 사항을 신경 쓸 필요가 없다.
ResourceLoader가 이러한 복잡한 부분을 대신 처리해준다.

코드가 별 차이가 없는 것 같다고?

PSA의 주요 장점은 유연성과 확장성이다.
예를 들어, 만약 나중에 파일을 읽는 방법을 바꾸거나 다른 저장소(예: 웹, 데이터베이스 등)에서 데이터를 가져오고 싶다면, PSA를 사용한 경우에는 ResourceLoader만 변경하면 된다.
그러나 PSA를 사용하지 않은 경우에는 FileOperationsWithoutPSA 클래스의 구현 자체를 수정해야 한다.

또한, 스프링에서는 ResourceLoader 외에도 다양한 추상화를 제공하여 다양한 환경에서도 동일한 코드를 유지할 수 있다.

그래서 PSA를 사용하면, 우리가 어떤 실제 구현을 사용하고 있는지에 대해 덜 신경 쓰고 비즈니스 로직에 집중할 수 있다는 것.
이것이 PSA의 주된 이점이다.

위 코드에서는 바로 느껴지지 않는 '그 장점'을 한 번 구현해 보자.

PSA를 사용하지 않는 경우:

public class FileOperationsWithoutPSA {
    public void readFile() {
        // 여기에는 파일을 읽어 들이는 코드가 들어갑니다.
        System.out.println("Reading file using FileOperationsWithoutPSA");
    }
}

public class MainWithoutPSA {
    public static void main(String[] args) {
        FileOperationsWithoutPSA fileOps = new FileOperationsWithoutPSA();
        fileOps.readFile();
    }
}

지금은 파일에서 데이터를 읽어 오는 코드가 구현되어 있다고 치자.
나중에 웹에서 데이터를 읽어오고 싶다면, FileOperationsWithoutPSA 클래스를 바꿔야 한다.

PSA를 사용하는 경우:

public interface DataReader {
    void readData();
}

public class FileDataReader implements DataReader {
    public void readData() {
        System.out.println("Reading data from a file.");
    }
}

public class WebDataReader implements DataReader {
    public void readData() {
        System.out.println("Reading data from the web.");
    }
}

public class MainWithPSA {
    public static void main(String[] args) {
        DataReader reader = new FileDataReader();  // PSA를 사용하기 때문에 이 부분만 변경하면 나머지 코드는 그대로 사용 가능
        reader.readData();
        
        reader = new WebDataReader();  // 웹에서 데이터를 읽고 싶다면 이 부분만 바꾸면 됩니다.
        reader.readData();
    }
}

PSA를 사용하는 경우에는, 나중에 데이터를 어디서 읽을지 변경하고 싶다면 DataReader의 구현만 바꾸면 된다(FileDataReader 또는 WebDataReader).

와, 2장 내용 중 일부만 작성했음에도 불구하고
진짜 힘들었다.
2~3장을 같이 진행할 수 있을 줄 알았는데 3장은 곧 작성하겠습니다!

이 글은 골든래빗 《스프링 부트 3 백엔드 개발자 되기 - 자바 편》의 2장 써머리입니다.

profile
여기는 일상 블로그, 기술 블로그는 https://yourhealingcoder.tistory.com/

0개의 댓글