반응형

이벤트란?

프로그래밍을 하다 보면 이벤트(Event)라는 용어를 자주 듣게 됩니다. 이벤트웹 페이지에서 발생하는 각종 사건이나 상호작용을 가리키는데, 이러한 일이 일어났을 때 브라우저가 신호를 보내고 우리가 등록한 코드가 실행됩니다.

버튼을 클릭하거나 키보드를 통해 입력하는 것처럼 사용자가 이벤트를 발생시킬 수도 있고, 페이지가 로드되면서 어플리케이션이 스스로 이벤트를 발생시킬 수도 있습니다.

웹 페이지 안에서 이런 이벤트들은 이벤트 핸들러를 통해서 처리할 수 있습니다.

이벤트 핸들러는 이벤트에 대응하여 이벤트가 발생했을 때 수행할 동작을 하는 함수입니다.

이벤트 리스너라고도 합니다.

예를 들어 사용자가 버튼을 클릭하면 브라우저는 "클릭 이벤트"라는 신호를 발생시키고, 이에 반응하여 우리가 지정한 함수(이벤트 핸들러)가 실행될 수 있습니다.

이벤트 객체란?

이벤트 객체는 이벤트가 발생하면 브라우저는 이벤트에 대한 정보를 담은 객체를 전달하는데 이를 이벤트 객체라고 합니다.

일반적으로 이벤트 핸들러 함수를 정의할 때는 event , e 라는 매개변수를 넣습니다.

(넣지 않아도 됩니다. 하지만 이벤트 헨들러 함수는 항상 첫 번째 매개변수를 이벤트 객체로 인식합니다)

이 변수가 바로 이벤트 객체입니다.

이벤트 객체에는 해당 이벤트와 관련된 다양한 정보가 들어 있습니다.

예를 들어서 event.target 속성은 이벤트가 발생한 대상 element를 가리키빈다.

키보드 이벤트라면 어떤 키를 눌렀는지 알려주는 key 속성이 들어있는 KeyBoradEvent 객체가 전달되고, 마우스 이벤트의 경우에는 마우스의 좌표나 버튼 정보 등이 들어있는 MouseEvent 객체가 전달됩니다.

이러한 이벤트 객체 덕분에 이벤트 핸들러 함수 안에서 어떤 일이 일어났는지 잘 파악하고 처리할 수 있습니다.

이벤트 종류 소개

자바스크립트에서 다룰 수 있는 이벤트의 종류는 매우 많습니다.

  • 사용자의 입력
  • 브라우저의 상태 변화
  • 사용자의 클릭
  • 사용자의 마우스 위치

마우스 이벤트

마우스 이벤트는 마우스를 이용한 사용자의 동작과 관련된 이벤트 입니다.

이벤트
설명
click
사용자가 마우스로 요소를 클릭할 때 발생 (버튼을 눌렀다가 떼었을 때)
dblclick
요소를 더블 클릭할 때 발생
mouseover
마우스 커서를 요소 위에 올렸을 때 발생
mouseout
요소 위에 있던 마우스 커서가 밖으로 벗어났을 때 발생
more
  • mousedown: 마우스 버튼을 누른 순간
  • mouseup: 누른 마우스 버튼을 떼는 순간
  • mousemove: 마우스 커서의 움직임

버튼을 클릭하면 새로운 탭을 열거나 특정 요소의 색을 바꾸는 동작들을 할 수 있겠습니다.

키보드 이벤트

키보드를 입력할 때 발생하는 이벤트입니다. 폼에 글자를 입력하거나 게임에서 키 조작을 처리할 때 활용됩니다.

대표적으로는 아래 것들이 있습니다.

이벤트
설명
keydown
사용자가 키보드를 눌렀을 때 발생 (키를 누르는 순간 한 번 발생)
keyup
눌렀던 키를 떼는 순간 발생
keypress
키를 누를 때 발생하는 이벤트 (문자 입력과 관련된 키에서 동작, 현재는 keydown으로 대체됨)

키보드 이벤트의 이벤트 객체는 KeyBoardEvent이고, 여기에 어떤 키를 눌렀는지 알려주는 (key 등) 정보가 들어있습니다. 예를 들어서 event.key를 통해서 사용자가 눌린 키 값을 알아 낼 수 있습니다. 이를 활용하면 엔터 키를 감지해 폼을 전송하거나 할 수 있겠습니다.

폼 이벤트

폼과 관련된 요소에서 발생하는 이벤트입니다. 사용자 입력이나 폼 제출 등 사용자 입력 양식에 특화된 이벤트들입니다. 주요한 폼 이벤트들은 아래와 같습니다.

이벤트
설명
submit
사용자가 폼을 제출할 때 발생 (<form> 요소에서 사용, 폼 데이터 검증 및 전송 처리)
change
입력 필드(<input>, <select> 등)의 값이 변경될 때 발생 (체크 박스 상태 변경, 텍스트 입력 후 포커스를 잃었을 때 등)
focus / blur
입력 요소가 포커스를 받을 때(focus), 포커스를 잃을 때(blur) 발생 (UI에서 입력 필드 강조나 검증 메시지 표시할 때 사용)

폼 이벤트들은 사용자 입력을 검증하거나 실시간으로 반응하는 양식(form) 관련 기능을 만들 때 꼭 알아야 하는 이벤트들입니다.

이벤트 사용 방법 소개

이벤트를 처리하려면 요소에 이벤트 리스너를 등록해야합니다.

이벤트 리스너는 이름처럼 특정 이벤트가 발생하기를 경청하고 있다가, 해당 이벤트가 발생하면 등록된 이벤트 리스너 함수를 실행합니다.

이벤트를 등록하는 방법은 두 가지가 있습니다.

  • addEventListener 메소드 사용
  • HTML 태그에 직접 이벤트 속성 추가

가장 많이 사용하고 권장되는 방법은 addEventListener 메소드를 사용하는 것입니다.

addEventListener 사용해서 이벤트 등록하기

Copy
요소노드.addEventListener("이벤트명", 이벤트헨들러함수)
  • 첫 번째 인자로 문자열 이벤트 타입명을 지정합니다. 클릭이면 “click” , 키보드 입력이면 "keydown” 을 입력합니다.
  • 두 번째 인자로는 이벤트 발생 시 호출한 핸들러 함수를 전달합니다.

이 함수는 해당 이벤트가 일어날 때 실행될 코드 블럭입니다.

예를 들어서 버튼 요소에 클릭 이벤트 헨들러 등록하고 싶다면 아래와 같이 합니다.

Copy
const btn = document.querySelector('button');
btn.addEventListener('click', function(event) {
    console.log('버튼이 클릭되었습니다!');
});

이제 브라우저는 해당 버튼을 사용자가 클릭하는 순간 우리의 함수가 호출되어 “버튼이 클릭되었습니다!” 라는 메세지를 콘솔에 출력합니다.

한 요소에 여러 개의 리스너를 등록할 수도 있습니다. addEventListener를 같은 요소에 여러번 호출하면 각각의 핸들러가 모두 실행됩니다.

또한 addEventListener를 통해서 등록한 리스너는 나중에 다시 제거할 수도 있습니다. 필요에 따라서 removeEventListener를 호출해서 특정 핸들러의 동작을 그만두게 할 수 있습니다.

HTML 속성으로 이벤트 등록

이벤트를 등록하는 다른 방법으로는 HTML 태그에 이벤트를 직접 작성하는 방식이 있습니다.

예를 들어서 <button onclick="alert('Hello');">클릭</button> 처럼 요소의 attribute 노드 로 onclick 를 달아서 클릭 시 실행할 자바스크립트 코드를 직접 넣는 방법입니다. 옛날에는 이런 방식을 많이 사용했지만 지금은 권장되는 방법은 아닙니다.

(그래도 배우는 이유가 있는데 React 배울 때 알 수 있습니다)

간단한 예를 들어보겠습니다.

Copy
<button onclick="changeColor()">배경 색상 변경</button>

그리고 자바스크립트 코드를 changeColor 함수를 정의합니다.

Copy
<script>
  function changeColor() {
    document.body.style.backgroundColor = 'skyblue';
  }
</script>

이렇게 하면 사용자가 버튼을 클릭할 때마다 onclick 속성에 지정된 changeColor() 함수가 호출되어 페이지 배경 색상이 하늘색으로 바뀝니다.

동작 자체는 잘 되지만 HTML과 자바스크립트 코드가 혼재되어서 유지보수가 어렵습니다. 그리고 가독성도 아주 별로에요.

그래서 이런 방법은 지양하는 것이 좋습니다.

이벤트 사용 실습

이제 이론을 봤으니, 간단한 실습 예제로 이벤트를 사용하는 방법을 확인해봅시다.

버튼을 클릭하면 텍스트를 바꾸고, 이벤트 객체의 정보를 콘솔에 출력하는 예제를 만들어 볼게요.

같이 해봅시다!

Copy
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>이벤트 실습</title>
</head>
<body>

  <button id="myButton">눌러보세요</button>
  <p id="message">버튼을 아직 누르지 않았습니다.</p>

  <script>
    // 여기에 자바스크립트 코드를 작성합니다!
  </script>

</body>
</html>

우선 실습을 하기 전에 이벤트 객체를 console.log 로 찍어보고 넘어가겠습니다.

그리고 event 객체를 같이 뒤져봅시다.

버튼을 클릭해서 이벤트 객체 찍어보기
Copy
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>이벤트 실습</title>
  </head>
  <body>
    <button id="myButton">눌러보세요</button>
    <p id="message">버튼을 아직 누르지 않았습니다.</p>

    <script>
      const btn = document.getElementById("myButton");

      btn.addEventListener("click", (event) => {
        console.log(event);
      });
    </script>
  </body>
</html>

이제 버튼을 누르면 p태그의 내용이 바뀌도록 해보겠습니다.

  1. 버튼 클릭해서 msg 요소 텍스트 바꾸기
  2. 버튼 클릭으로 자기 요소의 배경색 바꾸기

위 코드에서는 HTML에 하나의 버튼과 하나의 <p> 요소가 있습니다. 자바스크립트에서 getElementById로 이 요소들을 가져와 변수 btn과 msg에 저장한 후, btn에 대해 click 이벤트 리스너를 등록했습니다.

이벤트 핸들러 함수 내부를 보면, 먼저 msg 요소의 텍스트를 "버튼이 눌렸습니다!"로 바꾸고 있습니다.

 

그 다음 e.target.style.backgroundColor = 'yellow' 코드를 통해 이벤트 객체 etarget 속성, 즉 클릭된 요소 자체의 배경색을 노란색으로 변경했습니다. 마지막으로 console.log를 사용해 이벤트 객체 e의 type 속성을 출력했는데, 클릭 이벤트의 경우 "click"이라는 문자열이 들어있어 콘솔에 이벤트 타입: click이라고 표시됩니다. 이 페이지를 실제로 열고 버튼을 누르면, 페이지 내 텍스트가 "버튼이 눌렸습니다!"로 바뀌고 버튼의 배경색도 노란색으로 변합니다.

 

개발자 콘솔을 열어보면 "이벤트 타입: click"이라는 로그가 찍힌 것을 확인할 수 있습니다. 이처럼 이벤트 리스너 내부에서 이벤트 객체 e를 활용하면 어떤 요소에서 이벤트가 발생했는지(e.target), 어떤 종류의 이벤트인지(e.type) 등 유용한 정보를 얻어 활용할 수 있습니다.

