Recording/멋쟁이사자처럼 BE 13기

[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_33일차_"스프링 DI/IoC"

LEFT 2025. 1. 17. 18:07

🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [33]일차

🚀33일차에는 스프링에서의 DI (의존성 주입)과 IoC (제어의 역전)를 배우고 이를 활용하여 Bean 생성, @Component 등을 접할 수 있었다.

개념들에 대한 이해는 할 수 있었지만 이를 활용하여 Bean을 생성하고 활용하는 부분에 대해서는 아직 이해가 부족한 것 같아서 회고를 통해 많이 배워야겠다.


스프링 어노테이션(@)

  • POJO기반으로써 결합도를 낮추고 유연성을 높인다.
    즉, 종속되어 있어 결합도를 높이는 상속을 사용하지 않고도 상속처럼 동작할 수 있도록 어노테이션 사용

  • ex. @GetMapping :
    @RestController의 자손 격 어노테이션으로 URL 요청이 들어오면 컨트롤러의 클래스를 찾아서 가져와 사용
    ex. Get방식 이외로는 PostMapping, DeleteMapping, PatchMapping등이 있다.

 

Spring Boot 실행클래스

  • @SpringBootApplication 어노테이션이 담당

  • SpringApplication.run()
    ➡️스프링 부트(프레임워크)를 실행

  • 자바의 클래스를 설정파일로 사용할 경우 “어노테이션”이 중요
    ➡️어노테이션과 클래스의 내용이 설정파일의 역할을 한다. (Java Config)
    (XML형식 설정파일보다 친숙한 자바클래스가 기본 설정파일이 되었다.)

  • Java Config는 yaml, properties와 함께 설정하는 역할

  • SpringBoot 실행클래스가 포함하는 다양한 어노테이션
    ➡️@Configuration : 해당 클래스가 스프링 설정 클래스임을 나타내는 어노테이션
    ➡️@EnableAutoConfiguration : 스프링 부트에서 자동구성을 활성화
    ➡️@ComponentScan : 지정된 패키지와 하위 패키지에서 컴포넌트를 스캔 후 스프링 빈으로 등록
    ➡️@Component : 스프링에서 컴포넌트를 스캔하고 빈으로 등록할떄 사용되는 가장 기본적인 어노테이션

스프링 코어와 IoC

  • DI (의존성 주입), AOP (관련 지향 프로그래밍)이 이에 해당

▶️예제 - DI (Dependency Injection) 의존성 주입

  • TV 인터페이스를 구현하는 클래스 STV와 LTV가 있다고 가정
TV tv = new STV();
TV tv = new LTV();

tv.turnOn();
tv.soundDown();
  • 추상적인 인터페이스를 구현하여 구체적인 구현체를 만들 수 있을 것이다. (implements)
  • ➡️new 생성자를 스프링부트에서 담당하도록 할 수 있다.
// TV 생성 공장 (대신 객체 생성)
public static TV getTV(String tvName){	
	TV tv = null;
	if("STV".equalsIgnoreCase(tvName)){
		tv = new STV();
	else{
		tv = new LTV();
	}
	return tv;
}
  • 이처럼 객체생성을 담당하는 클래스를 만들고 그 클래스가 대신 객체 생성을 해주고 있다.
  • 이러한 공장 역할을 스프링부트가 담당하게됨
// main
TV tv = TVFactory.getTV(args[0]);

tv.turnOn();
tv.soundDown();
  • args에 STV를 넣어놓으면 main 코드는 바뀌지 않지만 STV가 생성
  • 이는 new를 사용하는 방법보다 결합도를 더 낮출 수 있다.
  • 객체 생성을 직접하지 않고 스프링에서 담당

스프링은 BeanFactory와 ApplicationContext등의 공장을 통해 이러한 기능을 제공


BeanFactory

  • 스프링 프레임워크에서 IoC 컨테이너의 가장 기본적이고 최상위의 인터페이스
  • 구현체나 상위 계층의 설정에 의해 동작하는 간단한 인터페이스
  • 객체(Bean)의 생성, 의존성 주입 및 생명주기 관리를 담당 (Bean = 일정한 규칙을 가진 자바 객체)
  • 특징 :
    ➡️
    BeanFactory는 요청 시점에만 Bean을 초기화
    ➡️단순한 환경에서 사용하며 AOP같은 기술은 사용할 수 없다.

ApplicationContext

  • BeanFactory를 확장한 인터페이스로, 대부분의 스프링 프로젝트에서 사용
  • @Configuration과 @Bean 같은 설정을 동작시켜줌

어노테이션의 종류

  • 객체 생성 어노테이션 (Bean 정의)
    ➡️@Component, @Service, @Repository, @Controller
    ➡️스프링 컨테이너에 자동으로 등록

  • DI (Dependency Injection, 의존성 주입) 어노테이션
    ➡️@Autowired, @Qualifier, @Resource
    ➡️자동으로 Bean의 의존성을 주입

  • 설정 어노테이션
    ➡️@Configuration, @Bean
    ➡️설정 클래스 정의,  Bean 정의를 위해 사용

▶️실습 - Bean 생성

public class MyBean {
    private String name;
    private int count;
    
    public MyBean() {
    }
    public MyBean(String name, int count) {
        this.name = name;
        this.count = count;
    }
    //... Getter, Setter, toString
}
    •  생성할 객체를 정의하고 기본생성자와 매개변수를 받는 생성자를 만듦

 

public class MyBeanConfig {
    @Bean 
    public MyBean myBean(){
        return new MyBean();
    }
}
  • @Bean
    ➡️ Bean등록을 의미하는 어노테이션으로
    스프링 공장(ex. BeanFactory, ApplicationContext)에게 어떤 빈을 관리할지 알려줌

  • @Bean 사용(Java Config방식)이전 XML태그의 사용과 달리 오타를 추적할 수 있다.
    ➡️ ex. <bean id="myBean" class="sample.bean.MyBean"/> (오타를 추적하지 못함)

  • public MyBean myBean()
    ➡️리턴타입에 생성할 객체
    ➡️클래스명은 이 생성할 객체를 가리킬 id를 명시

▶️실습 - 생성된 Bean 사용

package sample.run;

import sample.bean.MyBean;

public class SpringExam01 {
    public static void main(String[] args) {
        // 1. 직접 객체를 생성하는 경우라면
        MyBean bean = new MyBean();
        bean.setName("Bruno");
        System.out.println(bean.getName());
    }
}

  • 기존에는 이처럼 new키워드를 통해 인스턴스를 생성하고 Setter메소드를 호출하여 객체를 사용했다.
  • ➡️스프링 프레임워크에서는 "스프링 IoC 컨테이너" 에게 객체 생성을 맡긴다.
    (스프링이 제공하는 공장 사용 = BeanFactory, ApplicationContext)

스프링의 객체 생성 방법

  • XML을 통해 Bean 등록
  • Java Config를 통해 Bean등록
  • 어노테이션 사용하여 등록 : 가장 간단한 방법으로 내가 만든 객체에게 붙여줄 수 있지만
    외부 라이브러리나 외부 코드에게는 붙여주지 못함

  • 이외로는 LookUp방식이 있다.

객체 생성 방법 - LookUp방식

// 1. 가져온 후 형변환하는 LookUp방식
MyBean bean1 = (MyBean) context.getBean("myBean"); 
bean1.setName("Ditt");
System.out.println(bean1);

// 2. 형변환 후 가져오는 LookUp방식
MyBean bean2 = context.getBean("myBean", MyBean.class);
  • (MyBean) context.getBean("myBean");
    ➡️공장에게 getBean()처럼 “myBean”이라는 id의 Bean을 달라는 요청
    ➡️getBean()하는 시점에 객체가 생성

  • 이 방법은 다른 방법들처럼 DI(Dependency Injection)방식이 아닌 DL(Dependency Lookup) 방식

  • DL (의존관계 검색) : 
    의존관계가 있는 객체를 외부에서 주입받는 것이 아닌 의존관계가 필요한 객체에서 직접 검색

  • @Bean으로 등록된 Bean은 기본으로 싱글턴 패턴 적용(=@Scope를 적용하지 않음)
  • 따라서 bean1과 bean2객체를 생성해도 (== 비교연산자)로 비교해보면
    이 둘의 객체의 인스턴스는 “같다”고 판단 (=같은 객체를 여러개 등록할 수도 있음)

id없이 타입만으로 LookUp

// id없이 타입만 가지고 스프링이 매핑(=LookUp)해줌
Book book = context.getBean(Book.class);
System.*out*.println(book);
  • 전체 메모리 상에 Book이라는 Bean이 하나밖에 없으면 id없이 타입만 지정해도 객체가 출력됨
    (스프링이 Book타입을 찾아냄)

  • 전체 메모리 상에 Book이라는 Bean이 여러개일때는 id를 지정하지 않으면 (No qualifying bean of type...) 오류 발생
    (스프링이 Book타입을 찾을때 Book타입이 여러개이면 오류 발생)


@Scope를 적용한 @Bean

@Bean
@Scope("prototype")
public MyBean myBean2(){
    return new MyBean();
}
  • @Scope를 명시하여 기본으로 적용되어있던 싱글톤 패턴이 아닌 다른 패턴으로 명시하여 정의 가능
    ➡️@Scope를 설정하면 객체가 생성되는 시점이 달라짐
    - 싱글톤 :  공장이 세워질때 Bean이 만들어짐
    - 프로토타입 :  Bean이 생성될때마다 같은 id를 사용해도 각기 다른 Bean이 매번 만들어짐

객체 생성 방법 - Java Config방식

// 2-2. Java Config를 통해 Bean 등록
System.out.println("ApplicationContext 생성 전 ---");
ApplicationContext context = new AnnotationConfigApplicationContext(MyBeanConfig.class);
System.out.println("ApplicationContext 생성 후 ---");
  • new AnnotationConfigApplicationContext(MyBeanConfig.class)
    ➡️어떤 빈을 만들 것임을 알려주어야하므로 인자 위치에 MyBeanConfig.class

  • ApplicationContext : 공장의 인터페이스
  • ApplicationConfigApplicationContext : 공장의 구현체

  • 또 다른 Bean인 Book객체를 만든 후 테스트해보면

  • ApplicationContext 생성 전과 후 사이에 My Bean, Book 객체가 생성된 것 확인 가능

IoC (Inversion of Oontrol)

  • 제어의 역전
  • 객체 생성과 생명주기 관리의 제어권을 개발자에서 스프링 컨테이너로 넘기는 프로그래밍 패턴 (설계원칙)
  • 따라서 DI는 IoC의 한 구현 방법
    ➡️스프링코어는 IoC컨테이너를 통해 DI를 제공
    ➡️이처럼 스프링코어는 IoC를 기반으로 작동하기때문에 IoC가 없다면 DI 기능을 구현할 수 없음

▶️실습 - 여러 개의 Bean 사용 (주사위 게임)

public class Dice {
    private int face;

    public Dice() {
        System.out.println("Dice() 실행");
    }

    public Dice(int face) {
        this.face = face;
        System.out.println("Dice(int) 실행");
    }

    public int getNumber(){
        return (int)(Math.random() + face) + 1;
    }
}
  • 이 Dice클래스를 사용할 클래스들로 Player, Game가 있을때 이들은 선언만 하고
    초기화되는 시점은 "프로그램이 실행될때 주사위를 주입받게된다"

DI의 주입방법 3가지

  • 1.생성자를 통한 주입
  • 2.설정자(Setter)를 통한 주입
  • 3.필드를 통한 주입 (선언 시 @(어노테이션)을 붙임)

 

생성자를 통한 주입 예시

// 1. 생성자를 통한 주입
public Player(Dice dice) {
    this.dice = dice;
}
  • 생성자가 필요하고, 이 생성자는 개발자가 직접 만들어놔야함

 

설정자를 통한 주입 예시

// 2. 설정자를 통한 주입
public void setDice(Dice dice) {
    this.dice = dice;
}
  • 스프링 컨테이너가 이 Setter를 통해 주입함

▶️실습 - 1. 생성자 주입의 주사위 게임

public class Game {
    private List<Player> list; // 플레이어 리스트 (스프링 공장을 통해서 생성 및 주입 받음)
}

// 1. 생성자를 통한 주입
public Game(List<Player> list) {
    System.out.println("Game(List<Player> list) 생성자 실행!");
    this.list = list;
}

// 플레이어 리스트를 순회하여 각 플레이어가 play()할 수 있도록 구현
public void play(){
    for(Player player : list){
        player.play();
    }
}
  • 플레이어 리스트 또한 스프링 공장을 통해서 객체를 생성하고 플레이어들을 주입받을 것이다.

 

public class GameConfig {
    @Bean
    public Dice dice(){
        return new Dice(6);
    }
}
  • Dice 등록
  • 설정을 관리하는 GameConfig 클래스에 @Bean을 통해 Dice를 생성하고
  • Dice 타입에 생성자의 매개변수로 6(face, 면)을 받아 반환

 

@Bean
public Player Ben(Dice dice){
    Player player = new Player(dice);
    player.setName("벤");
    return player;
}

@Bean
public Player Fabian(Dice dice){
    Player player = new Player(dice);
    player.setName("파비안");
    return player;
}
  • 여러 명의 플레이어를 만든다.

 

@Bean
public Game game(List<Player> playerList){
    return new Game(playerList); // 생성자 주입
}
  • 리스트에 들어있는 플레이어들을 스프링 컨테이너가 Game에 자동으로 생성자 주입
  • (List<Player> playerList)
    ➡️스프링이 List에 Player타입을 받아서 playerList를 만들어줌
public static void main(String[] args) {
    ApplicationContext context = new AnnotationConfigApplicationContext(GameConfig.class);
    Game game = context.getBean(Game.class); // 하나 밖에 없으므로 id를 지정하지않음
    game.play();
}

생성자 주입 실행 결과

  • 생성자 주입대신 설정자 주입을 통한 방법으로 바꿔볼 수도 있을 것이다.

▶️실습 - 2. 설정자 주입의 주사위 게임

 

  • 설정자 주입을 할때에는 객체에 반드시 기본생성자가 있어야한다.
    ➡️기본생성자 생성 후 그 기본생성자를 참조하여 Setter메소드에 접근 가능
@Bean
public Player Ben(Dice dice){
//        Player player = new Player(dice); // 1. 생성자를 통한 주입
    Player player = new Player();
    player.setDice(dice); // 2. 설정자를 통한 주입
    player.setName("-ㅡ벤ㅡ-");
    return player;
}
	// ... 다른 플레이어들 @Bean
    
// 1. 생성자를 통한 주입
//    @Bean
//    public Game game(List<Player> playerList){
//        return new Game(playerList); // 생성자 주입
//    }

// 2. 설정자를 통한 주입
@Bean
public Game game(List<Player> playerList){
    Game game = new Game();
    game.setList(playerList); // 설성자를 통한 주입
    return game;
}
  • 1. 생성자를 통한 주입방법과 비교해보면
    @Bean의 플레이어 객체는 Player 생성자를 부를때 인자를 가지지 않고 기본생성자를 호출한다.

  • player.setDice(dice);
    ➡️그 후 player를 참조하여 setDice() Setter메소드를 호출하고 인자로는 dice 주사위 객체를 보낸다.

  • game.setList(playerList);
    ➡️생성자를 통해 Game에 주입하는 방법과는 달리 설정자를 통해 Game에 주입하는 방법은 game객체를 참조하여 setList() Setter 메소드에 접근하여 playerList를 인자로 보내준다.

임의 입력값 properties로 관리

  • GameConfig 클래스
// 1. 생성자를 통한 주입
@Bean
public Dice dice(){
    return new Dice(6);
}

// 2. 설정자를 통한 주입
@Bean
public Dice dice(){
    Dice dice = new Dice();
    dice.setFace(6);
    return dice;
}
  • "6"의 값을 인자로 보내서 주사위의 면을 설정하는 부분을 별도의 설정파일로 관리하도록 할 수 있다.
    ➡️resources → game.properties 생성 → "face=6" 입력

@PropertySource({"classpath:game.properties"})

public class GameConfig { ... }
  • @PropertySource({"classpath:game.properties"})
    ➡️파일명으로 찾지 않고 클래스의 경로로 찾는 방법으로 생성한 설정파일을 가져온다. (game.properties)

 

// 2. 설정자를 통한 주입
@Bean
public Dice dice(@Value("${face}") int face){
    Dice dice = new Dice();
    dice.setFace(face);
    return dice;
}
  • game.properties 설정파일로 지정한 값을 사용할때는
    - @Value("${face}") : 키 값을 가져옴
    - int face : 가져온 키 값의 value를 int face 에 담음

설정파일 값 지정 후 실행결과


정리하자면

  • Player클래스는 주사위 객체 (Dice)를 스프링 공장을 통해서 주입받음
    ➡️Dice를 주입받을때는 생성자를 통한 주입 혹은 설정자를 통한 주입이 가능하다.

  • Game클래스 또한 주사위 객체 (Dice)를 스프링 공장을 통해서 주입받음

  • Player클래스에 생성자를 통한 주사위 객체 (Dice)주입 :
    Player클래스에 생성자를 만듦
// 1. 생성자를 통한 주입
public Player(Dice dice) {
    this.dice = dice;
}

 

  • Player클래스에 설정자를 통한 주사위 객체 (Dice) 주입 :
    Player클래스의 Setter메소드 활용
 // 2. 설정자를 통한 주입
public Player() { }

public void setDice(Dice dice) {
    this.dice = dice;
}
  • 설정자를 통해서 주입받을때 Player 클래스는
    기본생성자 (public Player())와 Setter 메소드 (setDice(Dice dice)) 를 가져야한다.

  • Game클래스에 생성자를 통한 여러플레이어 객체 (List<Player>) 주입
    생성자의 매개변수로 List<Player> list를 가져와 주입받음
// 1. 생성자를 통한 주입 (컴포넌트 스캔에 필요)
public Game(List<Player> list) {
    System.out.println("Game(List<Player> list) 생성자 실행!");
    this.list = list;
}

 

  • Game클래스에 설정자를 통한 여러플레이어 객체 (List<Player>) 주입
    Game클래스의 Setter메소드 활용
// 2. 설정자를 통한 주입
public Game(){
    System.out.println("설정자를 통한 기본생성자 실행!");
}

public void setList(List<Player> list) {
    this.list = list;
}
  • 설정자를 통해서 주입받을때 Game 클래스는
    기본생성자 (public Game())와 Setter 메소드 (setList(List<Player> list)) 를 가져야한다.

 


  • Game, Player클래스에서 new 키워드를 사용하지 않고 스프링이 대신 객체 생성을 해주고 있는 것

🚀실습 - 3. 필드를 통한 주입 방법 (@Autowired 활용)

  • 클래스 내부에 있는 필드에 직접 의존성을 주입
  • 스프링 프레임워크에서는 @Autowired를 사용
  • 특징
    ➡️
    직접 필드에 의존성을 삽입하여 간결한 코드
    ➡️테스트 및 리팩토링 시에는 의존성을 명시적으로 주입할 방법이 없어 불리할 수 있음
    따라서 생성자나 설정자를 사용하지 않아 편리하지만 구조적으로는 명확하지 않다는 것

@Component
public class Dog {
    public void coming() {
        System.out.println("강아지가 오는 중입니다. ");
    }
}
@Component
public class MyDog {
    @Autowired // 필드에 의존성 주입
    private Dog dog;

    public void commanding() { // 강아지에게 명령 수행
        dog.coming();
    }
}
  • @Autowired ➡️ 스프링 컨테이너가 의존성을 자동으로 주입함
  • @Component와 함께 사용하여 스프링이 객체들을 자동으로 Bean에 등록하도록 함
  • Dog dog = new dog()으로 생성 후 dog.coming()처럼 메소드에 접근하지 않고
    필드에 의존성을 주입하는 방법으로 객체를 자동으로 Bean에 등록하여 coming() 이라는 메소드를 수행할 수 있음

자동으로 의존성 주입 - @ComponentScan

  • ⭐스프링 컨테이너가 의존성을 주입할 객체에는 컴포넌트임을 명시해야 스캔의 대상이 된다. (@Component)
  • 기본적으로 생성자 주입 방식을 사용
@Component
public class Dice {
    private int face;
    ...
}
  • 이처럼 @Component로 주사위 객체에 컴포넌트임을 명시한다.

 

@ComponentScan(basePackages = "sample")
@PropertySource({"classpath:game.properties"})
public class GameConfig {
  • 스캔을 담당할 설정 클래스 (Java Config)에 @ComponentScan처럼 명시한다.

  • (basePackages = "sample")
    ➡️반드시 명시
    ➡️sample패키지 안에 있는 것들만 스캔한다는 설정

@Component
public class Game { ... }
  • 이에 따라 Game클래스도 @Component로 등록해야 @ComponentScan역할을 하는 GameConfig 클래스가
    자동으로 의존성을 주입할 수 있다.

 

컴포넌트 등록 시 id 지정

// Book클래스
@Component("spring")
public class Book{ ... }
  • 이처럼 지정해주면 "spring"이라는 id로 Book컴포넌트를 얻어올 수 있다.

 

// main
Book book = context.getBean("spring",Book.class);
  • 이처럼 id로 접근 가능

컴포넌트로 취급되는 어노테이션

  • @Service :  @Component 어노테이션을 포함하므로 "@Service == @Component" 성립
  • @Controller : @Component 어노테이션을 포함하므로 "@Controller == @Component" 성립
  • @Repository : @Component 어노테이션을 포함하므로 "@Repository == @Component" 성립
  • @Configuration : @Component 어노테이션을 포함하므로 "@Configuration == @Component" 성립

⭐@Component가 확장된 형태가 @Service, @Controller, @Repository, @Configuration 등인 것


회원가입 서비스 구조

  • repository : 데이터를 담는 기능을 "DAO" 대신 "Repository" 라는 개념을 쓰기도 함

Controller 

  • Presentation Layer
  • 사용자와 커뮤니케이션을 담당 (=보여지는 부분을 담당)
  • Request : 사용자의 요청을 받아 Service로 전달
  • Response : Service에서 처리된 결과를 다시 사용자에게 응답
  • 사용자가 회원가입 요청을 보내면 "해당 데이터를 검증하고 Service에 전달하는 작업"이 Controller의 주된 역할
    ➡️ex. Spring MVC (사용자 요청을 처리하고 적절한 응답을 제공하기 위한 웹 애플리케이션 프레임워크)


Service

  • Business Layer
  • 비즈니스 로직을 담당
  • 핵심코드가 담기며 Controller와 Repository의 중간 계층
  • 회원가입 로직 (입력 데이터 유효성 검증, 중복 사용자 확인, 비밀번호 암호화, 사용자 데이터 Repository에 저장)
    ➡️ex. 암호화 라이브러리, 외부 API 등을 활용하는 기술 사용 가능
public User registerUser(UserDto userDto) {
    // 유효성 검증
    if (userRepository.existsByEmail(userDto.getEmail())) {
        throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
    }
    // ...
}

Repository

  • Data Layer
  • 데이터를 저장하고, 데이터와 통신하는 부분
    즉 데이터베이스와 직접 통신하여 데이터를 CRUD(Create, Read, Update, Delete)하는 것
  • 회원가입에서 사용자 정보를 저장하거나 조회하는 쿼리 처리를 담당
    ➡️ex. JPA (Java Persistence API, 데이터베이스와의 매핑을 간소화하는 ORM 기술)
  • 데이터베이스 작업을 위한 코드 작성을 최소화하면서 데이터를 다룰 수 있게 함

 

이처럼 프로젝트 시 업무를 나눠서 진행하는 것이 가장 일반적인 구조
➡️계층형 아키텍처 사용으로 "역할 분담"을 하면
- 각 계층이 명확한 역할을 가져 유지보수가 용이하며
- 기술 스택 변경에도 유연하고
- 각 계층을 개별적으로 테스트할 수 있어 테스트가 용이하다.


🚀 회고를 통해 지나쳤던 용어들에 대해 다시 알아보기도하고 계층형 아키텍처의 예시나 개념도 배울 수 있었다.

필드를 통한 의존성 주입 부분도 개인적으로 공부해볼 수 있었다.

주사위 게임을 통해 의존성 주입을 배워봤는데 단번에 이해하기 어려운 부분이 많았다.

이번 회고로 코드를 한 줄 한 줄 해석해나가며 객체들의 연관성에 대해 흐름을 따라가보니 스프링 IoC컨테이너가 도와주는 작업을 확인할 수 있었다.