728x90
반응형
SMALL

외부로부터 설정값들을 가져오는 방법엔 크게 4가지가 있다고 했다. 

  • OS 환경 변수
  • 자바 시스템 속성
  • 커맨드 라인 인수
  • 외부 설정 파일

Part.1에서는 세가지를 배웠다. OS 환경 변수, 자바 시스템 속성, 커맨드 라인 인수.

근데 배우고 보니 저 세가지 방법 모두가 코드에서 가져오는 방식이 다 다르다는 것을 깨달았다. 그럼 여기서 스프링은 이를 두고보지 않는다. 우리에게 추상화 기능을 제공해서 아주 편리하게 어떻게 설정값을 지정했던 상관없이 한 가지 방법으로 모든 방법을 사용할 수 있도록 한다. 다음 그림을 보자.

스프링에서는 커맨드 라인 옵션 인수이던, 자바 시스템 속성이던, OS 환경변수이던, 설정 데이터(파일)이던 상관없이 딱 하나 `Environment` 객체를 통해서 원하는 값을 가져올 수 있다.

 

Environment는 역할(인터페이스)이고 이를 구현한 여러 구현체가 있다. 우리는 그 각각의 세부적인 구현체에 대해 자세히 알 필요없이 그저 Environment만 가져다가 사용하면 된다. 이것이 바로 변경가능한 부분과 변경하지 않아도 되는 부분을 잘 분리했다라고 말 할 수 있는 상황이다.

 

EnvironmentCheck

package hello;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class EnvironmentCheck {

    private final Environment environment;

    public EnvironmentCheck(Environment environment) {
        this.environment = environment;
    }

    @PostConstruct
    public void init() {
        String url = environment.getProperty("url");
        String username = environment.getProperty("username");
        String password = environment.getProperty("password");

        log.info("url: {}, username: {}, password: {}", url, username, password);
    }
}

그래서 이렇게 스프링이 미리 만들어 둔 Environment를 주입받아서 getProperty()를 호출하면 끝이다.

실행결과

2024-06-14T21:50:32.324+09:00  INFO 43869 --- [           main] hello.EnvironmentCheck                   : url: devurl, username: dev_user, password: dev_pw

 

근데, 한가지 궁금한 부분이 생긴다. 그럼 만약 OS 환경 변수와 자바 시스템 속성 둘 다 또는 그 이상이 모두 같은 key를 가지는 값이 있을땐 무엇을 가져올까? 스프링이 자체적으로 우선순위를 만들어 두었다. 그 우선순위에 대해 알아보자.

 

예를 들어, 다음과 같이 커맨드 라인 옵션 인수자바 시스템 속성으로 같은 키를 지정했다면 어떤 값을 가져올까?

실행결과

url: devurl, username: dev_user, password: dev_pw

 

결과는 커맨드 라인 옵션 인수값을 가져온다. 즉, 자체적으로 우선순위가 커맨드 라인 옵션 인수가 더 높다는 뜻이다. 그럼 이걸 외워야하나?

 

우선순위는 상식 선에서 딱 2가지만 기억하면 된다.

  • 더 유연한 것이 우선권을 가진다 (변경하기 어려운 파일 보다 실행 시 원하는 값을 줄 수 있는 자바 시스템 속성이 더 우선권을 가진다)
  • 범위가 넓은 것보다 좁은 것이 우선권을 가진다(OS 환경 변수처럼 전역으로 여기저기 프로그램에서 가져올 수 있는 값보다 JVM 안에서만 접근 가능한 자바 시스템 속성이 더 우선순위가 높고, JVM 안에서 모두 접근 가능한 경우보다 커맨드 라인 옵션 인수는 main의 args를 통해서 들어오기 때문에 이 커맨드 라인 옵션 인수가 더 우선순위가 높다)

 

이제 스프링이 제공해주는 추상화 `Environment`를 통해 어떻게 외부에 데이터(설정값)를 저장했다고 해도 편리하게 가져다가 사용할 수 있게 됐다. 그럼 남은 한 가지, 외부 파일을 통해 가져오는 것도 알아보자.

 

설정 데이터1 - 외부 파일

지금까지 배운 내용으로 외부로부터 설정 데이터를 가져올 수 있게 됐다. 근데 사실 설정값이 지금이야 3개뿐이니 굉장히 간단하고 편해보이지만 실제 운영서버에서 사용되는 설정 데이터는 몇십개 몇백개도 존재할 수 있다. 그럼 벌써 머리 아프다. 관리하기가 굉장히 난처해진다.