완성코드
Copy
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>이벤트 실습</title>
</head>
<body>
  <button id="myButton">눌러보세요</button>
  <p id="message">버튼을 아직 누르지 않았습니다.</p>

  <script>
    const btn = document.getElementById('myButton');
    const msg = document.getElementById('message');

    // 버튼에 클릭 이벤트 리스너 등록
    btn.addEventListener('click', function(e) {
      // 이벤트 핸들러 내부
      msg.textContent = '버튼이 눌렸습니다!';      // <p> 태그의 내용 변경 (text node를 변경)
      e.target.style.backgroundColor = 'yellow';   // 이벤트 발생 대상(btn)의 배경색 변경
      console.log('이벤트 타입:', e.type);           // 콘솔에 이벤트 타입 출력 (예: "click")
    });
  </script>
</body>
</html>

이벤트 버블링 막기

마지막으로 알아볼 개념은 이벤트 버블링(Event Bubbling)과 이를 제어하는 방법입니다.

이벤트 버블링이란 부모-자식 관계에 있는 요소에서 이벤트가 발생할 때의 전파 특성을 말합니다.

간단히 말해, 어떤 요소에서 이벤트가 발생하면 그 이벤트가 해당 요소뿐만 아니라 부모 요소에도 전달되어 올라가는 현상을 이벤트 버블링이라고 합니다. 예를 들어 설명해볼게요. <div> 안에 <button>이 있는 구조에서 <div>와 <button> 모두 클릭 이벤트 핸들러를 등록했다고 해봅시다.

 

이 경우 사용자가 버튼을 클릭하면 먼저 버튼의 클릭 핸들러가 실행된 후, 이벤트가 상위 요소로 전파되어 결국 감싸고 있는 <div>의 클릭 핸들러도 이어서 실행됩니다.

이렇게 자식에서 발생한 이벤트가 부모로 전파되는 것이 버블링입니다 (마치 물속 거품이 아래에서 위로 올라가는 것과 비슷한 이미지라서 bubbling이라 부릅니다). 하지만 모든 경우에 이벤트 버블링이 바람직한 것은 아닙니다.

 

경우에 따라서는 버블링 때문에 원치 않는 부수 효과가 생기기도 합니다. 앞의 예시에서 사용자가 버튼만 클릭했는데 부모 <div>에 연결된 동작까지 실행되는 것은 의도와 다를 수 있겠지요.

이럴 때는 이벤트 전파를 중단시킬 수 있습니다.

방법은 매우 간단한데, 이벤트 핸들러 함수 내부에서 event.stopPropagation() 메서드를 호출하면 됩니다. stopPropagation()을 호출하면 해당 이벤트는 더 이상 상위 요소로 전파되지 않고 그 자리에서 전파가 멈춥니다.

이벤트 버블링 막기 실습

이론으로 알려드렸던 상황을 직접 구현해보겠습니다.

Copy
<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>이벤트 실습</title>
  </head>
  <body>
    <div id="parent" style="padding: 20px; background-color: #ddd">
      <button id="child">버튼</button>
    </div>

    <script>
      // 여기에 자바스크립트 코드를 작성합시다!
    </script>
  </body>
</html>

위 코드에서 회색 배경을 가진 <div id="parent">와 그 안에 <button id="child">가 있습니다.

 

parent <div>에는 클릭할 때 alert로 "부모 요소 DIV 클릭 이벤트 발생!"이 뜨도록 핸들러를 달았고, child 버튼에도 "자식 요소 BUTTON 클릭 이벤트 발생!" 경고창을 띄우는 핸들러를 달겠습니다. 이 상태로 페이지에서 버튼을 클릭하면 어떻게 될까요?

 

먼저 버튼의 핸들러가 실행되어 "자식 요소 BUTTON 클릭 이벤트 발생!" 얼럿이 뜨고, 그 다음 바로 부모 <div>의 핸들러도 실행되어 "부모 요소 DIV 클릭 이벤트 발생!" 얼럿이 나타납니다.

 

즉, 버튼 클릭 하나로 두 개의 핸들러가 순차적으로 호출된 것이죠.

이것이 이벤트 버블링으로 인한 효과입니다.

저는 하나의 요소만 클릭되게 하고 싶어서 이벤트 버블링에 의해서 두 요소가 클릭된 것으로 간주됩니다.

버튼의 클릭 이벤트가 상위 <div>로 전파되었기 때문입니다.

이제 버튼 핸들러에서

Copy
e.stopPropagation()

줄의 주석을 해제하고 다시 버튼을 클릭해보세요.

이번에는 버튼의 핸들러만 실행되고 부모 <div>의 핸들러는 실행되지 않습니다.

Copy
stopPropagation()

을 호출함으로써 버튼 클릭 이벤트가 부모로 퍼져나가는 것을 막았기 때문입니다.

이렇게 하면 사용자가 의도하지 않은 상위 요소의 동작이 발생하는 것을 예방할 수 있습니다.

완성 코드
Copy

 

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>이벤트 실습</title>
  </head>
  <body>
    <div id="parent" style="padding: 20px; background-color: #ddd">
      <button id="child">버튼</button>
    </div>

    <script>
      const parent = document.getElementById("parent");
      const child = document.getElementById("child");

      parent.addEventListener("click", function () {
        alert("부모 요소 DIV 클릭 이벤트 발생!");
      });

      child.addEventListener("click", function (e) {
        alert("자식 요소 BUTTON 클릭 이벤트 발생!");
        //e.stopPropagation(); // ← 이 줄을 활성화하면 버블링을 막습니다.
      });
    </script>
  </body>
</html>



반응형

'' 카테고리의 다른 글

[Web] DOM과 브라우저의 렌더링 프로세스  (0) 2025.07.24
반응형

자바스크립트를 배우고 개발을 하다 보면 브라우저에 기반한 여러가지 객체들을 마주할 일이 많습니다.

이 객체들이 어떻게 생겼고 프로퍼티가 뭐가 있는지 확인하기가 마냥 쉽지 만은 않습니다.

근데 자바스크립트는 웹 브라우저에서 동작하기 때문에 브라우저에 기반한 객체들을 잘 알수록 좋습니다.

이번에는 DOM에 대해서 살펴보겠습니다.

DOM이란?

DOM이란 Document Object Model 의 약자입니다.

Document는 문서, Object는 객체, Model은 모델. 직역하면 문서 객체 모델입니다.

여기서 두 단어로 분리해서 생각해봅시다. 문서 객체 모델

Document Object(문서 객체)

HTML은 <body>, <p>, … 등 여러 태그로 이루어져 있습니다.

 태그들을 자바스크립트가 이용할 수 있게 객체로 만들면 그것을 문서 객체라고 합니다.

그렇다면 문서 객체는 직관적으로 HTML 문서가 객체로 나타내져 있다고 생각할 수 있겠어요.

HTML 문서를 객체로 표현하면 자바스크립트가 이용할 수 있습니다.

그렇게 우리는 자바스크립트를 통해서 웹을 개발할 수 있어요.

Model(모델)

모델은 구조적이고, 논리적은 형태를 의미합니다.

실제로 DOM은 문서 객체들 간에 계층 구조를 Tree 형태로 가지고 있습니다.

따라서 DOM 웹 브라우저가 HTML을 브라우저가 인식하는 논리적인 형태를 의미합니다.

DOM의 계층 구조

앞서 DOM Tree의 계층 구조를 가진다고 말씀드렸습니다.

Tree 자료 구조를 잘 모르시면 봐주세요. 간단한 설명입니다.

실제로 아래와 같이 생겼습니다.

document 노드가 맨 위에 있고, 아래로 element, text, attribute 노드가 계층을 이룹니다.

모든 노드는 객체입니다.

Node란?

element 노드들은 html , head , body , meta , title , div 과 같은 HTML 요소 태그 자체를 나타내는 노드를 의미합니다.

text 노드들은 순수한 텍스트를 나타내는 노드를 말합니다.

attribute 노드들은 요소의 속성을 나타내는 노드를 의미합니다. 이해를 돕기 위해서 자그마한 코드 하나를 뜯어보겠습니다.

Copy
<div id="container">
	<h1 style="color: green;">Hello!!</h1>
	<p>hahaha <b>Good Luck~!</b></p>
</div>
  • div element 노드의 자식으로 h1 element 노드와 p element 노드, id="container”라는 attribute 노드가 있습니다.
  • h1 element 노드의 자식으로 Hello!!라는 text 노드 와 style="color: green;"라는 attribute 노드가 있습니다.
  • p element 노드 의 자식으로 hahaha라는 text 노드 와 b element 노드 가 있습니다.
  • b element 노드 의 자식으로 Good Luck~!라는 text 노드 가 있습니다.

브라우저의 작동 원리

초기 렌더링

모든 브라우저는 HTML과 CSS로 작성한 웹 페이지를 화면에 렌더링 시켜줍니다.

브라우저는 Critical Rendering Path라는 과정을 통해서 우리에게 보여줍니다.

  • 1단계 : 우리가 작성한 HTML과 CSS를 각각 DOM과 CSSOM이라는 모델로 변환합니다.
  • 2단계: 이전 단계에서 만들어진 DOM과 CSSOM을 합쳐 Render Tree라는 것을 만듭니다.
  • 3단계: Layout 단계입니다. 웹 페이지 안에서 HTML요소들이 어떤 위치에 어떤 사이즈로 등장할지 계산하는 과정을 Layout과정이라고 합니다.
  • 4단계: Painting 과정입니다. Painting 과정은 말 그대로 화면에 요소들을 그려내는 과정을 말합니다. 이 과정까지 마치면 우리는 실제로 눈으로 볼 수 있게 됩니다.

업데이트

업데이트는 이벤트에 따라서 화면이 실시간으로 변하는 것을 말합니다.

이 과정은 자바스크립트가 DOM 을 수정하면서 발생합니다.

우리가 removeChild, appendChild textContent, … 같은 DOM API를 이용해서 DOM을 수정하면 브라우저가 DOM의 변경을 감지하게 되고, Render Tree를 다시 만들고, Layout을 다시 계산하고, Painting을 다시 합니다.

이 과정을 통해서 업데이트가 됩니다.

하지만 한 가지 조심해야할 점이 있습니다

Layout을 재계산하는 것과 Painting을 다시 하는 것은 전체 계산에서도 가장 코스트가 많이 드는 과정입니다. 코스트가 많이 든다는 것은 연산 자체의 수행이 오래 걸린다는 말입니다.

(추가적으로 Layout 을 다시 그리면 Reflow , Painting 을다시하면 Repaint 라고도 합니다.)

결론적으로 DOM을 조작할 때는 DOM의 수정 횟수를 최소화하는 것이 유리합니다.

혹은 동일한 작업이라면 수정 사항들을 모아서 한번에 처리하는 것이 효율적입니다.

직접 확인해 볼 수 있는 코드
<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>김경우의 자시고개</title>
  </head>
  <script>
    function onClick() {
      const $ul = document.getElementById("ul");
      let list = "";

      for (let i = 0; i < 3000; i++) {
        $ul.innerHTML += `<li>${i}</li>`;
      }
    }
  </script>
  <body>
    <button onclick="onClick()">리스트 추가 버튼</button>
    <ul id="ul"></ul>
  </body>
