[LG U+ 유레카 3기]Spring DI | XML → Annotation → Java Config → Has-A → Interface + Qualifier 실습 정리

2025. 11. 3. 16:37Java/Spring

이 포스팅은 Spring Framework의 DI(Dependency Injection, 의존성 주입)을 처음부터 끝까지 한 단계씩 밟아가며 실습으로 정리한 내용입니다. 단순히 코드만 보여주는 게 아니라, 각 방식이 어떤 철학에서 출발했는지, 코드가 내부적으로 어떻게 동작하는지까지 — 아주 상세하게, “스프링 초보 개발자도 완벽히 이해할 수 있도록” 정리했습니다.


❶ 스프링 DI란?

DI는 Dependency Injection, 즉 의존성 주입을 의미합니다. 이 개념은 IoC (Inversion of Control, 제어의 역전)이라는 큰 철학 아래 존재합니다.

일반적으로 자바에서 객체를 사용할 때는 이렇게 작성하죠.

public class Store {
    private Pencil pencil = new Pencil();
}

위 코드는 겉보기엔 단순하지만, 설계적으로는 “강한 결합”이 발생합니다. Store 클래스가 Pencil의 구체적인 생성 방식에 직접 의존하기 때문이에요. 만약 Pencil을 Pen으로 바꾸려면? Store 내부 코드를 수정해야 합니다.

DI는 이 의존성을 외부에서 주입함으로써 결합도를 낮춥니다. 스프링 컨테이너가 “누가 누구에게 의존할지”를 대신 관리하고, 개발자는 “무엇을 할지”에만 집중할 수 있죠.


❷ IoC와 DI의 관계

- IoC (제어의 역전) : 객체 생성, 생명주기 관리, 의존 관계 설정 등 프로그램 제어권이 개발자에서 스프링 컨테이너로 넘어가는 것
- DI (의존성 주입) : IoC를 구현하는 방법 중 하나로, 객체가 사용할 의존 객체를 외부(컨테이너)에서 주입하는 행위

즉, IoC는 “누가 제어하느냐”의 개념이고, DI는 그 제어를 “어떻게 구현하느냐”의 구체적인 기술이에요.


❸ 스프링 DI의 세 가지 설정 방식

  • XML 기반 설정
  • Annotation 기반 설정
  • Java Configuration 기반 설정

이 세 가지 방식은 시대 순서대로 발전해 왔고, 각각의 장단점이 있습니다.


1️⃣ XML 기반 DI

초창기 스프링에서는 모든 Bean(객체)을 XML 설정 파일에 등록했습니다. 이 방식은 명시적이고 설정이 한눈에 보이지만, 매번 태그를 작성해야 해서 번거로웠습니다.

🧩 XML 설정 파일 (calc-xml.xml)

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd">

    <!-- Configuration By XML -->
    <bean id="calculator" class="com.mycom.myapp.calc.xml.Calculator"/>
</beans>

🧩 Calculator.java

package com.mycom.myapp.calc.xml;

public class Calculator {
    public int add(int n1, int n2) {
        return n1 + n2;
    }
}

🧩 Test.java

package com.mycom.myapp.calc.xml;

import org.springframework.context.support.ClassPathXmlApplicationContext;

// XML 기반 설정
public class Test {
    public static void main(String[] args) {
        // 1️⃣ XML 파일을 읽어와 스프링 컨테이너 생성
        ClassPathXmlApplicationContext context =
            new ClassPathXmlApplicationContext("xml/calc-xml.xml");

        // 2️⃣ XML의 bean 정의를 기반으로 객체를 가져오기 (DI)
        Calculator calculator = (Calculator) context.getBean("calculator");

        // 3️⃣ 메서드 실행
        System.out.println(calculator.add(3,7));

        // 4️⃣ 컨텍스트 종료 (자원 반환)
        context.close();
    }
}

이 방식에서는 객체를 직접 new 하지 않고, getBean()으로 스프링 컨테이너가 생성한 객체를 주입받습니다. 즉, 제어의 주도권이 완전히 스프링에게 넘어간 것이죠.


2️⃣ Annotation 기반 DI

XML에 Bean을 하나씩 등록하는 건 귀찮았어요. 그래서 스프링은 @Component@Autowired를 통해 Bean을 자동 등록/주입하는 방법을 제공하게 됩니다.

