728x90
반응형
SMALL

이번 포스팅에서 해 볼 내용은 라이브러리를 직접 만들고 만든 라이브러리를 스프링 부트 프로젝트에 적용하는데 자동 구성으로 적용해보는 방법을 작성해 보고자 한다. 왜 라이브러리를 직접 만들어보냐면, 이 라이브러리를 직접 만들고 제공하는 제공자 입장이 되어야 왜 스프링 부트의 자동구성이 필요하고 얼마나 효율적인지를 알게 되기 때문이다.

 

가정을 해보자:

스프링 부트를 사용하는 프로젝트를 진행중인 회사에서 누군가가 라이브러리를 만들었는데 그 라이브러리가 너무 좋아서 이팀 저팀에서 모두 그 라이브러리를 가져다가 사용하고 싶다는 요청이 들어왔다고 가정해보자. 

 

우선 라이브러리를 간단하게 만들어보자. 이 라이브러리는 현재 시스템의 메모리 상태를 알려주는 라이브러리다.

나만의 라이브러리 만들기

build.gradle

plugins {
    id 'java'
}

group = 'memory'
sourceCompatibility = '18'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.0.2'
    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:3.0.2'
}

test {
    useJUnitPlatform()
}

 

Memory

package memory;

public class Memory {
    private long used;
    private long max;

    public Memory(long used, long max) {
        this.used = used;
        this.max = max;
    }

    public long getUsed() {
        return used;
    }

    public long getMax() {
        return max;
    }

    @Override
    public String toString() {
        return "Memory{" +
                "used=" + used +
                ", max=" + max +
                '}';
    }
}

 

MemoryFinder

package memory;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MemoryFinder {

    public Memory get() {
        long max = Runtime.getRuntime().maxMemory();
        long total = Runtime.getRuntime().totalMemory();
        long free = Runtime.getRuntime().freeMemory();
        long used = total - free;

        return new Memory(used, max);
    }

    @PostConstruct
    public void init() {
        log.info("MemoryFinder init");
    }
}

 

MemoryController

package memory;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class MemoryController {

    private final MemoryFinder memoryFinder;

    @GetMapping("/memory")
    public Memory system() {
        Memory memory = memoryFinder.get();
        log.info("System memory: {}", memory);

        return memory;
    }
}

 

이렇게 만들면, 스프링 부트를 사용하는 특정 프로젝트에서 "/memory"로 접속하면 현재 시스템의 메모리 상태를 알려주는 그런 기능이다. 기능 자체에 중점을 두지 말고 라이브러리를 직접 만들어서 외부 프로젝트에서 가져다가 사용하는 것에 중점을 두자.

 

이제 이렇게 다 만들었으면 빌드를 해야한다. 빌드하는 법은 간단하다.

./gradlew clean build

커맨드로 빌드를 해도 되고, 인텔리제이를 사용하면 우측에 편의 기능이 있다.

build 버튼을 클릭하면 된다.

빌드를 하면 프로젝트 루트 경로에서 "build/libs" 해당 경로에 .jar 파일이 생긴다.

 

.jar 파일만 있으면 된다. 이제 다른 어떤 프로젝트에서 이 라이브러리를 사용한다고 가정해보자.

해당 프로젝트 루트 경로에 libs 라는 폴더를 하나 만든다. 그 폴더안에 저 .jar 파일을 넣으면 된다.

 

그 후에, build.gradle 파일에서 해당 jar 파일을 읽을 수 있게 해주면 끝난다.

다음 라인을 dependencies 안에 추가해주자.

implementation files('libs/memory-v1.jar')

 

build.gradle

plugins {
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'java'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation files('libs/memory-v1.jar') // 직접 만든 라이브러리를 다운로드 받는법
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

이러면 프로젝트에서 내가 만든 외부 라이브러리를 파일로 읽어들이는게 가능해진다. 이게 가능해졌다면 다음과 같이 .jar 파일의 내부 파일들도 보여야한다.

 

그러면 이 라이브러리가 제공하는 기능을 사용하기 위해 라이브러리가 제공해주는 컨트롤러를 빈으로 등록해야 한다. 왜냐하면 저 라이브러리는 "/memory" 라는 path에 요청을 해야 메모리 상태를 알려주지 않았던가?

 

그래서 빈을 등록한다.

MemoryConfig

package hello.config;

import memory.MemoryController;
import memory.MemoryFinder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MemoryConfig {
    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }
}

 

 

이렇게 컨트롤러를 등록하고 서버를 실행하면 다음과 같이 외부 라이브러리를 가져다가 사용할 수 있게 된 것이다.

근데, 너무 좋은데 이건 이 라이브러리가 어떤 것들을 빈으로 등록해야 하는지 우리가 알고 있기 때문이다. 무슨 말이냐면 이 라이브러리를 가져다가 사용하는 임의의 팀은 저렇게 빈을 등록해야 사용할 수 있다는 사실을 못 들었을 수도 있고 간과할 수도 있다. 그리고 불특정 다수의 팀들이 가져다가 사용한다면 그 팀이 10팀이면 10팀 모두 저런 빈을 등록해주는 귀찮은 과정이 필요하다는 사실이다. 그렇다면 라이브러리에서 자체적으로 자동 구성을 해준다면 얼마나 편할까? 이게 스프링 부트가 해주는 방식이고 이 방식을 그대로 적용해보자!

 

외부 라이브러리 자체에서 자동 구성 기능을 제공해주기

우선, 라이브러리의 소스를 수정하자. 먼저 @AutoConfiguration 애노테이션을 사용해서 필요한 빈을 등록해보자.

MemoryAutoConfig

package memory;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

@AutoConfiguration
@ConditionalOnProperty(name = "memory", havingValue = "on")
public class MemoryAutoConfig {

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }

    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }
}

그 다음, @ConditionalOnProperty 애노테이션을 사용해서, 환경변수로 들어오는 'memory'라는 키의 값이 'on'인 경우 이 자동 구성을 활성화한다. 

 

이렇게 한 다음에, 이 자동 구성을 사용할거라고 스프링 부트한테 알려줘야 한다. 그 방법은 다음 경로로 파일 하나를 추가한다.

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

경로가 상당히 중요하다. 스프링 부트가 띄워질 때 이 파일을 최초에 찾아보고 있으면 해당 파일에 적용된 자동 구성이 필요한 것들을 찾아가기 때문에. 해당 파일에 다음 한 줄을 추가해주자. 

pathyourpackage.MemoryAutoConfig

패키지명 + 클래스명으로 자동 구성 파일을 적어준 후, 이 상태에서 빌드를 해서 만든 .jar 파일이 있으면 된다.

이제 .jar 파일을 만들기 위해 빌드를 하자.

 

이 다음에 이 새롭게 만든 .jar 파일을 프로젝트에 새롭게 적용해주자.

위에서 했던대로 libs 폴더에 .jar 파일을 넣어주고 build.gradle 파일에서 이 파일을 읽을 수 있게 해주면 된다.

이렇게 자동 구성을 하게 설정했으면 굳이 빈으로 직접 등록해줄 필요가 없다. 그래서 MemoryConfig 파일은 삭제해버리자.

 

 

그런 다음에 지금 당장 하면 될까? 안된다. 왜냐하면 자동 구성을 하려면 조건을 추가했다. 환경변수로 memory=on 이라는 키/밸류쌍을 가질 경우에만 자동 구성이 등록되도록 말이다. 그래서 JVM 옵션에 이것을 추가해 준 다음에 다시 실행해보면 잘 될것이다.

이렇게 설정하고 실행하면 로그에 다음과 같이 메모리 파인더의 init()이 실행된다.

 

"/memory"로 가보면 다음과 같이 잘 나온다.

 

스프링 부트의 자동 구성을 좀 더 이해해보기

신기한 점은 아래 경로의 파일이다.

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

이 파일에 자동 구성하고자 하는 패키지 + 클래스 명을 작성하면 스프링 부트가 알아서 자동 구성을 해주는게 너무 신기하지 않은가?

이 파일은 스프링 부트가 제공하는 autoconfigure 라이브러리에도 있다.

그리고 실제로 이 파일안에는 무수히 많은 스프링 부트가 해주는 자동 구성 대상이 등록되어 있다.

그럼 스프링 부트는 어떻게 이 자동 구성을 다 읽어올 수 있을까?

 

비밀은 이 애노테이션이다.

@SpringBootApplication - @EnableAutoConfiguration - @Import({AutoConfigurationImportSelector.class})

스프링 부트 프로젝트의 시작 클래스의 붙어있는 애노테이션인 @SpringBootApplication 이 안에 들어가보면 @EnableAutoConfiguration이 애노테이션이 있다. 이름 그대로 자동 구성을 활성화하겠다는 애노테이션이다. 그리고 또 이 안에 들어가보면 @Import({AutoConfigurationImportSelector.class})이 애노테이션이 있는데, 이게 중요하다.

 

@Import로 설정 정보를 추가하는 방법은 두가지가 있다.

  • 정적인 방법: @Import({A.class, B.class})처럼, 정적으로 코드에 대상이 딱 박혀있는 경우가 있다. 이 경우 사용할 대상을 동적으로 변경할 수 없다. 딱 저 코드 그대로 A클래스와 B클래스를 추가하는 것
  • 동적인 방법: @Import(ImportSelector) 코드로 프로그래밍해서 설정으로 사용할 대상을 동적으로 선택할 수 있다.

그러니까, 다음 코드를 보자.

@Configuration
@Import({AConfig.class, BConfig.class})
public class AppConfig {...}

이런 경우처럼 딱 정해진 게 아니라, 특정 조건에 따라서 설정 정보를 선택해야 하는 경우에는 어떻게 해야할까?

동적인 방법인 `ImportSelector` 인터페이스를 사용하면 된다.

 

ImportSelector 라는 인터페이스는 스프링 부트가 제공해주는 동적으로 설정 정보를 추가하고 빼고 할 수 있게 해주는데 이 인터페이스를 직접 구현해보자.

 

HelloImportSelector

package hello.selector;

import org.springframework.context.annotation.ImportSelector;
import org.springframework.core.type.AnnotationMetadata;

public class HelloImportSelector implements ImportSelector {
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[]{"hello.selector.HelloConfig"};
    }
}

이 인터페이스는 selectImports()를 구현해야 한다. 그리고 이 메서드가 반환하는 문자열 배열안에 들어가 있는 건 클래스의 정보나, 그 정보들이 들어있는 파일명이 된다. (파일명이라고 하니까 위에서 만든 긴 파일명을 가진 파일이 갑자기 떠오른다.)

 

저 `hello.selector.HelloConfig`는 다음과 같이 생겼다.

package hello.selector;

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

@Configuration
public class HelloConfig {

    @Bean
    public HelloBean helloBean() {
        return new HelloBean();
    }
}

그냥 단순히 빈을 등록한 설정 클래스이다. 

 

그럼 이제 이렇게 빈을 등록하는 설정 클래스를 ImportSelector가 반환하게끔 설정한 후 저 ImportSelector를 어떻게 사용하면 되냐?

package hello.selector;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;

public class ImportSelectorTest {