</html>

DOM 조작하기

방금까지 살펴본 건 이미 작성된 HTML 태그들을 통해서 DOM의 개념을 살펴봤습니다.

사실 동적으로(HTML 태그를 차곡차곡 써넣는 방식 X) DOM을 조작할 수 있습니다.

바로 ⭐자바스크립트 코드⭐를 이용해서 할 수 있어요.

DOM을 조작할 때는 DOM API를 사용합니다. 이 API는 브라우저가 자바스크립트를 통해서 제공하는 내장된 API입니다.

자주 쓰는 DOM API 입니다.

실제로 아래의 코드를 기반으로 실습을 진행해보겠습니다.

우리가 할 실습은 class=”content”인 요소에 우리가 먼저 작성해둔 text를 DOM 조작을 통해서 다른 text로 바꿔보는 것입니다.

또한 id=”changeBtn”인 요소를 클릭하면 id=”title”인 요소의 내용이 바뀌도록 하겠습니다.

  1. class=”content” 인 요소에 애초에 작성되어있던 text를 DOM 조작을 통해 다른 text로 바꾸기
  2. id=”changeBtn” 인 요소를 클릭하면 “id=title” 인 요소의 내용이 바뀌게 하기
완성 코드
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>DOM 조작 예시</title>
  </head>
  <body>
    <h1 id="title">안녕하세요</h1>
    <p class="content">여기에 내용을 추가합니다.</p>

    <button id="changeBtn">내용 변경하기</button>

    <script>
	    const titleEleemnt = document.getElementById("title");
      // id로 요소 가져오기

      const contentElement = document.querySelector(".content");
      // class로 요소 가져오기

      const buttonElement = document.getElementById("changeBtn");
      // 버튼 요소 가져오기

      contentElement.textContent = "내용 변경 짠~!";
      // 요소의 텍스트 변경

      titleEleemnt.innerHTML = "<em>안녕하세요</em>";
      // 요소의 HTML 변경

      buttonElement.addEventListener("click", function () {
        titleEleemnt.textContent = "반가워요!";
        // 버튼 클릭 시 제목 변경
      });
    </script>
  </body>
</html>



반응형

'' 카테고리의 다른 글

[Web] 이벤트와 이벤트 버블링  (0) 2025.07.24
반응형

빈 생명주기 콜백

빈 생명주기 콜백은 스프링 빈이 생성되고 의존관계 주입이 완료된 후 초기화 작업을 수행하거나 스프링 컨테이너가 종료되기 직전에 소멸 작업을 수행하도록 돕는 기능이다.

데이터베이스 커낵션 풀이나 네트워크 소켓처럼 어플리케이션 시작 시점에 미리 필요한 연결을 맺고, 종료 시점에 연결을 끊는 작업에 주로 사용된다.

스프링 빈은 아래와 같은 라이프사이클을 가진다.

스프링 컨테이너 생성 → 스프링 빈 생성 → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸전 콜백 → 스프링 종료

객체의 생성과 초기화를 분리하는 것이 유지보수 관점에서 좋다.

생성자는 필수 정보를 받고 메모리를 할당하여 객체를 생성하는 책임을 진다.

초기화는 생성된 값들을 활용하여 외부 커넥션 연결과 같은 무거운 동작을 수행한다.

스프링 빈 생명주기 콜백을 지원하는 방법은 3가지가 있다.

  • 인터페이스 방식
  • 빈 등록 초기화, 소멸 메소드 지정 방식
  • 애노테이션 방식

인터페이스 방식

InitializingBean 인터페이스를 구현하여 afterPropertiesSet() 메소드로 초기화를 지원한다.

DisposableBean 인터페이스를 구현하여 destroy() 메소드로 소멸을 지원한다.

Copy
package hello.core.lifecycle;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

public class NetworkClient implements InitializingBean, DisposableBean {

    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("disconnect: " + url);
    }

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

    @Override
    public void destroy() throws Exception {
        disconnect();
    }
}
Copy
package hello.core.lifecycle;

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

public class BeanLifeCycleTest {

    @Test
    public 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("http://hello-spring.dev");

            return networkClient;
        }
    }
}

초기화와 소멸 시점에 명확하게 특정 로직을 실행할 수 있다.

하지만

코드가 스프링 전용 인터페이스에 의존하게 된다. 따라서 초기화, 소멸 메소드의 이름을 변경할 수 없다. 그리고 코드를 고칠 수 없는 외부 라이브러리에는 적용할 수 없게 된다.

그래서 이 방식은 현재는 거의 사용되지 않는 스프링 초창기 방법이다.

빈 등록 초기화, 소멸 메소드 지정 방식

이 방식은 @Bean 애노테이션에 initMethod와 destroyMethod 속성을 사용하여 초기화 메소드와 소멸 메소드의 이름을 직접 지정하는 방식이다.

먼저 NetworkClient 클래스 내부에 init()과 close()라는 이름의 초기화, 소멸 메소드를 직접 정의한다. 이 메소드들은 특별한 인터페이스를 구현하거나 특정 애노테이션을 붙일 필요가 없다.

Copy
package hello.core.lifecycle;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

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("disconnect: " + url);
    }

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

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

스프링 설절 클래스에서 @Bean 애노테이션을 사용할 때, initMethod와 destroyMethod 속성에 각각 정의한 초기화 및 소멸 메소드의 이름을 문자열로 지정한다.

Copy
package hello.core.lifecycle;

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

public class BeanLifeCycleTest {

    @Test
    public void lifeCycleTest() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);

        NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {

        @Bean(initMethod = "init", destroyMethod = "close")
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello-spring.dev");

            return networkClient;
        }
    }
}

코드를 실행하면 아래와 같은 결과를 얻을 수 있다.

  • 생성자 호출, url = null : NetworkClient 객체가 생성될 때 생성자가 호출된다. 이때 url은 아직 주입되지 않아 null인 상태다.
  • connect: “http://hello-spring.dev”, call: “http://hello-spring.dev : setUrl을 통해 url이 주입된 후 initMethod로 지정된 init() 메소드가 호출된다. 이 메소드 안에서 connect()와 call()이 정상적으로 url 값을 사용하여 실행된다.
  • disconnect: ““http://hello-spring.dev” : ac.close()가 호출되어 스프링 컨테이너가 종료될 때, destroyMethod로 지정된 close() 메소드가 호출되어 연결을 끊는 작업이 수행된다.

결과로 객체가 생성된 직후에는 의존성이 주입이 완료되지 않아 필요한 데이터를 사용할 수없지만 초기화 콜백 시점에는 모든 의존관계 주입이 완료되어 안전하게 초기화 작업을 수행할 수 있음을 알 수 있다.

장점

  • 메소드 이름을 자유롭게 지정할 수 있다
  • 스프링 빈이 스프링 전용 인터페이스에 의존하지 않기 때문에 스프링 코드에 종속적이지 않는다.
  • 코드를 수정할 수 없는 외부 라이브러리에도 초기화, 종료 메소드를 적용할 수 있다. 예를 들어서 외부 라이브러리의 특정 메소드를 초기화 또는 소멸 사점에 호출 할 수 있다.

종료 메소드 추론

@Bean 의 destroyMethod 속성에는 기본적으로 (inferred) 값이 설정되어 있다.

이 기능은 close, shutdown 과 같은 이름을 가진 메소드를 자동으로 종료 메소드로 인식하고 호출한다.

따라서 만약 소멸 메소드의 이름이 close나 shutdown이면 destroyMethod 속성을 따로 지정하지 않아도 자동으로 동작한다.

이 추론 기능을 원치 않는 경우 destroyMethod=”” 와 같이 빈 문자열을 지정하여 비활성화 할 수 있다.

결론적으로 이 방식은 스프링에 종속되지 않으면서 유연하게 초기화나 소멸 메소드를 지정할 수 있어서 외부 라이브러리 연동 시 특히 유용하다.

그러나 스프링에서 가장 권장하는 방법은 애노테이션 방식이다. 따라서 외부 라이브러리를 사용하는 경우에만 이 방식을 사용하는 것이 일반적이다.

애노테이션 방식

이 방식은 스프링에서 가장 권장하는 빈 생명주기 콜백 방식이다.

@PostConstruct 애노테이션은 빈의 의존관계 주입이 완료된 후 초기화 작업을 수행할 메소드에 붙이고, @PreDestroy 애노테이션은 빈이 소멸되기 직전에 호출될 메소드에 붙인다.

두 애노테이션은 스프링에 종속적인 기술이 아니라 JSR-250이라는 자바 표준이기 때문에, 스프링이 아닌 다른 컨테이너에서도 동작한다,

NetworkClient 클래스 내부에 정의된 메소드 init()과 소멸 메소드 clsoe()에 각각 @PostConstruct와 @PreDestroy애노테이션을 붙인다. 이 애노테이션을은 모두 javax.annotation 패키지에 들어있다.

Copy
package hello.core.lifecycle;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

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("disconnect: " + url);
    }

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

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

주의할 점은 스프링 부트 3.0이상에서는 javax 패키지 이름이 jakarta로 변경되었다.

따라서 두 애노테이션도 jakarta.annotation.~~~ 을 사용해야한다.

이 방식에서는 @Bean 애노테이션에 initMethod나 destroyMethod 속성을 따로 지정할 필요가 없다. 스프링 컨테이너가 @PostConstruct와 @PreDestroy 애노테이션이 붙은 메소드를 자동으로 인식하고 호출한다.

Copy
package hello.core.lifecycle;

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

public class BeanLifeCycleTest {

    @Test
    public 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("http://hello-spring.dev");

            return networkClient;
        }
    }
}

이 방식은 애노테이션 하나만 붙이면 되기 때문에 사용하는게 매우 편한다.

또 스프링 전용 인터페이스에 의존하지 않고 JSR-250이라는 자바 표준이기 때문에, 스프링이 아닌 다른 컨테이너에서도 잘 동작한다.

컴포넌트 스캔과도 잘 어울린다.

하지만 코드를 고칠 수 없는 외부 라이브러리에는 적용이 불가능하다. 그래서 외부 라이브러리를 사용할 때는 초기화, 소멸 메소드 지정 방식을 사용해야 한다.

반응형

' > Spring' 카테고리의 다른 글

[Spring] 의존관계 자동 주입  (0) 2025.07.21
[Spring] 컴포넌트 스캔  (1) 2025.07.21
[Spring] 싱글톤 컨테이너  (0) 2025.07.21
[Spring] 스프링 컨테이너와 스프링 빈  (0) 2025.07.21
[Spring] 스프링 핵심 원리 이해2  (0) 2025.07.21
반응형

스프링의 의존 관계 자동 주입

의존 관계 자동 주입은 스프링이 애플리케이션 컴포넌트 간의 의존관계를 자동으로 연결해주는 기능이다. 이전에는 AppConfig 와 같은 설정 클래스에서 개발자가 직접 객체를 생성하고, 생성자를 통해 필요한 객체들을 주입해 주었다.

이 과정에서 관심사의 분리, DIP 위반 해결, OCP 준수와 같은 객체 지향 원칙을 적용할 수 있었다,

