[스프링] 스프링5 프로그래밍 입문 - 3장 스프링 DI

June·2021년 5월 23일
0

DI

DI는 Dependency Injection으로 우리말로는 '의존 주입'이다. 한 클래스가 다른 클래스의 메서드를 실행할 때 이를 의존이라고 한다. DI는 의존하는 객체를 직접 생성하는 대신 의존 객체를 전달받는 방식을 사용한다.

코드

src/../Member.java

package spring;

import java.time.LocalDateTime;

public class Member {
    
    private Long id;
    private String email;
    private String password;
    private String name;
    private LocalDateTime registerDateTime;

    public Member(Long id, String email, String password, String name,
        LocalDateTime registerDateTime) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.name = name;
        this.registerDateTime = registerDateTime;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public String getName() {
        return name;
    }

    public LocalDateTime getRegisterDateTime() {
        return registerDateTime;
    }

    public void changePassword(String oldPassword, String newPassword) {
        if (!password.equals(oldPassword)) {
            throw new WrongIdPasswordException();
        }
        this.password = newPassword;
    }
}

src/../WrongPasswordException.java

package spring;

public class WrongIdPasswordException extends RuntimeException {

}

src/../MemberDao.java

package spring;

import java.util.HashMap;
import java.util.Map;

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);
    }
}

src/../DuplicateMemberException.java

package spring;

public class DuplicateMemberException extends RuntimeException {

    public DuplicateMemberException(String message) {
        super(message);
    }
}

src/../RegisterRequest.java

package spring;

public class RegisterRequest {
    
    private String email;
    private String password;
    private String confirmPassword;
    private String name;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getConfirmPassword() {
        return confirmPassword;
    }

    public void setConfirmPassword(String confirmPassword) {
        this.confirmPassword = confirmPassword;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    
    public boolean isPasswordEqualToConfirmPassword() {
        return password.equals(confirmPassword);
    }
}

src/../MemberRegisterService

package spring;

import java.time.LocalDateTime;

public class MemberRegisterService {

    private MemberDao memberDao;

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

    public Long regist(RegisterRequest req) {
        Member member = memberDao.selectByEmail(req.getEmail());
        if (member != null) {
            throw new DuplicateMemberException("dup email " + req.getEmail());
        }
        Member newMember = new Member(req.getEmail(), req.getPassword(), req.getName(),
            LocalDateTime.now());
        memberDao.insert(newMember);
        return newMember.getId();
    }

}

동일한 이메일을 갖는 회원이 존재하지 않으면 Member 객체를 생성한 뒤 저장한다.

src/../ChangePasswordService

package spring;

public class ChangePasswordService {

    private MemberDao memberDao;

    public void changePassword(String email, String oldPwd, String newPwd) {
        Member member = memberDao.selectByEmail(email);
        if (member == null) {
            throw new MemberNotFoundException();
        }

        member.changePassword(oldPwd, newPwd);
        memberDao.update(member);
    }

    public void setMemberDao(MemberDao memberDao) {
        this.memberDao = memberDao;
    }
}

src/../MemberNotFoundException

package spring;

public class MemberNotFoundException extends RuntimeException {

}

객체 조립기

객체 생성에 사용할 클래스를 변경하기 위해 객체를 주입하는 코드 한 곳만 변경하면 된다고 했다. 그렇다면 실제 객체를 생성하는 코드는 어디에 있을까? main 메서드에서 의존 대상 객체를 생성하고 주입하는 방법이 나쁘진 않다.

이 방법보다 좀 더 나은 방법은 객체를 생성하고 의존 객체를 주입해주는 클래스를 따로 작성하는 것이다. 의존 객체를 주입한다는 것은 서로 다른 두 객체를 조립한다고 생각할 수 있는 데, 이런 의미에서 이 클래스를 조립기라고도 한다.

src/../Assembler.java

package assembler;

import spring.ChangePasswordService;
import spring.MemberDao;
import spring.MemberRegisterService;

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 getRegSvc() {
        return regSvc;
    }

    public ChangePasswordService getPwdSvc() {
        return pwdSvc;
    }
}

Assembler 코드를 사용하는 코드는 Assembler 객체를 생성하고, get 메서드를 이용해서 필요한 객체를 구하면 된다.

정리하면 조립기는 객체를 생성하고 의존 객체를 주입하는 기능을 제공하낟. 또한 특정 객체가 필요한 곳에 객체를 제공한다.

조립기 사용 예제

src/../MainForAssembler.java

package main;

import assembler.Assembler;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import spring.ChangePasswordService;
import spring.DuplicateMemberException;
import spring.MemberNotFoundException;
import spring.MemberRegisterService;
import spring.RegisterRequest;
import spring.WrongIdPasswordException;