    @Test
    void selectorConfig() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SelectorConfig.class);
        HelloBean bean = ac.getBean(HelloBean.class);
        assertThat(bean).isNotNull();
    }

    @Configuration
    @Import(HelloImportSelector.class)
    public static class SelectorConfig {

    }
}

@Import()안에 ImportSelector를 구현한 구현체를 넣어주면 된다. 그러면? 스프링 부트는 저 ImportSelectorselectImports()를 실행해서 얻게되는 결과값을 동적으로 자동 구성할 수 있게 해준다.

 

그리고, 실제로 스프링 부트는 

@SpringBootApplication - @EnableAutoConfiguration - @Import({AutoConfigurationImportSelector.class})

이 애노테이션에서 AutoConfigurationImportSelector라는 ImportSelector를 구현한 구현체를 통해 파일명이 

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

이러한 모든 파일들을 모든 라이브러리에서 다 찾아낸다. 이게 스프링 부트가 자동 구성을 해주는 방식이다.

 

결론

  • 라이브러리를 직접 만들어서 외부 프로젝트에서 사용하는 방법을 알아봤다.
  • 라이브러리를 사용할 때 자동 구성을 통해 build.gradle 파일에서 라이브러리를 다운만 받으면 아무것도 따로 해 줄 필요없이 손쉽게 라이브러리 기능을 사용하는 것도 알아봤다.
  • 스프링 부트에서 수많은 것들이 이런식으로 자동 구성을 통해 개발자대신 여러 설정을 해주고 있다는 것도 알아봤다.

 

728x90
반응형
LIST

'Spring, Apache, Java' 카테고리의 다른 글

외부 설정과 프로필 관리 Part.2  (0) 2024.06.14
외부 설정과 프로필 관리 Part.1  (0) 2024.06.14
Spring Boot를 사용하는 이유  (0) 2024.05.30
Bean Scope  (0) 2024.05.27
Bean LifeCycle Callback  (0) 2024.05.27
728x90
반응형
SMALL

가장 중요한 건 Spring BootSpring Framework와 다른게 아니다. 그저 Spring Framework를 편리하게 사용할 수 있게 기본셋과 자동화 된 작업들이 매우 많이 있을 뿐이다. 

 

Spring Boot가 어떤걸 대신 해줄까? 대표적인 것들은 다음과 같다.

 

  • 톰캣 내장 서버
  • 최적의 라이브러리 버전 관리
    • 손쉬운 빌드 구성을 위한 스타터 종속성 제공
    • 스프링과 외부 라이브러리의 버전을 자동으로 관리 (예를 들어, 스프링 부트 3.0은 A라는 외부 라이브러리의 이 버전과 잘 어울려요! 하고 알아서 맞춰 준다)
  • 자동 구성: 프로젝트 시작에 필요한 스프링과 외부 라이브러리의 빈을 자동 등록해준다.
  • 프로덕션 준비: 모니터링을 위한 메트릭, 상태 확인 기능 제공

정말 여러모로 개발자 대신 많은 것들을 해준다. 그리고 이제 스프링 부트가 없는 전으로는 돌아갈수도 없다. 즉, 게임체인져라는 소리다.

그럼 진짜 스프링 부트가 없던 시절 스프링 프레임워크로 개발하고 서버에 띄우려면 어떻게 해야했을까? 이 과정을 스프링부트가 없던 시절로 돌아가서 직접 해보고 아 이런 불편함을 해결해 주는구나를 직접 느껴보자.

 

저걸 다 해볼 필요는 없고 톰캣을 직접 설치하고, 직접 실행하고, WAR 파일을 직접 배포해서 서버를 띄우는 작업을 해보자.

 

톰캣 설치

WAS의 대표적인 톰캣을 직접 PC에 설치해보자.

공식 사이트

 

Apache Tomcat® - Welcome!

The Apache Tomcat® software is an open source implementation of the Jakarta Servlet, Jakarta Pages, Jakarta Expression Language, Jakarta WebSocket, Jakarta Annotations and Jakarta Authentication specifications. These specifications are part of the Jakarta

tomcat.apache.org

들어가면 좌측에 다운로드 섹션이 있다. 거기에 가장 최신 버전으로 설치를 해보자. (현재는 10이 가장 최신 버전이고 11은 아직 Alpha 버전)

 

.zip 파일을 내려받고 압축을 풀면된다. 풀면 다음 스텝을 거쳐야한다.

1. 톰캣설치폴더/bin 이동

2. chmod 755 *

3. ./startup.sh (톰캣 실행) (중지는 ./shutdown.sh)

 

그럼 톰캣이 실행되어야 정상이다. 그리고 웹 브라우저에 다음 경로를 입력해본다. http://localhost:8080

 

정상적으로 톰캣 화면이 보여지면 잘 띄워진 것. 만약, 제대로 띄워지지 않은 경우엔 로그 파일을 확인해봐야 한다.

로그 경로: 톰캣설치폴더/logs/catalina.out

 

설치가 끝났으니까 톰캣 서버에 띄울 우리만의 웹 서버를 만들어야 한다. 한번 만들어보고 직접 설치한 톰캣위에 올려보자.

 

프로젝트 설정

build.gradle

plugins {
    id 'java'
    id 'war'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.10.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'

    implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
}

test {
    useJUnitPlatform()
}
  • 아무것도 없는 상태로 프로젝트를 생성한 다음에 build.gradle 파일만을 설정한다.
  • plugins에 id 'war'를 넣어줘야 이 프로젝트를 빌드할 때 'war' 파일로 만들어준다.
  • 스프링 없이 서블릿을 사용해서 간단하게 웹 서버를 만들어 볼 것이기 때문에 implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' 이 부분을 추가해준다.

 

TestServlet

package org.example.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

/**
 * <a href="http://localhost:8080/test">http://localhost:8080/test</a>
 * */
@WebServlet(urlPatterns = "/test")
public class TestServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("TestServlet.service");

        resp.getWriter().println("TestServlet.service");
    }
}
  • 기본 패키지 경로 안에 servlet이라는 패키지를 하나 만들고 TestServlet 클래스를 만든다.
  • 이 클래스는 서블릿이다. 그래서 HttpServlet을 상속받고 간단하게 로그를 출력하고 응답으로 "TestServlet.service"를 찍어주고 끝난다.
  • 그래서 이 서블릿의 경로는 http://localhost:8080/test 이다.

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
index.html
</body>
</html>
  • java, resources 와 같은 레벨에 webapp이라는 폴더를 만들고 그 폴더안에 이 index.html 파일을 추가한다.
  • 이 파일은 웰컴 페이지가 된다.

 

이렇게 만들고 나면 이제 테스트 해보기 위해 이 프로젝트를 빌드한다. 터미널에 이 프로젝트 루트 경로에 가서 다음 명령어를 입력한다.

./gradlew build

 

정상적으로 수행이 되면, 프로젝트 루트 경로에 build 라는 폴더가 하나 생긴다. 그 안에 libs 폴더로 들어가면 .war 파일이 보일것이다.

그 파일 한번 압축을 풀어보자. 어떻게 보이는지 한번 보고 가면 좀 이해가 잘 된다.

jar -xvf yourwarfile.war

 

압축을 풀면 다음과 같이 보여질 것이다.

  • index.html: webapp 폴더에 만든 index.html 파일이다.
  • META-INF: html, css과 같은 정적 파일들이 포함된다.
  • WEB-INF: 자바 파일을 컴파일 한 .class파일들이 있고 사용하는 외부 라이브러리(.jar)파일이 보관된다.

이게 .war 파일이 생겨먹은 모습이다. 이것을 톰캣 서버에 배포하면 이제 웹 서버를 띄울 수 있는것이다.

한번 톰캣에 배포해보자.

 

1. 우선 톰캣설치폴더/webapps 경로로 가야한다.

2. 기본으로 들어있는 것들을 다 삭제한다.

3. 이 경로에 위 .war 파일을 복사한다.

4. 톰캣 서버를 내렸다가 다시 띄운다.

5. http://localhost:8080 으로 접속해본다.

 

그럼 다음과 같이 우리가 만든 웰컴 페이지가 보여진다.

서블릿도 호출해보자.

 

그리고 서블릿을 호출할 때 찍었던 로그를 catalina.out 파일에서 확인해보자.

 

 

그리고 만약 로컬에서 작업하고 다시 톰캣에 배포하고 이 반복 작업이 너무 귀찮으니까 IntelliJ에서 이 작업을 대신 해주는데 어떻게 하는지 알아보자. (유료 버전이니까 유료 버전이 아니라면 패쓰!)

 

Run > Edit Configurations

 

좌측 상단 + 버튼 클릭

 

'tomcat'이라고 검색하면 Tomcat Server 아래 Local 클릭

 

하단 Application server를 설정해주면 되는데, 톰캣 서버가 깔린 루트 경로를 지정해주면 된다.

 

Deployment 탭 > + 버튼 > Artifact 

 

둘 중 아무거나 선택해도 되는데 (exploded)로 선택

 

Application context는 꼭 아무것도 없는걸로 비워야 한다. 

 

이렇게 설정하고 Apply > Run

 

서버가 잘 띄워진다.

중간 결론

이렇게 해서 톰캣을 설치하고 만든 프로젝트를 war로 패키징해서 배포하고 웹 서버를 띄울 수 있다. 상당히 번거로운 이 작업을 스프링 부트가 대신 해줬던 것이다. 그러니까 스프링 부트 없던 시절로는 돌아갈 수가 없는것이다. 

 

이 뿐이 아니라 버전 관리를 용이하게 해주고, 효율적인 모니터링 제공이나 개발자 대신 해주는 빈 등록과 같은 여러 작업들이 스프링 부트를 사용할 수 밖에 없게 한다. 

 

자동구성

자동 구성이란, 스프링 부트가 개발자 대신 이것저것 여러가지를 빈으로 자동 등록해주는데, 이 덕분에 개발 생산성은 극대화되고 안정성도 높아진다. 가장 대표적으로 스프링 부트가 자동 구성을 해주는 빈은 `DataSource`인데 데이터베이스와 연동하기 위해서 반드시 필요한 정보들을 가지고 있는 클래스이다. 그래서 `DataSourceAutoConfiguration`이라는 클래스를 찾아보면 다음과 같이 생겨먹었다.

사진을 보면, 가장 상단에 @AutoConfiguration 애노테이션이 보인다. 이게 자동으로 빈으로 등록하겠다는 애노테이션이라고 보면 된다.

저 하나하나의 애노테이션이 지금 당장은 중요하지 않지만, 이렇듯 스프링 부트가 대신 빈으로 등록해준다는 것을 이해하고 넘어가면 된다.

 

참고로, 저 @AutoConfiguration의 `before = {SqlInitializationAutoConfiguration.class}`는 자동구성을 하려면 SqlInitializationAutoConfiguration 이라는 클래스가 그 전에 먼저 자동 구성이 되어야 한다는 사전 조건이라고 생각하면 된다.

 

그리고 @ConditionalOnClass 애노테이션은 조건이다. 간단하게 설명해서 저 애노테이션에 들어간 클래스들이 등록이 되어 있어야만 자동 구성으로 구성이 될 것을 허락하는 애노테이션. 이건 직접 해보면 더 좋다.

 