하지만 스프링 빈이 수십, 수백 개가 되면 AppConfig 에서 일일이 @Bean 을 튱해 등록하고 의존관계를 명시하는 것은 매우 번거롭고 관리하기가 어려워진다.

이 문제를 해결하기 위해서 스프링은 컴포넌트스캔(@ComponentScan)의존관계 자동 주입(@Autowired) 기능을 제공하여 개발의 편의를 높였다.

컴포넌트 스캔은 @Component 애노테이션이 붙은 클래스들을 자동으로 스프링 빈으로 등록하며, @Autowired 는 스프링 컨테이너가 해당 스프링 빈을 찾아 자동으로 의존관계를 주입해준다.

다양한 의존관계 주입 방법

스프링에서 의존 관계를 주입하는 방법은 크게 4자기이다.

생성자 주입

  • 생성자가 호출되는 시점에 딱 1번만 호출되는 것이 보장된다.
  • 주로 불변이면서 필수적인 의존관계에 사용된다.
  • 객체 생성 시점에 모든 의존관계가 주입되므로 일관성 있는 상태를 유지할 수 있다.
  • 주입할 데이터가 누락될 경우 컴파일 오류가 발생하여 가장 빠르고 확실하게 문제를 알 수 있다.
  • 필드에 final 키워드를 사용할 수 있어, 컴파일 시점에 주입 누락 오류를 방지할 수 있다.
  • 가장 권장되는 주입방식이다.
Copy
@Component
public class OrderServiceImpl implements OrderService {
 private final MemberRepository memberRepository;
 private final DiscountPolicy discountPolicy;
 @Autowired
 public OrderServiceImpl(MemberRepository memberRepository, DiscountPolic discountPolicy) {
	 this.memberRepository = memberRepository;
	 this.discountPolicy = discountPolicy;
 }
}

수정자 주입

  • setter 메소드를 통해 의존 관계를 주입하는 방식이다.
  • 선택적이거나 변경 가능성이 있는 의존관계에 사용된다.
  • @Autowired 의 기본 동작은 주입할 대상이 없으면 오류가 발생하지만 required = false 옵션을 통해 주입 대상이 없어도 동작하게 할 수 있다.
Copy
@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;
 }
}

필드 주입

  • 필드에 @Autowired 애노테이션을 직접 붙여 주입하는 방식이다.
  • 코드가 매우 간결해 보이지만, 외부에서 변경이 불가능하여 테스트하기 어렵다는 점이 치명적 단점이다.
  • DI 프레임워크(스프링) 없이 순수한 자바 코드로 단위 테스트를 할 때, 의존관계 주입이 누락되어 NPE(Null Point Exception)이 발생할 수 있다.
  • 일반적으로는 사용하지 않는 것을 권장한다. 애플리케이션의 실제 코드와 관계 없는 테스트 코드나 @Configuration 같은 스프링 설정을 목적으로 하는 곳에서만 특별한 용도로 사용될 수 있다.
Copy
@Component
public class OrderServiceImpl implements OrderService {
 @Autowired
 private MemberRepository memberRepository;
 @Autowired
 private DiscountPolicy discountPolicy;
}

일반 메소드 주입

  • 생성자 주입이나 수정자 주입처럼 특정 규약없이 일반 메소드에 @Autowired 를 적용하여 주입받는 방식이다.
  • 한 번에 여러 필드를 주입받을 수 있다.
  • 일반적으로는 잘 사용하지 않는다.
Copy
@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;
 }
}

옵션 처리

주입할 스프링 빈이 선택적이라 없어도 동작해야 하는 경우가 있다. @Autowired 의 기본 required 옵션은 true 이므로, 주입 대상이 없으면 오류가 발생한다. 이를 처리할 수 있는 방법들은 아래와 같다.

  • @Autowired(required=false) : 자동 주입할 대상이 없으면 해당 메소드 자체가 호출되지 않는다.
  • org.springframework.lang.@Nullable : 자동 주입할 대상이 없으면 null 이 입력된다.
  • Optional<> : 자동 주입할 대상이 없으면 Optional.empty 가 입력된다.

코드 예시

Copy
package hello.core.autowired;

import hello.core.member.Grade;
import hello.core.member.Member;
import jakarta.annotation.Nullable;
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.context.annotation.Bean;
import java.util.Optional;

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

    static class TestBean {
//        @Bean
//        public Member member() {
//            return new Member(1L, "guest", Grade.BASIC);
//        }

				// 호출 안됨: required = false 이므로 주입 대상이 없으면 메소드 자체 호출 X
        @Autowired(required = false)
        public void setNoBean1(Member noBean1) {
            System.out.println("noBean1 = " + noBean1);
        }

				// null 호출: 주입 대상이 없으면 null이 주입됨
        @Autowired
        public void setNoBean2(@Nullable Member noBean2) {
            System.out.println("noBean2 = " + noBean2);
        }

				// Optional.empty 호출: 주입 대상이 없으면 Optional.empty가 주입됨
        @Autowired
        public void setNoBean3(Optional<Member> noBean3){
            System.out.println("noBean3 = " + noBean3);
        }
    }
}

현재 Memebr 는 스프링 빈이 아니다.

따라서 3가지 경우에서 모두 호출되지 않는다.

생성자 주입을 선택해야 하는 이유

스프링을 포함한 대부분 DI 프레임워크는 생성자 주입을 가장 권장한다.

주된 이유를 아래와 같다.

  • 불변성: 대부분의 의존관계는 애플리케이션 종료 시점까지 변경될 일이 없으며, 오히려 변경되면 안된다. 생성자 주입은 객체 생성 시점에 딱 한 번만 호출되므로, 이후에 변경될 우려가 없어 불변하게 설계할 수 있다. 수정자 주입은 setXXXX 메소드를 public으로 열어두어야 하므로, 누군가 실수로 값을 변경할 가능성이 있다.
  • 필수 의존관계 누락 방지: 생정자 주압은 필요한 모든 의존관계를 생성자의 파라미터로 받기 때문에, 필수적인 의존관계가 누락될 경우 컴파일 시점에 오류가 발생한다. 따라서 개발자가 문제를 즉시 인지하고 해결할 수 있게 도와준다. 반면 수정자 주입이나 필드 주입은 런타임에 NPE가 발생할 수 있다.
  • final 키워드 사용: 생성자 주입 방식만 필드에 final 키워드를 사용할 수 있다. final 은 필드 값이 한 번 할당되면 변경될 수 없음을 의미하며, 생성자에게 값이 설정되지 않는 오류를 컴파일 시점에 막아준다.

결론적으로, 기본적으로 생성자 주입을 사용하고, 필수 값이 아닌 경우에만 수정자 주입을 옵션으로 사용하는것이 좋다. 필드 주입은 사용하지 말자.

롬복과 최신 트렌드

생성자 주입 방식은 불변성을 보장하고, 필수 의존관계 누락 시 컴파일 시점에 오류를 발견할 수 있으며, 필드에 final 키워드를 사용할 수 있는 등 여러 장점이 있었다. 그러나 final 필드가 많아질 경우, 생성자의 코드가 길어져 번거로울 수 있다는 단점이 있었다.

이런 단점을 롬복(Lombok)라이브러리의 @RequiredArgsConstructor 애노테이션을 활용하면 해결할 수 있다.

자동 생성자 생성으로 @RequiredArgsConstructor 애노테이션은 final 키워드가 붙은 필드들을 자동으로 인식하여 해당 필드를 인자로 받는 생성자를 자동으로 생성해준다. 이제 더 이상 굳이 직접 생성자 코드를 작성하지 않아도 된다.

롬복은 자바의 애노테이션 프로세서 기능을 사용하여 컴파일 시점에 생성자 코드를 자동으로 생성해준다,

롬복을 사용하면 필드 선언만으로 의존 관계 주입을 위한 생성자 코드를 생략할 수 있어 코드가 매우 간결해진다. 이는 불필요한 보일러플레이트 코드를 줄여 전체적인 코드 가독성을 높여준다.

생성자 주입의 모든 장점을 유지한다. @RequiredArgsConstructor 는 결국 생성자 주입 방식을 사용하므로, 불변성 보장, 필수 의존 관계 누락 방지 (컴파일 시점 오류 발견), 그리고 final 키워드 사용 가능이라는 생성자 주입의 모든 핵심 장점을 그대로 유지한다.

Copy
package hello.core.order;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService{

    //private final MemberRepository memberRepository = new MemoryMemberRepository();
    //private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

//    @Autowired
//    public OrderServiceImpl(MemberRepository memberRepository, 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);
    }

    // AppConfig가 초기화될 때 이 부분이 싱글톤인지 확인하기 위해 생성한 테스트용 함수
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

주석 처리 되지 않는 코드들을 보면 확실하게 코드의 양이 줄어들면서 가독성이 좋아진 것을 알 수 있다.

조회 빈이 2개 이상일 때 문제점

@Autowired 의 기본 동작은 타입으로 스프링 빈을 조회하여 의존 관계를 주입한다.

이는 ac.getBean(DiscountPolicy.class)와 유사하게 동작한다고 할 수 있다. (정확하게 그런건 아님)

Copy
@Autowired
private DiscountPolicy discountPolicy

만약에 DiscountPolicy 인터페이스에 FixDiscountPolicy와 RateDiscountPolicy 두 개의 하위 타입 스프링 빈이 모두 등록되어 있는 경우를 예로 들 수 있다.

Copy
@Component
public class FixDiscountPolicy implements DiscountPolicy {}
Copy
@Component
public class RateDiscountPolicy implements DiscountPolicy {}

@Component 애노테이션이 FixDiscountPolicy와 RateDiscountPolicy 타입으로 자동 주입을 시도하면, 스프링은 어떤 빈을 주입해야 할지 알 수 없어서 NoUniqueBeanDefinitionException 오류를 발생시킨다.

NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy

오류 메시지는 “하나의 빈을 기대했지만 fixDiscountPolicy, rateDiscountPolicy 두 개가 발견되었다”고 명확하게 알려준다.

해결책으로 하위 타입으로 직접 지정하여 주입받을 수도 있지만, 이는 DIP를 위반하고 유연성이 떨어진다.

따라서 이름만 다르고 완전한 동일한 타입의 빈이 2개 있는 경우에는 이 방법으로 해결할 수 없다.

@Autowired 필드 명, @Qualifier, @Primary

1. @Autowired 필드 명 매칭

@Autowired 는 먼저 타입 매칭을 시도한다.

만약 타입 매칭 결과 여러 개의 빈이 발견되면, 필드 이름이나 파라미터 이름으로 빈 이름을 추가로 매칭해서 주입힌다.

Copy
@Autowired
private DiscountPolicy rateDiscountPolicy

위 코드에서 필드 이름이 rateDiscountPolicy이므로, DiscountPolicy 타입의 빈 중에서 rateDiscountPolicy라는 이름의 빈을 찾아 주입한다.

2. @Qualifier 사용

@Qualifier 는 주입 시 추가적인 구분자를 붙여주는 방법이다.

빈 이름을 직접 변경하는 것이 아니라 특정 빈에 대한 별칭이나 설명을 추가하여 주입 대상을 명확히 한다.

빈을 등록 시에 적용한다.

Copy
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {} 
Copy
@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {}

주입받을 필드나 생성자/수정자 파라미터에 @Qualifier 를 붙여 등록된 qualifier 이름을 명시한다.

