본문 바로가기
WEB/SPRING

[Spring] Dependency Injection

by snow_white 2022. 6. 2.

의존이란?

DI는 'Dependency Injection'의 약자로 우리말로는 '의존 주입'이라고 한다.

스프링에서의 의존은 객체 간의 의존을 의미한다.

아래의 회원 가입 처리 기능을 구현한 코드로 이해해보자.

public class MemberRegisterService {
	// 의존 객체를 직접 생성
	private MemberDao memberDao;

	public MemberRegisterService(MemberDao memberDao) {
		this.memberDao = memberDao;
	}

	public Long regist(RegisterRequest req) {
		// 이메일로 회원 데이터(Member) 조회
		Member member = memberDao.selectByEmail(req.getEmail());
		if (member != null) {
			// 같은 이메일을 가진 회원이 이미 존재하면 익셉션 발생
			throw new DuplicateMemberException("dup email " + req.getEmail());
		}
		// 같은 이메일을 가진 회원이 존재하지 않으면 DB에 삽입
		Member newMember = new Member(
				req.getEmail(), req.getPassword(), req.getName(), 
				LocalDateTime.now());
		memberDao.insert(newMember);
		return newMember.getId();
	}
}
public class MemberDao {

	private static long nextId = 0;

	private Map<String, Member> map = new HashMap<>();

	public Member selectByEmail(String email) {
		return map.get(email);
	}

	public void insert(Member member) {
		member.setId(++nextId);
		map.put(member.getEmail(), member);
	}

	public void update(Member member) {
		map.put(member.getEmail(), member);
	}
}

서로 다른 회원은 동일한 이메일 주소를 사용할 수 없다는 요구사항이 있다고 가정할 때 selectByEmail() 메소드를 이용해서 동일한 이메일을 가진 회원 데이터가 존재하는지 확인한다.

 

여기서 DB 처리를 위해 MemberDao 객체를 사용하는데 이렇게 한 클래스가 다른 클래스의 메소드를 실행할 때 이를 '의존'한다고 표현한다.

따라서 'MemberRegisterService 클래스가 MemberDao 클래스에 의존한다'고 표현할 수 있다.

 

의존은 변경에 의해 영향을 받는 관계를 의미한다. 예를 들어 MemberDao의 insert() 메소드의 이름을 insertMember()로 변경하면 이 메소드를 사용하는 MemberRegisterService 클래스의 소스 코드도 함께 변경된다. 이렇게 변경에 따른 영향이 전파되는 관계를 '의존'한다고 표현한다.

 

객체 조립기

DI에서 객체 생성에 사용할 클래스를 변경하기 위해 (그 객체를 사용하는 코드를 변경하지 않고) 객체를 주입하는 코드 한 곳만 변경하면 된다.

물론 아래와 같이 main 메소드에서 의존 대상 객체를 생성하고 주입하는 방법도 있지만 좀 더 나은 방법으로 객체를 생성하고 의존 객체를 주입해주는 클래스를 따로 작성하는 법을 알아보자.

public class Main {
	public static void main(String[] args) {
    	MemberDao memberDao = new MemberDao();
        MemberRegisterService regSvc = new MemberRegisterService(memberDao);
        ChangePasswordService pwdSvc = new ChangePasswordService();
        pwdSvc.setMemberDao(memberDao);
        ... // regSvc와 pwdSvc를 사용하는 코드
    }
}

 

의존 객체를 주입한다는 것은 서로 다른 두 객체를 조립한다고 생각할 수 있는데, 이런 의미에서 이 클래스를 조립기라고도 표현한다.

아래 코드를 보면 MemberRegisterService 객체와 ChangePasswordService 객체에 대한 의존을 주입한다.

MemberRegisterService는 생성자를 통해 MemberDao 객체를 주입받고, ChangePasswordService는 세터를 통해 주입받는다. 

public class Assembler {

	private MemberDao memberDao;
	private MemberRegisterService regSvc;
	private ChangePasswordService pwdSvc;

	public Assembler() {
		memberDao = new MemberDao();
		regSvc = new MemberRegisterService(memberDao);
		pwdSvc = new ChangePasswordService();
		pwdSvc.setMemberDao(memberDao);
	}

	public MemberDao getMemberDao() {
		return memberDao;
	}

	public MemberRegisterService getMemberRegisterService() {
		return regSvc;
	}

	public ChangePasswordService getChangePasswordService() {
		return pwdSvc;
	}

}

 