예를 들어, 현재 시스템의 메모리 상태를 알려주는 클래스가 있다고 가정해보자. 

Memory

package memory;

public class Memory {
    private long used;
    private long max;

    public Memory(long used, long max) {
        this.used = used;
        this.max = max;
    }

    public long getUsed() {
        return used;
    }

    public long getMax() {
        return max;
    }

    @Override
    public String toString() {
        return "Memory{" +
                "used=" + used +
                ", max=" + max +
                '}';
    }
}

 

MemoryFinder

package memory;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MemoryFinder {

    public Memory get() {
        long max = Runtime.getRuntime().maxMemory();
        long total = Runtime.getRuntime().totalMemory();
        long free = Runtime.getRuntime().freeMemory();
        long used = total - free;

        return new Memory(used, max);
    }

    @PostConstruct
    public void init() {
        log.info("MemoryFinder init");
    }
}

 

MemoryController

package memory;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class MemoryController {

    private final MemoryFinder memoryFinder;

    @GetMapping("/memory")
    public Memory system() {
        Memory memory = memoryFinder.get();
        log.info("System memory: {}", memory);

        return memory;
    }
}

이렇게 컨트롤러를 통해 `/memory` 로 들어가면 메모리를 상태를 알려주는 편의 기능을 만들었다고 생각해보고, 이 기능은 직접 만든게 아니라 외부 라이브러리라고 가정해보자. (그래서 일부러 패키지도 스프링 부트 메인이 있는 패키지가 아니라 다른 패키지로 만들었다.)

 

MemoryConfig

package hello.config;

import memory.MemoryCondition;
import memory.MemoryController;
import memory.MemoryFinder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MemoryConfig {

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }

    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }
}

패키지가 스프링 부트의 메인 클래스 밖에 있으므로 이 메모리 관련 기능을 빈으로 등록하자.

 

메모리 상태가 잘 보인다. 근데, 이 메모리 정보를 운영 서버와 개발 서버를 분리했을 때 개발 서버에서만 동작하도록 설정하고 싶을 수 있다. 아니면 이런 특정 조건일 땐 보여주고 특정 조건이 아닐 땐 보여주지 않고 싶은 이런 경우, 이럴때 @Conditional 애노테이션을 사용해서 빈으로 등록할지 아닐지를 결정할 수 있다. (@ConditionalOnClass 애노테이션이 곧 @Conditional 이라고 생각하면 된다)

 

우선 그러려면, 스프링 부트가 제공해주는 Condition 이라는 인터페이스를 구현하는 클래스를 만들어야 한다.

MemoryCondition

package memory;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;

@Slf4j
public class MemoryCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        // Jar 실행 중 옵션을 다음과 같이 줄 때: -Dmemory=on
        String memory = context.getEnvironment().getProperty("memory");
        log.info("memory = {}", memory);
        return "on".equals(memory);
    }
}

Condition이라는 인터페이스는 matches()를 구현해야 하는데, 이 메서드가 참인 경우 @Conditional 애노테이션이 빈으로 등록하게 허락해주고, 거짓인 경우 @Conditional 애노테이션이 빈으로 등록하지 못하게 막아준다. 그럼 이 클래스를 어디서 사용하면 될까? 그렇다. 아까 빈으로 등록하기 위해 만들었던 MemoryConfig에 붙여주면 된다.

 

MemoryConfig

package hello.config;

import memory.MemoryCondition;
import memory.MemoryController;
import memory.MemoryFinder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;

@Configuration
@Conditional(MemoryCondition.class) // 여기!
public class MemoryConfig {

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }

    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }
}

이렇게 하면, MemoryCondition 이라는 클래스가 가진 matches()가 참 또는 거짓인 경우에 따라 이 MemoryConfig가 스프링 부트에 의해 구성이 될지 아닐지를 결정하게 된다. 어디서 많이 본 모양이다. 그렇다. 스프링 부트가 자동 구성을 해주는 DataSourceAutoConfiguration도 아까 이렇게 비슷하게 생겼었다. 

 

실제로 그런지 확인해보자.

 

JVM 옵션을 주지 않았을 때

JVM 옵션을 줬을 때

 

이렇게 스프링 부트는 자동 구성할 때 조건을 통해 자동 구성 빈이 등록될지 아닐지도 알아서 해준다. 어쩔때 그럴까? 개발자가 직접 빈을 등록할때가 그렇다. 그게 아니더라도 이 @Conditional은 빈을 등록할 때 유용하게 사용할 수 있어보이니 잘 기억해두자.

 

근데! 이거보다 훨씬 더 간단하게 스프링 부트가 제공해주는 것이 있다. 

@ConditionalOnProperty(name = "memory", havingValue = "on")

이렇게 @ConditionalOnProperty 애노테이션으로 똑같이 JVM 옵션이 다음 조건과 같으면 이 구성정보를 등록하겠다는 애노테이션.

 

MemoryConfig

package hello.config;

import memory.MemoryCondition;
import memory.MemoryController;
import memory.MemoryFinder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;

@Configuration
// @Conditional(MemoryCondition.class)
@ConditionalOnProperty(name = "memory", havingValue = "on")
public class MemoryConfig {

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }

    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }
}
728x90
반응형
LIST
728x90
반응형
SMALL

빈 스코프란?

"스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때 까지 유지된다"고 배웠다. 이것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문이다. 스코프는 말 그대로 빈이 존재할 수 있는 범위를 뜻한다.

 

근데 스프링은 싱글톤 스코프 외에 더 많은 범위를 제공한다.

  • 싱글톤: 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프이다.
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈때 까지 유지되는 스코프
    • session: 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
    • application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프

 

빈 스코프는 다음과 같이 지정할 수 있다.

 

컴포넌트 스캔 자동 등록 

@Scope("prototype")
@Component
public class HelloBean {}

 

수동 등록

@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
    return new HelloBean();
}

 

 

조금 더 자세히 프로토타입 스코프와 싱글톤 스코프의 차이를 알아보자.

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다. 반면에 프로토타입 스코프를 스프링 컨테이너에서 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.

 

싱글톤 빈 요청

 

 

프로토타입 빈 요청

1. 클라이언트는 프로토타입 스코프 빈을 스프링 컨테이너에 요청한다.

2. 스프링 컨테이너는 이 시점에 프로토타입 빈을 생성하고 의존관계 주입도 해준다.

3. 스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환한다.

4. 이후에 스프링 컨테이너는 더이상 그 빈을 관리하지 않는다. 같은 요청이 오더라도 항상 새로운 프로토타입 빈을 생성해서 반환한다.

 

그래서, 핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입을 하고, 초기화까지만 처리한다는 것이다. 클라이언트에게 반환한 후 스프링 컨테이너는 생성된 프로토타입 빈을 관리하지 않는다. 프로토타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에게 있다. 그래서 @PreDestroy 같은 소멸 메서드는 호출되지 않는다.

 

 

테스트 해보자!

 

PrototypeTest

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class PrototypeTest {

    @Test
    void prototypeBeanFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);

        System.out.println("find prototypeBean1");
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);

        System.out.println("find prototypeBean2");
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);

        System.out.println("prototypeBean1 = " + prototypeBean1);
        System.out.println("prototypeBean2 = " + prototypeBean2);

        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);

        ac.close();
    }

    @Scope("prototype")
    static class PrototypeBean {
        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }

        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

실행결과

find prototypeBean1
SingletonBean.init
find prototypeBean2
SingletonBean.init
prototypeBean1 = org.example.springcore.scope.PrototypeTest$PrototypeBean@700fb871
prototypeBean2 = org.example.springcore.scope.PrototypeTest$PrototypeBean@3b35a229

 

실행결과를 보면 초기화 메서드까지는 스프링 컨테이너가 해준다는 것을 알 수 있다. 초기화 뿐 아니라 의존관계 주입도 역시 해준다. 근데 넘겨받은 프로토타입 빈의 참조값을 보면 두개가 다르다. 즉, 요청이 올때마다 새로운 빈을 생성한다는 것을 확인할 수 있다. 그리고 더 중요한 건 @PreDestroy가 호출되지 않는다. 왜냐? 생성하고 돌려준 뒤 더는 관리하지 않으니까 빈 생명주기에 소멸 생명주기는 없는것이다. 

 

 

근데, 프로토타입 빈과 싱글톤 빈을 같이 사용할 땐 어떤 문제가 생긴다. 무슨 문제인지 알아보자.

 

프로토타입 빈과 싱글톤 빈을 같이 사용할 때 생기는 문제점

일단 프로토타입 빈의 존재 의미를 먼저 생각해보자. 프로토타입 빈은 클라이언트가 요청할 때 마다 새로 생성되는 것이 바로 프로토타입 빈의 생성의 의미다. 이 점을 잘 유의하고 다음 내용을 보자.

 

만약, 싱글톤 빈에 의존관계 주입이 필요한데 그 의존관계가 프로토타입인 경우엔 어떻게 동작할까?

우선 프로토타입 빈은 count라는 필드를 가지고, 그 값을 하나씩 증가시키는 addCount()가 있다.

그리고 싱글톤 빈은 프로토타입 빈을 주입받는다. 

 

그럼 최초의 클라이언트가 이 싱글톤 빈을 요청해서 스프링 컨테이너로부터 빈을 돌려받으면 그때 싱글톤 빈은 의존관계 주입이 모두 끝난 상태로 클라이언트에게 전달된다. 그리고 이 빈은 싱글톤 빈이라 스프링 컨테이너가 닫히는 순간까지 스프링 컨테이너에 의해 관리된다.

 

그래서 클라이언트는 이 싱글톤 빈이 가진 logic()을 호출하고 그 메서드는 프로토타입 빈의 addCount()를 호출한 후 적용된 값을 반환하는 간단한 메서드이다. 그럼 최초의 count 값은 0이니까 1로 증가된다.

 

그리고 나서, 또 다른 클라이언트가 이 싱글톤 빈을 스프링한테 요청했다. 여기가 중요하다! 그럼 싱글톤 빈은 스프링 컨테이너에 의해 관리되는 빈이기 때문에 이전에 만들어진 빈 그대로를 다시 반환한다.

 

그럼, 이 또 다른 클라이언트의 의도는 프로토타입 빈이라는 요청할때마다 따끈따끈한 새로운 빈을 받길 원하는데 이미 count값이 1인 상태인 빈을 받는것이다. 왜냐? 싱글톤 빈이 이미 기존에 의존관계가 다 주입이 된 상태고 그 상태 그대로로 스프링 컨테이너에 의해 관리되는 중이기 때문이다.

 

 

그래서 코드로 직접 이 상황을 만들어보자.

 

SingletonWithPrototypeTest1

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class SingletonWithPrototypeTest1 {

    @Test
    void prototypeFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
        prototypeBean.addCount();

        assertThat(prototypeBean.getCount()).isEqualTo(1);

        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean2.addCount();
        assertThat(prototypeBean.getCount()).isEqualTo(1);
    }

    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac =
                new AnnotationConfigApplicationContext(PrototypeBean.class, SingletonBean.class);

        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);

        int count1 = singletonBean1.logic();
        assertThat(count1).isEqualTo(1);

        int count2 = singletonBean2.logic();
        assertThat(count2).isEqualTo(2);
    }

    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;

        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy " + this);
        }
    }

    @Scope("singleton")
    static class SingletonBean {
        private final PrototypeBean prototypeBean;

        public SingletonBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }
}

 