생성자 자동 주입

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

수정자 자동 주입

Copy
@Autowired
public DiscountPolicy setDiscountPolicy(@Qualifier("mainDiscountPolicy")DiscountPolicy discountPolicy) {
 this.discountPolicy = discountPolicy;
}

@Qualifier 끼리 매칭을 시도한다.

만약에 @Qualifier 이름을 가진 빈을 찾지 못하면, Qualifier 이름을 가진 스프링 빈을 추가로 찾는다.

그래도 찾지 못하면 NoSuchBeanDefinitionException 예외가 발생한다.

그래서 @Qualifier는 @Qualifier를 찾는 용도로만 사용하는 것이 명확하고 좋다.

@Qualifier("mainDiscountPolicy") 처럼 문자열을 직접 사용하는 대신, @MainDiscountPolicy 와 같이 커스텀 애노테이션을 만들어 컴파일 시 타입 체크를 강화할 수 있다.

이는 여러 애노테이션을 모아서 사용하는 스프링 기능 중 하나다.

3. @Primary 사용

@Primary 는 여러 빈이 매칭될 때 우선순위를 부여하는 방법이다.

@Autowired 시 @Primary 가 붙은 빈이 우선권을 가진다.

Copy
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}
@Component
public class FixDiscountPolicy implements DiscountPolicy {}

@Primary 를 붙이면 @Qualifier 를 별도로 붙일 필요 없이 자동으로 우선순위가 높은 빈이 주입된다.

자주 사용하는 주된 빈에 @Primary 를 적용하고, 특별한 경우에만 사용하는 보조 빈을 @Qualifier 를 지정하여 사용하는 것이 좋다.

스프링은 자동보다는 수동이, 넓은 범위의 선택권보다는 좁은 범위의 선택권이 우선순위가 높다.

따라서 @Qualifier 가 @Primary 보다 더 높은 우선순위를 가진다.

@Qualifier 가 명시적으로 지정되면 @Primary 의 우선순위가 무시된다.

애노테이션 직접 만들기

@Qualifier("mainDiscountPolicy") 처럼 문자열을 직접 사용하는 방식은 컴파일 시점에 타입 체크가 되지 않는다는 단점이 있다.

따라서 오타가 있어도 컴파일 에러가 발생하지 않아 런타임에 오류를 발견하게 될 수 있다.

이런 문제를 해결하기 위해서 스프링은 개발자가 @Qualifier 와 같은 기능을 포함하는 새로운 애노테이션을 직접 만들 수 있도록 해준다.

커스텀 애노테이션 정의

@MainDiscountPolicy 라는 이름의 커스텀 애노테이션을 만들어보자.

이 애노테이션은 @Qualifier("mainDiscountPolicy") 애노테이션을 내부에 포함하여 정의된다.

Copy
package hello.core.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 {
}
  • @Target : 이 애노테이션을 적용할 수 있는 대상을 지정한다. (필드, 메소드, 파라미터, 타입, 다른 애노테이션 타입 등…)
  • @Retention : 애노테이션 정보가 유지되는 시점을 지정한다. (RUNTIME 까지 유지된다.)
  • @Documented : 이 애노테이션이 Javadoc 문서에 포함되도록 한다.
  • @Qualifier("mainDiscountPolicy") : 이 커스텀 애노테이션이 실질적으로 “mainDiscountPolicy” 라는 Qualifier 역할을 수행하도록 한다.

빈 등록 시 커스텀 애노테이션을 적용한다.

스프링 빈으로 등록하는 클래스에 @Qualifier 대신 직접 만든 @MainDiscountPolicy 애노테이션을 붙여준다.

Copy
@Component
@MainDiscountPolicy // @Qualifier("mainDiscountPolicy") 대신 사용
public class RateDiscountPolicy implements DiscountPolicy {}

의존관계 주입 시 커스텀 애노테이션을 사용한다.

빈을 주입받는 클래스에서는 @Qualifier 대신 @MainDiscountPolicy 애노테이션을 사용하여 의존관계를 주입받는다.

Copy
// 생성자 자동 주입 예시
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, @MainDiscountPolicy DiscountPolicy discountPolicy) { // @MainDiscountPolicy 사용
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

// 수정자 자동 주입 예시
@Autowired
public DiscountPolicy setDiscountPolicy(@MainDiscountPolicy DiscountPolicy discountPolicy) { // @MainDiscountPolicy 사용
    this.discountPolicy = discountPolicy;
}

이렇게 하면 스프링은 @MainDiscountPolicy 애노테이션이 붙은 빈을 찾아 주입하게 된다.

장점

  • 문자열 기반 Qualifier 와 달리, 애노테이션을 사용하기 때문에 오타 등으로 인한 오류를 컴파일 시점에 발견할 수 있다.
  • 코드의 의도를 더 명확하게 드러낼 수 있으며 가독성이 높아진다.
  • 스프링은 @Qualifier 뿐만 아니라 @Autowired 를 포함한 다른 애노테이션들도 함께 조합해서 사용할 수 있는 기능을 제공한다. 따라서 여러 애노테이션을 모아서 하나의 의미 있는 커스텀 애노테이션을 만들 수 있다.

주의점

  • 애노테이션에는 상속 개념이 없기 때문에, 이러한 조합 기능은 자바 언어의 기능이 아닌 스프링이 지원하는 기능이다.
  • 뚜렷한 목적 없이 무분별하게 스프링이 제공하는 기능을 재정의하여 애노테이션을 만드는 것은 유지보수에 더 큰 혼란을 줄 수 있으므로 주의해야한다.

조회한 빈이 모두 필요할 때, List, Map

조회한 빈이 모두 필요할 때 List와 Map을 사용하는 방법이 있다.

일반적으로 @Autowired 는 특정 타입의 단일 빈을 주입받지만, 때로는 특정 인터페이스를 구현한 모든 빈들이 필요한 경우가 있다. 예를 들어, 할인 서비스를 제공하는데 클라이언트가 rate 할인, fix 할일 등 여러 할인 정책 중 하나를 선택할 수 있도록 해야 할 때 사용된다.

이런 시나리오에서 스프링은 전략 패턴을 매우 간단하게 구현할 수 있도록 한다.

Copy
package hello.core.autowired;

import hello.core.AutoAppConfig;
import hello.core.discount.DiscountPolicy;
import hello.core.member.Grade;
import hello.core.member.Member;
import org.assertj.core.api.Assertions;
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 java.util.List;
import java.util.Map;

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 fixDiscountPrice = discountService.discount(member, 20000, "fixDiscountPolicy");

        Assertions.assertThat(discountService).isInstanceOf(DiscountService.class);
        Assertions.assertThat(fixDiscountPrice).isEqualTo(1000);

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


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

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

            System.out.println("policyMap = " + policyMap);
            System.out.println("policies = " + policies);
        }

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

위 DiscountService 클래스의 생성자를 살펴보자

  • Map<String, DiscountPolicy> policeMap : 스프링 컨테이너는 이 Map 의 키에 스프링 빈의 이름을 넣어주고, 그 값으로 DiscountPolicy 타입으로 조회한 모든 스프링 빈 인스턴스를 담아준다.
  • List<DiscountPolicy> policies : DiscountPolicy 타입으로 조회한 모든 스프링 빈 인스턴스를 List에 담아준다. 이 리스트는 빈의 등록 순서에 따라 정렬될 수 있다.

DiscountService의 discount() 메서드는 discountCode라는 문자열을 인자로 받는다.

이 discountCode (예: "fixDiscountPolicy" 또는 "rateDiscountPolicy")를 사용하여 policyMap에 해당 이름의 DiscountPolicy 스프링 빈을 찾아 실행한다.

이렇게 함으로써 클라이언트의 선택에 따라 동적으로 다른 할인 정책을 적용할 수 있다,

만약 해당하는 타입의 스프링 빈이 하나도 없다면 스프링 빈은 빈 컬렉션(empty List)이나 빈 Map을 주입한다.

위 예제는 AutoAppConfig.class와 DiscountService.class를 동시에 스프링 컨테이너에 등록하는 방식으로 테스트가 진행된다.

ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);

이 코드는 스프링 컨테이너를 생성하고, AutoAppConfig와 DiscountService 클래스를 파라미터로 넘겨 해당 클래스들을 자동으로 스프링 빈으로 등록한다.

장점

  • 특정 인터페이스의 모든 구현체를 한 번에 주입받아 동적으로 활용할 수 있으므로, 나중에 새로운 구현체가 추가되어도 DiscountService 코드를 변경할 필요 없이 확장이 용이하다. 이는 OCP를 지키는데 도움된다.
  • 클라이언트의 요청에 따라 적절한 구현체를 선택하는 전략 패턴을 깔끔하게 구현할 수 있다.

주의점

  • 소스에서는 “자동 등록을 사용하고 있가 때문에 파악하려면 여러 코드를 찾아봐야 한다”는 문제점이 있다. 따라서 Map 이나 List 에 어떤 빈들이 주입될지 코드만 보고 한 눈에 파악하기 어렵다.

해결 방안

  • 이런 경우에는 수동으로 빈을 등록하여 설정 정보에 명확하게 빈의 이름과 구현체를 드러내는 것이 가독성과 유지보수에 좋다. 예를 들어, DiscountPolicyConfig와 같은 별도의 @Configuration 클래스에 @Bean을 사용하여 명시적으로 등록할 수 있다,
  • 자동 등록을 계속 사용하고 싶다면, DiscountPolicy의 구현 빈들만 따로 모아서 특정 패키지에 함께 묶어두는 것이 파악하기 쉽다. 핵심은 코드를 보고 바로 이해할 수 있도록 명확하게 만드는 것이다.

 

반응형

' > Spring' 카테고리의 다른 글

[Spring] 빈 생명주기 콜백  (0) 2025.07.22
[Spring] 컴포넌트 스캔  (1) 2025.07.21
[Spring] 싱글톤 컨테이너  (0) 2025.07.21
[Spring] 스프링 컨테이너와 스프링 빈  (0) 2025.07.21
[Spring] 스프링 핵심 원리 이해2  (0) 2025.07.21
반응형

컴포넌트 스캔은 스프링 프레임워크의 핵심 기능 중 하나로, 스프링 빈을 자동으로 등록하고 의존관계를 자동으로 주입하는 매커니즘이다.

이 기능으로 개발자가 수십, 수백 개의 스프링 빈을 일일이 설정 정보에 등록해야 하는 번거로움을 해결할 수 있다.

컴포넌트 스캔을 활성화하려면 설정 정보 클래스에 @ComponentScan 애노테이션을 붙여주면 된다.

컴포넌트 스캔의 필요성 및 개요

기존에는 @Bean 애노테이션이나 XML 설정으로 스프링 빈을 직접 나열해서 등록했다.

하지만 빈의 수가 많아지면 이는 비효율적이고 실수할 가능성이 높아진다.

이 문제를 해결하기 위해 스프링은 컴포넌트 스캔이라는 기능을 제공하여 설정 정보 없이도 스프링 빈을 자동으로 등록할 수 있게 해준다.

또한 등록된 빈들의 의존관계도 @Autowired 애노테이션을 통해 자동으로 주입한다.

예시 코드: AutoAppConfig.java

Copy
package hello.core;

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

@Configuration
@ComponentScan(
    excludeFilters = @Filter(type = FilterType.ANNOTATION, classes =
    Configuration.class))