🧩 XML 설정 (component-scan)

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           https://www.springframework.org/schema/beans/spring-beans.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context-4.3.xsd">

    <!-- Annotation 스캔 설정 -->
    <context:component-scan base-package="com.mycom.myapp.calc.annotation"/>
</beans>

🧩 Calculator.java

package com.mycom.myapp.calc.annotation;

import org.springframework.stereotype.Component;

@Component // 스프링이 자동으로 Bean 등록
public class Calculator {
    public int add(int n1, int n2) {
        return n1 + n2;
    }
}

🧩 Test.java

package com.mycom.myapp.calc.annotation;

import org.springframework.context.support.ClassPathXmlApplicationContext;

// Annotation 기반 설정
public class Test {
    public static void main(String[] args) {
        // Annotation 기반 컨텍스트 로드
        ClassPathXmlApplicationContext context =
            new ClassPathXmlApplicationContext("xml/calc-annotation.xml");

        // @Component 로 등록된 Bean을 가져옴
        Calculator calculator = (Calculator) context.getBean("calculator");

        System.out.println(calculator.add(3,7));
        context.close();
    }
}

여기서 @Component는 XML의 <bean>을 대체하고, 은 “이 패키지 안에서 @Component가 붙은 클래스를 찾아라”라는 의미입니다.


3️⃣ Java Configuration 기반 DI

스프링 3.x 이후부터는 순수 자바 코드로 설정을 관리할 수 있게 되었어요. XML 파일이 사라지고, 타입 안정성(type safety)도 확보되었습니다.

🧩 CalConfiguration.java

package com.mycom.myapp.calc.configuration;

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

@Configuration
public class CalConfiguration {
    @Bean // Bean 등록
    public Calculator calculator() {
        return new Calculator();
    }
}

🧩 Calculator.java

package com.mycom.myapp.calc.configuration;

public class Calculator {
    public int add(int n1, int n2) {
        return n1 + n2;
    }
}

🧩 Test.java

package com.mycom.myapp.calc.configuration;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

// Java Config 기반 설정
public class Test {
    public static void main(String[] args) {
        // XML 대신 Java Configuration을 사용하는 Application Context
        AnnotationConfigApplicationContext context =
            new AnnotationConfigApplicationContext(CalConfiguration.class);

        Calculator calculator = (Calculator) context.getBean("calculator");
        System.out.println(calculator.add(3,7));

        context.close();
    }
}

이제 XML이 완전히 사라졌습니다. 코드 내부에서 설정과 로직이 함께 존재하지만, 훨씬 가볍고 명확하죠. 실무에서는 Java Config 방식이 거의 표준처럼 사용됩니다.


4️⃣ Has-A 관계에서의 DI

객체지향에서 “Has-A” 관계는 한 클래스가 다른 객체를 속성으로 가지고 있는 것을 의미합니다. 예를 들어, HasaCalculatorCalculator 객체를 멤버 필드로 갖는 구조입니다.

🧩 HasaCalculator.java

@Component // DI가 가능한 클래스
public class HasaCalculator {

    // #1 Field Injection
    // @Autowired
    // Calculator calculator;

    // #2 Setter Injection
    // Calculator calculator;
    // @Autowired
    // public void setCalculator(Calculator calculator) {
    //     this.calculator = calculator;
    // }

    // #3 Constructor Injection
    // public HasaCalculator(Calculator calculator) {
    //     this.calculator = calculator;
    // }

    // #4 Constructor + 기본생성자 + @Autowired
    Calculator calculator;
    public HasaCalculator() {}

    @Autowired
    public HasaCalculator(Calculator calculator) {
        this.calculator = calculator;
    }

    public int add(int n1, int n2) {
        System.out.println("Has A Calculator add()");
        return calculator.add(n1, n2);
    }
}

🧩 Calculator.java

@Component
public class Calculator {
    public int add(int n1, int n2) {
        System.out.println("Calculator add()");
        return n1 + n2;
    }
}

🧩 Test.java

public class Test {
    public static void main(String[] args) {
        ClassPathXmlApplicationContext context =
            new ClassPathXmlApplicationContext("xml/hasa-calc-annotation.xml");

        HasaCalculator hasaCalculator = (HasaCalculator) context.getBean("hasaCalculator");
        System.out.println(hasaCalculator.add(30, 9));

        context.close();
    }
}

 실행 결과