1. 싱글톤 빈과 프로토타입 빈 두개가 있다.

2. 싱글톤 빈은 프로토타입 빈을 주입받는다.

3. 싱글톤 빈의 logic()은 프로토타입 빈의 addCount()를 호출하고 프로토타입 빈의 count 값을 반환한다.

4. singletonClientUsePrototype() 테스트를 실행하면 첫번째로 싱글톤 빈을 요청한 순간에 싱글톤 빈이 필요한 모든 의존관계가 주입이 된 상태로 스프링 컨테이너에 유지된다.

5. 두번째로 요청한 싱글톤 빈은 이미 첫번째에서 요청된 싱글톤 빈과 100% 같은 객체이다.

6. logic()을 실행하면 count 값은 공유된다.

 

이런 문제가 발생한다. 프로토타입 빈의 존재 의미가 없어져버렸다. 이 문제를 해결할 수 있는 방법은 뭐가 있을까?

Provider로 프로토타입 빈을 꺼내오기

스프링 컨테이너에 등록된 빈이 필요할 때 가져오는 방법 중 `Privider`를 이용하는 방법이 있다.

이 `Provider`를 이용하는 건 의존관계 주입(DI)이 아니라 필요한 의존관계를 직접 찾는 DL(Dependency Lookup)방식이다.

그래서 다음과 같이 간단하게 사용할 수 있다.

 

SingletonWithPrototypeTest1

package org.example.springcore.scope;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import static org.assertj.core.api.Assertions.*;

public class SingletonWithPrototypeTest1 {

    ...

    @Scope("singleton")
    static class SingletonBean {

        private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

        public SingletonBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
            this.prototypeBeanProvider = prototypeBeanProvider;
        }

        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }
}

스프링에서 제공하는 ObjectProvider를 사용하고 내가 사용하고자 하는 의존관계를 직접 제네릭 타입에 넣어주면 된다.

그리고 생성자 주입을 통해 이 ObjectProvider를 주입받으면 필요한 곳에서 getObject()를 호출해서 꺼내올 수 있다.

 

getObject()를 호출하면 스프링 컨테이너에 빈을 달라고 요청하는 작업이랑 같다고 생각하면 된다. 그럼 스프링 컨테이너는 프로토타입 빈이기 때문에 그때 그때 생성을 한다.

 

참고로 이 스프링이 제공하는 ObjectProvider는 프로토타입 빈 뿐 아니라 그냥 DL이 필요한 경우엔 언제든지 사용할 수 있다.

 

그럼 이런일이 빈번하게 일어날까?

애시당초에 프로토타입 빈을 사용할 일 자체가 매우 드물다. 그리고 만약 싱글톤 빈 안에 프로토타입 빈을 사용할 일이 있다면 그냥 위 방법처럼 ObjectProvider를 사용해서 프로토타입 빈 그 자체의 의미를 살려주면 된다.

 

 

웹 스코프

웹 스코프는 웹 환경에서만 동작하는 빈이다. 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다. 따라서 종료 메서드가 호출된다.

 

웹 스코프의 종류

  • request: HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
  • session: HTTP Session과 동일한 생명주기를 가지는 스코프
  • application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
  • websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프

 

HTTP Request 요청 당 각각 할당되는 request 스코프

 

웹 환경 추가

웹 스코프를 테스트해보기 위해 웹 환경을 추가해보자.

 

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-web'

 

request 스코프 예제

동시에 여러 HTTP 요청이 오면 정확히 어떤 요청이 남긴 로그인지 구분하기 어려울 때 사용하기 딱 좋은게 바로 request 스코프이다.

다음과 같이 로그가 남도록 request 스코프를 활용해서 추가 기능을 개발해보자.

[d06b992f...] request scope bean create
[d06b992f...][http://localhost:8080/log-demo] controller test
[d06b992f...][http://localhost:8080/log-demo] service id = testId
[d06b992f...] request scope bean close
  • 기대하는 공통 포맷: [UUID][URL]message
  • UUID를 사용해서 HTTP 요청을 구분하자.

 

MyLogger

package org.example.springcore.common;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Setter;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
@Scope(value = "request")
public class MyLogger {
    private String uuid;
    @Setter
    private String requestURL;

    public void log(String message) {
        System.out.println("[" + uuid + "] " + "[" + requestURL + "] " + message);
    }

    @PostConstruct
    public void init() {
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean create: " + this);
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean close: " + this);
    }
}
  • 로그를 출력하기 위한 MyLogger 클래스
  • @Scope(value = "request")를 사용해서 request 스코프로 지정했다. 이제 이 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸된다.
  • 이 빈이 생성되는 시점에 자동으로 @PostConstruct 초기화 메서드를 사용해서 UUID를 생성해서 저장해둔다. 이 빈은 HTTP 요청당 하나씩 생성되므로, UUID를 저장해두면 다른 HTTP 요청과 구분할 수 있다.
  • 이 빈이 소멸되는 시점에 @PreDestroy를 사용해서 종료 메시지를 남긴다.
  • requestURL은 이 빈이 생성되는 시점에는 알 수 없으므로 외부에서 setter로 입력 받는다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final MyLogger myLogger;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");

        return "OK";
    }
}
  • 테스트를 위한 컨트롤러
  • HttpServletRequest를 통해서 요청 URL을 받는다.
  • requestURLmyLogger에 저장해둔다. myLogger는 HTTP 요청 당 각각 구분되므로 다른 HTTP 요청 때문에 값이 섞이는 걱정은 하지 않아도 된다.
참고로, requestURL을 MyLogger에 저장하는 부분은 컨트롤러 보다는 공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋다.

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service Id: " + id);
    }
}
  • 비즈니스 로직이 있는 서비스 계층에서도 로그를 출력한다.
  • 중요한 점은 request scope를 사용하지 않고 파라미터로 이 모든 정보를 서비스 계층에 넘긴다면, 파라미터가 많아서 지저분해진다. 더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 된다. 웹과 관련된 부분은 컨트롤러까지만 사용해야 한다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋다.
  • request scopeMyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, MyLogger의 멤버변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있다.

 

과연 잘 동작할까? 기대와 달리 에러가 발생한다.

Error creating bean with name 'myLogger': Scope 'request' is not active for the
current thread; consider defining a scoped proxy for this bean if you intend to
refer to it from a singleton;

어떤 에러냐면, 스프링 애플리케이션을 실행하는 시점에 싱글톤 빈은 생성해서 주입이 가능하지만, request 스코프 빈은 아직 생성되지 않는다. 이 빈은 실제 고객의 요청이 들어와야 생성되는 빈이다. 그러다보니 주입할 수 없는 빈을 싱글톤 빈이 가지고 있다고 에러를 내는 것이다.  

 

해결방법 1 - ObjectProvider

첫번째 간단한 해결방법은 DL이다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        MyLogger myLogger = myLoggerProvider.getObject();

        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");

        return "OK";
    }
}

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service Id: " + id);
    }
}

 

  • ObjectProvider 덕분에 ObjectProvider.getObject()를 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있다.
  • ObjectProvider.getObject()를 호출하는 시점에는 HTTP 요청이 진행중이므로 request scope 빈의 생성이 정상 처리된다.
  • ObjectProvider.getObject()LogDemoController, LogDemoService에서 각각 한번씩 따로 호출해도, 같은 HTTP 요청이기 때문에 동일한 스프링 빈이 반환된다.

그래서 실행 결과를 확인해보면 정상적으로 출력된다.

[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] request scope bean create: org.example.springcore.common.MyLogger@4cd4281b
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] [http://localhost:8080/log-demo] controller test
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] [http://localhost:8080/log-demo] service Id: testId
[39c7ec2c-0a1e-450e-bc19-bcb763f4fd6b] request scope bean close: org.example.springcore.common.MyLogger@4cd4281b

 

근데 Provider가 아니라 코드를 더 줄일 수 있는 해결 방법이 있다.

 

해결방법 1 - Proxy

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {
}
  • @Scope에서 proxyModeTARGET_CLASS로 지정한다. 
  • 이렇게 하면 MyLogger의 프록시 클래스를 만들어서 HTTP Request 요청이 들어올 때 생성할 수 있는게 아니라 미리 생성해둔 프록시를 주입시켜둘 수 있다.

LogDemoController

package org.example.springcore.web;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final MyLogger myLogger;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {

        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");

        return "OK";
    }
}

 

LogDemoService

package org.example.springcore.web;

import lombok.RequiredArgsConstructor;
import org.example.springcore.common.MyLogger;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogDemoService {

    private final MyLogger myLogger;

    public void logic(String id) {
        myLogger.log("service Id: " + id);
    }
}

 

실행결과

[93f232c4-f367-4cd9-a3f0-191cc1ef6290] request scope bean create: org.example.springcore.common.MyLogger@b04b639
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] [http://localhost:8080/log-demo] controller test
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] [http://localhost:8080/log-demo] service Id: testId
[93f232c4-f367-4cd9-a3f0-191cc1ef6290] request scope bean close: org.example.springcore.common.MyLogger@b04b639

 

이제 Provider 없이도 잘 동작한다. CGLIB는 이미 다뤘던 내용이니까 어떤 원리로 돌아가는지 이해할 수 있다.

중요한 건, AOP에 대한 이해다. 클라이언트 코드에 부가적인 관점(기능)을 추가하고 싶을 때 클라이언트 코드에 전혀 손을 대지 않아도 가능하다는 것.

728x90
반응형
LIST
728x90
반응형
SMALL

스프링 컨테이너에 등록하는 빈은 생명주기를 가진다. 생각해보자. 스프링이 나 대신 빈으로 등록하고 관리해주면 등록하는 과정이나 삭제하는 과정이 분명 있는게 어찌보면 당연하다.

 

그리고, 스프링 빈으로 등록하고 관리하는 과정중엔 초기화 하거나 삭제할때 꼭 해줘야하는 작업이 있을수도 있다.

예를 들면 데이터베이스를 사용할 때 스프링을 띄우면서 동시에 커넥션 풀에 커넥션들을 확보한다던지 스프링 서버를 내릴때 썼던 자원을 다시 반납하는 작업이라던지 이런 필수적으로 시작과 끝에 해줘야하는 작업들이 종종있다.

 

스프링에 내가 빈을 등록할 때 그런 작업이 필요하다면 어떻게 해야 적절하게 할 수 있을까? 스프링이 제공하는 대표적인 방법이 3가지 정도 있다.

  • InitializingBean, DisposableBean
  • 빈 등록 초기화, 소멸 메서드
  • @PostConstruct, @PreDestroy

하나씩 모두 알아보자.

 

InitializingBean, DisposableBean

우선, 약간의 가정이 필요하다. 어떤 애플리케이션에서 다른 네트워크로 연결해야 하는 빈이 있다고 가정하고 그 빈을 직접만들때 초기화와 소멸 작업을 해줘야 한다고 생각해보자. 그리고 그 클래스는 다음과 같다.

 

NetworkClient

package org.example.springcore.lifecycle;

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
        connect();
        call("초기화 연결 메시지");
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public void connect() {
        System.out.println("connect = " + url);
    }

    public void call(String message) {
        System.out.println("call = " + url + ", message = " + message);
    }

    public void disconnect() {
        System.out.println("close = " + url);
    }
}

 