그래서 그렇게 많은 데이터를 관리하기엔 파일로 관리하는게 최고다.

 

그래서 파일로 관리를 하고 애플리케이션 로딩 시점에 해당 파일을 읽어들이면 된다. 그 중에서도 .properties 파일이나 .yml 파일이 key=value 형식으로 설정값을 관리하기에 아주 적합하다.

 

그래서 .jar 파일을 빌드를 통해 만들고 그 .jar 파일이 있는 경로에 application.yml 파일을 만들어보자.

위 사진과 같이 .jar 파일이 존재하는 경로에 application.yml 파일을 만들어서 그 안에 key=value 값을 넣었다.

이 상태에서 애플리케이션을 실행해보자.

보이는 것과 같이 외부 파일을 읽어들여 값을 잘 찍는것을 볼 수 있다. 근데 이렇게 하는 것은 어떤 불편함이 있냐?

서버가 10대라면 10대 서버마다 이러한 파일을 다 만들어야 하는 불편함이 있다. 그리고 설정값이 변경되면 또 10대 모두 다 변경해줘야 한다. 

 

그럼 어떻게 해결할까?

설정 데이터2 - 내부 파일 분리

이 외부 파일을 관리하는 것은 상당히 쉽지 않은 일이다. 설정을 변경할 때 마다 서버에 들어가서 각각의 변경 사항을 수정해두어야 한다. (물론 이것을 자동화 하기 위해 노력을 할 수는 있다) 

 

이런 문제를 해결하는 간단한 방법은 설정 파일을 프로젝트 내부에 포함해서 관리하는 것이다. 그리고 빌드 시점에 함께 빌드되게 하는 것이다. 이렇게 하면 애플리케이션을 배포할 때 설정 파일의 변경 사항도 함께 배포할 수 있다. 쉽게 이야기해서 jar 하나로 설정 데이터까지 포함해서 관리하는 것이다. 

위 그림을 보면 프로젝트 안에 설정 데이터를 포함하고 있다. 

  • 개발용 설정 파일: application-dev.properties
  • 운영용 설정 파일: application-prod.properties
  • 빌드 시점에 개발, 운영 설정 파일을 모두 포함해서 빌드한다.
  • app.jar는 개발, 운영 두 설정 파일을 모두 가지고 배포된다.
  • 실행할 때 어떤 설정 데이터를 읽어야 할지 최소한의 구분은 필요하다.
  • 실행할 때 외부 설정을 사용해서 개발 서버는 dev라는 값을 제공하고, 운영 서버는 prod라는 값을 제공하면 된다. 그리고 스프링에서 이것을 프로필이라고 미리 정의해두고 사용자들에게 제공하고 있다.

"그럼 이 프로필 정보는 어떻게 넘겨요?" 지금까지 했던 커맨드 라인 옵션 인수, VM 옵션 등으로 넘길 수 있다.

우선, 이 내부 설정 파일을 만들어보자. main/resources 경로에 다음 파일을 추가하자.

 

application-dev.yml

username: dev_user
password: dev_password
url: dev.db.com

 

application-prod.yml

username: prod_user
password: prod_password
url: prod.db.com

 

어? 근데 왜 그림에서는 .properties를 사용하는데 여기선 .yml을 사용하나요?

.properties 파일로 사용해도 되고 .yml 파일로 사용해도 된다. 이것도 취향 차이인데, 개인적으로 .yml 파일을 더 선호한다.

그 이유는 크게 2가지가 있다.

  • 들여쓰기를 통한 가시화 증대 (본인 취향)
  • 중복 키가 존재하면 .properties는 가장 마지막에 작성한 값이 적용되는 반면, .yml 파일은 에러를 발생시켜준다.

난 이 두번째 이유가 너무 좋다. 다음 예시를 보자.

application-dev.properties

이 파일에 `url` 이라는 키를 세 개나 중복해서 작성했다. 지금이야 한 눈에 보이지만 이 파일이 꽤나 커져서 위에서 작성했는지 까먹고 아래에서 또 작성할 여지가 분명히 존재한단 말이다. 아마 이 빨간줄은 인텔리제이 유료 버전이 똑똑하게 알려주는 것 같은데 다른 IDE나 기본 파일 에디터는 이런 것도 안 알려줄거다. 

 

이 상태로 실행해보면 결과가 다음과 같다.

가장 마지막에 선언한 값이 적용된다. 뭐 어떻게 보면 유연하다고 볼 수도 있는데 난 이런 유연함은 싫다.

 

application-dev.yml