Has A Calculator add()
Calculator add()
39

- Field 주입: 간단하지만 테스트하기 어렵고 순환 참조 감지 불가

- Setter 주입: 유연하지만 객체의 불변성이 깨짐

- Constructor 주입: 가장 권장되는 방식 (Spring Boot의 기본)


5️⃣ 인터페이스 다중 구현 + @Qualifier

만약 인터페이스를 여러 클래스가 구현하면, 스프링은 어떤 Bean을 주입해야 할지 모르게 됩니다. 이럴 때 @Qualifier를 사용해 정확한 Bean을 지정할 수 있습니다.

🧩 Calculator.java (인터페이스)

public interface Calculator {
    int add(int n1, int n2);
}

🧩 CalculatorImpl.java

@Component("aaa")
public class CalculatorImpl implements Calculator {
    @Override
    public int add(int n1, int n2) {
        System.out.println("CalculatorImpl add()");
        return n1 + n2;
    }
}

🧩 CalculatorImpl2.java

@Component("bbb")
public class CalculatorImpl2 implements Calculator {
    @Override
    public int add(int n1, int n2) {
        System.out.println("CalculatorImpl2 add()");
        return n1 + n2;
    }
}

🧩 HasaCalculator.java

@Component
public class HasaCalculator {
    private final Calculator calculator;

    public HasaCalculator(@Qualifier("bbb") Calculator calculator) {
        this.calculator = calculator;
    }

    public int add(int n1, int n2) {
        System.out.println("Has A Calculator add()");
        return calculator.add(n1, n2);
    }
}

🧩 Test.java

public class Test {
    public static void main(String[] args) {
        ClassPathXmlApplicationContext context =
            new ClassPathXmlApplicationContext("xml/all-calc-annotation.xml");

        HasaCalculator hasaCalculator = (HasaCalculator) context.getBean("hasaCalculator");
        System.out.println(hasaCalculator.add(30, 9));

        context.close();
    }
}

이제 “bbb”로 등록된 Bean이 주입되어 실행됩니다.

Has A Calculator add()
CalculatorImpl2 add()
39

@Qualifier("beanName")은 다중 Bean이 존재할 때 특정 Bean을 명시적으로 선택하도록 도와주는 어노테이션입니다.

만약 @Qualifier 가 되어있지않다면

//No qualifying bean of type 'com.mycom.myapp.calc.all.Calculator' available:

//expected single matching bean but found 2: calculatorImpl,calculatorImpl2

이런 에러가 발생합니다. 강사님이 자주 등장하는 에러라 하더라고요.


6️⃣ 결론 및 핵심 정리

구분 설정 방식 특징
XML 기반 <bean> 설정 명시적이지만 번거롭다
Annotation 기반 @Component / @Autowired 자동 스캔으로 편리
Java Config 기반 @Configuration / @Bean 코드로 설정, 타입 안정성 높음
Has-A 구조 Field / Setter / Constructor 주입 객체 간 합성 관계 실습
Qualifier 사용 @Qualifier("beanName") 다중 Bean 충돌 시 명시적 지정

이 실습은 단순한 예제가 아니라, 스프링이 어떻게 객체를 만들고 연결하는지를 이해하게 해주는 “스프링의 심장” 같은 내용이에요.
XML → Annotation → Java Config → Has-A → Interface 구조까지 순차적으로 익히면 DI뿐만 아니라 IoC, Bean 스코프, AOP까지 한 번에 연결 지어 이해할 수 있습니다.


📘 마무리

스프링은 결국 “객체의 생명주기를 컨테이너가 관리한다”는 철학에서 출발합니다. DI는 이 철학을 코드로 구현한 구체적인 기술이고, 여기에 Annotation과 Java Config가 더해져 현대적인 스프링 생태계를 완성했습니다.

Spring Boot에서는 이 모든 과정을 @SpringBootApplication 하나로 통합하지만, 그 내부에서는 여전히 같은 DI 원리가 작동하고 있습니다.

스프링을 진짜 이해하려면 “DI와 IoC가 왜 필요한가”를 몸으로 익히는 게 중요합니다. 이번 예제처럼 직접 여러 방식을 시도해보면, 스프링의 매직 같은 Bean 주입이 사실은 “매우 논리적이고 단순한 원리”임을 알게 될 거예요.