코드를 보면, connect(), disconnect()가 각각 초기화 때 실행할 메서드 소멸 시 실행할 메서드이다.

만약 저렇게 생성자에 connect()를 넣으면 원하는대로 동작할까? 테스트 해보자.

 

BeanLifeCycleTest

package org.example.springcore.lifecycle;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

public class BeanLifeCycleTest {

    @Test
    void lifeCycleTest() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        NetworkClient client = ac.getBean(NetworkClient.class);

        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("https://hello-spring.dev");
            return networkClient;
        }
    }
}

우선, 저 NetworkClient를 빈으로 등록을 해야 한다. 빈으로 등록할 때 생성자로 인스턴스를 만들고 URL을 세팅한다. 그리고 반환하는데 스프링 컨테이너에서 빈을 꺼내올 때 어떤 결과를 도출하는지 보자. 

 

@Test 애노테이션이 붙은 테스트 코드를 보자.

  • AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
    • 우선 스프링 컨테이너를 가져온다. 
  • NetworkClient client = ac.getBean(NetworkClient.class);
    • 스프링 컨테이너를 가져와서 빈으로 등록한 NetworkClient 타입의 빈을 꺼낸다.
  • ac.close();
    • 그 후 스프링 컨테이너를 종료한다.

 

실행결과

생성자 호출, url = null
connect = null
call = null, message = 초기화 연결 메시지

 

URL이 제대로 설정되지 않았다. 왜냐하면 생성자에서 connect()를 호출하는데 생성자로 인스턴스를 만든 다음에 setUrl()을 실행했으니 당연한 결과다. 그럼 초기화를 안전하고 적절하게 하는 방법인 InitializingBean, DisposableBean를 사용해보자.

public class NetworkClient implements InitializingBean {
    ...
    
    @Override
    public void afterPropertiesSet() throws Exception {

    }
}

InitializingBean을 구현하면 구현해야 하는 메서드인 afterPropertiesSet()이 있다. 이 메서드는 빈으로 등록될 준비가 모두 끝난 상태에서 실행되는 메서드이다. 이 메서드에 위에서 사용했던 connect()를 넣으면 된다. 다른 말로 초기화할 때 필요한 작업을 이 메서드 안에서 하면 된다.

@Override
public void afterPropertiesSet() throws Exception {
    connect();
    call("초기화 연결 메시지");
}

그럼 빈으로 등록된 후 이 메서드는 자동으로 실행되게 된다. 다시 위 테스트 코드를 실행해보자.

 

실행결과

생성자 호출, url = null
connect = https://hello-spring.dev
call = https://hello-spring.dev, message = 초기화 연결 메시지

이번엔 생성자 호출 후 정상적으로 URL이 적용되어 있다. 아무것도 하지 않아도 빈 등록 후 실행되는 메서드를 실행하는 이 InitializingBean를 사용하면 초기화가 간단하게 진행된다. 그럼 이제 소멸 메서드도 사용해보자.

public class NetworkClient implements InitializingBean, DisposableBean {
    ...
    
    public void disconnect() {
        System.out.println("close = " + url);
    }
    
    @Override
    public void destroy() throws Exception {
        disconnect();
    }
}

이번엔 DisposableBean을 구현했다. 이 인터페이스는 destroy()를 가지고 있고 이게 스프링 컨테이너가 내려가기 전 실행되는 메서드라고 생각하면 된다. 즉, 빈이 소멸되기 바로 직전에 실행되는 메서드. 다시 위 테스트 코드를 실행해보자.

 

실행결과

생성자 호출, url = null
connect = https://hello-spring.dev
call = https://hello-spring.dev, message = 초기화 연결 메시지
close = https://hello-spring.dev

이번엔 disconnect() 메서드까지 잘 실행됐다. 이렇게 생성과 소멸관련 메서드를 이용해서 초기화와 자원정리가 가능하다.

근데 이 방식은 우선 인터페이스를 구현해야 하는 단점이 있고, 너무 옛날 방식이라 요새는 거의 사용하지 않는다. 그래서 위 2가지 다른 방법을 또 알아보자.

 

빈 등록 초기화, 소멸 메서드

이 방법은 인터페이스나 외부 다른 것에 의존하지 않고도 초기화와 소멸이 가능하다. 꽤나 깔끔한 방법으로 보인다. 바로 코드로보자.

 

NetworkClient

package org.example.springcore.lifecycle;

public class NetworkClient {
	
    ...

    public void init() {
        connect();
        call("초기화 연결 메시지");
    }

    public void close() {
        disconnect();
    }
}

이렇게 내가 초기화나 소멸 시 실행할 메서드를 작성하고 빈을 등록할때 "내 초기화 메서드와 소멸 메서드는 이거야!" 라고 알려주기만 하면 된다.

 

빈 등록 코드를 보자.

@Configuration
static class LifeCycleConfig {
    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        NetworkClient networkClient = new NetworkClient();
        networkClient.setUrl("https://hello-spring.dev");
        return networkClient;
    }
}

 

저 코드에서 바로 이 부분이 알려주는 부분이다. 꽤나 깔끔하고 직관적이다.

@Bean(initMethod = "init", destroyMethod = "close")

 

이렇게 하고 위 테스트 코드를 그대로 실행해보자.

실행결과

생성자 호출, url = null
connect = https://hello-spring.dev
call = https://hello-spring.dev, message = 초기화 연결 메시지
close = https://hello-spring.dev

 

의도대로 잘 동작한다. 그리고 이 destroyMethod 속성은 꽤 특별한 옵션이 있다. 기본값 추론이라는 기능인데 통상적으로 소멸 시 호출하는 메서드의 이름은 close, shutdown을 많이 쓰는데 이런 이름의 메서드로 만들어 놓으면 내가 저렇게 직접 destroyMethod = "close"라고 작성해주지 않아도 알아서 소멸 시 실행해준다. 

 

@PostConstruct, @PreDestroy

이게 젤 마지막에 있는 이유가 있다. 결론부터 말하면 그냥 이걸 쓰면 된다.

애노테이션으로 적용하는 방법이고 스프링에서도 이 방법을 권장한다.

 

NetworkClient

package org.example.springcore.lifecycle;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;

public class NetworkClient {

    ...

    @PostConstruct
    public void init() {
        connect();
        call("초기화 연결 메시지");
    }

    @PreDestroy
    public void close() {
        disconnect();
    }
}

 

이렇게 적용하면 끝이다. 깔끔하고 군더더기 없다. 그리고 가장 편하다. 다시 테스트 코드를 실행해보자.

 

BeanLifeCycleTest

package org.example.springcore.lifecycle;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

public class BeanLifeCycleTest {

    @Test
    void lifeCycleTest() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        NetworkClient client = ac.getBean(NetworkClient.class);

        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("https://hello-spring.dev");
            return networkClient;
        }
    }
}

이제 @Bean에 뭐 다른 옵션을 줄 필요없다. 바로 실행해보자.

 

실행결과

생성자 호출, url = null
connect = https://hello-spring.dev
call = https://hello-spring.dev, message = 초기화 연결 메시지
close = https://hello-spring.dev

 

제일 간단하고 제일 명확하다. 유일한 단점은 코드를 고칠 수 없는 외부 라이브러리를 초기화하거나 종료해야 할 땐 사용할 수 없다는 것인데 이럴때만 두번째 방법인 @BeaninitMethod, destroyMethod 옵션을 사용하자.

728x90
반응형
LIST
728x90
반응형
SMALL

이전 포스팅에 이어, 타입 하나에 여러 빈이 등록이 될 수 있는데 그 빈이 모두 필요할땐 어떻게 다뤄야 하는지를 작성하려고 한다.

예를 들면 이런 경우를 보자.

유저가 선택한 할인 정책에 따라, 고정 할인과 비율 할인을 둘 다 사용할 경우가 있을 수 있다. 그러니까 서비스 자체적으로 정책을 결정하는게 아니고 유저가 선택한 할인 정책에 따라 할인을 해주는 서비스가 있을 수 있다. 그땐 둘 다 필요하다. 이런 경우에는 List, Map을 사용할 수 있다.

 

DiscountService

static class DiscountService {
    private final Map<String, DiscountPolicy> policyMap;
    private final List<DiscountPolicy> policies;

    public DiscountService(Map<String, DiscountPolicy> policyMap,
                           List<DiscountPolicy> policies) {
        this.policyMap = policyMap;
        this.policies = policies;
        System.out.println("policyMap = " + policyMap);
        System.out.println("policies = " + policies);
    }
}

위 코드를 보자. 생성자 주입을 통해 DiscountPolicy 빈을자동 주입하려고 한다.

근데 2개 이상인 경우 이렇게 Map 또는 List로 받으면 된다. (참고로, 둘 중 하나를 사용하면 된다. 그냥 둘 다 가능하다는 것을 보여주기 위해 작성한 것 뿐이다)

 

실행 결과를 보면 다음과 같이 잘 들어간다.

실행결과

policyMap = {fixDiscountPolicy=org.example.springcore.discount.FixDiscountPolicy@5f9be66c, rateDiscountPolicy=org.example.springcore.discount.RateDiscountPolicy@3abada5a}
policies = [org.example.springcore.discount.FixDiscountPolicy@5f9be66c, org.example.springcore.discount.RateDiscountPolicy@3abada5a]

 

그래서 둘 다 필요한 경우엔 어떻게 담는지를 알아봤고, 이렇게 둘 다 가져왔을 때 그때 그때 상황에 맞게 사용하는 코드를 작성해서 테스트 해보자.

 

AllBeanTest

package org.example.springcore.autowired;

import org.assertj.core.api.Assertions;
import org.example.springcore.AutoAppConfig;
import org.example.springcore.discount.DiscountPolicy;
import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.*;

public class AllBeanTest {

    @Test
    void findAllBean() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);

        DiscountService discountService = ac.getBean(DiscountService.class);
        Member member = new Member(1L, "userA", Grade.VIP);
        int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");

        assertThat(discountService).isInstanceOf(DiscountService.class);

        assertThat(discountPrice).isEqualTo(1000);

        int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
        assertThat(rateDiscountPrice).isEqualTo(2000);
    }

    static class DiscountService {
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
            this.policyMap = policyMap;
            this.policies = policies;
        }

        public int discount(Member member, int price, String discountCode) {
            DiscountPolicy discountPolicy = policyMap.get(discountCode);
            return discountPolicy.discount(member, price);
        }
    }
}

 

discount()는 세번째 파라미터로 discountCode를 받는다. 이 파라미터가 fixDiscountPolicyrateDiscountPolicy냐에 따라 할인가격을 결정하면 되는 메서드이다. 그래서 굉장히 유연하게 할인 정책을 갈아 끼울 수 있다. 이는 전략패턴과도 유사한 모습이 보인다.

 