public class MainForAssembler {

    private static Assembler assembler = new Assembler();
    
    
    
    public static void main(String[] args) throws IOException {
        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;
            }
            printHelp();
        }
    }

    private static void processChangeCommand(String[] arg) {
        if (arg.length != 4) {
            printHelp();
            return;
        }
        ChangePasswordService changePwdSvc = assembler.getChangePasswordService();
        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 processNewCommand(String[] arg) {
        if (arg.length != 5) {
            printHelp();
            return;
        }
        MemberRegisterService regSvc = assembler.getMemberRegisterService();
        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("이미 존재하는 이메일입니다");
        }
    }

    private static void printHelp() {
    }
}

Assembler 객체를 생성할 때, 생성자에서 필요한 객체를 생성하고 의존을 주입한다.

스프링의 DI 설정

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

스프링을 이용한 객체 조립과 사용

src/../AppCtx

package config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.ChangePasswordService;
import spring.MemberDao;
import spring.MemberRegisterService;

@Configuration
public class AppCtx {
    
    @Bean
    public MemberDao memberDao() {
        return new MemberDao();
    }
    
    @Bean
    public MemberRegisterService memberRegSvc() {
        return new MemberRegisterService(memberDao());
    }
    
    @Bean
    public ChangePasswordService changePwdSvc() {
        ChangePasswordService pwdSvc = new ChangePasswordService();
        pwdSvc.setMemberDao(memberDao());
        return pwdSvc;
    }
}

@Bean 어노테이션이 붙은 각각의 메서드마다 한 개의 빈 객체가 생성된다. 이때 메서드 이름을 빈 객체의 이름으로 사용한다.

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

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

ApplicationContext ctx = new AnnotaionConfigApplicationContext(AppCtx.class);

src/../MainForSpring

package main;


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

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
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 {

        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;
            }
            printHelp();
        }
    }

    private static void processChangeCommand(String[] arg) {
        if (arg.length != 4) {
            printHelp();
            return;
        }
        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 processNewCommand(String[] arg) {
        if (arg.length != 5) {
            printHelp();
            return;
        }
        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("이미 존재하는 이메일입니다");
        }
    }

    private static void printHelp() {
    }
}

MainForSpring 클래스가 MainForAssembler 클래스와 다른 점은 Assembler 클래스 대신 스프링 컨테이너인 ApplicaionContext를 사용했다는 것 뿐이다.

DI 방식 1 : 생성자 방식

스프링 자바 살정에서는 생성자를 이용해서 의존 객체를 주입했다. 생성자에 전달할 의존 객체가 두 개 이상이어도 동일한 방식으로 주입하면 된다.

src/../MemberPrinter

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

    public Long regist(RegisterRequest req) {
        // 주입 받은 의존 객체의 메서드를 사용
        Member member = memberDao.selectByEmail(req.getEmail());
        if (member != null) {
            throw new DuplicateMemberException("dup email " + req.getEmail());
        }
        Member newMember = new Member(req.getEmail(), req.getPassword(), req.getName(),
            LocalDateTime.now());
        memberDao.insert(newMember);
        return newMember.getId();
    }

MemberListPrinter

package spring;

import java.util.Collection;

public class MemberListPrinter {
    
    private MemberDao memberDao;
    private MemberPrinter printer;

    public MemberListPrinter(MemberDao memberDao, MemberPrinter memberPrinter) {
        this.memberDao = memberDao;
        this.printer = memberPrinter;
    }
    
    public void printAll() {
        Collection<Member> members = memberDao.selectAll();
        members.forEach(m -> printer.print(m));
    }
}

AppCtx

@Configuration
public class AppCtx {
    ...
    
    @Bean
    public MemberPrinter memberPrinter() {
        return new MemberPrinter();
    }
    
    @Bean
    public MemberListPrinter listPrinter() {
        return new MemberListPrinter(memberDao(), memberPrinter());
    }
}

세터 메서드 방식

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

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

MemberInfoPrinter

package spring;

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();
    }

    public void setMemberDao(MemberDao memberDao) {
        this.memDao = memberDao;
    }

    public void setPrinter(MemberPrinter printer) {
        this.printer = printer;
    }
}

두 개의 세터 메서드를 정의하고 있다.

AppCtx

package config;

@Configuration
public class AppCtx {

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

생성자 vs 세터 메서드

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

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

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

@Configuration 설정 클래스의 Bean 설정과 싱글톤

AppCtx

@Configuration
public class AppCtx {

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

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