public class AutoAppConfig {
}

위 AutoAppConfig 클래스에는 @Bean 으로 등록된 클래스가 하나도 없지만, @ComponentScan 애노테이션이 붙어 있다.

이 애노테이션은 @Component 가 붙은 모든 클래스를 스캔하여 스프링 빈으로 등록한다.

스프링 빈의 기본 이름은 클래스명을 사용하되 맨 앞 글자만 소문자로 변경된다.

(예: MemberServiceImpl은 memberServiceImpl )

만약 빈의 이름을 직접 지정하고 싶다면 @Component(”memberService2”) 와 같이 애노테이션 안에 이름을 부여할 수 있다.

참고로 @Configuration 에노테이션 자체도 소스코드를 열어보면 내부에 @Component 애노테이션이 붙어있기 때문에 컴포넌트 스캔의 대상이 된다.

AutoAppConfig 예시에서는 기존 설정 클래스(AppConfig, TestConfig 등)와의 충돌을 피하기 위해 excludeFilters 를 사용하여 @Configuration 이 붙은 클래스들을 스캔 대상에서 제외했다.

컴포넌트 스캔의 기본 스캔 대상 및 탐색 위치

@ComponentScan은 @Component 외에도 특정 계층을 나타내는 아래의 애노테이션들을 포함한다.

이 애노테이션들 또한 내부적으로 @Component를 포함하고 있다.

  • @Controller : 스프링 MVC 컨트롤러로 인식된다.
  • @Service : 특별한 처리는 없지만, 개발자들이 핵심 비즈니스 로직을 인식하는데 도움을 준다.
  • @Repository : 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해 준다.
  • @Configuration : 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 처리를 한다.

탐색 위치 지정

@ComponentScan 은 기본적으로 애노테이션이 붙은 설정 정보 클래스의 패키지부터 하위 패키지를 모두 탐색한다. 특정 패키지부터 탐색을 시작하도록 지정할 수도 있다.

  • basePackages : 탐색할 패키지의 시작 위치를 지정한다. 여러 시작위치를 지정하는 것도 가능하다.
  • basePackageClasses : 지정한 클래스의 패키지를 탐색 시작 위치로 지정한다.

권장하는 방법은 패키지 위치를 별도로 지정하지 않고, 설정 정보 클래스를 프로젝트 최상단에 두는 것이다.

스프링 부트 @SpringBootApplication 애노테이션 안에 @ComponentScan 을 포함하고 있으며, 이를 프로젝트 시작 루트에 두는 것이 관례이다.

의존 관계 자동 주입

컴포넌트 스캔을 통해 빈이 등록된 후, 빈들 간의 의존관계 주입은 @Autowired 애노테이션이 담당한다.

@Autowired 는 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입해 준다.

기본 조회 전략은 타입(Type)이 같은 빈을 찾아 주입하는 것이다.

예시 코드: @Component, @Autowired 추가

MemoryMemberRepository 클래스에 @Component 추가

Copy
@Component
public class MemoryMemberRepository implements MemberRepository {}

RateDiscountPolicy 클래스에 @Component 추가

Copy
@Component
public class RateDiscountPolicy implements DiscountPolicy {}

MemberServiceImpl 클래스에 @Component, @Autowired 추가 (생성자 주입 방식)

Copy
@Component
public class MemberServiceImpl implements MemberService {
	private final MemberRepository memberRepository;
	@Autowired
	public MemberServiceImpl(MemberRepository memberRepository) {
		this.memberRepository = memberRepository;
	}
}

OrderServiceImpl 클래스에 @Component, @Autowired 추가 (생성자 주입 방식)

Copy
@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;
	}
}

중복 등록과 충돌

스프링에서 컴포넌트 스캔을 통해 스프링 빈이 등록될 때 동일한 빈 이름이 발생한 경우이다.

이 상황은 크게 두 가지로 나누어 설명할 수 있다.

자동 빈 등록 vs 자동 빈 등록

  • 컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 그 이름이 같은 경우 스프링은 오류를 발생시킨다.
  • 이때 ConflictingBeanDefinitionException 예외가 발생한다.

수동 빈 등록 vs 자동 빈 등록

  • 수동으로 빈을 등록하면서 자동 빈 등록과 동일한 빈 이름을 사용하는 경우이다.
  • 이때는 수동 빈 등록이 우선권을 가진다. 수동으로 등록된 빈이 자동으로 등록된 빈을 오버라이딩 해버린다.
  • 수등 빈 등록시에는 “Overriding bean definition for bean ‘memoryMemberRepository’ with a different definition: replacing” 과 같은 로그가 생긴다.
  • 이런 동작은 개발자가 의도했을 수도 있지만, 종종 여러 설정이 꼬여서 발생하여 잡기 어려운 버그를 유발할 수 있다.

최근 스프링 부트는 이러한 혼란을 방지하기 위해 수동 빈 등록과 자동 빈 등록이 충돌하면 오류가 발생하도록 기본 값을 변경했다.

만약 이 기본 동작을 변경하고 싶다면 spring.main.allow-bean-definition-overriding=true 설정을 통해 오버라이딩을 허용할 수 있다.

위 충돌들은 모두 @ComponentScan 을 통해 빈을 자동으로 찾아서 등록하는 과정에서 @Component 가 붙은 클래스들의 빈 이름이 겹치거나 @Configuration 에 @Bean 으로 수동 등록한 빈 이름이 자동으로 스캔된 빈 이름과 겹칠 때 발생할 수 있다.

반응형
반응형

웹 어플리케이션과 싱글톤

보통의 어플리케이션에서는 여러 유저가 동시에 같은 요청을 할 수 있다.

아래의 첫번째 테스트를 확인해보자.

Copy
package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class SingletonTest {

    @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();
        
				// 1. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService1 = appConfig.memberService();
        
        // 2. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService2 = appConfig.memberService();
				
				// 두 객체의 참조값이 다름
        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
}

첫번째 테스트의 스프링이 없는 순수 DI 컨테이너는 AppConfig는 요청을 할 때 마다 객체를 새롭게 생성한다.

이에 대한 해결 방법으로 객체가 딱 1개만 생성되고, 이 객체를 모든 유저가 공유하도록 설계하면 된다.

이런한 패턴을 싱글톤 패턴이라고 한다,

싱글톤 패턴

클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다.

그래서 객체 인스턴스가 여러개 생성되는 것을 “막아야한다.”

따라서 prviate 생성자를 사용해서 외부에서 임의로 객체를 생성하지 못하도록 막아야 한다.

Copy
package hello.core.singleton;

public class SingletonService {

		// static 키워드로 영역에 딱 1개만 객체를 생성해둔다.
    private static final SingletonService instance = new SingletonService();
    
    // public으로 열어서 객체 인스턴스가 필요하면 이 메소드를 통해서만 조회되도록 허용한다.
    public static SingletonService getInstance() {
        return instance;
    }
    
    // 생성자를 private로 선언해서 외부에서 new 키워드로 객체를 생성하지 못하도록 한다.
    private SingletonService() {}
    
    public void login() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

싱글톤 패턴의 테스트 코드를 살펴보자

Copy
  @Test
  @DisplayName("싱슬톤 패턴 적용한 객체 사용")
  void singletonServiceTest() {
      SingletonService singletonService1 = SingletonService.getInstance();
      SingletonService singletonService2 = SingletonService.getInstance();

      Assertions.assertThat(singletonService1).isSameAs(singletonService2);
  }

private로 new 를 통한 새로운 객체 생성을 막았다.

테스트 결과를 확인해보면 호출할 때 마다 같은 객체 인스턴스를 반환하는 것을 알 수 있다.

싱글톤 패턴을 이용하면 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있다.

하지만 문제점도 존재한다.

싱글톤 패턴의 문제점

  • 스프링 없이 싱글톤 패턴을 구현하려면 코드 자체가 많이 들어간다.
  • 의존 관계에서 클라이언트 코드가 구체 클래스에 의존하고 있다. DIP 위반
  • DIP를 위반하고 있기 때문에 OCP도 위반할 가능성이 높다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • private 생성자를 사용하기 때문에 자식 클래스를 만들기 어렵다.
  • 유연성이 떨어진다.
  • 그래서 안티패턴으로 불리기도 한다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리한다.

지금까지 우리가 사용한 스프링 빈이 바로 싱글톤이다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 자동으로 관리해준다.

이전에 우리가 만들었던 컨테이너를 보면 객체 하나만을 생성해서 관리했다.

스프링 컨테이너는 싱글톤 컨테이너 역할을 한다.

스프링 컨테이너는 이런 기능으로 싱글톤 패턴의 단점들을 해결하면서 객체를 싱글톤으로 유지해준다.

Copy
    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void springContainer() {

        //AppConfig appConfig = new AppConfig();
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        MemberService memberService1 = ac.getBean("memberService", MemberService.class);
        MemberService memberService2 = ac.getBean("memberService", MemberService.class);

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }

스프링 컨테이너 덕분에 유저의 요청이 들어올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 재사용할 수 있다.

싱글톤 방식의 주의점

싱글톤 방식은 객체를 하나만 생성해서 여러 클라이언트가 하나의 같은 객체를 공유하는 방식이기 때문에 싱글톤 객체는 자신의 상태를 유지하도록 설계하면 안된다.

즉, 무상태로 설계해야한다.

무상태라는 말은 다음과 같다

  • 특정 클라이언트에 의존적인 코드가 있으면 안된다
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
  • 가급적 read only
  • 객체의 필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야한다.

상태를 유지할 경우 발생하는 문제점

Copy
package hello.core.singleton;

public class StatefulService {

    private int price;


    public void order (String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        this.price = price; // 여기서 문제 발생
    }

    public int getPrice() {
        return price;
    }

}
Copy
package hello.core.singleton;

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

import static org.junit.jupiter.api.Assertions.*;

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

        //Thread A : A사용자 10000원 주문
        statefulService1.order("userA", 10000);

        //Thread B : B사용자 20000원 주문
        statefulService2.order("userB", 20000);
        