결론

타입으로 조회한 빈이 2개 이상이고 모두 필요한 경우에 List, Map으로 받을 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

인터페이스를 구현하는 구현체를 빈으로 등록할 때 2개 이상 등록하는 경우가 있을 수 있다.

예를 들어, OrderService에서 사용하는 DiscountPolicy를 구현한 구현체가 fixDiscountPolicy, rateDiscountPolicy 이렇게 두개가 존재하고 이 두개가 모두 빈으로 등록되는 경우는 생각보다 많다.

 

그럼 자동 주입을 하는 경우 타입을 통해 빈을 조회한다. 즉, 구체클래스인 fixDiscountPolicy, rateDiscountPolicy로 조회하는게 아니라 타입인 DiscountPolicy로 조회한다는 뜻이다. 이 경우 유니크하지 않다는 에러가 발생한다.

 

해결하는 방법은 가장 심플하게 구체 클래스로 빈을 조회하면 되는데 이는 DIP(Dependency Inversion Principle, 의존관계 역전 법칙)를 위반하는 행위이다. 그래서 이 방법 말고 다음 3가지 방법을 소개한다.

 

  • 필드명, 파라미터 명 매칭
  • @Qualifier
  • @Primary

필드 명 매칭

다음 코드를 보자.

OrderServiceImpl

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

문제가 되는 부분은 저 DiscountPolicy 필드이다. 이 타입으로 빈을 조회하게 되면 두 개의 빈이 조회가 되니까 문제가 된다. 이때 필드명을 구체적으로 작성해주면 된다.

수정된 OrderServiceImpl

package org.example.springcore.order;

import lombok.RequiredArgsConstructor;
import org.example.springcore.discount.DiscountPolicy;
import org.example.springcore.member.Member;
import org.example.springcore.member.MemberRepository;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy rateDiscountPolicy;

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = rateDiscountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

이번엔 필드명을 구체적으로 rateDiscountPolicy로 작성했다. 이러면 타입으로 조회한 결과에서 여러 빈 중 이 필드명과 일치하는 빈을 스프링이 자동으로 주입해준다.

 

그래서, 필드명 매칭은 먼저 타입으로 조회해서 매칭을 시도하고, 그 결과에 여러 빈이 있을 때 추가로 동작하는 기능이다.

 

@Qualifier

이건 뭐냐면 추가적으로 애노테이션 정보를 넣어서 원하는것을 찾게 해주는 기능이다.바로 코드로 보자.

FixDiscountPolicy

package org.example.springcore.discount;

import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {

    private static final int discountFixAmount = 1000;

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return discountFixAmount;
        }
        return 0;
    }
}

RateDiscountPolicy

package org.example.springcore.discount;

import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
@Qualifier("rateDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {

    private static final int discountPercent = 10;

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return price * discountPercent / 100;
        }
        return 0;
    }
}

 

두 구체 클래스에 붙은 @Qualifier 애노테이션을 보자. 각각의 구체 클래스에 추가적인 이름을 부여한 것이다.

그리고 주입받는 쪽은 다음과 같이 그 중에서 무엇을 넣어줄건지를 결정하면 된다. 대신에 이건 lombok이랑 같이 사용할 경우 설정 파일을 추가적으로 작업해줘야 하는데 솔직히 그 시간에 그냥 생성자 만들어서 쓰는게 맞다고 생각이 든다. 그래서 다음 코드를 보자.

 

OrderServiceImpl

package org.example.springcore.order;

import org.example.springcore.discount.DiscountPolicy;
import org.example.springcore.member.Member;
import org.example.springcore.member.MemberRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository,
                            @Qualifier("rateDiscountPolicy") DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

 

음.. 근데 @Qualifier를 사용하기보단 필드명 매칭이 훨씬 더 간결해 보인다. 그리고 @Qualifier는 같은 @Qualifier를 찾는 메커니즘이다. 그러니까 @Qualifier("rateDiscountPolicy")라고 찾는다면 이 애노테이션이 달려있는 구현체가 있어야 맞다. 근데 그런 구현체가 없으면 빈 이름이 rateDiscountPolicy인 빈을 찾으려고 시도한다. 그러니까 어떻게 보면 예측하기 어려워지는 코드가 될 수 있다.

 

근데 이제 좀 재밌는게 있다. @Qualifier("rateDiscountPolicy") 이건 문자열을 집어 넣는다. 그 말은? 잘못된 문자열을 넣어도 컴파일 단계에서 에러를 잡아낼 수 없다는 소리다. 단적인 예로 내가 @Qualifier("rtaeDiscountPolicy") 이렇게 작성해도 전혀 에러로 잡아주지 않는다. 그리고 런타임 시에 문제가 딱 터질거다. 최악의 에러라고 볼 수 있다. 이것을 방지하기 위해 커스텀 애노테이션을 만들 수도 있다.

MainDiscountPolicy

package org.example.springcore.annotation;

import org.springframework.beans.factory.annotation.Qualifier;

import java.lang.annotation.*;

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}

 

이렇게 나만의 애노테이션을 만들고 그 안에 @Qualifier("mainDiscountPolicy") 이렇게 만들어 넣으면 이 애노테이션이 저 @Qualifier 역할까지 하게 된다. 그리고 가져다가 사용하는 쪽은 이렇게 문자열을 잘못 입력 할 걱정없이 사용할 수 있다.

 

RateDiscountPolicy

package org.example.springcore.discount;

import org.example.springcore.annotation.MainDiscountPolicy;
import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.springframework.stereotype.Component;

@Component
@MainDiscountPolicy
public class RateDiscountPolicy implements DiscountPolicy {

    private static final int discountPercent = 10;

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return price * discountPercent / 100;
        }
        return 0;
    }
}

 

OrderServiceImpl

package org.example.springcore.order;

import lombok.RequiredArgsConstructor;
import org.example.springcore.annotation.MainDiscountPolicy;
import org.example.springcore.discount.DiscountPolicy;
import org.example.springcore.member.Member;
import org.example.springcore.member.MemberRepository;
import org.springframework.stereotype.Component;

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository,
                            @MainDiscountPolicy DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

 

그래서 이런 식으로도 가능하다. 근데 중요한 건 남용은 오히려 유지보수에 독이 된다. 스프링이 기본적으로 제공하는 거의 대부분의 애노테이션으로 다 해결이 가능하다. 그래서 정말 사용할 필요가 있는 경우에 사용하는 것을 고려해보자.

 

@Primary

이게 오히려 @Qualifier보다 편하고 깔끔하다. 타입으로 빈을 조회했을 때 여러개의 결과가 나올 수 있는데 그 중 @Primary가 붙은 빈을 주입하는 방식이다.

 

RateDiscountPolicy

package org.example.springcore.discount;

import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {

    private static final int discountPercent = 10;

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return price * discountPercent / 100;
        }
        return 0;
    }
}

FixDiscountPolicy

package org.example.springcore.discount;

import org.example.springcore.member.Grade;
import org.example.springcore.member.Member;
import org.springframework.stereotype.Component;

@Component
public class FixDiscountPolicy implements DiscountPolicy {

    private static final int discountFixAmount = 1000;

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return discountFixAmount;
        }
        return 0;
    }
}

 

위 두 구현체 중 RateDiscountPolicy@Primary 애노테이션이 붙었다. 이 경우 이 구현체를 주입한다는 그런 내용이다.

 

OrderServiceImpl

package org.example.springcore.order;

import lombok.RequiredArgsConstructor;
import org.example.springcore.discount.DiscountPolicy;
import org.example.springcore.member.Member;
import org.example.springcore.member.MemberRepository;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

그래서 코드도 다시 lombok을 사용해서 더 깔끔하고 군더더기 없게 됐다.

그럼 만약, @Qualifier, @Primary 둘 다 있을때 우선순위가 뭐가 더 높을까? @Primary는 기본값처럼 동작하는 것이고 @Qualifier는 매우 상세하게 동작한다. 그러니까 @Primary는 있으면 사용하는거고 없어도 그만이다의 느낌을 받으면 되는데 @Qualifier를 지정하면 막 필드 옆에다가도 @Qualifier를 붙여서 두 개를 매칭시키고 하는 이런 상세한 동작을 요한다. 스프링에선? 상세한게 더 우선순위다. 즉, @Qualifier가 더 우선순위가 높다.

 

결론

그래서 결론은 3개 모두 적절히 잘 사용하면 된다. 빈의 중복은 발생할 가능성이 꽤나 농후하기 때문에 그때 적절한 방법으로 풀어나가면 된다. @Primary@Qualifier를 조합해서 사용하기도 하고 필드명 매칭만을 사용하기도 한다. 아니면 애시당초에 컴포넌트 스캔 자동 빈 등록에서 원하는것만 등록해버려도 된다. 

728x90
반응형
LIST
728x90
반응형
SMALL

우선 DI가 무엇인지 어떤 장점이 있는지 왜 스프링에서 핵심 중 하나인지는 이해했다.

그럼 스프링에서 이 의존관계를 주입하는 방법에 대해서 알아보려고 하는데 다음과 같은 방법들이 있다.

  • 생성자 주입
  • setter 주입
  • 필드 주입
  • 일반 메서드 주입

생성자 주입

생성자를 통해서 주입하는 방법이다. 가장 중요하고 가장 많이 사용되며 가장 안전한 주입 방식이다.

 

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

이렇게 생성자를 통해 스프링 빈을 주입을 할 수 있다. 그리고 위 코드처럼 생성자가 하나라면 `@Autowired`를 생략할 수 있다.

 

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

미리 내는 결론이다. 의존관계 주입은 생성자 주입을 사용하자. 세터 주입은 결국 세터라는 굉장히 위험한 메서드를 `public`으로 열어야 하고 필드 주입은 순수 자바로 테스트 자체가 불가능하다. 그래서 그냥 생성자 주입을 사용하면 된다.

setter 주입

수정자를 통해 주입하는 방식이다.

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Autowired
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

이 문제의 단점은 수정자를 만드는 것 그 자체에 있다. setter를 만든 순간부터 버그가 생길 수 있는 어마어마한 문을 활짝 열어놓는 것이다.

난 절대 사용하지 않는 방식이다. 정말 필요한 상황이 아니라면.

 

필드 주입

이 얘기하려고 이 게시글 만들었다. 필드 주입 절대 사용하지 않기로 한다.

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private DiscountPolicy discountPolicy;
}

 

"근데 너무 간결해서 사용하고 싶은데요?"

 

그렇다. 진짜 깔끔해 보이고 왜 문제가 생기는 걸까? 하는 생각이 든다. 문제는 테스트할 때 발생한다.

스프링 컨테이너를 띄우는 테스트가 아니라 순수 자바로 테스트할 때 이 필드에 값을 넣어줄 방법이 없다.

 

다음 코드를 보자.

@Test
void fieldInjection() {
    OrderServiceImpl orderService = new OrderServiceImpl();

    // OrderServiceImpl이 사용하는 MemberRepository, DiscountPolicy를 어떻게 초기화 해주지?

    orderService.createOrder(1L, "itemA", 10000); // NullPointerException 발생
}