이 상태로 실행해보자. 다음과 같이 에러가 발생한다. 그리고 친절하게 DuplicateKeyException 이라고 알려준다. 얼마나 좋은가!

 

아무튼 이런 이유로 .yml 파일을 선호하고 .yml 파일로 작성했고 계속 진행해보자.

 

스프링은 이런 곳에서 사용하기 위해 프로필이라는 개념을 지원한다. `spring.profiles.active` 외부 설정에 값을 넣으면 해당 프로필을 사용한다고 판단한다. 그리고 프로필에 따라 다음과 같은 규칙으로 해당 프로필에 맞는 내부 설정 파일을 조회한다.

  • application-{profile}.yml

그래서 실행하는 방법은 대표적으로 다음 두가지와 같다.

  • java -Dspring.profiles.active=dev -jar Xxx.jar (VM 옵션)
  • java -jar Xxx.jar --spring.profiles.active=dev (커맨드 라인 옵션 인수)
한번 더 주의! VM 옵션은 -jar 앞에 작성해야 하고, 커맨드 라인 옵션 인수는 -jar 뒤에 작성해야 한다.

 

실행결과

 

이제 정말 간단하게 설정 데이터를 프로젝트 내에 위치시켜 외부로 빼서 서버마다 설정값이 변경되면 적용해줄 필요도 없고 빌드 파일 자체에 모든것이 담겨있게 됐다. 그러나 인간의 욕심은 끝이 없다. 이제 이 파일이 나뉘어져 있다 보니 각각의 설정값이 어떤 대조점이 있는지 한눈에 보기 어렵다는 점이 있다. 그럼 이건 또 어떻게 해결할까?

설정 데이터3 - 내부 파일 합체

스프링은 또 이 설정 파일을 각각 분리해서 관리하면 한눈에 전체가 들어오지 않는 단점이 있다는 것을 알고 물리적인 하나의 파일 안에서 논리적으로 영역을 구분하는 방법을 제공한다. 그리고 이 방법을 대부분 실무에서 많이 사용하고 있다. 

대부분 실무에서 많이 사용하고 있다는 말은 통상적이지 절대적은 아니다. 파일을 application-prod.yml, application-dev.yml로 구분하는 방식도 많이 사용한다. 난 오히려 이게 더 편하기도 하다. 약간 취향의 차이로 생각하면 좋을 것 같다.

 

그럼 이제 어떻게 하면 될까? 위에서는 이런식으로 구분을 했다.

 

application-dev.yml

username: dev_user
password: dev_password
url: dev.db.com

 

application-prod.yml

username: prod_user
password: prod_password
url: prod.db.com

 

이 파일을 하나로 합쳐보자. 그리고 스프링이 제공하는 .yml 구분자(---)로 두 프로필을 구분해보자.

application.yml

spring:
  config:
    activate:
      on-profile: dev

username: dev_user
password: dev_password
url: dev.db.com

---

spring:
  config:
    activate:
      on-profile: prod

username: prod_user
password: prod_password
url: prod.db.com

 

이렇게 하면 끝이다. 한 파일로 두 프로필을 논리적으로 구분해서 관리할 수 있다. 

이제 이게 가장 통상적으로 많이 사용되는 방식이고, 어지간하면 이렇게 사용하면 된다. 

 

그럼, 위 설정 파일은 프로필이 반드시 주어져야 정상적으로 동작할 것 처럼 보인다. 맞다. 그럼 프로필을 주지 않으면 어떤 현상이 일어날까? 여기서도 우선순위가 존재하는데 그 부분을 알아보자.

우선순위 - 설정 데이터

자, 만약 내가 프로필을 따로 주지 않고 실행하면 이 상태에선 어떤 값이 적용될까?

application.yml

spring:
  config:
    activate:
      on-profile: dev

username: dev_user
password: dev_password
url: dev.db.com

---

spring:
  config:
    activate:
      on-profile: prod

username: prod_user
password: prod_password
url: prod.db.com

 

실행결과

 

스프링은 프로필을 따로 받지 않을때 "default"라는 기본 프로필을 사용한다. 그리고 결과로 보면 알 수 있듯 어떤 값도 적용되지 않았다.

쉽게 말해 이 설정 프로필에 우선순위란게 존재하지 않는다. 그럼 어떻게 읽어들일까?

 

"위에서 아래로 읽어들인다"

 

다음과 같이 작성했다고 가정해보자.

application.yml

username: local_user
password: local_password
url: local.db.com

---

spring:
  config:
    activate:
      on-profile: dev