        //Thread A : 사용자 주문 금액 조회
        int price = statefulService1.getPrice();
        //ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
        System.out.println("price = " + price);

        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

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

@Configuration과 싱글톤

AppConfig 코드를 다시 보면 수상한 점이 하나 있다.

Copy
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        System.out.println("AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        System.out.println("AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        System.out.println("AppConfig.discountPolicy");
        //return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

memberService 빈을 만드는 코드에서 memberRepository()를 호출한다.

이 메소드는 MemoryMemberRepository()를 호출해서 객체를 생성한다.

orderService 빈을 만드는 코드도 똑같다.

결과적으로 각각 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것 처럼 보인다.

스프링 컨테이너가 이 문제를 어떻게 해결하는지 알아보자.

먼저 MemberServiceImpl과 OrderServiceImpl 의 MemberRepository 조회 테스트용 코드를 추가해주자.

Copy
// AppConfig가 초기화될 때 이 부분이 싱글톤인지 확인하기 위해 생성한 테스트용 함수
public MemberRepository getMemberRepository() {
    return memberRepository;
}

테스트 코드

Copy
package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.member.MemberRepository;
import hello.core.member.MemberServiceImpl;
import hello.core.order.OrderServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ConfigurationSingletonTest {
    @Test
    void configurationTest() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

        MemberRepository memberRepository1 = memberService.getMemberRepository();
        MemberRepository memberRepository2 = orderService.getMemberRepository();

        System.out.println("memberRepository = " + memberRepository);
        System.out.println("memberRepository1 = " + memberRepository1);
        System.out.println("memberRepository2 = " + memberRepository2);

        Assertions.assertThat(memberRepository1).isSameAs(memberRepository2);
    }
}

확인해보면 memberRepository 인스턴스는 모두 같은 인스턴스가 공유되고 있다.

AppConfig의 설정 파일을 어떻게 읽는지 로그를 찍어보면서 확인해보자.

Copy
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        System.out.println("AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        System.out.println("AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        System.out.println("AppConfig.discountPolicy");
        //return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

스프링 컨테이너가 각각 @Bean 을 호출해서 스프링 빈을 생성한다. 그래서 MemberRepository() 는 다음과 같이 총 3번 호출될거라 예상할 수 있다.

  • 스프링 빈 등록을 위해 memberRepository() 호출
  • memberService() 로직에서 memberREpository() 호출
  • orderService() 로직에서 memberRepository() 호출

하지만 출력 결과는 모두 1번씩만 호출된다.

@Configuration과 바이트 코드 조작의 마법

스프링 컨테이너는 싱글톤 레지스트리다. 따라서 스프링 빈이 싱글톤이 되도록 보장해야한다.

그런데 스프링이 자바 코드까지 어떻게 제어하기는 어렵다.

자바 코드 상으로는 3번 호출되어야 하지만 스프링은 클래스의 바이트 코드를 조작하는 라이브러리를 사용한다.

모든 비밀은 @Configuration을 적용한 AppConfig 에 있다.

Copy
    @Test
    void configurationDeep() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        AppConfig bean = ac.getBean(AppConfig.class);

        System.out.println("bean.getClass() = " + bean.getClass());

    }

처음에 AnnotationConfigApplicationContext 에 파라미터로 넘겨 준 AppConfig 도 스프링 빈으로 등록된다.

따라서 AppConfig도 스프링 빈이다.

AppConfig 스프링 빈을 조회해서 클래스 정보를 출력해보자.

bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70

약간 이상하다 순수 클래스라면 아래와 같이 나와야했다.

class hello.core.AppConfig

기존 클래스 이름 되에 CGLIB 가 붙었다.

이건 내가 만든 클래스가 아니라 스프링이 CGLIB 라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.

이 새롭게 만들어진 임의의 클래스가 싱글톤을 보장해준다.

대략적으로 예상하건데 아래와 같이 바이트 코드가 작성되었을 가능성이 있다.

Copy
@Bean
public MemberRepository memberRepository() {

	 if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
			 return 스프링 컨테이너에서 찾아서 반환;
	 } else { //스프링 컨테이너에 없으면 기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
			 return 반환
	 }
}

@Bean 이 붙은 메소드마다 이미 스프링 빈이 존재하면 빈을 반환하고 스프링 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다.

덕분에 싱글톤이 보장된다.

추가적으로 AppConfig@CGLIB는 AppConfig의 자식 타입이므로, AppConfig 타입으로 조회할 수 있다.

@Configuration을 적용하지 않고 @Bean 만 적용하면?

@Configuration을 붙이면 바이트 코드를 조작하는 CGLIB 기술을 사용해서 싱글톤을 보장한다. 하지만 만약 @Bean만 적용하면??

이때는 AppConfig가 CGLIB기술 없이 직접 스프링 빈에 등록된다.

또한 다른 빈들도 여러번 호출된다.

당연하게도 여러번 호출되어서 객체도 여러개가 생성되었기 때문에 같은 인스턴스인지 테스트 하는 코드는 모두 실패한다.

따라서 항상 @Configuration을 사용하자.

반응형
반응형

스프링 컨테이너 생성

Copy
ApplicationContext applicationContext =
 new AnnotationConfigApplicationContext(AppConfig.class); 

여기서 ApplicationContext를 스프링 컨테이너라고 한다.

ApplicationContext는 현재 인터페이스

스프링 컨테이너를 생성하는 방법은 여러가지인데 보통 ApplicationContext 사용함

(XML도 있음.)

스프링 컨테이너를 생성할 때는 구성 정보를 지정해주어야 함.

여기서는 AppConfig.class 로 지정.

스프링 컨테이너는 파라미터로 받은 설정 클래스의 정보를 사용해서 스프링 빈을 등록함.

먼저 빈들을 싸악 등록함.

빈들을 모두 등록한 뒤에 빈들의 의존 관계를 주입한다.

스프링은 빈을 생성하는 단계와 빈을 주입하는 단계가 구별되어 있음.

그런데 자바 코드로 스프링 빈을 등록하면 생성자를 호출하면서 의존관계 주입도 한번에 처리됨.

컨테이너에 등록된 모든 빈 조회하기

Copy
package hello.core.beanfind;

import hello.core.AppConfig;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ApplicationContextInfoTest {
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    @Test
    @DisplayName("모든 빈 출력하기")
    public void findAllBean() {
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();

        for (String beanDefinitionName : beanDefinitionNames) {
            Object bean = ac.getBean(beanDefinitionName);
            System.out.println("name = " + beanDefinitionName + " object = " + bean);
        }
    }

    @Test
    @DisplayName("애플리케이션 빈만 출력하기")
    public void findApplicationBean() {
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();

        for (String beanDefinitionName : beanDefinitionNames) {
            BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);

            if(beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
                Object bean = ac.getBean(beanDefinitionName);
                System.out.println("name = " + beanDefinitionName + " object = " + bean);
            }
        }
    }
}

모든 빈 조회하기

첫번째 테스트는 스프링에 등록된 모든 빈 정보를 출력한다.

  • ac.getBeanDefinitionNames() : 스프링에 등록된 모든 빈 이름을 조회해서 String[] 타입으로 반환
  • ac.getBean() : 빈 이름으로 빈 객체(인스턴스)를 조회한다.

어플리케이션 빈 조회하기

두번째 테스트는 스프링 내부에서 사용하는 빈을 제외하고 내가 Config 파일에 직접 등록한 빈만을 출력한다.

스프링 내부에서 사용하는 빈은 getRole()로 구분할 수 있다.

  • ROLE_APPLICATION : 일반적으로 사용자가 정의한 빈
  • ROLE_INFRASTRUCTURE : 스프링이 내부적으로 사용하는 빈

스프링 빈 조회 - 기본

스프링 컨테이너에서 스프링 빈을 찾으려면 getBean() 메소드를 사용한다.

  • getBean(빈이름, 타입)
  • getBean(타입)

조회 대상 스프링 빈이 스프링 컨테이너에 없으면 예외 발생함

⇒ NoSuchBeanDefinitionException: No bean named 'xxxxx' available

Copy
package hello.core.beanfind;