결과적으로  Assembler가 생성한 객체는 아래 그림과 같이 연결된다.

조립기가 생성한 객체의 연결 관계

 

스프링은 Assembler 클래스의 생성자 코드처럼 필요한 객체를 생성하고 생성한 객체에 의존을 주입한다. 

이제 Assembler 대신 스프링을 사용하는 코드를 살펴보자.

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

import spring.ChangePasswordService;
import spring.MemberDao;
import spring.MemberInfoPrinter;
import spring.MemberListPrinter;
import spring.MemberPrinter;
import spring.MemberRegisterService;
import spring.VersionPrinter;

@Configuration
public class AppCtx {

	@Bean
	public MemberDao memberDao() {
		return new MemberDao();
	}
	
	@Bean
	public MemberRegisterService memberRegSvc() {
   		// (1)
		return new MemberRegisterService(memberDao());
	}
	
	@Bean
	public ChangePasswordService changePwdSvc() {
		// (2)
		ChangePasswordService pwdSvc = new ChangePasswordService();
		pwdSvc.setMemberDao(memberDao());
		return pwdSvc;
	}

 

위의 코드에서 @Configuration 애노테이션은 스프링 설정 클래스를 의미한다.

이 애노테이션을 붙여야 스프링 설정 클래스로 사용할 수 있다.

 

@Bean 애노테이션은 해당 메서드가 생성한 객체를 스프링 빈이라고 설정한다. 위 코드의 경우 세 개의 메서드에 @Bean 애노테이션을 붙였는데 각각의 메서드마다 한 개의 빈 객체를 생성한다. 이때 메서드 이름은 빈 객체의 이름으로 사용한다.

예를 들어 memberDao() 메서드를 이용해서 생성한 빈 객체는 "memberDao"라는 이름으로 스프링에 등록된다.

 

(1)

MemberRegisterService 생성자를 호출할 때 memberDao() 메서드를 호출한다. 즉 memberDao()가 생성한 객체를 MemberRegisterService 생성자를 통해 주입한다.

 

(2)

ChangePasswordService 타입의 빈을 설정한다. 이 메서드는 세터(setMemberDao() 메서드)를 이용해서 의존 객체를 주입한다.

 

설정 클래스를 만들었다고 해서 끝나는 것이 아니다!

객체를 생성하고 의존 객체를 주입하는 것은 스프링 컨테이너이므로 설정 클래스를 이용해서 컨테이너를 생성해야 한다.

ApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class);

 

컨테이너를 생성하면 getBean() 메서드를 이용해서 사용할 객체를 구할 수 있다. 다음은 getBean() 메서드의 사용 예를 보여준다.

스프링 컨테이너(ctx)로부터 이름이 "memberRegSvc"인 빈 객체를 구한다.

앞서 자바 설정을 보면 다음 코드처럼 이름이 "memberRegSvc"인 @Bean 메서드를 설정했다.

이 메서드는 MemberRegisterService 객체에 생성자를 통해 memberDao를 주입한다.

따라서 위 코드에서 구한 MemberRegisterService 객체는 내부에서 memberDao 빈 객체를 사용한다.

// 컨테이너에서 이름이 memberRegSvc인 빈 객체를 구한다.
MemberRegisterService regSvc = ctx.getBean("memberRegSvc", MemberRegisterService.class);

 

위의 Assembler 클래스를 이용해서 작성한 MainForSpring 클래스를 살펴보자.

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import config.AppCtx;
import spring.ChangePasswordService;
import spring.DuplicateMemberException;
import spring.MemberNotFoundException;
import spring.MemberRegisterService;
import spring.RegisterRequest;
import spring.WrongIdPasswordException;

public class MainForSpring {

	private static ApplicationContext ctx = null;
	
	public static void main(String[] args) throws IOException {
   		// (1) 스프링 컨테이너를 생성한다.
		ctx = new AnnotationConfigApplicationContext(AppCtx.class);
		
		BufferedReader reader = 
				new BufferedReader(new InputStreamReader(System.in));
		while (true) {
			System.out.println("명령어를 입력하세요:");
			String command = reader.readLine();
			if (command.equalsIgnoreCase("exit")) {
				System.out.println("종료합니다.");
				break;
			}
			if (command.startsWith("new ")) {
				processNewCommand(command.split(" "));
				continue;
			} else if (command.startsWith("change ")) {
				processChangeCommand(command.split(" "));
				continue;
			} else if (command.equals("version")) {
				processVersionCommand();
				continue;
			}
			printHelp();
		}
	}