username: dev_user
password: dev_password
url: dev.db.com

--- # .yml 파일에서 구분자 (스프링이 직접 정의한 것이기 때문에 반드시 이 '---' 여야 한다.)

spring:
  config:
    activate:
      on-profile: prod

username: prod_user
password: prod_password
url: prod.db.com

제일 상단에는 어떠한 프로필도 적용시키지 않았다. 이런 경우 기본값(default) 프로필로 적용이 된다.

그럼, 스프링은 이 상태에서 아무런 프로필도 받지 않으면 당연히 저 위에 세 개가 적용된다.

 

근데 만약, 프로필을 'dev'로 받는다면 어떤식으로 적용되냐? 다음 순서대로 적용된다.

  • 먼저 기본값인 가장 상위 세 개가 적용된다.
  • 근데, 프로필이 'dev' 이므로 'dev' 전용 설정 값을 적용한다. (여기서 같은 키라면 그 값이 대체된다)

한번 'dev'로 적용해서 실행해보자.

실행결과

 

결과를 보면 알 수 있듯 'dev'값으로 적용됐다. 이건 가장 상위에 세 개를 건너 뛴 게 아니다. 먼저 가장 상위에 세 개를 적용한 후 `dev`라는 프로필을 받았으니 그 프로필에 한하여 적용되는 설정값을 추가적으로 적용한 결과다. 다만 같은 키가 있기에 대체된 것.

 

극단적인 예시로 가장 하위에 아무런 프로필이 없는 값을 주면? 대체된다.

application.yml

spring:
  config:
    activate:
      on-profile: dev

username: dev_user
password: dev_password
url: dev.db.com

--- # .yml 파일에서 구분자 (스프링이 직접 정의한 것이기 때문에 반드시 이 '---' 여야 한다.)

spring:
  config:
    activate:
      on-profile: prod

username: prod_user
password: prod_password
url: prod.db.com

--- 

url: local.db.com

이렇게 가장 하위에 기본 프로필에 적용되는 값을 주면 그 값은 무조건 적용이 된다.

실행결과

그래서, 보통은 가장 상위에 기본으로 적용될 값을 모두 세팅해 둔 후에 각 프로필마다 변경되거나 추가적으로 바뀔 값을 지정하는 방식으로 많이 사용한다.

 

심지어, 프로필을 두개도 적용할 수 있다. 거의 이럴일은 없지만 이렇게 실행하면 진짜 두개의 프로필이 모두 활성화된다.

  • java -Dspring.profiles.active=dev,prod -jar Xxx.jar

실행결과

결과를 보면 알 수 있듯 두개의 프로필이 활성화됐다. 그럼 위에서 말한것처럼 위에서 아래로 읽어 들이는 설정 파일은 결국 'dev'도 읽고 'prod'도 읽으니까 최종적으로 url, username, password의 값은 'prod'값으로 적용이 될 수 밖에 없다는 것을 안다.

 

결론과 우선순위 - 전체

이제 전체적으로 외부 설정을 읽어 들일 때 우선순위가 어떻게 적용되는지 확인해보자.

아래로 내려갈수록 우선순위가 높은것이다.

  • 설정 데이터(application.yml)
  • OS 환경 변수
  • 자바 시스템 속성
  • 커맨드 라인 옵션 인수
  • @TestPropertySource (테스트에서 사용)

이걸 외우는게 아니라, 그냥 두가지 큰 틀을 알고 있으면 된다.

  • 더 유연한 것이 우선권을 가진다. (변경하기 어려운 파일보다 실행 시 원하는 값을 줄 수 있는 자바 시스템 속성이 더 우선권을 가진다)
  • 범위가 넓은 것보다 좁은 것이 더 우선권을 가진다. (다른 말로, 더 디테일한게 우선권을 가진다)
    • OS 환경변수보다 자바 시스템 속성이 더 우선권을 가진다.
    • 자바 시스템 속성보다 커맨드라인 옵션 인수가 더 우선권을 가진다.

 

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

하나의 애플리케이션을 여러 다른 환경에서 사용해야 할 때가 있다. 대표적으로 개발이 잘 진행되고 있는지 내부에서 확인하는 용도의 개발 환경, 그리고 실제 고객에게 서비스하는 운영 환경이 있다.

 

이렇게 두 환경에서는 서로 다른 데이터와 서로 다른 환경을 사용한다.

  • 개발 환경: 개발 서버, 개발 DB
  • 운영 환경: 운영 서버, 운영 DB