createOrder()는 내부적으로 MemberRepositoryDiscountPolicy를 사용한다. 그럼 그 둘은 초기화 된 상태여야한다. 

스프링이 띄워질 때 빈으로 등록될 것들을 전부 찾아 빈으로 등록하고 @Autowired를 찾아 자동 주입을 하는데 스프링이 없는 순수 자바 테스트는 어떻게 이 필드에 값을 채워넣겠는가? 못한다. 해결할 수 있는 방법은 두가지가 있다.

  • 생성자를 만든다: 생성자를 만들고 순수 자바 테스트할 때 저 두개의 필드를 초기화하면 된다.  => 그럼 처음부터 생성자 주입을 하면 된다.
  • 세터를 만든다: 수정자를 만들고 순수 자바 테스트할 때 세터로 값 넣어주면 된다 => 그럼 처음부터 setter 주입을 하면 된다.

필드 주입은 어떻게 생각을 해도 필요가 없다. 하지말자. 근데 해도 되는 경우가 있다.

  • 스프링을 사용해서 테스트 하는 경우
package org.example.springcore;

import org.example.springcore.order.OrderService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class SpringCoreApplicationTests {
    
    @Autowired
    OrderService orderService;

    @Test
    void contextLoads() {
    }

}

이렇게 애시당초에 `@SpringBootTest` 애노테이션이 붙은 테스트는 스프링 컨테이너를 만들어서 테스트를 하는건데 이 경우에는 필드 주입을 하는게 오히려 더 간결하고 좋을 수 있다.

 

일반 메서드 주입

아무 메서드에 그냥 주입을 하는 방식인데, 이 또한 사용하지 말자.

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;
    
    @Autowired
    public void init(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

이렇게 일반 메서드로도 주입이 가능한데, 생성자로 주입하면 될 것을 굳이 이렇게 할 이유가 없다.

 

중간 결론

그래서 하고싶은 말은 거의 대부분의 경우 생성자 주입으로 의존관계를 주입하면 된다. 정말 가끔가다 위 설명대로 필드 주입을 사용해도 되는 경우가 있다. 그때는 사용하되 일반적으로는 사용하지말자. 이제 생성자 주입을 더 편리하게 사용하는 방법을 알아보자. Lombok의 도움을 받아 훨씬 더 편하게 사용할 수 있다.

 

참고로, 자동 주입이 될 수 있는 것은 OrderServiceImpl이 스프링이 관리하는 빈이기 때문이다! @Component 애노테이션이 붙어있다. 스프링이 컴포넌트 스캔으로 해당 클래스를 빈으로 등록했기 때문에 자동 주입도 가능한 것이다. 빈으로 관리하는 대상이 아니면 자동 주입을 위해 사용하는 @Autowired는 아무런 효력이 없다.

 

@Autowired 옵션 설정

스프링 빈을 자동 주입하려고 @Autowired를 사용했는데, 자동 주입 대상이 스프링 빈이 아닌 경우 기본값은 에러가 발생한다.

근데, 스프링 빈이면 자동 주입하고 빈이 아니면 그냥 주입이 안된 상태로 에러는 발생하지 않게 막는 방법이 크게 3가지가 있다.

 

  • `required = false` 옵션
  • @Nullable
  • Optional

세 가지가 있다. 솔직히 크게 중요한 내용은 아니라고 보는데 정말 가끔은 이런 경우가 있을 수도 있기 때문에 한번은 보고 넘어가는게 좋을듯하다.

package org.example.springcore;

import org.example.springcore.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.lang.Nullable;

import java.util.Optional;

public class AutowiredTest {

    @Test
    void AutowiredOption() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);
    }

    static class TestBean {

        @Autowired(required = false)
        public void setNoBean1(Member member) {
            System.out.println("member = " + member);
        }

        @Autowired
        public void setNoBean2(@Nullable Member member) {
            System.out.println("member = " + member);
        }

        @Autowired
        public void setNoBean2(Optional<Member> member) {
            System.out.println("member = " + member);
        }
    }
}

위 코드를 보면 Member를 자동으로 주입하는데 Member는 스프링 빈 대상이 아니다. 그래서 자동 주입이 일어나지 않는다. 그럼 이 경우 required = false 옵션을 줘서 자동 주입을 아예 실행조차 하지 않게 하여 에러를 막는 방법이 있고 @Nullable을 사용해서 해당 값에 null을 넣는 방법이 있고, Optional을 사용해서 있으면 받고 없으면 Optional.empty로 반환하게 하는 방법이 있다.

 

@Test 실행결과

member = Optional.empty
member = null

 

Lombok을 사용해서 매우 깔끔하게 생성자 주입하기

나는 이 lombok 없이는 살 수 없다. 위 생성자 주입 예제 코드로 만든 코드를 한번 보자.

혹시 lombok에 대해 잘 알지 못한다면 Java lombok을 검색하면 문서를 통해 쉽게 이해할 수 있다.

 

OrderServiceImpl

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

이 코드를 다음과 같이 바꿀 수 있다.

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
}

 

어마 무시하게 심플하다. 저 @RequiredArgsConstructor 애노테이션은 `final`이 붙은 즉 초기화가 반드시 필요한 필드들을 가지고 생성자를 알아서 만들어준다. 그러니까 위에 코드와 이 코드가 백퍼센트 동일한 코드인거다. 필드 주입보다 훨씬 간단하다. 그래서 생성자 주입보단 필드 주입이 더 깔끔해 보이는 그런 유혹까지도 뿌리칠 수 있게 됐다. 

 

이제 생성자 주입을 잘 사용하면 된다.

728x90
반응형
LIST
728x90
반응형
SMALL

스프링을 사용할 때 스프링 빈으로 등록하는 가장 편리한 방법은 컴포넌트 스캔이다.

스프링 빈으로 등록하고 싶은 타입(클래스, 인터페이스)에 그저 `@Service`, `@Controller`, `@Repository`, `@Component` 이런 애노테이션을 붙이기만 하면 스프링이 자동으로 빈으로 등록해주기 때문에.

 

근데, 스프링에 용빼는 재주가 있는게 아니다. 결국 스프링이 컴포넌트 스캔을 하기 위해선 어디서부터 컴포넌트 스캔을 하고 어떤것들을 제외하고 등등의 설정을 다 해줘야한다. 그리고 그것을 이 `@ComponentScan`이라는 애노테이션으로 간단하게 할 수 있다. 그리고 이건? 컴포넌트 스캔을 위해 필수적으로 필요한 애노테이션이다. 

 

"네? 저는 스프링부트에서 저 애노테이션 안쓰고도 잘 되던데요?"

 

스프링부트에서 최초의 시작지점인 메인 클래스에 붙어있는 `@SpringBootApplication``@ComponentScan`을 가지고 있기 때문에 가능한 현상이다.

@SpringBootApplication

 

그래서 이 `@ComponentScan`이 어떤 것들을 해주는지 알아보자.

 

어디서부터 스캔할지를 알려준다.

만약, 스프링부트를 사용하지 않는다면 직접 `@ComponentScan`을 어딘가에 설정해줘야 한다. 다음 예시 코드를 보자. 

AutoAppConfig

package org.example.springcore;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

@Configuration
@ComponentScan(
        basePackages = "org.example.springcore.member", // 컴포넌트 스캔 대상의 시작지점 지정
        basePackageClasses = AutoAppConfig.class, // 지정한 클래스가 위치한 패키지부터 하위 패키지까지 컴포넌트 스캔 대상이 된다
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class) // 컴포넌트 스캔 대상 제외
)
public class AutoAppConfig {

}

`@Configuration` 애노테이션은 스프링한테 "나를 빈으로 등록도 하고, 내가 스프링 컨테이너에 필요한 설정 파일이야."라고 말해주는 애노테이션이다. 이 애노테이션이 붙은곳을 보고 `AnnotationConfigApplicationContext`라는 애노테이션 기반의 설정 클래스가 스프링 컨테이너를 설정하는데 "아 이 녀석(@Configuration이 붙은 클래스)을 보고 스프링 빈으로 등록할 녀석들을 등록하면 되겠구나!" 라고 생각한다. 

 

근데 이 클래스를 보니 `@ComponentScan` 이라는 애노테이션이 달려있다. 

`@Component` 애노테이션이 붙은 모든 타입을 다 찾아서 이 AutoAppConfig 클래스에 빈으로 등록하라는 뜻으로 생각하면 된다.

 

근데 옵션이 있다.

  • basePackages: 컴포넌트 스캔을 시작할 패키지를 지정한다. 지정한 패키지부터 하위 패키지를 싹 돌면서 `@Component` 애노테이션이 붙은 곳을 찾는다.
  • basePackageClasses: 컴포넌트 스캔을 시작할 클래스를 지정한다. 이 클래스의 패키지부터 하위 패키지를 싹 돌면서 `@Component` 애노테이션이 붙은 곳을 찾는다.
  • excludeFilters: 컴포넌트 스캔에서 제외할 대상들을 설정한다.

근데 basePackages, basePackageClasses, excludeFilters는 전부 다 Optional이다. 심지어 스프링부트가 만들어준 @SpringBootApplication 애노테이션에도 basePackages, basePackageClasses 이들은 없다. 없으면 어떻게 되는걸까?

 

@ComponentScan 애노테이션이 붙은 클래스의 패키지부터 하위 패키지를 싹 스캔한다.

 

그래서 요새는 모두가 다 스프링 부트로 시작하기 때문에 굳이 `@ComponentScan` 애노테이션을 직접 설정하고 작업하지 않아도 알아서 다 해주니까 상관없지만 어떻게 컴포넌트 스캔이 될 수 있고 스프링부트가 우리 대신 뭘 해주는지 이해하는것은 의미가 있을 것 같았다.

 

@Service, @Controller 같은 애노테이션은 왜 스캔이 되는건가요?

`@ComponentScan``@Component` 애노테이션이 붙어 있는 곳을 싹 찾는다고 했다. 근데 @Service, @Controller, @Repository를 사용해도 전부 다 컴포넌트 스캔이 된다. 어떤 이유에서일까?

 

저 애노테이션들은 전부 다 `@Component` 애노테이션을 가지고 있기 때문이다.

@Service
@Repository
@Controller

 

전부 다 `@Component`를 가지고 있다.

 

"아아, 상속받는거네요 그럼?"

 

아니다. 애노테이션은 상속이라는 개념이 없다. 위 사진처럼 @Controller@Component를 붙인다고 상속받는것을 의미하지 않는다.

즉, 자바에서 제공하는 기능이 아니라 스프링이 우리 대신 어떤 작업을 해주는 것이다. 

 

내가 만든 애노테이션으로 빈을 등록할 수 있다.

거의 그럴일이 없는데, 내가 직접 애노테이션을 만들어서 빈으로 등록하게 설정할 수도 있다.

애노테이션을 먼저 만들어보자.

 

MyIncludeComponent

package org.example.springcore.scan.filter;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyIncludeComponent {
}

 

MyExcludeComponent