	private static void processNewCommand(String[] arg) {
		if (arg.length != 5) {
			printHelp();
			return;
		}
        // 스프링 컨테이너로부터 이름이 "memberRegSvc"인 빈 객체를 구한다.
		MemberRegisterService regSvc = 
				ctx.getBean("memberRegSvc", MemberRegisterService.class);
		RegisterRequest req = new RegisterRequest();
		req.setEmail(arg[1]);
		req.setName(arg[2]);
		req.setPassword(arg[3]);
		req.setConfirmPassword(arg[4]);
		
		if (!req.isPasswordEqualToConfirmPassword()) {
			System.out.println("암호와 확인이 일치하지 않습니다.\n");
			return;
		}
		try {
			regSvc.regist(req);
			System.out.println("등록했습니다.\n");
		} catch (DuplicateMemberException e) {
			System.out.println("이미 존재하는 이메일입니다.\n");
		}
	}

	private static void processChangeCommand(String[] arg) {
		if (arg.length != 4) {
			printHelp();
			return;
		}
        // 스프링 컨테이너로부터 이름이 "changePwdSvc"인 빈 객체를 구한다.
		ChangePasswordService changePwdSvc = 
				ctx.getBean("changePwdSvc", ChangePasswordService.class);
		try {
			changePwdSvc.changePassword(arg[1], arg[2], arg[3]);
			System.out.println("암호를 변경했습니다.\n");
		} catch (MemberNotFoundException e) {
			System.out.println("존재하지 않는 이메일입니다.\n");
		} catch (WrongIdPasswordException e) {
			System.out.println("이메일과 암호가 일치하지 않습니다.\n");
		}
	}

	private static void printHelp() {
		System.out.println();
		System.out.println("잘못된 명령입니다. 아래 명령어 사용법을 확인하세요.");
		System.out.println("명령어 사용법:");
		System.out.println("new 이메일 이름 암호 암호확인");
		System.out.println("change 이메일 현재비번 변경비번");
		System.out.println();
	}
}

(1)   스프링 컨테이너는 Assembler와 동일하게 객체를 생성하고 의존 객체를 주입한다. Assembler는 직접 객체를 생성하는 반면에 AnnotationConfigApplicationContext는 설정 파일(AppCtx 클래스)로부터 생성할 객체와 의존 주입 대상을 정한다.
 

MainForSpring 클래스를 실행할 때 몇 가지 발생할 수 있는 에러 주의사항!

1. 빈 설정 메서드에 @Bean을 붙이지 않은 경우
2. @Bean 설정 메서드의 이름과 getBean() 메서드에 전달한 이름이 다른 경우

 

 

1️⃣ DI 방식 1 : 생성자 방식

MemberRegisterService 클래스를 보면 아래 코드처럼 생성자를 통해 의존 객체를 주입받아 필드(this.memberDao)에 할당했다.

public class MemberRegisterService {
	private MemberDao memberDao;

	// 생성자를 통해 의존 객체를 주입 받음
	public MemberRegisterService(MemberDao memberDao) {
    	// 주입 받은 객체를 필드에 할당
		this.memberDao = memberDao;
	}

	public Long regist(RegisterRequest req) {
		// 주입 받은 의존 객체의 메서드를 사용
		Member member = memberDao.selectByEmail(req.getEmail());
		...
		memberDao.insert(newMember);
		return newMember.getId();
	}
}

스프링 자바 설정에서는 생성자를 이용해서 의존 객체를 주입하기 위해 해당 설정을 담은 메서드를 호출했다.

@Bean
public MemberDao memberDao() {
	return new MemberDao();
}
	
@Bean
public MemberRegisterService memberRegSvc() {
	return new MemberRegisterService(memberDao());
}

생성자에 전달한 의존 객체가 두 개 이상이어도 동일한 방식으로 주입하면 된다. 

public class MemberDao {

	... 생략

	private Map<String, Member> map = new HashMap<>();

	// 추가하기
	// 생성자의 전달한 의존 객체 두 개 이상일 때 실행할 메서드
	public Collection<Member> selectAll() {
		return map.values();
	}
}

 

2️⃣ DI 방식 2 : 세터 메서드 방식

생성자 외에 세터 메서드를 이용해서 객체를 주입받기도 한다. 일반적인 세터(setter) 메서드는 자바빈 규칙에 따라 다음과 같이 작성한다.