문제는 각각의 환경에 따라서 서로 다른 설정값이 존재한다는 점인데, 대표적으로 DB만 하더라도 개발 환경에서는 개발DB를 사용하고 개발DB에 접근하려면 dev.db.com 이라는 URL 정보가 필요하고 운영 환경에서 운영DB에 접근하려면 prod.db.com 이라는 URL을 사용해야 한다.

 

이 문제를 해결하는 가장 단순한 방법은 다음과 같이 각각의 환경에 맞게 애플리케이션을 빌드하는 것이다.

  • 개발 환경에는 `dev.db.com`이 필요하므로 이 값을 애플리케이션 코드에 넣은 다음에 빌드해서 개발용 jar 파일을 만든다.
  • 운영 환경에는 `prod.db.com`이 필요하므로 이 값을 애플리케이션 코드에 넣은 다음에 빌드해서 운영용 jar 파일을 만든다.

 

그러나 이 방식은 이러한 문제들이 있다.

  • 환경에 따라 빌드를 여러번 해야 한다.
  • 개발 버전과 운영 버전의 빌드 결과물이 다르다. 따라서 개발 환경에서 검증 되더라도 운영 환경에서 다른 빌드 결과를 사용하기 때문에 예상치 못한 문제가 발생할 수 있다. 개발용 빌드가 끝나고 검증한 다음에 운영용 빌드를 해야 하는데 그 사이에 누군가 다른 코드를 변경할 수도 있다. 한마디로 진짜 같은 소스코드에서 나온 결과물인지 검증하기가 어렵다.
  • 각 환경에 맞추어 최종 빌드가 되어 나온 빌드 결과물은 다른 환경에서 사용할 수 없으므로 유연성이 떨어진다. 향후 다른 환경이 필요하면 그곳에 맞도록 또 빌드를 해야한다.

결론은, 위 방식은 안 좋은 방식이다. 그래서 보통은 다음과 같이 빌드를 한번만 하고 각 환경에 맞추어 실행 시점에 외부 설정값을 주입한다.

  • 배포 환경과 무관하게 하나의 빌드 결과물을 만든다. 이 안에는 설정값을 두지 않는다.
  • 설정값은 실행 시점에 각 환경에 따라 외부에서 주입한다. 
    • 개발 서버에서는 app.jar를 실행할 때 `dev.db.com` 값을 외부 설정으로 주입한다.
    • 운영 서버에서는 app.jar를 실행할 때 `prod.db.com` 값을 외부 설정으로 주입한다.

이런 방식을 사용한다면 빌드도 한번만 하면 되고, 개발 버전과 운영 버전의 빌드 결과물이 같기 때문에 개발 환경에서 검증되면 운영 환경에서도 믿고 사용할 수 있다. 그리고 이후에 새로운 환경이 추가되어도 별도의 빌드 과정 없이 같은 app.jar를 사용해서 손쉽게 새로운 환경을 추가할 수 있다.

유지보수하기 좋은 애플리케이션 개발의 가장 기본 원칙은 변하는 것과 변하지 않는 것을 분리하는 것이다. (DI, 전략 패턴, OCP원칙 등)

 

이제, 이러한 내용을 인지한 채 외부 설정에 대해 공부해보자.

외부 설정

애플리케이션을 실행할 때 필요한 설정값을 외부에서 어떻게 불러와서 애플리케이션에 전달할 수 있을까?

외부 설정은 일반적으로 다음 4가지 방법이 있다.

  • OS 환경 변수: OS에서 지원하는 외부 설정, 해당 OS를 사용하는 모든 프로세스에서 사용 (Ex: JAVA_HOME)
  • 자바 시스템 속성: 자바에서 지원하는 외부 설정, 해당 JVM안에서 사용
  • 자바 커맨드 라인 인수: 커맨드 라인에서 전달하는 외부 설정, 실행시 main(args) 메서드에서 사용
  • 외부 파일(설정 데이터): 프로그램에서 외부 파일을 직접 읽어서 사용
    • 애플리케이션에서 특정 위치의 파일을 읽도록 해둔다. (Ex: data/env.txt)
    • 그리고 각 서버마다 해당 파일안에 다른 설정 정보를 남겨둔다.
      • 개발 서버 env.txt: url=dev.db.com
      • 운영 서버 env.txt: url=prod.db.com

하나씩 차근차근 직접 해보면서 알아가보자. 

 

외부 설정 - OS 환경 변수

우선, OS별 환경 변수가 어떻게 저장되어 있는지 확인하는 방법이 있다. 검색하면 바로 나올거고 Mac은 `printenv`를 입력하면 된다.

 