package org.example.springcore.scan.filter;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyExcludeComponent {
}

 

이 두개의 애노테이션을 직접 만든다. 이름만 봐도 알 수 있듯 MyIncludeComponent 이건 빈으로 등록할 애노테이션, MyExcludeComponent 이건 빈으로 등록하지 않을 애노테이션이다. 테스트 해보자.

 

ComponentFilterAppConfigTest

package org.example.springcore.scan.filter;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

import static org.assertj.core.api.Assertions.*;

public class ComponentFilterAppConfigTest {

    @Test
    void filterScan() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(ComponentFilterAppConfig.class);
        BeanA beanA = ac.getBean("beanA", BeanA.class);
        assertThat(beanA).isNotNull();

        assertThatThrownBy(() -> ac.getBean("beanB", BeanB.class))
                .isInstanceOf(NoSuchBeanDefinitionException.class);
    }

    @Configuration
    @ComponentScan(
            includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
            excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
    )
    static class ComponentFilterAppConfig {

    }
}

우선, @Configuration 애노테이션이 붙은 설정 클래스를 하나 만들고 그 클래스에 @ComponentScan 애노테이션을 붙인다. 여기서 필터를 설정할 수 있는데 빈으로 등록시킬 필터와 빈에 제외시킬 필터를 설정할 수 있다.

includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)

 

이렇게 설정하면 MyExcludeComponent 애노테이션이 붙은 타입(클래스, 인터페이스)은 빈 등록에서 제외된다.

MyIncludeComponent 애노테이션이 붙은 타입(클래스, 인터페이스)은 빈 등록에 포함된다. 

그리고 참고로, 저 `type = FilterType.ANNOTATION`은 기본값이라 굳이 작성하지 않아도 된다.

이렇게 작성해도 무방하다는 소리다.

includeFilters = @ComponentScan.Filter(classes = MyIncludeComponent.class),
excludeFilters = @ComponentScan.Filter(classes = MyExcludeComponent.class)

 

이렇게 직접 애노테이션을 만들고 해당 애노테이션으로 컴포넌트 스캔을 통해 스프링 빈을 등록하게 할 수 있지만, 이런 경우는 거의 없다. 이미 스프링에서 필요한 애노테이션은 다 만들어놨기 때문에 가져다가 사용만 하면 된다. 그래도 직접 해보는것에 의의를 두자.

 

결론

결론은 스프링 부트를 모두가 사용하는 요즘엔 @ComponentScan 에 대해 생각할 필요없이 내가 컴포넌트 스캔을 하고 싶으면 대상에 @Component, @Repository, @Service, .. 이런 애노테이션을 붙여주면 된다. 

 

근데, 그 스프링부트가 우리 대신 @ComponentScan을 만들어줄 뿐이다. 

결론 = 스프링부트 짱🙌

 

728x90
반응형
LIST
728x90
반응형
SMALL

정답은 아닌데 개인적으로 맞다고 본다. 스프링은 기본적으로 스프링 빈을 싱글톤으로 등록한다.

싱글톤이 안티패턴이라고까지 불릴 정도로 싱글톤은 여러 문제점이 있는데 스프링은 그 문제들을 전부 해결해준 상태로 싱글톤으로 등록해준다.

 

싱글톤의 문제점

  • 싱글톤을 만들어내야 하는 코드 자체가 번거롭다.
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. (싱글톤클래스.getInstance())
  • 구체 클래스에 의존한다는것은 DIP(의존관계 역전 법칙)를 위반한다는 뜻이고 그 뜻은 OCP원칙을 위반할 가능성이 높다.
  • 테스트하기 어렵다
  • 자식 클래스를 만들기 어렵거나 불가능하다

 

싱글톤은 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든 인스턴스가 하나이고 그걸 공유하는 방식이라면 상태를 유지하게 설계하면 안된다. 

 

  • 무상태로 설계하거나, 읽기 전용 필드만을 취급하는게 문제가 발생하지 않을 가장 좋은 방법이다.
  • 필드 대신 자바에서 공유되지 않는 지역변수나 ThreadLocal을 사용해야 한다.

 

스프링 빈 필드에 공유값을 설정하면 장애가 발생할 수 있고 이 장애가 이 원인인걸 파악하기가 정말 어렵다.

아래 예시 코드를 들어서 상태를 가지는 경우에 발생하는 문제를 보자.

 

StatefulService

package org.example.springcore.singleton;

public class StatefulService {

    private int price;

    public void order(String name, int price) {
        System.out.println("name = " + name + ", price = " + price);
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}

위 클래스를 스프링 빈으로 등록할 것이다(싱글톤). 그리고 price 필드를 공유하고 있다. 거기다가 필드는 읽기 전용도 아니고 쓰기가 행해지고 있다.

 

StatefulServiceTest

package org.example.springcore.singleton;

import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

import static org.assertj.core.api.Assertions.*;

class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
        StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);

        // ThreadA: A사용자 10000원 주문
        statefulService1.order("userA", 10000);
        // ThreadB: B사용자 20000원 주문
        statefulService2.order("userB", 20000);

        // ThreadA: 사용자A는 주문 금액을 조회한다. 사용자 A 입장에선 당연히 10000원을 기대한다.
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);

        // 하지만 2만원이 된다.
        assertThat(price).isEqualTo(20000);
    }

    static class TestConfig {
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

실행결과

name = userA, price = 10000
name = userB, price = 20000
price = 20000

 

멀티 쓰레드 환경을 만드는 건 좀 더 복잡하니까 동시에 사용자가 스프링 빈 서비스에 요청을 했다고 가정해보자.

사용자A(Thread A)는 만원짜리 상품을 주문한다.

사용자B(Thread B)는 2만원짜리 상품을 주문한다.

 

당연히 사용자A는 본인의 주문 금액은 만원이라고 생각하고 결제를 한다. 결과는 2만원으로 결제가 된다. 

 

이런 문제가 발생한다. 싱글톤이 값을 공유하는 순간부터 그 코드는 문제가 생길 여지가 너무 크다.

그럼 어떻게 해결할까?

 

여러 해결방법이 있지만 지역변수를 사용하는 아주 간단한 해결방법이 있다.

 

StatefulService

package org.example.springcore.singleton;

public class StatefulService {
    
    public int order(String name, int price) {
        System.out.println("name = " + name + ", price = " + price);
        return price;
    }
}

 

그냥 이 코드처럼 결제 금액을 알려주기 위해 반환하면 그만이다. 왜 굳이 필드에 값을 저장해서 getter를 사용해야 하는가?

 

결론

결론은 스프링 빈 또는 싱글톤에서 (합리적인 이유가 있지 않은 이상) 적어도 나는 상태를 무상태로 설계하기로 마음먹었다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리했다.

  • SRP: 단일 책임 원칙 (Single Responsibility Principle)
  • OCP: 확장에는 열려있고 변경에는 닫혀있는 원칙 (Open/Closed Principle)
  • LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)
  • ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
  • DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)

하나씩 알아보자.

 

SRP (Single Responsibility Principle)

단일 책임 원칙이라는 뜻의 SRP. 무슨 말일까? 한 클래스는 하나의 책임만 가져야 한다.

하나의 책임이라는 것은 모호하다. 책임의 크기를 말하는게 아니다. 문맥과 상황에 따라서도 다르다.

 

중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것.

예를 들어, MVC 패턴에서 V는 View이다. UI와 관련된 책임을 가지는 부분이고 이 곳에선 비즈니스 로직이나 그 외 것들엔 책임을 지게 해선 안된다(안되는게 좋다).

 

개인적인 생각으로 JSP가 대체 기술이 생긴 이유는 이 단일 책임 원칙이 잘 지켜지지 않기 때문이다.

JSP는 뷰와 로직을 동시에 가지고 있다. 책임이 너무 크고 분산되어 있지 않다. 이를 대체하기 위해 스프링 MVC 패턴이 등장했다.

 

OCP (Open/Closed Principle)

개방-폐쇄 원칙이라고 하는데 난 확장에는 열려있고 변경에는 닫혀있는 원칙이라고 표현한다. 

즉, 기술의 확장이 있어도 코드의 변경이 없어야 한다는 뜻인데 굉장히 알 수 없는 말이다. 

 

무엇을 떠올리면 되냐면 DI를 떠올리면 된다. 클라이언트 코드는 그저 인터페이스만을 의존한다. 인터페이스를 구현한 기술이 10개든 100개든 어떤 기술이 새로 만들어지고 어떤 기술로 갈아끼우던지 클라이언트 코드는 변경할 부분이 없다. 인터페이스를 의존하기 때문이다.

 

이게 OCP 원칙이고 5가지 원칙 중 가장 중요한 원칙이라고 생각한다. 의존관계의 결정을 나중으로 미루는 것. 그에 따라 확장성과 유지보수에 훨씬 유리해지고 코드의 변경은 최소화하거나 아예 하지 않아도 된다.

 

LSP (Liskov Substitution Principle)

리스코프 치환 원칙이라고 하는데 제일 어려워 보이지만 제일 쉬운 원칙이다.

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

 

"무슨 말이세요?"

 

그러니까, 쉽게 말해서 자동차라는 인터페이스가 있고 엑셀밟기 라는 메서드를 정의했으면 이 인터페이스를 구현하는 구현체는 엑셀밟기 메서드를 구현할 때 앞으로 가는 기능을 구현해야 하지 뒤로 가는 기능을 구현해선 안된다는 뜻이다. 프로그램이 의도하는 정확성을 지켜야 한다는 뜻이다. 근데 스포츠카와 기본 자동차는 같은 엑셀을 밟더라도 속도의 차이가 현저히 날 수 있다. 그러나 둘 다 엑셀을 밟으면? 앞으로 간다. 이게 포인트.

 

그 구현하는 방식이 조금씩 다르더라도 정확한 의도대로 구현해야 한다는 원칙.

 

ISP (Interface Segregation Principle)

인터페이스 분리 원칙. 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

예를 들어, 자동차 인터페이스가 있을 때 이 인터페이스는 운전과 관련된 메서드와 정비와 관련된 메서드 둘 다를 정의할 수 있지만 애시당초에 자동차 인터페이스를 운전 인터페이스, 정비 인터페이스로 분리하는게 더 좋다는 것이다.

 

왜 더 좋을까?

 

첫번째는, 저 둘로 분리하게 됐을 때 정비 인터페이스 자체가 변해도 운전자 클라이언트 - 운전 인터페이스 간에는 어떠한 영향도 주지 않는다.

 

두번째는, 인터페이스가 명확해지고 대체 가능성이 높아진다.

 

DIP (Dependency Inversion Principle)

의존관계 역전 원칙으로, OCP와 같이 중요한 원칙 중 하나라고 생각한다. 프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다." 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

 

쉽게 이야기하면, 클라이언트 코드는 구체클래스를 의존하는게 아니라 인터페이스를 의존해서 기술에 변경이 있어도 클라이언트 코드에 수정이 필요없게 설계하라는 뜻이다. 이 말을 한 단어로 DI라고 하는 것이고. 의존관계의 결정을 나중으로 미루는 것이다.

728x90
반응형
LIST

+ Recent posts