import hello.core.AppConfig;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ApplicationContextBasicFindTest {

    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    @Test
    @DisplayName("빈 이름으로 조회")
    public void findBeanByName() {
        MemberService memberService = ac.getBean("memberService", MemberService.class);
        Assertions.assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("이름없이 타입으로만 조회")
    public void findBeanByType() {
        MemberService memberService = ac.getBean(MemberService.class);
        Assertions.assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("구체 타입으로 조회")
    public void findBeanByName2() {
        MemberService memberService = ac.getBean("memberService", MemberServiceImpl.class);
        Assertions.assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("빈 이름으로 조회X")
    public void findBeanByNameX() {
        //MemberService memberService = ac.getBean("xxxx", MemberService.class);
        org.junit.jupiter.api.Assertions.assertThrows(
                NoSuchBeanDefinitionException.class,
                () -> ac.getBean("xxxx", MemberService.class));
    }
    // 두번째 파라미터인 람다 표현식이 실행되었을 때 첫번째 파라미터인 예외가 터져야 성공
}

스프링 빈 조회 - 동일한 타입이 둘 이상

타입으로 조회 시 같은 타입의 스프링 빈이 둘 이상이면 에러 발생.

이때는 빈 이름을 지정해주어야 함.

혹은 getBeansOfType() 메소드 사용하면 해당 타입의 모든 빈을 튜플들로 조회 가능함.

Copy
package hello.core.beanfind;

import hello.core.AppConfig;
import hello.core.discount.DiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

public class ApplicationContextSameBeanFindTest {
    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SameBeanConfig.class);

    @Test
    @DisplayName("타입으로 조회시 같은 타입이 2개 이상 존재하면 중복 오류가 발생한다.")
    public void findBeanByTypeDuplicate() {
        //MemberRepository bean = ac.getBean(MemberRepository.class);

        Assertions.assertThrows(
                NoUniqueBeanDefinitionException.class,
                () -> ac.getBean(MemberRepository.class));
    }

    @Test
    @DisplayName("타입으로 찾지말고 이름 지점")
    public void findBeanByName() {
        MemberRepository bean = ac.getBean("memberRepository1", MemberRepository.class);
        org.assertj.core.api.Assertions.assertThat(bean).isInstanceOf(MemoryMemberRepository.class);
    }

    @Test
    @DisplayName("특정 타입 모두 조회하기")
    public void findAllBeanByType() {
        Map<String, MemberRepository> beansOfType = ac.getBeansOfType(MemberRepository.class);
        for (String key : beansOfType.keySet()) {
            System.out.println("key = " + key + " value = " + beansOfType.get(key));
        }

        System.out.println("beansOfType = " + beansOfType);

        org.assertj.core.api.Assertions.assertThat(beansOfType.size()).isEqualTo(2);
    }

    //SameBeanConfig에 static이 붙어있으니까 scope이 딱 이 테스트 파일에 국한
    @Configuration
    static class SameBeanConfig {

        @Bean
        public MemberRepository memberRepository1() {
            return new MemoryMemberRepository();
        }

        @Bean
        public MemberRepository memberRepository2() {
            return new MemoryMemberRepository();
        }
    }
}

스프링 빈 조회 - 상속 관계

부모 타입으로 조회하면, 자식 타입도 함께 조회된다.

그래서 모든 자바 객체의 최고 부모인 Object 타입으로 조회하면, 모든 스프링 빈을 조회한다.

Copy
package hello.core.beanfind;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;
import java.util.Objects;

public class ApplicationContextExtendsFindTest {

    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

    @Test
    @DisplayName("부모 타입으로 조회 시, 자식이 둘 이상 있으면 중복 오류 발생")
    public void findBeanByParentTypeDuplicate() {
        Assertions.assertThrows(
                NoUniqueBeanDefinitionException.class,
                () -> ac.getBean(DiscountPolicy.class));

    }

    @Test
    @DisplayName("부모 타입으로 조회 시, 자식이 둘 이상 있으면 빈 이름을 지정하면 된다.")
    public void findBeanByParentTypeBeanName() {
        DiscountPolicy rateDiscountPolicy = ac.getBean("rateDiscountPolicy", DiscountPolicy.class);
        org.assertj.core.api.Assertions.assertThat(rateDiscountPolicy).isInstanceOf(DiscountPolicy.class);
    }

    @Test
    @DisplayName("특정 하위 타입으로 조회")
    public void findBeanBySubType() {
        RateDiscountPolicy bean = ac.getBean("rateDiscountPolicy", RateDiscountPolicy.class);
        org.assertj.core.api.Assertions.assertThat(bean).isInstanceOf(RateDiscountPolicy.class);
    }

    @Test
    @DisplayName("부모 타입으로 모두 조회")
    public void findAllBeanByParentType() {
        Map<String, DiscountPolicy> beansOfType = ac.getBeansOfType(DiscountPolicy.class);
        org.assertj.core.api.Assertions.assertThat(beansOfType.size()).isEqualTo(2);
    }

    @Test
    @DisplayName("부모 타입으로 모두 조회 - Object")
    public void findAllBeanByObjectType() {
        Map<String, Object> beansOfType = ac.getBeansOfType(Object.class);
        for (String key : beansOfType.keySet()) {
            System.out.println("key = " + key + " value = " + beansOfType.get(key));
        }
    }

    @Configuration
    static class TestConfig {

        @Bean
        public DiscountPolicy rateDiscountPolicy() {
            return new RateDiscountPolicy();
        }

        @Bean
        public DiscountPolicy fixDiscountPolicy() {
            return new FixDiscountPolicy();
        }
    }
}

BeanFactory와 ApplicationContext

BeanFactory

  • 스프링 컨테이너의 최상위 인터페이스
  • 스프링 빈을 관리하고 조회하는 역할을 담당
  • 지금까지 우리가 사용했던 메소드 대부분이 이 녀석으로부터 출발

ApplicationContext

  • BeanFactory 기능을 모두 상속받아서 제공한다.
  • 빈을 관리하고 검색하는 기능을 BeanFactory가 제공해주는데, 둘의 차이는??
  • 어플리케이션을 개발할 때는 빈을 관리하고 조회하는 기능은 물론이고, 수 많은 부가 기능이 필요함.

ApplicationContext는 BeanFactory 외에도 여러 인터페이스를 상속받고 있음

BeanFactory를 직접 사용할 일은 거의 없다. 기능이 많은 ApplicationContext를 사용한다.



반응형
반응형

새로운 할인 정책 개발

정률 할인 정책을 추가

기존에 만들어놨던 DiscountPolicy 인터페이스를 구현한 RateDiscountPolicy 클래스 생성

Copy
package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;

public class RateDiscountPolicy implements DiscountPolicy{

    private int discountPerent = 10;

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

새로 만든 정률 할인 기능 테스트 코드 작성

vip면 할인하고 vip아니면 할인 안하는 테스트 작성

@Displayname은 아래 테스트 결과 창에 이름 표시 기능임.

Copy
package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class RateDiscountPolicyTest {

    RateDiscountPolicy discountPolicy = new RateDiscountPolicy();

    @Test
    @DisplayName("VIP는 10% 할인 되어야한다.")
    void vip_o() {
        //given
        Member member = new Member(1L, "memberVIP", Grade.VIP);

        //when
        int discount = discountPolicy.discount(member, 10000);

        //then
        Assertions.assertThat(discount).isEqualTo(1000);
    }

    @Test
    @DisplayName("VIP가 아니면 할인하지마")
    void vip_x() {
        //given
        Member member = new Member(2L, "memberVIPX", Grade.BASIC);

        //when
        int discount = discountPolicy.discount(member, 10000);
        
        //then
        Assertions.assertThat(discount).isEqualTo(1000);
    }
}

현재의 할인 정책의 적용 방법과 문제점

현재로서는 할인 정책을 바꾸려면 OrderServiceImpl 의 코드 자체를 수정해야함.

현재 OrderServiceImpl 은 DiscountPolicy 인터페이스에 의존하고 있지만 이로부터 생성된 구현체인 FixDiscountPolicy 에도 의존하고 있다.

그렇다고 FixDiscountPolicy를 RateDiscountPolicy로 바꾸면 OCP 위반이다.

⇒ OCP 위반

⇒ DIP 위반

무조건 OrderServiceImpl이 인터페이스에만 의존하도록 바꿔야한다.

인터페이스에만 의존하도록 바꾼 코드

final 키워드는 항상 할당을 해줘야하기 때문에 키워드는 일단 제거

Copy
public class OrderServiceImpl implements OrderService {
 //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
 private DiscountPolicy discountPolicy;
}

근데 이렇게만 작성하면 discountPolicy는 NULL이기 때문에 Null Pointer Exception이 발생한다.

여기서 OrderServiceImpl 에는 더 이상의 구현체가 들어가면 안되기 때문에 누군가가 discountPolicy 에 구현체를 결정하고 주입해주어야 한다.

AppConfig 등장

AppConfig 가 구현 객체를 생성하고, 연결하는 책임을 가지는 별도의 설정 클래스가 되도록 만들자.

Copy
package hello.core;

import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }
}

AppConfig에 있는 메소드들은 전체적으로 구현 객체를 생성해서 반환하고 있다.

MemberServiceImpl에 생성자 주입

Copy
package hello.core.member;

public class MemberServiceImpl implements MemberService{

    //private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

더 이상 MemberServiceImpl은 MemoryMemberRepository 에 의존하지 않는다.

단지 MemberRepository 인터페이스에만 의존한다.

MemberServiceImpl은 생성자로 자신에게 어떻게 구현된 memberRepository가 올 지 알 수 없다.

오로지 AppConfig만 알고 있다.

따라서 객체의 생성과 연결은 AppConfig가 담당한다.

⇒ DIP 만족

⇒ 근데 OCP는 아직 만족하지 않은 것 같은데..?

다른 녀석들도 AppConfig가 생성하고 주입하도록 바꿔주자.

OrderServiceImpl에 생성자 주입

Copy
package hello.core.order;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;

public class OrderServiceImpl implements OrderService{

    //private final MemberRepository memberRepository = new MemoryMemberRepository();
    //private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, 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);
    }
}

MemberApp에서 AppConfig 실행

Copy
package hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;

public class MemberApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        //MemberService memberService = new MemberServiceImpl();

        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(member.getId());
        System.out.println("new member = " + member.getName());
        System.out.println("find Member = " + findMember.getName());

    }
}

OrderApp에서 AppConfig 실행

Copy
package hello.core;

import hello.core.member.*;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class OrderApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        OrderService orderService = appConfig.orderService();
        MemberService memberService = appConfig.memberService();
        //MemberService memberService = new MemberServiceImpl();
        //OrderService orderService = new OrderServiceImpl();

        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);

        System.out.println("order = " + order);
        System.out.println("order.calculatePrice() = " + order.calculatePrice());

    }
}

테스트 코드 수정

@BeforeEeah 는 테스트 메소드가 실행되기 전에 공통으로 수행해야 할 초기화 코드를 정의할 때 사용하는 아노테이션이다.

@Test 메소드 호출되기 직전에 매번 실행됨.

테스트 메소드 수만큼 반복 호출되어서 테스트 간 상태가 공유되지 않음.

매번 테스트에 필요한 객체를 새로 생성하고 초기화

Copy
package hello.core.member;

import hello.core.AppConfig;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class MemberServiceTest {
    MemberService memberService;

    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
    }

    @Test
    void join() {
        //given
        Member member = new Member(1L, "memberA", Grade.VIP);

        //when
        memberService.join(member);
        Member findMember = memberService.findMember(1L);

        //then
        Assertions.assertThat(member).isEqualTo(findMember);
    }
}
Copy
package hello.core.order;

import hello.core.AppConfig;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class OrderServiceTest {
    MemberService memberService;
    OrderService orderService;

    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
        orderService = appConfig.orderService();
    }

    @Test
    void createOrder(){
        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);
        Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);

    }
}

AppConfig 리팩토링

현재 제어권을 AppConfig가 가져가고 클라이언트 코드들은 단순히 실행에만 집중하도록 했지만, AppConfig만 보고 내가 구현한 시스템에 대해서 한번에 알기 어렵다.

또 인스턴스 생성에 대한 중복도 보인다.

AppConfig만 보고 역할과 구현 클래스가 한 눈에 들어와야한다.

Copy
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    public DiscountPolicy discountPolicy() {
        //return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

이제 정액 할인 정책을 정률 할인 정책으로 바꾸고 싶으면 AppConfig에서 discountPolicy 메소드 내부에 리턴할 인스턴스만 바꾸어주면 된다.

스프링으로 전환하기

지금까지는 순수한 자바 코드로만 DI 적용했음.

이제 진짜 스프링 적용하기

먼저 Appconfig에 어노테이션들을 추가해주자.

Copy
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        //return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

@Configuration은 AppConfig를 구성한다는 뜻의 어노테이션

각 메서드에는 @Bean 을 붙여주어서 스프링 컨테이너에 빈으로 등록

MemberApp에서 스프링 컨테이너 사용

Copy
package hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class MemberApp {
    public static void main(String[] args) {
        //AppConfig appConfig = new AppConfig();
        //MemberService memberService = appConfig.memberService();
        //MemberService memberService = new MemberServiceImpl();

        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);

        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(member.getId());
        System.out.println("new member = " + member.getName());
        System.out.println("find Member = " + findMember.getName());

    }
}

여기서

Copy
ApplicationContext applicationContext =
    new AnnotationConfigApplicationContext(AppConfig.class);

이 부분에서 DI 컨테이너가 구현된 것이다.

여기서는 자바 설정 클래스(@Configuration 이 붙은 AppConfig )를 읽어서 그 안에 정의된 @Bean 메소드를 모두 실행해 모든 빈을 생성하고 관리한다.

Copy
MemberService memberService =
    applicationContext.getBean("memberService", MemberService.class);

여기서는 의존 관계까 주입되고 있다, Dependency Injection

getBean() 을 호출해서 AppConfig의 memberService 라는 키로 등록된 BeanDefinition을 찾는다. 요청한 타입인 MemberService.class 와 실제 반환 객체인 MemberServiceImpl 가 호환되는지 확인하고 리턴한다.

OrderApp에 스프링 컨테이너 등록

Copy
package hello.core;

import hello.core.member.*;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class OrderApp {
    public static void main(String[] args) {
        //AppConfig appConfig = new AppConfig();
        //OrderService orderService = appConfig.orderService();
        //MemberService memberService = appConfig.memberService();
        //MemberService memberService = new MemberServiceImpl();
        //OrderService orderService = new OrderServiceImpl();

        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
        OrderService orderService = applicationContext.getBean("orderService", OrderService.class);

        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 20000);

        System.out.println("order = " + order);
        System.out.println("order.calculatePrice() = " + order.calculatePrice());

    }
}

스프링 컨테이너

여기서 ApplicationContext를 스프링 컨데이너 라고 한다.

기존에는 AppConfig 를 사용해서 직접 객체를 생성하고 DI를 했지만, 이제부터는 스프링 컨테이너를 통해서 제어한다. ⇒ IoC

스프링 컨테이너는 @Configuration 이 붙은 AppConfig 를 설정 정보로 사용한다. 여기서 @Bean 이라 적힌 메소드를 모두 호출해서 반환된 객체를 스프링 컨테이너로 등록한다.

이렇게 등록된 객체를 스프링 빈이라고 한다.

스프링 빈은 @Bean 이 붙은 메소드의 명을 스프링 빈의 이름으로 사용한다. 이름을 직접 정할 수 있긴 한데 관례 상 그대로 사용하자. 괜히 메소드 명 잘 지어놓고 바꿔서 쓰지 말자.

반응형

+ Recent posts