그럼, 이 OS 환경 변수를 코드상에서 어떻게 읽을까? 간단하다. 다음 코드를 보자.

 

OsEnv

package hello.external;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;

@Slf4j
public class OsEnv {

    public static void main(String[] args) {
        Map<String, String> envMap = System.getenv();
        for (Map.Entry<String, String> keyValue : envMap.entrySet()) {
            log.info("key: {}, value: {}", keyValue.getKey(), keyValue.getValue());
        }
    }
}

실행결과

> Task :OsEnv.main()
15:45:14.720 [main] INFO hello.external.OsEnv - key: __CFBundleIdentifier, value: com.jetbrains.intellij
15:45:14.723 [main] INFO hello.external.OsEnv - key: PATH, value: /Users/cw.choiit/.local/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Library/Apple/usr/bin:/usr/local/go/bin:/Users/cw.choiit/flutter/bin:/Users/cw.choiit/.pub-cache/bin:/Users/cw.choiit/Library/Android/sdk
15:45:14.723 [main] INFO hello.external.OsEnv - key: ANDROID_SDK, value: /Users/cw.choiit/Library/Android/sdk
15:45:14.723 [main] INFO hello.external.OsEnv - key: SHELL, value: /bin/zsh
15:45:14.723 [main] INFO hello.external.OsEnv - key: PAGER, value: less
15:45:14.723 [main] INFO hello.external.OsEnv - key: IJ_RESTARTER_LOG, value: /Users/cw.choiit/Library/Logs/JetBrains/IntelliJIdea2024.1/restarter.log
15:45:14.723 [main] INFO hello.external.OsEnv - key: LSCOLORS, value: Gxfxcxdxbxegedabagacad
15:45:14.723 [main] INFO hello.external.OsEnv - key: JAVA_HOME, value: /Users/cw.choiit/Library/Java/JavaVirtualMachines/openjdk-22.0.1/Contents/Home
15:45:14.724 [main] INFO hello.external.OsEnv - key: OLDPWD, value: /
15:45:14.724 [main] INFO hello.external.OsEnv - key: USER, value: cw.choiit
15:45:14.724 [main] INFO hello.external.OsEnv - key: ZSH, value: /Users/cw.choiit/.oh-my-zsh
...

 

그런데, OS 환경 변수는 이 프로그램 뿐 아니라 다른 곳 어디서도 읽어올 수가 있다. 여러 프로그램에서 사용하는게 맞을 때도 있지만 딱 이 자바 프로그램에만 적용하고 싶을때가 있다. 이렇게 특정 자바 프로그램 안에서만 사용할 수 있는 자바 시스템 속성에 대해 알아보자.

 

외부 설정 - 자바 시스템 속성

자바 시스템 속성(Java System Properties)은 실행한 JVM 안에서 접근 가능한 외부 설정이다. 추가로 자바가 내부에서 미리 설정해두고 사용하는 속성들도 있다.

 

자바 시스템 속성은 다음과 같이 자바 프로그램을 실행할 때 사용한다.

  • java -Durl=dev -jar app.jar
  • `-D` VM옵션을 통해서 key=value 형시기을 주면 된다.
  • 순서에 주의해야 한다. `-D` 옵션이 -jar 보다 앞에 있다.

그럼 이렇게 실행하면 코드로 어떻게 읽을까? 이것도 간단하다.

JavaSystemProperties

package hello.external;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.Properties;

@Slf4j
public class JavaSystemProperties {
    public static void main(String[] args) {
        Properties properties = System.getProperties();
        for (Map.Entry<Object, Object> keyValue : properties.entrySet()) {
            log.info("key = {}, value = {}", keyValue.getKey(), keyValue.getValue());
        }
    }
}

System.getProperties()로 전체 속성을 가져올 수 있다. (타입이 Properties인데 이 PropertiesMap의 자식 타입이다)

실행결과

15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = apple.awt.application.name, value = JavaSystemProperties
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = sun.management.compiler, value = HotSpot 64-Bit Tiered Compilers
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = ftp.nonProxyHosts, value = local|*.local|169.254/16|*.169.254/16|lx.astxsvc.com|*.lx.astxsvc.com|localhost|*.localhost
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.runtime.version, value = 18.0.2+0
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = user.name, value = cw.choiit
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = path.separator, value = :
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = os.version, value = 14.5
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.runtime.name, value = OpenJDK Runtime Environment
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = file.encoding, value = UTF-8
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.vm.name, value = OpenJDK 64-Bit Server VM
...

 