    @Bean
    public ChangePasswordService changePwdSvc() {
        ChangePasswordService pwdSvc = new ChangePasswordService();
        pwdSvc.setMemberDao(memberDao());
        return pwdSvc;
    }
}

여기서 궁금증이 생긴다. memberDao()가 새로운 객체를 생성해서 리턴하므로 meberRegSvc()에서 사용된 것과 changePwdSvc()는 서로 다른 MemberDao 객체가 아닌가? 어떻게 싱글톤이 가능할까?

스프링은 설정 클래스를 그대로 사용하지 않는다. 대신 설정 클래스를 상속한 새로운 설정 클래스를 만들어서 사용한다. 스프링이 런타임에 생성한 설정 클래스는 다음과 유사한 방식으로 동작한다.

public class AppCtxExt extends AppCtx {
    private Map<String, Object> beans = ...;    
    @Override
    public MemberDao memberDao() {
        if (!beans.containsKey("memberDao"))
            beans.put("memberDao", super.memberDao());    
        return (MemberDao) beans.get("memberDao");
    }

스프링이 런타임에 생성한 설정 클래스의 memberDao() 메서드는 매번 새로운 객체를 생성하지 않는다. 대신 한 번 생성한 객체를 보관했다가 이후에는 동일한 객체를 리턴한다.

두 개 이상의 설정 파일 사용하기

스프링을 이용해서 어플리케이션 개발하다보면 적게는 수십에서 많게는 수백여 개 이상의 빈을 설정한다. 설정하는 빈의 개수가 증가하면 한 개의 클래스 파일에 설정하는 것보다 영역별로 설정 파일을 나누면 관리하기 편해진다.

스프링은 한 개 이상의 설정 파일을 이용해서 컨테이너를 생성할 수 있다.

AppConf1

package config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.MemberDao;
import spring.MemberPrinter;

@Configuration
public class AppConf1 {
    
    @Bean
    public MemberDao memberDao() {
        return new MemberDao();
    }

    @Bean
    public MemberPrinter memberPrinter() {
        return new MemberPrinter();
    }
}

AppConf2

@Configuration
public class AppConf2 {
    
    @Autowired
    private MemberDao memberDao;
    @Autowired
    private MemberPrinter memberPrinter;
    
    @Bean
    public MemberRegisterService memberRegSvc() {
        return new MemberRegisterService(memberDao);
    }
    
    @Bean
    public ChangePasswordService changePwdSvc() {
        ChangePasswordService pwdSvc = new ChangePasswordService();
        pwdSvc.setMemberDao(memberDao);
        return pwdSvc;
    }
    
    @Bean
    public MemberListPrinter listPrinter() {
        return new MemberListPrinter(memberDao, memberPrinter);
    }
    
    @Bean
    public MemberInfoPrinter infoPrinter() {
        MemberInfoPrinter infoPrinter = new MemberInfoPrinter();
        infoPrinter.setMemberDao(memberDao);
        infoPrinter.setPrinter(memberPrinter);
        return infoPrinter;
    }
}

여기서 @Autowired 애노테이션은 스프링의 자동 주입 기능을 위한 것이다. 스프링 설정 클래스의 필드에 @Autowired 애노테이션을 붙이면 해당 타입의 빈을 찾아서 필드에 할당한다.

설정 클래스가 두 개 이상이어도 스프링 컨테이너를 생성하는 코드는 크게 다르지 않다.

ctx = new AnnotaionConfigApplicationContext(AppConf1.class, AppConf2.class);

@Configuration 애노테이션, 빈, @Autowired 애노테이션

@Autowired 애노테이션은 스프링 빈에 의존하는 다른 빈을 자동으로 주입하고 싶을 때 사용한다.

MemberInfoPrinter

public class MemberInfoPrinter {

	@Autowired
    private MemberDao memDao;
    @Autowired
    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();
    }

	... 세터 생략
}

스프링은 @Configuration 애노테이션이 붙은 설정 클래스를 내부적으로 스프링 빈으로 등록한다. 그리고 다른 빈과 마찬가지로 @Autowired가 붙은 대상에 대해 알맞은 빈을 자동으로 주입한다.

@Import 애노테이션 사용

두 개 이상의 설정 파일을 사용하는 또 다른 방법은 @Import 애노테이션을 사용하는 것이다. @Import 애노테이션은 함께 사용할 설정 클래스를 지정한다.

AppConfImport

@Configuration
@Import(AppConf2.class)
public class AppConfImport {

    @Bean
    public MemberDao memberDao() {
        return new MemberDao();
    }
    
    @Bean
    public MemberPrinter memberPrinter() {
        return new MemberPrinter();
    }
}

MainForSpring

public class MainForSpring {

    private static ApplicationContext ctx = null;

    public static void main(String[] args) throws IOException {

        ctx = new AnnotationConfigApplicationContext(AppConfImport.class);
    }  
}

0개의 댓글