1. 메서드 이름이 set으로 시작한다.
2. set 뒤에 첫 글자는 대문자로 시작한다.
3. 파라미터가 1개이다.
4. 리턴 타입이 void이다.
public class MemberPrinter {

	public void print(Member member) {
		System.out.printf(
				"회원 정보: 아이디=%d, 이메일=%s, 이름=%s, 등록일=%tF\n", 
				member.getId(), member.getEmail(),
				member.getName(), member.getRegisterDateTime());
	}
}
public class MemberInfoPrinter {

	private MemberDao memDao;
	private MemberPrinter printer;

	public void printMemberInfo(String email) {
		Member member = memDao.selectByEmail(email);
		if (member == null) {
			System.out.println("데이터 없음\n");
			return;
		}
		printer.print(member);
		System.out.println();
	}
	// MemberDao 객체 의존 주입
	public void setMemberDao(MemberDao memberDao) {
		this.memDao = memberDao;
	}
	// MemberPrinter 객체 의존 주입
	public void setPrinter(MemberPrinter printer) {
		this.printer = printer;
	}

}

세터 메서드를 이용해서 의존을 주입하는 설정 코드를 AppCtx 클래스에 추가해야 한다.

... 생략
import spring.MemberPrinter;
... 생략

@Configuration
public class AppCtx {

	... 생략
	
	@Bean
	public MemberInfoPrinter infoPrinter() {
		MemberInfoPrinter infoPrinter = new MemberInfoPrinter();
		infoPrinter.setMemberDao(memberDao());
		infoPrinter.setPrinter(memberPrinter());
		return infoPrinter;
	}
}

위 코드에서 infoPrinter 빈은 세터 메서드를 이용해서 memberDao 빈과 memberPrinter 빈을 주입한다.

 

AppCtx 클래스에 세터 메서드 방식을 사용하는 설정을 추가했으므로 MainForSpring 코드에 MemberInfoPrinter 클래스를 사용하는 코드를 추가한다.

... 생략
import spring.MemberInfoPrinter;

public class MainForSpring {

	private static ApplicationContext ctx = null;
	
	public static void main(String[] args) throws IOException {
		ctx = new AnnotationConfigApplicationContext(AppCtx.class);
		
		BufferedReader reader = 
				new BufferedReader(new InputStreamReader(System.in));
		while (true) {
			... 생략
			} else if (command.equals("list")) {
				processListCommand();
				continue;
			} else if (command.startsWith("info ")) {
				processInfoCommand(command.split(" "));
				continue;
			}
			printHelp();
		}
	}
	... 생략
	private static void processInfoCommand(String[] arg) {
		if (arg.length != 2) {
			printHelp();
			return;
		}
		MemberInfoPrinter infoPrinter = 
				ctx.getBean("infoPrinter", MemberInfoPrinter.class);
		infoPrinter.printMemberInfo(arg[1]);
	}
}

 

👁‍🗨 생성자 vs. 세터 메서드

- 생성자 방식 : 빈 객체를 생성하는 시점에 모든 의존 객체가 주입된다.
- 설정 메서드 방식 : 세터 메서드 이름을 통해 어떤 의존 객체가 주입되는지 알 수 있다.

각 방식의 장점은 곧 다른 방식의 단점이다. 예를 들어 생성자의 파라미터 개수가 많을 경우 각 인자가 어떤 의존 객체를 설정하는지 알아내려면 생성자의 코드를 확인해야 한다. 하지만 설정 메서드 방식은 메서드 이름만으로도 어떤 의존 객체를 설정하는지 쉽게 유추할 수 있다.

반면에 생성자 방식은 빈 객체를 생성하는 시점에 필요한 모든 의존 객체를 주입받기 때문에 객체를 사용할 때 완전한 상태로 사용할 수 있다. 하지만 세터 메서드 방식은 세터 메서드를 사용해서 필요한 의존 객체를 전달하지 않아도 빈 객체가 생성되기 때문에 객체를 사용하는 시점에 NullPointerException이 발생할 수 있다.

생성자 방식 권장!
필수적으로 사용해야 하는 레퍼런스 없이는 해당 인스턴스를 만들지 못 하도록 강제할 수 있기 때문에 의존성 없이는 인스턴스를 생성하지 못 하도록 방지할 수 있다.

 

3️⃣ DI 방식 3 : Field 변수 방식

public class MemberInfoPrinter {

	@Autowired
	private MemberDao memDao;

	public void printHello() {
		this.memDao.hello();
	}
}

댓글