사용자가 직접 VM 옵션을 추가해서 그 값을 읽어와보자.

 

JavaSystemProperties

package hello.external;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.Properties;

@Slf4j
public class JavaSystemProperties {
    public static void main(String[] args) {
        Properties properties = System.getProperties();
        for (Map.Entry<Object, Object> keyValue : properties.entrySet()) {
            log.info("key = {}, value = {}", keyValue.getKey(), keyValue.getValue());
        }

        String url = System.getProperty("url");
        String username = System.getProperty("username");
        String password = System.getProperty("password");

        log.info("url = {}", url);
        log.info("username = {}", username);
        log.info("password = {}", password);
    }
}

위에서 VM 옵션으로 url, username, password를 추가했다. 그리고 출력해보면 결과는 다음과 같다.

실행결과

...
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = sun.io.unicode.encoding, value = UnicodeBig
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = socksNonProxyHosts, value = local|*.local|169.254/16|*.169.254/16|lx.astxsvc.com|*.lx.astxsvc.com|localhost|*.localhost
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = java.class.version, value = 62.0
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = username, value = testuser
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - url = dev
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - username = testuser
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - password = test

 

사용자가 직접 코드로도 자바 시스템 속성을 추가할 수 있다 다만, 이건 코드 내에서 직접 작성하는 것이라서 외부로 설정을 분리하는 효과는 없다. 

다음과 같이 System.setProperty()로 직접 추가가 가능하다.

System.setProperty("Hi", "Hello World");

실행결과

...
16:03:35.282 [main] INFO hello.external.JavaSystemProperties - key = Hi, value = Hello World
...

 

외부 설정 - 커맨드 라인 인수

커맨드 라인 인수(Command Line Arguments)는 애플리케이션 실행 시점에 외부 설정값을 main(args) 메서드의 args 파라미터로 전달하는 방법이다.

 

다음과 같이 사용한다.

  • java -jar app.jar dataA dataB
  • 순서에 주의해야 한다. `.jar` 뒤에 스페이스로 구분해서 데이터를 전달한다. 위의 경우 dataA, dataBargs에 전달된다.

자바 시스템 속성이랑 유사하지만 명확히 구분지어야 한다. 

  • `-D`옵션은 `.jar` 앞에, 커맨드 라인 인수는 `.jar` 뒤에

그럼 이렇게 들어오는 인수는 다음과 같이 받으면 된다.

 

CommandLineV1

package hello.external;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CommandLineV1 {
    public static void main(String[] args) {
        for (String arg : args) {
            log.info("{}", arg);
        }
    }
}

 

실행결과

> Task :CommandLineV1.main()
16:06:53.708 [main] INFO hello.external.CommandLineV1 - dataA
16:06:53.711 [main] INFO hello.external.CommandLineV1 - dataB

 

근데 보면 알겠지만, 이는 `key=value` 형식이 아니다. 애플리케이션 개발하는 입장에서는 명확하게 특정 키에 해당하는 값을 찾는게 편할 때가 많다. 근데 이 경우 통으로 문자열이다. 그래서 만약 저 형식으로 데이터를 `A=dataA B=dataB` 이렇게 넣어도 저 문자열 하나가 그냥 통 문자열이라서 가져다 사용하는 개발자 입장에서는 `=`를 기준으로 데이터를 파싱하고 변환하고 이러한 번거로움이 있다.

 

근데 이를 스프링에서는 편리하게 사용할 수 있도록 스프링 만의 표준 방식을 정의해서 제공해준다. 그것을 알아보자.

 

외부 설정 - 커맨드 라인 옵션 인수

그러니까 스프링은 커맨드 라인 인수를 받을 때 key=value 형식으로 받게 도와준다. 개발자들은 이런 key=value 형식의 데이터를 선호한다. 그래서 스프링이 이렇게 받을 수 있게 도와주는데 그 방법은 `--`이다.

  • java -jar app.jar --username=dev_user --password=dev_pw

이렇게 `--`로 원하는 key=value 형식의 데이터를 전달해주고 받을 수 있다. 스프링이 자체적으로 제공해주는 기능이고 자바의 기본 기능은 아니다.

 

CommandLineV2

package hello.external;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.DefaultApplicationArguments;

import java.util.List;
import java.util.Set;

