지금까지는 UserDao 의존성 주입을 위해 생성자를 사용했으나, 자바 관례에서는 수정자를 사용한다.
수정자란 Setter 메서드로 오브젝트 내의 애트리뷰트 값을 변경하는 것이 목적이며, 입력 값에 대한 검증 작업을 수행할 수도 있다.
public class UserDao{
private ConnectionMaker connectionMaker;
public void setConnectionMaker(ConnectionMaker connectionMaker){
this.connectionMaker = connectionMaker
}
...
}
2.2. 일반 메서드를 통한 주입
수정자 메서드는 관례에 따라 set으로 시작하고 한번에 하나의 파라미터만 가질 수 있다. 이런 제약이 싫다면 여러개의 파라미터를 갖는 일반 메서드를 DI용으로 사용할 수 있으나, 일반적으로 수정자 메서드를 사용한다.
2.3. 수정자 메서드를 선호하는 이유
1) 자바 빈 규약을 준수 (멤버변수에 getter/setter 메서드가 있어야 한다)
2) XML을 사용한 DI 처리 시 수정자 메서드와 연관되는 부분이 있다.
XML을 사용한 DI부분을 보면 위 내용을 이해할 수 있을 것이다.
3. XML을 사용한 DI
의존성 주입을 하는 방법에는 DaoFactory 클래스 처럼 Java 코드 구현하는 방법과 XML 파일로 구현하는 방법이 있다. XML은 단순한 텍스트 파일이기 때문에 다루기 쉽고, 쉽게 이해할 수 있으며, 별도의 빌드 작업이 없다는 것이 장점이 있다.
3.1. XML 설정
XML 설정 시 <beans> 를 루트 엘리먼트로 하며, 하위에 여러 개의 <bean> 태그를 통해 bean들을 정의한다. <beans>를 DaoFactory의 @Configuration, <bean>을 @Bean으로 대응해서 생각하면 된다. <bean> 태그는 id와 class라는 속성을 갖는데 id는 빈의 이름, class는 빈의 클래스 경로를 넣어준다.
<bean id = "connectionMaker" class = "org.example.user.factory.DConnectionMaker"/>
의존관계 설정은 <bean>의 <property> 태그를 사용한다. <property> 태그는 name과 ref라는 속성을 갖는데 name은 빈 오브젝트의 멤버변수 이름을, ref는 수정자 메서드를 통해 주입해줄 빈의 이름이다.
name에 입력하는 값에는 조건이 하나가 붙는데 바로 name에 대한 수정자 메서드가 있어야 한다는 점이다. 이 조건이 붙는 이유는 XML을 통한 DI시 객체 의존 관계 설정을 수정자 메서드로 처리하기 때문이다.
이 부분이 앞서 언급했던 'DI 처리 시 수정자 메서드와 매핑되는 부분'이다.
그리고 name멤버 변수에 대한 수정자 메서드(setter)가 있어야 한다는 것이 자바 빈 규약 중 하나이다.
이에 따라 UserDao의 생성자 메서드를 통해 의존 객체를 받아오던 방식을 수정자 메서드를 통한 방식으로 변경하였다.
public class UserDao{
private ConnectionMaker connectionMaker;
public void setConnectionMaker(ConnectionMaker connectionMaker){
this.connectionMaker = connectionMaker
}
...
}
이 내용을 바탕으로 DaoFactory를 수정하면 아래와 같으며,
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao(){
UserDao userDao = new UserDao();
userDao.setConnectionMaker(connectionMaker());
return userDao;
}
@Bean
public ConnectionMaker connectionMaker(){
return new DConnectionMaker();
}
}
DaoFactory를 XML로 변경하면 아래와 같다.
<?xml version="1.0" encoding="UTF-8"?>
<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">
<bean id = "userDao" class ="org.example.user.dao.UserDao">
<property name="connectionMaker" ref = "connectionMaker"></property>
</bean>
<bean id = "connectionMaker" class = "org.example.user.factory.DConnectionMaker"/>
</beans>
만약 name에 입력한 멤버변수의 수정자 메서드가 없다면, XML을 통한 의존 주입 시 name 부분에서 수정자를 만들라는 메시지와 함께 오류가 발생한다.
3.2. XML을 사용하는 애플리케이션 컨텍스트
이제 애플리케이션 컨텍스트 생성 시 DaoFactory 설정파일을 읽는 방법 대신 XML 설정파일을 읽는 방법으로 변경해야 한다. 방법은 GenericXmlApplicationContext 생성자를 사용하고, 생성자 파라미터로 XML파일의 클래스 패스를 지정하면 된다.
XML 설정파일의 이름은 관례에 따라 applicationContext.xml이라고 만든 후 앞 내용을 참고하여 XML 파일을 작성한다.
작성이 완료되면 main 메서드에서 AnnotationConfigApplicationContext 생성자를 통한 ApplicationContext 부분을 주석처리한 후 GenericXmlApplicationContext으로 변경한다.
public static void main(String[] args) throws SQLException, ClassNotFoundException {
ApplicationContext applicationContext = new GenericXmlApplicationContext("applicationContext.xml");
// ApplicationContext applicationContext = new AnnotationConfigApplicationContext(DaoFactory.class);
UserDao userDao = applicationContext.getBean("userDao", UserDao.class);
User user = new User();
user.setId("test123");
user.setPassword("1234");
user.setName("테스터");
userDao.add(user);
}
XML 설정파일을 통한 DI가 처리되어 테이블에 데이터가 Insert 됨을 확인할 수 있다.
4. DataSource 사용하기
지금은 DB Connection을 ConnectionMaker.makeConection() 메서드를 통해 가져오고 있지만, 자바에서 지원하는 DataSource라는 인터페이스를 사용하여 가져오도록 수정해보자.
DataSource 의 구현체 클래스는 SimpleDriverDataSource을 사용한다. 스프링에서 제공하므로 라이브러리 의존성을 추가해주자.
ConnectionMaker 대신 DataSource를 사용하고, DConnectionMaker, NConnectionMaker 대신 SimpleDriverDataSource를 사용하기 때문에 ConnectionMaker, DConnectionMaker, NConnectionMaker 클래스를 삭제하자.
4.2. UserDao 코드 수정
DataSource에 대한 멤버변수를 생성하고, XML 설정을 사용하기 위해 DataSource에 대한 수정자 메서드를 생성한다. 기존 사용했던 conectionMaker.makeConnection() 대신 dataSource.getConnection()으로 변경한다.
public class UserDao {
private DataSource dataSource;
public void setDataSource(DataSource dataSource){
this.dataSource = dataSource;
}
public void add(User user) throws SQLException {
Connection c = dataSource.getConnection();
...
}
public User get(String id) throws SQLException{
Connection c = dataSource.getConnection();
...
}
}
4.3. DaoFactory 수정
XML을 통해 DI를 처리하긴 하지만 Java 코드 부분도 한번 수정해보았다. SimpleDriverDataSource의 사용 방법은 다음과 같이 수정자 메서드를 통해 DriverClass, Url, Username, Password를 설정한 후 해당 객체를 DataSource 형으로 리턴하면 된다.
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao(){
UserDao userDao = new UserDao();
userDao.setDataSource(dataSource());
return userDao;
}
@Bean
public DataSource dataSource(){
SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
dataSource.setUrl("jdbc:mysql://localhost/spring");
dataSource.setUsername("DB 접속 ID");
dataSource.setPassword("DB 접속 Password");
return dataSource;
}
}
4.4. XML 파일 수정
XML 파일의 DI 정보를 다음과 같이 수정한다.
<?xml version="1.0" encoding="UTF-8"?>
<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">
<bean id = "dataSource" class ="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="driverClass" value="com.mysql.jdbc.Driver"></property>
<property name="url" value = "jdbc:mysql://localhost/spring"></property>
<property name="username" value = "DB 접속 ID"></property>
<property name="password" value = "DB 접속 Password"></property>
</bean>
<bean id = "userDao" class = "org.example.user.dao.UserDao">
<property name = "dataSource" ref = "dataSource"></property>
</bean>
</beans>
여기서 <property> 속성으로 value라는 값이 처음 사용된다. value는 name에 대한 멤버변수의 값을 주입하는 속성이며, 수정자 메서드의 파라미터에 맞게 자동으로 변환하여 주입한다.
예를들어 driverClass의 값을 주입할 때 "com.mysql.jdbc.Driver"라는 문자열을 넣고 있는데, 내부적으로 동작할 때에는setDriverClass 수정자 메서드의 파라미터 자료형에 맞게 문자열을 클래스로 변환하는 중간과정을 거치게 된다. 최종적으로는 문자열이 아닌 클래스 형태로 변환되어 수정자 메서드를 호출하게 된다.
XML을 통한 DI 방식을 사용해보았다. 실무에서 담당했던 프로젝트 중 대부분은 XML을 통한 DI가 사용되고 있었는데, 실제로 어떻게 DI되는지는 생각해보지 않았었다.
이번 스터디를 통해 자바 코드에 수정자 메서드를 왜 넣었는지와 값 주입 시 아무 생각없이 문자열을 넣을 수 있었던 이유를 실제 동작 메커니즘과 함께 이해하게 되었다.
최근에는 DI를 담당하는 설정파일 없이 @Autowired나 final을 사용하여 생성자를 통한 DI를 하고 있어 이러한 메커니즘을 굳이 이해할 필요가 있을까라는 생각이 들 수 있으나, 실무에서는 오래된 서비스를 운용할 케이스도 있으므로 충분히 이해하고 넘어가야할 가치가 있는 부분이라고 생각된다.
오브젝트와 관계를 설정하고 리턴하는 순수 자바코드 형태의 '오브젝트 팩토리'와 '어플리케이션 컨텍스트'는 하나의 큰 차이가 있다. 어플리케이션 컨텍스트는 빈을 모두 '싱글톤'으로 만든다는 점이다.
아래 예제를 보면 알 수 있듯이 애플리케이션 컨텍스트에서 조회한 userDao에 대해 getBean을 두 번 호출할 경우 모두 동일한 객체가 리턴되었고, DaoFactory에서 userDao 메서드를 호출할 경우 다른 객체를 리턴하고 있다. (DaoFactory 관련 코드는 1주차 게시글에 기재되어 있음)
서버환경에서는 객체를 싱글톤으로 만드는게 성능적으로 좋기 때문이다. 예를들어 클라이언트에서 요청이 올 때마다 관련 오브젝트를 새로 생성한다고 생각해보자. 한 요청에 5개의 오브젝트가 생성되어야 한다면, 100번의 요청에는 500개의 오브젝트가 생성된다. 생성 후 사용되지 않는 객체는 자바의 GC에 의해 처리되는데, 처리해야할 객체가 많으니 GC 횟수가 많아져 서버 리소스가 자주 사용될 것이고, 이는 곧 서버 부하를 가져오게 된다.
만약 싱글톤으로 빈을 생성한다면, 한 요청에 5개의 오브젝트를 생성하는게 아닌 공유하게 될 것이고, GC로 인한 부하도 발생하지 않을 것이다. 이처럼 오브젝트를 싱글톤으로 생성하고 관리하는 것을 '싱글톤 레지스트리'라고 하며, 애플리케이션 컨텍스트는 싱글톤 레지스트리의 역할도 수행한다고 할 수 있다.
3.1. 멀티 스레드 환경에서의 싱글톤
멀티 스레드 환경에서는 하나의 싱글톤 객체에 여러 스레드가 접근하게 된다. 그렇기에 싱글톤 객체의 상태 정보를 수정하는 케이스는 절대 있어서는 안된다. 상태정보는 일반적으로 오브젝트 내 비지니스 로직과 관련되어 사용된다. 상태 정보가 수정된다면 비지니스 로직의 결과 값이 바뀔 수 있다는 뜻인데, 멀티 스레드 환경에서는 상태 값이 덮어 씌워지게 된다. 즉 스레드 각각이 원하지 않는 값을 읽어 올 수 있다는 뜻이다.
그렇기에 싱글톤은 인스턴스 필드 값을 변경하고 유지하는 상태가 아닌 무상태(stateless) 방식으로 만들어야 한다. 단, 읽기 전용의 상태 값이라면 덮어 씌워질 위험이 없으므로 상태 방식으로 만들어도 상관없다.
3.2. 스프링 빈의 스코프
스프링이 관리하는 빈이 생성되고, 존재하고, 적용하는 범위를 빈의 스코프라고 한다. 빈의 스코프는 일반적으로 싱글톤이다. 하지만 경우에 따라 싱글톤 이외의 스코프를 가질 수 있다. 이에 대해선 추후 10장에서 자세히 다룰 예정이라고 한다.
4. 의존관계 주입(DI)
IoC는 스프링만의 용어가 아니다. 1주차에서 공부했듯이 IoC라는 개념은 소프트웨어에서 자주 발견할 수 있는 일반적인 개념이다. 객체지향적인 설계를 할 경우 자연스럽게 IoC를 적용할 수 있다. 때문에 스프링을 IoC 컨테이너라고 하기엔 타 소프트웨어와 확실히 구분되지 않았다. 이러한 이유로 스프링 IoC 방식의 핵심 기능을 나타내는 의존 관계 주입(Dependency Injection)이라는 용어가 만들어져 사용되기 시작했다.
스프링의 동작 원리는 IoC라고 할 수 있지만, 타 프레임워크와 차별화되서 제공되는 핵심 기능은 의존 관계 주입이라고 할 수 있기 때문이다. 그렇다면 의존관계란 뭘까?
5. 의존관계
두 개의 클래스가 의존관계에 있다는 것을 설명할 때에는 방향성도 같이 설명해야한다. 즉, 누가 누구에게 의존하는 관계에 있다는 식이어야 한다는 말이다. UML 모델에서는 두 클래스의 의존관계를 점선으로 표시하며, 아래 그림을 'A가 B에 의존한다'라고 한다.
의존한다는 건 대상이 변하면 자신에게 영향을 미친다는 뜻이다. 여기서는 B가 변하면 A에 영향을 미친다. B의 기능이 추가되거나, 변경되거나, 형식이 바뀌면 그 영향이 A로 전달되기 때문이다.
예를들어 A가 B를 사용하는 경우 B에 정의된 메소드를 호출해서 사용하게 되는데, B의 메소드 형식이 바뀌면 A도 그에 따라 수정해야하고, B의 형식은 그대로지만 기능이 바뀔경우 A의 기능 수행에도 영향을 미칠 수 있다. 반대로 B는 A에 영향을 받지 않지 않는다. B에서 A를 사용하지 않기 때문이다. 일반적으로 이렇게 사용의 관계에 있는 경우 의존 관계가 맺어진다.
5.1. UserDao의 의존관계
지금까지 작업했던 UserDao는 ConnectionMaker에 의존하고 있다. ConnectionMaker 인터페이스가 변한다면 그 영향을 UserDao가 받을 수 있다. 하지만, ConnectionMaker 인터페이스를 구현한 클래스, 즉 DConnectionMaker가 다른 것으로 바뀌어도 UserDao에 영향을 주지 않는다. 인터페이스를 사용했기 때문에 구현 클래스와의 관계가 느슨해졌기 때문이다.
public class UserDao {
private ConnectionMaker connectionMaker;
public UserDao(ConnectionMaker connectionMaker){
this.connectionMaker = connectionMaker;
}
...
}
위 그림에서 알 수 있듯이 UserDao 클래스는 ConnectionMaker 인터페이스에게만 의존한다. UserDao 입장에서 DConnectionMaker 클래스의 존재도 알지 못하기에 이 클래스에는 의존하지 않는다.
이처럼 인터페이스를 통한 의존관계를 갖는 경우에는 UserDao가 본인이 사용할 오브젝트가 어떤 클래스로 만든것인지 미리 알 수 없다. DaoFactory와 같이 DI 기능을 담당하는 클래스에 미리 정의해 놓을 순 있으나, UserDao 클래스나 ConnectionMaker의 코드 내에서는 드러나지 않는다. 이를 알 수 있는 시점은 어플리케이션이 실행된 이후인 런타임 시점이다. 이렇게 런타임 시점에 의존 관계를 맺는 대상을 의존 오브젝트라고 하다. 그리고 이렇게 구체적인 의존 오브젝트와 그것을 사용할 오브젝트를 런타임 시에 연결해주는 작업을 의존관계 주입이라고 정의한다.
의존관계 주입 구체적인 의존 오브젝트와 그것을 사용할 오브젝트를 런타임 시에 연결해주는 작업을 말하며, 아래 세가지 조건을 충족한다. 1) 클래스 코드에는 구체적인 의존 오브젝트에 대한 의존관계가 드러나지 않는다. 이를 위해서는 인터페이스에만 의존하고 있어야 한다. 2) 런타임 시점의 의존관계는 컨테이너나 팩토리같은 제 3의 존재가 결졍한다. 3) 의존관계는 사용할 오브젝트에 대한 래퍼런스를 외부에서 제공해줌으로써 만들어진다.
6. 의존관계 주입의 응용
6.1. 기능 구현의 교환
인터페이스 구현체로 기능을 구현하고 있으니, 기능 구현을 교환하려면 인터페이스에 대한 구현체 클래스를 새로 생성 후 DI 하는 부분에서 클래스만 교체하면 된다.
6.2. 부가 기능 추가
DAO가 DB를 얼마나 많이 연결해서 사용하는 지를 파악하기 위해 DB 연결 횟수를 카운팅하는 기능을 추가한다고 가정한다면, DAO 로직마다 Count를 추가하는 게 아닌 DAO와 DB 커넥션을 만드는 오브젝트 사이에 연결 횟수를 카운팅하는 오브젝트를 추가하면 된다. 오브젝트 추가는 DI 설정으로 한다.
public class CountingConnectionMaker implements ConnectionMaker{
int counter = 0;
private ConnectionMaker realConnectionMaker;
CountingConnectionMaker(ConnectionMaker realConnectionMaker){
this.realConnectionMaker = realConnectionMaker;
}
@Override
public Connection makeConnection() throws ClassNotFoundException, SQLException {
this.counter++;
return realConnectionMaker.makeConnection();
}
public int getCounter(){
return this.counter;
}
}
...
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao(){
return new UserDao(countingConnectionMaker());
}
@Bean
public ConnectionMaker countingConnectionMaker(){
return new CountingConnectionMaker(realConnectionMaker());
}
@Bean
public ConnectionMaker realConnectionMaker(){
return new DConnectionMaker();
}
}
...
public class Main {
public static void main(String[] args) throws SQLException, ClassNotFoundException {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(DaoFactory.class);
UserDao userDao = applicationContext.getBean("userDao", UserDao.class);
// DAO 사용 코드
CountingConnectionMaker ccm = applicationContext.getBean("countingConnectionMaker",
CountingConnectionMaker.class);
System.out.println(ccm.getCounter());
}
}
7. 회고
이번 주차에서는 빈의 스코프와 DI에 대해 알아보았다. 이번 기회로 DI에 대한 개념을 재정리 할 수 있게 되었다. DI가 단순히 어떠한 객체를 주입받는다라고 생각했었지만 코드 내에 의존 오브젝트가 드러나서는 안되고, 의존관계를 런타임 시점에 맺어야 한다는 것을 새롭게 알게되었다.
또한 DI 활용 부분을 보며 AOP, 프록시와 같은 성질을 가진 기능들도 DI로 구현이 가능하겠구나 라는 생각이 들면서 이 기능이 스프링에서 제공하는 다양한 부분에 사용되고 있겠구나 라는 생각도 들었다.
EJB프레임워크에 반발하여 만든 POJO 방식의 프레임워크이다. EJB에 반발, POJO 방식이란 뭘까? 이는 자바 프로그래밍의 역사와 관련이 있다.
EJB는 Enterprise Java Bean의 약자로 엔터프라이즈급 어플리케이션 개발을 위한 사용되던 프레임워크이다. 비즈니스 로직을엔티티 빈이라는 곳에 넣어두고 JSP에서 이를 실행시키는 서버 컴포넌트이다.
EJB의 큰 특징은 비지니스 로직 개발에 초점을 두어 객체지향적인 프로그래밍을 하지 않았다는 것인데, 이때문에 객체가 가진 기능이 많아지고, 책임이 많아짐에 따라 객체가 '무거워지게' 되었다. 이러한 EJB의 객체지향적이지 않은 개발 방식에 반발심을 가져 POJO 라는 말이 등장했다.
POJO란 Plain Old Java Object 가볍고 오래된 방식의 자바 오브젝트라는 뜻이다. 자바 객체는 객체 지향적으로 설계해야 하며 가벼워야한다는 자바 프로그래밍의 기본 철학을 되새기기 위해 나온 단어이다.
EJB 기술의 혼란속에서 잃어버렸던 객체 지향 기술의 가치를 회복하고 기본으로 돌아가기 위해 만들어진 프레임워크가 바로 스프링이다.
3. 초난감 DAO 예제
초난감 DAO 예제를 두번 작성하였다. 처음 작성할 때에는 관심사를 구분하는 것에 초점을 맞췄고, 두번째 작성할 때에는 스프링의 IoC와 어떻게 연결되는지에 초점을 맞췄다.
객체지향적인 설계를 위한 첫걸음은 관심사를 분리하는 것이다. 아래 초난감 DAO인 UserDao에 대해 관심사를 분리하는 방법을 Step By Step으로 알아보았다.
public class UserDao {
public void add(User user) throws ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
// 실행시킬 쿼리 생성
PreparedStatement ps = c.prepareStatement(
"insert into users(id, name, password) values(?,?,?)");
ps.setString(1, user.getId());
ps.setString(2, user.getName());
ps.setString(3, user.getPassword());
// execute : 수행 결과를 Boolean 타입으로 반환
// executeQuery : select 구문을 처리할 때 사용하며, 수행 결과를 ResultSet 타입으로 반환
// executeUpdate : INSERT, UPDATE, DELETE 구문을 처리할 때 사용하며, 반영된 레코드 수를 int 타입으로 반환.
ps.executeUpdate();
// Close
ps.close();
c.close();
}
public User get(String id) throws ClassNotFoundException, SQLException{
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
// 쿼리 및 파라미터 셋팅
PreparedStatement ps = c.prepareStatement(
"select * from users where id = ?");
ps.setString(1, id);
// executeQuery 메서드를 사용하여 실행 결과를 ResultSet으로 반환
ResultSet rs = ps.executeQuery();
// ResultSet 값이 존재하는지 확인하며 존재할 경우 해당 레코드(다음 레코드)로 이동
rs.next();
User user = new User();
user.setId(rs.getString("id"));
user.setName(rs.getString("name"));
user.setPassword(rs.getString("password"));
rs.close();
ps.close();
c.close();
return user;
}
}
이 클래스에는 현재 두가지 관심사가 있다. 첫째는 DB와 연결을 위한 Connection을 어떻게 가져올 것인가. 둘째는 사용자 등록 및 조회를 위해 DB에 보낼 SQL을 어떻게 작성할 것인가. 셋째는 작업이 끝나면 사용한 리소스는 어떻게 반환할 것인가. 이번 예제에서는 첫째 관심사인 DB와 연결을 분리하는 것을 목표로 하였다.
3.1. 중복 코드에 대한 메서드 추출
get, add 메서드에 Connection을 가져오는 로직이 중복되어 있다. 중복된 로직은 메서드로 추출하였다. 이로써 얻을 수 있는 이점은 Connection 정보가 변경될 경우 생성한 메서드의 코드만 변경하면 된다.
public class UserDao {
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = getConnection();
...
}
public User get(String id) throws ClassNotFoundException, SQLException{
Connection c = getConnection();
...
return user;
}
public Connection getConnection() throws ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
return DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
}
}
3.2. 상속을 통한 분리
메서드 추출만으로 변화에 유연하게 대처할 수 있으나, 변화를 반길 순 없다. 변화를 반기는 DAO, 즉 어떤 변화에도 끄떡없는 DAO를 만들어보자.
교재에서 가정한 변화의 예시는 다음과 같았다.
필자가 구현한 UserDao가 업계에 알려지면서 N사와 D사에서 사용자 관리를 위해 UserDao를 구매하겠다는 주문이 들어왔다. 그런데 납품 과정에서 문제가 발생했다. N사와 D사가 각기 다른 종류의 DB를 사용하고 있고, DB 커넥션을 가져오는 데 있어 독자적으로 만든 방법을 적용싶어한다는 점이다. 더 큰 문제는 UserDao를 구매한 후에도 DB 커넥션을 가져오는 방법이 변경될 가능성이 있다는 점이다. 이 경우 UserDao의 코드를 고객에게 제공해주고, 변경이 필요하면 getConnection() 메서드를 수정해서 사용하라고 할 수 있으나, 고객에게 소스를 직접 공개하고 싶지 않아 컴파일된 클래스 파일만 제공하려 한다. UserDao 소스코드를 N사와 D 사에게 제공해주지 않고도 고객 스스로 원하는 DB 커넥션 생성 방식을 적용해가면서 UserDao를 사용하게 할 수 있을까?
이 경우 UserDao 코드를 한단계 더 분리한다. UserDao를 추상 클래스로 만들고 getConnection() 을 추상메서드로 만드는 방법이다. 그럼 각자 UserDao를 상속받아 getConnection 메서드만 각자 회사의 독자적인 기술로 만들기만 하면 된다. 이를 상속을 통한 분리라고 한다.
public abstract class UserDao {
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = getConnection();
...
}
public User get(String id) throws ClassNotFoundException, SQLException{
Connection c = getConnection();
...
return user;
}
public abstract Connection getConnection() throws ClassNotFoundException, SQLException;
}
public class DUserDao extends UserDao{
@Override
public Connection getConnection() throws ClassNotFoundException, SQLException {
// D사 Connection 생성 코드
}
}
public class NUserDao extends UserDao{
@Override
public Connection getConnection() throws ClassNotFoundException, SQLException {
// N사 Connection 생성 코드
}
}
N사, D사에서는 각각 NUserDao, DUserDao 클래스를 만들고 UserDao를 상속받아 getConnection을 정의하면 된다. 이로써 DAO의 핵심 기능인 '어떻게 데이터를 등록하고 가져올 것인가'라는 관심을 담당하는 UserDao와, 'DB 연결 방법은 어떻게 할 것인가' 라는 관심을 담고 있는 NUserDao, DUserDao가 클래스 레벨로 분리되었다. 클래스 단위로 분리되면서 변경 작업은 한층 용이해졌다. 이제는 UserDao의 코드는 한 줄도 수정할 필요 없이 DB 연결 기능을 클래스를 만들어 사용할 수 있다.
또한 이번 리팩토링으로 인해 UserDao와 구현 클래스는 템플릿 메서드 패턴과 팩토리 메서드 패턴이 적용되었다고 말할 수 있다.
템플릿 메서드 패턴 이처럼 슈퍼클래스에 기본적인 로직의 흐름(get, add)을 만들고, 그 기능의 일부(getConnection)를 추상 메소드나 오버라이딩이 가능한 메소드로 만든 뒤 서브 클래스에서 구현하도록 하는 방법을 디자인 패턴에서 템플릿 메서드 패턴이라고 한다.
팩토리 메서드 패턴 UserDao의 서브클래스의 getConnection() 메서드는 Connection 오브젝트를 어떻게 생성할 것인지를 결정하는 방법이라고도 볼 수 있다. 이렇게 서브클래스에서 구체적인 오브젝트 생성 방법을 결정하게 하는 것을 팩토리 메서드 패턴이라고 한다.
하지만 이 방식은 상속을 사용했다는 단점이 있다. 상속은 한계가 있다. 바로 다중 상속을 허용하지 않는다는 점이다. 만약 NUserDao와 DUserDao가 다른 목적을 위해 다른 클래스를 상속하고 있다면 UserDao를 상속받지 못하게 된다.
또 다른 문제는 상속을 통한 상하위 클래스의 관계는 밀접하다는 점이다. 서브 클래스는 슈퍼 클래스의 기능을 직접 사용할 수 있다. 그래서 슈퍼 클래스 내부의 변경이 있을 때 서브클래스를 함께 수정하거나 다시 개발해야 할 수도 있다.
확장된 기능인 DB 커넥션을 생성하는 코드를 다른 DAO 클래스에 적용할 수 없다는 것도 단점이다. Dao 클래스들이 계속 만들어진다면 상속을 통해 만들어진 getConnection() 의 구현 코드가 매 DAO 클래스마다 중복될 것이다.
3.3. 클래스의 분리
DB 커넥션과 관련된 부분을 서브 클래스가 아니라, 아예 별도의 클래스로 만들고 이렇게 만든 클래스를 UserDao에서 생성하여 사용하게 만든다. 이럴 경우 상속을 통한 한계점을 극복할 수 있고, 다른 DAO 클래스에도 해당 클래스를 사용하게 만든다면 구현코드의 중복도 막을 수 있다.
public class UserDao {
private ConnectionFactory connectionFactory;
public UserDao(){
connectionFactory = new ConnectionFactory();
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = connectionFactory.makeNewConnection();
...
}
public User get(String id) throws ClassNotFoundException, SQLException{
Connection c = connectionFactory.makeNewConnection();
...
}
}
public class ConnectionFactory {
public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
return DriverManager.getConnection("jdbc:mysql://localhost/spring","xxx","xxx");
}
}
ConnectionFactory 클래스를 만들고 UserDao에서는 ConnectionFactory 필드를 추가한다. N사에서 ConnectionFactory 방식으로 Connection을 가져오고 있다고 가정한다. 이 후 기본 생성자 주입을 통해 ConnectionFactory를 받아 add(), get() 메서드에서 사용하도록 한다.
성격이 다른 코드를 클래스로 분리하였으나 N사와 D사에서 상속을 통해 DB 커넥션 기능을 사용했던 게 불가능해졌다. 현재 UserDao가 N사의 커넥션 생성 방식인 ConnectionFactory 클래스에 종속되어 있기 때문에 D 사에서 다른 커넥션 생성 방식, 예를들면 SimpleConnectionFactory 클래스를 만들어 사용한다고 할 경우 UserDao를 그에 맞게 또 변경해줘야하기 때문이다. 결국 처음으로 돌아와버린 격이다.
이렇게 클래스를 분리한 경우에도 자유로운 확장이 가능하게 하려면 구체적인 방법에 종속되지 않도록 해야한다. 즉, ConnectionFactory, SimpleConnectionFactory와 같은 클래스와 UserDao를 이어주는 추상적인 연결고리를 만들어주는 것이다.
추상화 추상화란 어떤 것들의 공통적인 성격을 뽑아내서 분리해내는 작업을 말한다. 자바가 추상화를 위해 제공하는 가장 유용한 도구는 인터페이스이다.
즉, UserDao에서 사용하는 커넥션 생성 방식에 대한 객체를 만드려면 구체적인 클래스 하나를 선택해야겠지만, 인터페이스를 사용할 경우 구체적인 클래스가 정확히 무엇인지 몰라도 된다. 인터페이스를 도입해야한다.
3.4. 인터페이스 도입
ConnectionMaker라는 인터페이스를 정의하고 DB Connection을 가져오는 메소드 이름을 makeConnection이라고 정한다. 이 인터페이스를 사용하는 UserDao는 ConnectionMaker 인터페이스 타입의 오브젝트라면 어떤 클래스로 만들어졌는지 상관없이 makeConnection() 메서드를 호출하기만 하면 Connection 타입의 오브젝트를 만들어서 돌려줄 것이라 기대할 수 있다.
public interface ConnectionMaker {
public Connection makeConnection() throws ClassNotFoundException, SQLException;
}
public class DConnectionMaker implements ConnectionMaker{
@Override
public Connection makeConnection() throws ClassNotFoundException, SQLException {
// D사의 Connection 생성 코드
}
}
public class NConnectionMaker implements ConnectionMaker {
@Override
public Connection makeConnection() throws ClassNotFoundException, SQLException {
// N사의 Connection 생성 코드
}
}
public class UserDao {
private ConnectionMaker connectionMaker;
public UserDao(){
this.connectionMaker = new DConnectionMaker();
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = connectionMaker.makeConnection();
...
}
public User get(String id) throws ClassNotFoundException, SQLException{
Connection c = connectionMaker.makeConnection();
...
}
}
UserDao에서는 ConnectionMaker의 인터페이스와 인터페이스 메서드인 makeConnection()을 사용하도록 하고 N사와 D사에서는 ConnectionMaker의 인터페이스 구현체를 각자 생성하도록 하였다. 하지만 문제가 하나 있다. UserDao의 기본 생성자에 DConnectionMaker라는 클래스를 생성하여 connectionMaker에 주입하고 있다. 이렇게 된 이유는 어떤 ConnectionMaker 구현 클래스를 사용할지를 UserDao에서 결정하고 있기 때문이다. UserDao가 사용할 ConnectionMaker의 구현 클래스를 설정해주는 관심이 UserDao에 남아있다. 이러한 설정을 관계 설정이라고 한다. 이 관계설정이라는 관심을 분리해야한다.
3.5. 관계설정 분리
관계를 설정하는 클래스를 만들고 그 클래스의 관심사를 ConnectionMaker 구현체를 결정하는 것으로, 즉 관계 설정 책임을 담당하는 클래스로 사용한다. 필자는 Main 이라는 클래스를 생성하고 main 메서드에서 관계를 설정해주었다.
N사에 대한 ConnectionMaker 방식을 사용하고자 할 경우 UserDao 생성자 파라미터로 NConnectionMaker 객체를, D사에 대한 ConnectionMaker 방식을 사용하고자 할 경우 DConnectionMaker 객체를 넘겨주면 된다. 그럼 UserDao의 코드 변경 없이 확장까지 가능한 UserDao 클래스가 구현되었다.
public class UserDao {
private ConnectionMaker connectionMaker;
public UserDao(ConnectionMaker connectionMaker){
this.connectionMaker = connectionMaker;
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = connectionMaker.makeConnection();
...
}
public User get(String id) throws ClassNotFoundException, SQLException{
Connection c = connectionMaker.makeConnection();
...
}
}
public class Main {
public static void main(String[] args) throws SQLException, ClassNotFoundException {
ConnectionMaker connectionMaker = new DConnectionMaker();
UserDao userDao = new UserDao(connectionMaker);
User user = new User();
user.setId("test");
user.setName("테스트");
user.setPassword("1234");
userDao.add(user);
...
}
}
4. 초난감 DAO 예제의 목적
그럼 초난감 DAO 의 목적은 무엇이었을까. 필자가 생각했을 땐 객체지향 설계의 기본 원칙과 패턴들이 이런것이다 라는 것을 상기시켜주기 위함이 아닐까 생각한다.
관심사를 분리시키다보니 객체 지향적으로 코드가 리팩토링됐다. 이 과정에서 템플릿 메서드 패턴, 팩토리 메서드 패턴, 전략 패턴이 적용되었고, 객체지향 설계 원칙 중 하나인 개방폐쇄 원칙이 적용되었다. 이렇게 리팩토리된 코드는 높은 응집도와 낮은 결합도를 가졌다고 표현한다.
개방 폐쇄 원칙 개방 폐쇄 원칙(OCP)은 객체지향 5대 원칙(SOLID) 중 하나로 '클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다'라는 원칙이다.
UserDao는 DB 연결 방법 기능을 확장하는 데는 열려있다. 그에반에 UserDao의 핵심 기능, 즉 주요 관심사인 등록과 조회는 DB 연결 방법의 변화에는 영향을 받지 않기때문에 변경에는 닫혀있다라고 할 수 있다. 다시말하면 인터페이스를 통한 확장 포인트는 확장을 위해 열려있고, 인터페이스를 사용하여 주요 관심사를 수행하는 클래스는 자신의 변화가 불필요하게 일어나지 않도록 굳게 닫혀되어 있다.
전략 패턴 전략 패턴은 자신의 기능 중 필요에 따라 변경이 필요한 로직을 인터페이스를 통해 외부로 분리시키고, 이를 구현한 구체적인 로직을 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴이다.
개선한 Main - UserDao - ConnectionMaker 구조를 디자인 패턴의 시각으로 보면 전략 패턴이라고 한다. 전략 패턴의 정의를 우리가 구현한 클래스에 대입하면 다음과 같다.
UserDao 클래스에서 변경이 필요한 DB Connection을 가져오는 로직을 ConnectionMaker라는 인터페이스를 사용해 외부로 분리시키고, 이를 구현한 구체적인 로직인 DConnectionMaker, NConnectionMaker를 필요에 따라 Main 클래스에서 바꿔 사용할 수 있게 하는 것이다.
높은 응집도 개방 폐쇄 원칙은 높은 응집도와 낮은 결합도를 가진다. 응집도가 높다는 건 하나의 클래스가 하나의 관심사에 집중되어 있다는 뜻한다. 관심사에 대한 변경이 일어날 때 해당 클래스에서 많은 변경이 일어난다면 응집도가 높고, 다른 클래스에서도 변경이 일어난다면 응집도가 낮다는 것이다.
UserDao 클래스는 사용자의 데이터를 처리하는 관심사에 집중되어 있고, ConnectionMaker의 구현체는 자체적인 Connection을 생성하는 관심사에 집중되어 있다. 이렇듯 하나의 관심사가 하나의 클래스에만 응집되어 있으므로 응집도가 높다라고 할 수 있다.
낮은 결합도 결합도가 낮다는 건 관심사가 다른 클래스와 느슨하게 연결된 것을 뜻한다. 여기서 결합도란 하나의 오브젝트가 변경이 일어날 때에 관계를 맺고 있는 다른 오브젝트에게 변화를 요구하는 정도를 말한다.
ConnectionMaker 인터페이스의 도입으로 인해 DB 연결 기능을 구한한 클래스가 바뀌더라도 UserDao의 코드는 변경될 필요가 없다. ConnectionMaker와 UserDao의 결합도가 낮다는 뜻이다.
5. IoC
IoC는 Inversion Of Controller의 약자로 제어의 역전을 뜻한다. 갑자기 이 용어가 튀어나온 이유는 작성한 예제에도 IoC가 적용되어 있기 때문이다.
UserDao라는 클래스에서 사용하는 ConnectionMaker의 구현체는 UserDao 본인이 결정한게 아니다. Main 클래스에서 ConnectionMaker의 구현 객체를 제어하고 있다. 이를 제어의 역전이라고 한다. 제어의 역전이 적용되면 특정 오브젝트는 자신이 사용할 오브젝트를 스스로 선택하지 않게 된다. 생성하지도 않으며 자신도 어떻게 만들어지는지 알 수 없다.
6. 오프젝트 팩토리
자신이 사용할 오브젝트를 다른 클래스에서 제어한다는 IoC의 개념을 되새기며 UserDao 코드를 리팩토링 해보자.
현재 Main 클래스는 UserDao의 기능이 잘 동작하는지에 대한 관심과 UserDao 클래스에 구현 객체를 제어하는 IoC 기능에 대한 관심까지 갖고 있다. 관심사가 하나가 아니므로 응집도가 낮고 결합도가 높은 상황이다. IoC 기능에 대한 관심사 분리를 해보자.
분리시킬 기능을 담당하는 클래스를 만들어야하는데, 이 클래스의 역할은 객체의 생성 방법을 결정하고 그렇게 만들어진 오브젝트를 돌려주는 것(IoC의 기능)이다. 이런 일을 하는 오브젝트를 팩토리 또는 오브젝트 팩토리라고 부른다.
팩토리 역할을 맡을 클래스를 DaoFactory라고 하고 UserDao, ConnectionMaker에 대한 생성 작업을 DaoFactory로 옮긴다.
public class DaoFactory {
public UserDao userDao(){
ConnectionMaker connectionMaker = new DConnectionMaker();
return new UserDao(connectionMaker);
}
}
public class Main {
public static void main(String[] args) throws SQLException, ClassNotFoundException {
UserDao userDao = new DaoFactory().userDao();
User user = new User();
user.setId("test");
user.setName("테스터");
user.setPassword("1234");
userDao.add(user);
...
}
}
DaoFactory의 userDao 메서드를 호출하면 DConnectionMaker를 사용해 DB 커넥션을 가져오도록 한 UserDao 오브젝트를 반환한다. Main 클래스는 UserDao가 어떻게 만들어지고 초기화되는지에 대한 관심사에서 분리되었다.
만약 DaoFactory에 UserDao가 아닌 다른 DAO의 생성 기능을 넣으면 어떻게 될까? 그럼 아래와 같이 ConnectionMaker의 구현체를 생성하여 관계를 맺어주는 코드가 중복될 것이다. DAO가 많을 경우 ConnectionMaker의 구현체를 변경해야 한다면 모든 메서드를 수정해야하는 문제가 생긴다.
public class DaoFactory {
public UserDao userDao(){
ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
return new UserDao(connectionMaker);
}
public AccountDao accountDao(){
ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
return new AccountDao(connectionMaker);
}
public MessageDao messageDao(){
ConnectionMaker connectionMaker = new DConnectionMaker(); // 중복
return new MessageDao(connectionMaker);
}
}
중복 문제 해결을 위해 메서드를 분리하는 방법을 사용하면 아래와 같이 DAO 팩토리 메서드가 많아져도 중복코드로 인한 문제가 발생하지 않는다.
public class DaoFactory {
public UserDao userDao(){
return new UserDao(connectionMaker());
}
public AccountDao accountDao(){
return new AccountDao(connectionMaker());
}
public MessageDao messageDao(){
return new MessageDao(connectionMaker());
}
public ConnectionMaker connectionMaker(){
return new DConnectionMaker();
}
}
여기서 IoC 개념을 다시 한번 생각해보자. UserDao 클래스는 자기가 사용할 ConnectionMaker의 구현체를 자기가 결정하는 게 아니라 DaoFactory 클래스가 결정해주고 있다. UserDao 자신도 팩토리에 의해 수동적으로 만들어지고 자신이 사용할 오브젝트도 팩토리가 주는대로 사용하는 입장이 되었다. 바로 이게 제어의 역전 즉, IoC이다.
IoC를 이토록 강조한 이유는 IoC를 기본 배경으로 사용하는 대표적인 프레임워크가 바로 스프링이기 때문이다. 이제 이 스프링에서 사용하는 IoC를 적용해볼 차례이다.
7. 스프링 IoC
스프링 IoC 컨테이너에서 관리되는 자바 객체를빈(bean)이라고 하며, 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리(bean Factory)라고 한다. 보통 빈 팩토리보다는 이를 좀 더 확장한 용어인 애플리케이션 컨텍스트를 주로 사용한다. 빈 팩토리라는 용어는 빈을 생성하고 관계를 설정하는 IoC의 기본 기능에 초점을 맞춘 것이고, 애플리케이션 컨텍스트는 애플리케이션 전반에 걸치는 모든 구성요소의 제어 작업을 담당하는 IoC 엔진이라는 의미로 사용한다.
DaoFactory를 스프링의 빈 팩토리가 사용할 수 있는 설정정보로 만들어야 한다. @Configuration 어노테이션을 DaoFactory에 추가하고, 오브젝트를 만들어주는 메서드에 @Bean 어노테이션을 추가한다.
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao(){
return new UserDao(connectionMaker());
}
@Bean
public ConnectionMaker connectionMaker(){
return new DConnectionMaker();
}
}
이제 DaoFactory를 설정정보로 사용하는 애플리케이션 컨텍스트를 만들어야한다. 어플리케이션 컨텍스트는 ApplicationContext 타입의 오브젝트이며 @Configuration 어노테이션이 붙은 클래스를 설정정보로 사용하려면 AnnotationConfigApplicationContext 생성자를 사용하고 생성자 파라미터에 넣어주면 된다.
public class Main {
public static void main(String[] args) throws SQLException, ClassNotFoundException {
ApplicationContext applicationContext =
new AnnotationConfigApplicationContext(DaoFactory.class);
UserDao userDao = applicationContext.getBean("userDao", UserDao.class);
User user = new User();
user.setId("test");
user.setName("테스터");
user.setPassword("1234");
userDao.add(user);
...
}
}
getBean 메서드는 ApplicationContext가 관리하는 빈을 요청하는 메서드이다. userDao는 ApplicationContext에 등록된 빈의 이름이며, DaoFactory에서 @Bean 어노테이션이 붙은 메서드이다. 메서드 이름이 바로 빈의 이름이 되는것이다.
애플리케이션의 동작 방식은 다음과 같다.
1) 애플리케이션 컨텍스트 객체 생성 시 DaoFactory 클래스를 설정정보로 등록해두고, @Bean이 붙은 메소드의 이름을 가져와 빈 목록을 만들어둔다.
2) 클라이언트가 애플리케이션 컨텍스트의 getBean() 메서드를 호출하면 자신의 빈 목록에서 요청한 이름이 있는지 찾는다.
3) 있다면 빈을 생성하는 메서드를 호출해서 오브젝트를 생성시킨 후 클라이언트에게 반환한다.
* 이 시점에 생성된 오브젝트는 스프링 컨테이너에 생성된 오브젝트 즉 빈(bean)이며, 스프링 컨테이너 빈의 스코프는 기본적으로 싱글톤이다. 때문에 똑같이 getBean() 메서드를 한번 더 호출할 경우 오브젝트를 생성하지 않고 이미 등록된 빈을 리턴한다.
SELECT count(m) FROM Member m, Team t WHERE m.username = t.name
5. 조인 예제
- 테스트를 위해 teamA와 teamB를 생성하였다. 멤버 0부터 9까지는 teamA, 멤버 10부터 19까지는 teamB에 속하도록 하였고, 멤버 20부터 29까지는 팀이 없도록 테스트 데이터를 세팅하였다. 데이터 셋 코드는 다음과 같다.
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);
for(int i =0 ; i< 10 ; i++){
Member member = new Member();
member.setUsername("멤버"+i);
member.setAge(i);
member.changeTeam(teamA);
em.persist(member);
}
for(int i =10 ; i< 20 ; i++){
Member member = new Member();
member.setUsername("멤버"+i);
member.setAge(i);
member.changeTeam(teamB);
em.persist(member);
}
for(int i =20 ; i< 30 ; i++){
Member member = new Member();
member.setUsername("멤버"+i);
member.setAge(i);
em.persist(member);
}
5.1. 내부 조인 예제
- 아래의 JPQL을 실행하여 내부 조인 시 team이 존재하는 Member 정보만을 리턴한다.
String innerJoinQuery = "select m from Member m inner join m.team t";
List<Member> list = em.createQuery(innerJoinQuery,Member.class)
.getResultList();
for(Member member : list){
System.out.println(member.toString());
System.out.println(member.getTeam());
}
// 출력 결과
Member{id=3, username='멤버0', age=0}
Team{id=1, name='teamA'}
Member{id=4, username='멤버1', age=1}
Team{id=1, name='teamA'}
...
Member{id=21, username='멤버18', age=18}
Team{id=2, name='teamB'}
Member{id=22, username='멤버19', age=19}
Team{id=2, name='teamB'}
5.2. 외부 조인 예제
- 아래의 JPQL을 실행하여 외부 조인 시 team이 존재하지 않는 Member 정보도 함께 리턴한다.
String leftJoinQuery = "select m from Member m left join m.team t";
List<Member> list2 = em.createQuery(leftJoinQuery,Member.class)
.getResultList();
for(Member member : list2){
System.out.println(member.toString());
System.out.println(member.getTeam());
}
// 출력 결과
Member{id=3, username='멤버0', age=0}
Team{id=1, name='teamA'}
Member{id=4, username='멤버1', age=1}
Team{id=1, name='teamA'}
...
Member{id=31, username='멤버28', age=28}
null
Member{id=32, username='멤버29', age=29}
null
5.3. 세타 조인 예제
- 테스트를 위해 member 이름이 teamA인 멤버를 생성하였다. 실제 실행된 쿼리 확인 결과, 두 테이블을 크로스 조인 후 조건에 해당하는 값만을 조회한다.
Member thetaMember = new Member();
thetaMember.setUsername("teamA");
thetaMember.changeTeam(teamA);
em.persist(thetaMember);
String thetaJoinQuery = "select m from Member m, Team t where m.username = t.name";
List<Member> list3 = em.createQuery(thetaJoinQuery,Member.class)
.getResultList();
for(Member member : list3){
System.out.println(member.toString());
System.out.println(member.getTeam());
}
// 출력 결과
Member{id=33, username='teamA', age=0}
Team{id=1, name='teamA'}
6. 조인 대상 필터링
- SQL에서 사용하던 on과 동일하게 사용한다. 내부 조인, 외부 조인도 동일한 방식으로 사용 가능하다.
// 내부 조인에 대한 필터링 - 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
JPQL : SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'
SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.team_id = t.id and t.name = 'A'
// 외부 조인에 대한 필터링 - 회원의 이름과 팀 이름이 같은 대상 외부조인
JPQL : SELECT m, t FROM Member m LEFT JOIN Team t ON m.username = t.name
SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name
7. 회고
- JPQL에서 사용하는 조인과 SQL에서 사용하는 조인은 조인의 개념만 잘 알고있다면 어렵지 않게 적용함을 알았다. 모든 조인에 세타 조인을 사용해도 되나, 크로스 조인으로 인한 성능 저하를 고려해봤을 때 테이블의 연관관계를 확실히 이해하고 그에 따른 조인 전략을 구상하는 게 중요함을 느꼈다.
- 조회할 대상은 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자, 등 기본 데이터 타입)이 있다. 각 타입의 의미는 예제를 통해 쉽게 이해 가능하다.
SELECT m FROM Member m // Member 엔티티를 조회하는 엔티티 프로젝션
SELEECT m.team FROM Member m // Member 엔티티와 관계를 맺고 있는 Team 엔티티를 조회하는 엔티티 프로젝션
SELECT m.address FROM Member m // Member 엔티티에 임베디드 타입인 address를 조회하는 임베디드 타입 프로젝션
SELECT m.username, m.age FROM Member m // 기본 데이터 타입들을 조회하는 스칼라 타입 프로젝션
4. 여러 값 조회하기
- 한 로우에 여러 값을 조회한다는 것은 반환 값이 명확하지 않다는 뜻이다. 예를들어 위의 스칼라 타입 프로젝션 같은 경우 username이라는 String과, age라는 int형을 조회하는데 두 값을 한번에 받을 수 있는 타입이 존재하지 않기 때문이다.
- 이처럼 반환 값이 명확하지 않을 경우 1차적으로 Query 타입으로 조회하게 된다.
- getResultList 사용 시에는 결과를 ArrayList<Object>로, getSingleResult 사용 시에는 결과를 Object 형태로 리턴받는다.
- 조회한 로우의 각 속성들을 조회하고 싶다면 Object를 Object[]로 캐스팅하여 result[0], result[1]과 같이 조회해야한다.
- 이러한 매커니즘을 활용하여 여러 값을 조회하는 방법은 크게 3가지가 있다.
1) 앞서 언급한 Query 타입으로 조회하는 방법
2) 반환 값을 Object[]로 명확히 하여 TypeQuery 타입으로 조회하는 방법
3) new 명령어로 조회하는 방법
4.1. Query 타입 조회
// Query 타입 조회 방법
Object result2 =
em.createQuery("select m.username, m.id from Member m where m.id = 1L").getSingleResult();
Object[] objects = (Object[])result2;
System.out.println(objects[0]); // username
System.out.println(objects[1]); // id
- Query 타입으로 조회 시 Object로 받게 되며, 각 속성에 접근하기 위해 반드시 Object 배열로 캐스팅해야하고 배열 번호로 접근해야 하는 단점이 있다.
4.2. TypedQuery 타입 조회(= Object[] 조회)
// TypedQuery 타입 조회 방법 == Object[] 타입 조회
Object[] result3 =
em.createQuery("select m.username, m.id from Member m where m.id = 1L",Object[].class).getSingleResult();
System.out.println(result3[0]); // username
System.out.println(result3[1]); // id
- TypedQuery 타입으로 조회 시 createQuery 메서드에서 Object[] 로 캐스팅 작업을 먼저 하기때문에 추가적인 캐스팅 코드를 작성하지 않는다. 하지만 배열 번호를 통한 접근은 그리 좋지 않아보인다.
4.3. new 생성자를 통한 조회
// new 생성자를 통한 조회
MemberDto result3 =
em.createQuery("select new jqpl.MemberDto(m.id, m.username) from Member m where m.id = 1L",MemberDto.class).getSingleResult();
System.out.println(result3.getId()); // username
System.out.println(result3.getUsername()); // id
...
// MemberDto.java
public class MemberDto {
private Long id;
private String username;
public MemberDto(Long id, String username) {
this.id = id;
this.username = username;
}
...
// getter, setter
}
- new 생성자를 사용할 경우 JPQL의 조회 형태에 맞는 생성자를 가진 DTO 클래스를 생성해야 한다.
- 배열의 번호를 통한 접근이 아니고 캐스팅 코드가 없는 깔끔한 조회 방식이나 JPQL에 DTO에 대한 풀 패키지 경로를 입력해야하는 단점이 있다. 하지만 이는 쿼리 DSL에서 극복되었으므로 이 방식을 사용하는 것이 권장된다.
5. 페이징 API
- JPA는 페이징 처리 시 setFirstResult, setMaxResults라는 메서드로 추상화한다.
setFirstResult(int startPosition) // startPosition : 조회할 시작 위치
setMaxResults(int maxResult) // maxResult : 조회할 데이터 수
- 내부적으로 동작되는 쿼리는 JPA에 설정한 Database 방언에 맞게 실행된다.
6. 페이징 API 예제
- 나이 오름차순으로 조회한 멤버 리스트들 중 0번째부터 시작해 10개의 데이터를 조회하는 예제이다. setFirstResult(0), set MaxResults(10)으로 하여 간단히 조회 가능하다.
for(int i =0 ; i< 100 ; i++){
Member member = new Member();
member.setUsername("멤버"+i);
member.setAge(i);
em.persist(member);
}
// 0번째부터 시작해서 10개의 데이터를 조회한다.
List<Member> list =
em.createQuery("select m from Member m order by m.age asc",Member.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
for(Member member : list){
System.out.println(member.toString());
}
//실행결과
Member{id=1, username='멤버0', age=0}
Member{id=2, username='멤버1', age=1}
Member{id=3, username='멤버2', age=2}
Member{id=4, username='멤버3', age=3}
Member{id=5, username='멤버4', age=4}
Member{id=6, username='멤버5', age=5}
Member{id=7, username='멤버6', age=6}
Member{id=8, username='멤버7', age=7}
Member{id=9, username='멤버8', age=8}
Member{id=10, username='멤버9', age=9}
- JPQL은 Java Persistence Query Language의 약자로 자바에서 사용하는 객체 지향 쿼리 언어이다. 객체 지향이라는 말에 맞게 쿼리를 작성할 때 테이블을 대상으로 작성하는게 아닌 엔티티를 대상으로 작성한다.
- JPQL은 결국에 SQL로 변환되며, 특정 데이터베이스에 의존하지 않는다.
3. JPQL 기본 문법
- JPQL은 SQL 문법과 거의 동일하지만 다른 점이 몇가지 있다.
1) 엔티티와 속성은 대소문자를 구분하므로 엔티티 클래스에 정의한 대로 사용해야 한다.
2) SELECT, FROM, WHERE 과 같은 JPQL 키워드는 대소문자를 구분하지 않는다.
3) SQL에서는 테이블 명을 입력했다면 JPQL에서는 테이블 명이 아닌 엔티티 명을 사용해야 한다.
4) 별칭이 필수이다. (ex. select m from Member m)
4. TypeQuery와 Query
- TypeQuery는 반환 값이 명확할 때, Query는 불명확할 때 사용한다.
// 반환 타입이 Member 클래스로 명확
TypedQuery<Member> typedQuery =
em.createQuery("select m from Member m", Member.class);
// 반환 타입이 username, id로 불명확
Query query =
em.createQuery("select m.username, m.id from Member m");
5. 결과 조회
- 결과 조회 시 Query 및 TypeQuery의 메서드인 getResultList(), getSingleResult() 중 하나를 사용한다.
5.1. getResultList
- 결과가 하나 이상일 때 리스트를 반환하고, 결과가 없을 때 빈 리스트를 반환한다.
- null 체크 할 필요가 없다.
5.2. getSingleResult
- 결과가 정확히 하나일 때 단일 객체를 반환하고, 없을 때 NoResultException, 둘 이상일 때 NonUniqueResultException 예외를 발생시킨다.
- 이처럼 예외가 발생할 수 있기에 try, catch를 통한 핸들링이 필요하다. Spring Data JPA에서는 값이 없을 경우 예외를 발생시키는 부분을 개선하였으며 null 혹은 Optional 객체를 리턴하도록 구현되어 있다.
TypedQuery<Member> list =
em.createQuery("select m from Member m", Member.class);
// 결과가 하나 이상일 것을 예상하여 getResultList를 통해 반환받음
List<Member> memberList = list.getResultList();
TypedQuery<Member> single =
em.createQuery("select m from Member m where m.id = 1L", Member.class);
// 결과가 하나일 것을 예상하여 getSingleResult를 통해 반환받음.
// 만약 결과가 2개 이상이거나 없을 경우 예외가 발생함.
Member singleMember = single.getSingleResult();
6. 파라미터 바인딩
- 이름 기준과 위치 기준으로 바인딩 하는 방법이 있다.
- 이름 기준으로 사용 시 쿼리에 ":" 구문을 사용하며, 위치 기준으로 사용 시 "?번호" 구문을 사용한다.
- 위치 기반 바인딩은 쿼리 중간에 조건이 하나 더 추가될 경우 쿼리에 사용한 번호가 밀려 에러를 유발할 수 있다. 이에따라 위치 기반보다는 이름 기준 바인딩이 권장된다.
* TypedQuery와 Query는 메서드 체이닝을 지원하기에 한번에 처리 가능하다.
// 이름 기준 파라미터 바인딩
TypedQuery<Member> query
= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class);
query.setParameter("username", "심승경");
query.setParameter("id", 1L);
Member singleMember = query.getSingleResult();
// 메서드 체이닝 방식
Member singleResult
= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class)
.setParameter("username", "심승경")
.setParameter("id", 1L)
.getSingleResult();
// 위치 기준 파라미터 바인딩
singleResult =
em.createQuery("select m from Member m where m.id = ?1 and m.username = ?2", Member.class)
.setParameter(1, 1L)
.setParameter(2, "심승경")
.getSingleResult();
JavaDoc를 사용해 주석들을 문서화하고, asciidoctor를 사용해 API도 문서화하는 방법을 알아보았다.
3. 느낀점
사실 asciidoctor를 통해 API 문서화 방법을 실습하면서 'Swagger를 통해 API 문서를 생성하는 방법을 알려주는게 더 좋지 않았을까' 라는 생각이 많이 들었다. javaDoc과 같은 경우는 API 명세 뿐 아니라 비지니스 로직에 대한 명세도 상세히 기입할 수 있어 이점이 있으나, asciidoctor과 같은 경우는 개발자가 작성한 테스트 코드를 통해 API 명세를 제공하여 누락될 여지가 있고, 실제 API 테스트도 Swagger가 훨씬 간편하기 때문이다.
도커의 개념, 도커 파일, 이미지 생성, 컨테이너 빌드에 대한 내용이 한 시간 남짓한 강의로 제공되었는데, 강의의 코드를 보고 따라치는 느낌이라 도커에 대한 자세한 내용은 얻어갈 순 없었다. 도커에 대한 일반적인 프로세스 및 개념정도의 이해를 목적으로 한 것 같았다.
이로써 마지막 8주차 강의까지 모두 끝이났다. 8주간 받았던 피드백들 하나하나 정말 소중했고, 특히 테스트 주도 개발을 위해 어떻게 시작해야 할지에 대한 방향이 잡힌 것 같다. 8주의 기간동안 받았던 피드백들을 다시한번 복기하며 정리하여 추후 있을 토이 프로젝트 배운 내용을 적용할 수 있도록 할 것이다.
인증 시 JWT RefreshToken와 AccessToken을 발급하는 방식을 적용해보았다. RefreshToken은 DB에 저장하였고, AccessToken 만료 시 RefreshToken을 통해 AccessToken을 재발급해주는 기능을 구현하였다. 현재는 AccessToken 만료 시 클라이언트에게 401에러와 Expired 에러를 알려주는데, 이렇게 할 경우 클라가 RefreshToken을 통해 AccessToken을 재발급받는 API를 타야하는 번거로움이 있다. RefreshToken을 세션에 넣어두고 AccessToken이 만료됐을 때 세션에서 RefreshToken을 가져와 AccessToken을 재발급해주는 Filter를 생성한다면 이러한 번거로움을 없앨 수 있을 것 같다.
2.2. 어노테이션 기반 인가처리
이전에는 SecurityConfig 설정 파일 antMatcher.hasRole과 같은 메서드를 사용하여 인가처리를 하였는데, Controller 메서드에 @PreAuthorize 를 사용하여 인가처리를 하는 방법을 알게되었다. 설정파일에 할 경우 antMatcher.hasRole 메서드의 순서에 따라 본인이 원하는 인가 처리가 되지 않을 수 있는데 반에 이 방식은 보다 명시적인 느낌을 받았다.
2.3. JWT + SpringSecurity
SpringSecurity에 JWT 인증 방식을 적용해보았다. AccessToken와 RefreshToken을 사용한 JWT 인증의 프로세스를 이해하니 폼 인증 방식보다 훨씬 유연하다는 생각이 들었다.
3. 느낀점
RefreshToken, AccessToken을 사용한 JWT 인증, 인가 프로세스, SpringSecurity 설정 파일에 입력했던 CSRF, Session 관련 메서드들의 의미와 사용 이유를 확실히 이해하게 되었다.
TDD를 위해 테스트 코드를 선 작성하고 로직을 구현하는 것도 훨 자연스러워졌다. 이 기능은 어떤 클래스가 책임져야 할지, 전역객체에 너무 의존하는 로직은 아닌지, 테스트가 어렵진 않을지 생각하며 코드를 작성하는 습관을 갖는게 중요하다는 것도 확실히 느끼게 된 주 차 였다.
JWT는 인증, 인가에 사용하는 Json 형식의 토큰이고, 인코딩되어있다 정도로만 알고있었으나 이번 멘토링 과제에 적용하기 위해 공부해보니 자세해서 정리된 글들이 많이 보였다. 열심히 구글링하여 이해한 내용들과 필자가 궁금한 점들을 정리해보았다.
2. JWT란?
JWT는 Json Web Token의 약자로 '웹에서 사용되는 Json 형식의 토큰'이다. 토큰에는 사용자의 권한 및 기본 정보, 서명 알고리즘 등이 포함되어 있다. 개인정보는 저장하지 않는데, 이유는 정보성 데이터가 저장되는 Payload는 쉽게 조회할 수 있기 때문이다.
JWT는 서버에 저장되지 않고 클라이언트에서 저장하기 때문에 서버의 메모리 부담을 덜 수 있다. 이처럼 서버에 상태값을 저장하지 않는 것을 무상태(Stateless)라 하며 JWT는 이 무상태 성질을 갖는다.
JWT 사용 프로세스는 다음과 같다.
1. 로그인을 성공했을 때 JWT를 생성하여 클라이언트에게 응답해준다.
2. 클라이언트는 요청마다 Authrization 헤더에 Bearer 타입으로 JWT 값을 넣어 서버로 보낸다.
3. 서버는 JWT 값을 검증하여 요청 처리 여부를 결정한다.
3. JWT 구조
JWT는 Header, Payload, Signature로 구성되며, 각 부분은 온점(.)으로 구분된다.
Header에는 토큰의 유형과 서명 알고리즘, Payload에는 권한 및 기본 정보, Signature에는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용하고, 개인키로 서명한값(이를 전자서명이라고 한다)이 담겨있다. Signature를 통해 토큰에 대한 위변조 여부를 체크할 수 있다.
아래 이미지는 JWT 에 대한 인코딩, 디코딩 데이터를 확인할 수 있는 jwt.io 라는 사이트에서 제공하는 예제를 캡쳐한 것이다. 빨간색 부분이 Header, 보라색 부분이 Payload, 하늘색 부분이 Signature에 해당한다.
4. 토큰은 암호화 된거 아냐?
앞서 Payload는 쉽게 조회할 수 있어 개인정보를 저장하지 않는다고 했었는데, 위 사진을 보면 '어딜봐서 쉽게 조회할 수 있다는거지? 딱 봐도 암호화 되어있는것 같은데?' 라고 생각할 수 있다. 하지만 Header와 Payload는 Base64 URL-safe Encode 형식으로 인코딩되어있을 뿐이고 Signature 만 암호화 되어있다.
지금은 Payload에 sub, name, iat와 같은 값들이 들어있으나 일반적으로 "role":"admin" 혹은 "role":"user"와 같은 권한 정보도 포함시킨다. 그런데 여기서 다음과 같은 궁금증이 생겼다. 권한정보를 임의로 바꾼다면 admin 권한도 갖을 수 있지 않을까?
클라이언트가 응답 받은 JWT의 Payload 값을 디코딩한 후 role에 대한 value값을 user 에서 admin으로 바꾼 후 다시 인코딩하여 서버에게 전달하면 admin 권한을 갖는 것과 동일하게 처리될지 궁금했다. 어차피 서버에서도 인증 정보를 따로 저장하지 않기 때문이다.
결론은 그딴 상술은 통하지 않는다 였다.
JWT의 세번째 항목인 Signature가 토큰의 위변조를 체크하기 때문이다. Signature는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용한 값이 들어있다. 서버에서 토큰을 발급한 시점의 Header와 Payload 값에 대한 해싱 값이 Signature에 있으며, Header와 Payload 값이 위변조 됐다면 해싱 값이 일치하지 않아 위변조된 토큰임을 알아차릴 수 있다.
6. 클라이언트의 토큰 저장위치
JWT를 Access Token, Refresh Token으로 분리하고 refresh Token은 http Only secure 쿠키에, 액세스 토큰은 로컬변수에 저장하는 방식을 채택해야한다. 추가로 http only secure 옵션과 XSS 공격을 막는 필터를 추가해야한다. 여기서 액세스 토큰은 인가, 인증정보가 들어있는 토큰, 리프레시 토큰은 액세스 토큰을 재발급하기 위한 토큰이다.
CSRF 공격을 막기 위해서는 쿠키에 액세스 토큰이 있어서는 안된다. 그러므로 쿠키에는 리프레시 토큰을 저장하고 액세스 토큰은 로컬변수에 저장해야한다.http only는 스크립트를 통한 쿠키 접근을 막는 옵션이고, secure는 네트워크 감청에 의한 쿠키 탈취를 막는 옵션이다. secure가 적용되어 있을 경우 https가 적용된 서버에 대해서만 통신이 가능하게 된다.
이렇게 되면 해커가 CSRF 공격을 하더라도 쿠키에는 액세스 토큰이 없기 때문에 인증 불가 상태가 되어 요청이 차단되고, http only secure 쿠키 특성 상 리프레시 토큰 조회는 불가능하다. 액세스 토큰은 로컬 변수에 저장되어 있으나 XSS 공격을 막으므로 스크립트를 통한 접근도 불가능하다. (참고:https://pronist.dev/143)
클라이언트로부터 요청이 들어왔을 때 실행되는 컨트롤러 메서드에 커스텀 어노테이션(@CheckJwtToken)이 있을 경우 JWT 토큰을 검증하도록 하였으며 이를 위해 앞단에 Interceptor를 구현하였다.
Interceptor의 테스트 코드 작성 시 preHandler메서드에 대한 테스트 작성이 어려웠다. HttpServletRequest, HttpServletResponse, Handler에 대한 파라미터를 테스트코드에서 어떻게 넘겨야할지 감이 안잡혔기 때문이다. 우여곡절 끝에 MockHttpServletRequest, MockHttpServletResponse, HandlerMethod로 구현하게 되었다.(멘토님 감사합니다.)
HandlerMethod로 구현한 이유 디스패처 서블릿은 애플리케이션이 실행될 때 모든 컨트롤러의 메소드를 추출한 뒤 HandlerMethod 형태로 저장해두고, 실제 요청이 들어오면 요청 조건에 맞는 HandlerMethod를 참조하여 해당 메서드를 실행시킨다. 실제 API에 대한 요청이 들어올 경우 Interceptor의 preHandler 메서드 파라미터인 handler에 HandlerMethod가 들어오기 때문에 HandlerMethod로 구현하였다.
HandlerMethod 생성자 파라미터는 beanObject, methodName, parameter로 구성되는데, 테스트 클래스에 MockHandler 클래스를 하나 만들어 아래과 같이 구현하고, beanObject는 테스트 클래스를, methodName에는 @CheckJwtToken가 포함된 메서드 명을 기재하였다. 이로써 Interceptor에 대한 테스트코드를 작성할 수 있었다. 아래는 MockHandler 클래스와 preHandler 메서드에 대한 테스트 코드이다.
class MockHandler{
@CheckJwtToken
public void handleRequest(){
}
}
@Test
void withInvalidTokenReturnFalse() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HandlerMethod handler = new HandlerMethod(new MockHandler(), "handleRequest");
request.setMethod("GET");
request.addHeader("Authorization", "Bearer "+INVALID_TOKEN);
// 유효하지 않은 토큰일 경우 PreHandle 메서드에서 예외가 발생하며 최종적으로 false를 리턴함.
boolean result = loginCheckInterceptor.preHandle(request, response, handler);
assertThat(result).isFalse();
}
2.3. ParameterizedTest
테스트 메서드의 로직은 동일하나 특정 변수 값만 다른 테스트 케이스가 있다. 예를들어 유효하지 않은 토큰 검증에 대한 테스트 시 토큰의 요청 값으로 null, 빈 값, 쓰레기값이 들어갈 수 있는데, 이를 각각 테스트하기 위해 다음과 같이 3개 메서드로 구현할 수 있다.
void parseTokenWithBlankToken(){
assertThatThrownBy(() -> authenticationService.parseToken(" "))
.isInstanceOf(InvalidTokenException.class);
}
void parseTokenWithNullToken(){
assertThatThrownBy(() -> authenticationService.parseToken(null))
.isInstanceOf(InvalidTokenException.class);
}
void parseTokenWithInvalidToken(){
// INVALID_TOKEN 은 실제 유효하지 않은 토큰을 담은 static 변수임.
assertThatThrownBy(() -> authenticationService.parseToken(INVALID_TOKEN))
.isInstanceOf(InvalidTokenException.class);
}
위의 경우 테스트 로직은 동일하나 토큰 값이 달라서 각각의 메서드를 만들어 처리하였는데, 이처럼 특정 변수 값만 다르고 로직이 동일할 경우 ParameterizedTest를 통해 하나의 메서드로 테스트 가능하다.
실무에서 JWT 토큰 적용을 검토했던 적이 있다. 적용을 목적으로 했던거라 개념적인 내용을 많이 건너뛰었던 것 같았는데 이번 기회에 JWT 토큰에 대한 기본 개념, 사용 이유, 보안상 이점, 한계점과 같은 것들을 공부하게 되어 좋았다.
테스트 코드는 Controller, Service, Repository 정도로만 구현을 하다가 이번에 처음으로 Interceptor를 구현하게 되었는데 처음 해보는거라 감이 아예 잡히지 않았었다. prehandle 메서드 검증을 위해 파라미터를 채우는 방법을 몰랐기 때문이었는데, 멘토님께서 도와주시어 해결할 수 있었다.