@Slf4j
public class CommandLineV2 {
    public static void main(String[] args) {
        for (String arg : args) {
            log.info("args: {}", arg);
        }

        ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
        log.info("SourceArgs = {}", List.of(applicationArguments.getSourceArgs()));
        log.info("NonOptionArgs = {}", applicationArguments.getNonOptionArgs());
        log.info("OptionsNames = {}", applicationArguments.getOptionNames());

        Set<String> optionNames = applicationArguments.getOptionNames();
        for (String optionName : optionNames) {
            log.info("option arg {}={}", optionName, applicationArguments.getOptionValues(optionName));
        }

        List<String> username = applicationArguments.getOptionValues("username");
        List<String> password = applicationArguments.getOptionValues("password");

        log.info("username = {}", username);
        log.info("password = {}", password);
    }
}

위 코드를 보면, DefaultApplicationArguments 라는 클래스를 사용한다. 여기에 커맨드 라인 인수를 넘겨주면 이 객체가 알아서 파싱을 해준다고 생각하면 된다. 그래서 실제로 위 커맨드처럼 실행을 하면 결과는 다음과 같다.

실행결과

> Task :CommandLineV2.main()
19:17:20.087 [main] INFO hello.external.CommandLineV2 - args: --username=dev_user
19:17:20.091 [main] INFO hello.external.CommandLineV2 - args: --password=dev_pw
19:17:20.104 [main] INFO hello.external.CommandLineV2 - SourceArgs = [--username=dev_user, --password=dev_pw]
19:17:20.104 [main] INFO hello.external.CommandLineV2 - NonOptionArgs = []
19:17:20.106 [main] INFO hello.external.CommandLineV2 - OptionsNames = [password, username]
19:17:20.106 [main] INFO hello.external.CommandLineV2 - option arg password=[dev_pw]
19:17:20.106 [main] INFO hello.external.CommandLineV2 - option arg username=[dev_user]
19:17:20.106 [main] INFO hello.external.CommandLineV2 - username = [dev_user]
19:17:20.106 [main] INFO hello.external.CommandLineV2 - password = [dev_pw]

 

근데 자세히 보면 타입이 List<String>이다. 이 이유는 같은 키로 된 여러개의 값을 전달할 수도 있기 때문이다.

  • java -jar app.jar --username=dev_user --password=dev_pw --username=dev_user2

이렇게 넘기면 배열로 두 개 모두를 받는다.

실행결과

...
19:23:58.534 [main] INFO hello.external.CommandLineV2 - option arg username=[dev_user, dev_user2]
19:23:58.534 [main] INFO hello.external.CommandLineV2 - username = [dev_user, dev_user2]
...

 

근데, 스프링 부트는 또 다르게 이런 방식을 도와준다. 어떻게 도와줄까?

외부 설정 - 커맨드 라인 옵션 인수와 스프링 부트

스프링 부트에서는 저 ApplicationArguments 라는 객체를 빈으로 자동으로 등록해준다.

그래서 어디서나 이 빈을 주입받아서 사용할 수 있게 된다.

 

CommandLineBean

package hello;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;

@Slf4j
@Component
public class CommandLineBean {

    private final ApplicationArguments arguments;

    public CommandLineBean(ApplicationArguments arguments) {
        this.arguments = arguments;
    }

    @PostConstruct
    public void init() {
        log.info("source = {}", List.of(arguments.getSourceArgs()));
        log.info("optionNames = {}", arguments.getOptionNames());

        Set<String> optionNames = arguments.getOptionNames();
        for (String optionName : optionNames) {
            log.info("option args {}={}", optionName, arguments.getOptionValues(optionName));
        }
    }
}

위 코드를 보면 ApplicationArguments를 주입받는다. 그리고 그 녀석을 통해서 값을 똑같이 가져오는 코드를 작성했다. 그리고 돌려보면 실행결과는 다음과 같다.

실행결과

 

이렇게 스프링 부트는 저 객체를 빈으로 자동으로 등록해주고 우리가 어디서든 주입받아 사용할 수 있게 도와준다.

 

결론

이렇게 외부로부터 어떤 데이터(설정값, 변수)를 가져오는 방법을 알아봤다. 모두 자주 사용되는 방식이다.

근데 문제가 있다. 모두 다 값을 가져오는 방식이 천차만별이다. OS 환경 변수값, 자바 시스템 속성값, 커맨드 라인 인수를 가져오는 방법이 모두 다르다. 이럴때 생각해야 하는건 `추상화`다. 인터페이스와 구현(역할과 구현)을 활용해서 스프링은 이 외부로부터 설정값을 가져오는 방식을 통합했다. 그 방법을 Part.2에서 알아보자

728x90
반응형
LIST
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

+ Recent posts