✅ 전략 클래스의 추가 정보
앞에서 JDBC 전략 패턴을 적용하기 위해, 클라이언트와 컨텍스트를 분리하고 deleteAll()
메서드를 사용할 때 DI 구조로 사용할 수 있게끔 만들었다. add()
에도 동일하게 적용해 보자. StatementStrategy
를 구현한 AddStatement
클래스를 생성하고 add()
메서드의 내용을 옮기자.
- AddStatement
package com.jhcode.spring.ch3.user.dao; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import com.jhcode.spring.ch3.user.domain.User; public class AddStatement implements StatementStrategy { User user; //== 생성자를 통해 필요한 객체 주입받기 ==// public AddStatement(User user) { this.user = user; } @Override public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } }
add()
메서드는 클라이언트로 받은 유저의 정보를 DB에 저장하는 기능이다. 해당 메서드의PreparedStatement
를 동작하기 위해선 유저의 정보가 필요하기 때문에AddStatement
객체를 생성할 때, 생성자의 매개변수로User
객체를 주입하여 사용하게 만들었다.PreparedStatement
객체인 pst를 return 함으로써jdbcContextWithStratementStategy()
메서드에서 pst 주입 받아 try-catch-finally 구문 안에서 쿼리문을 실행 시키고 자원을 안전하게 반환 받는다.
- UserDao, add()
public void add(User user) throws ClassNotFoundException, SQLException{ StatementStrategy st = new AddStatement(user); jdbcContextWithStatementsStrategy(st); }
AddStatement
객체를 생성할 때,add()
메서드를 실행하면서 인수로 받은User
객체를 주입한다. 이러면AddStatement
객체에서PreparedStatement
쿼리문의 와일드카드 값을 수정자(Setter)를 통해User
객체의 정보를 사용하여 변경한다.UserDaoTest
를 동작하면 테스트가 성공하는 것을 볼 수 있다.
✅ 전략과 클라이언트의 동거, 중첩 클래스
지금까지의 코드를 살펴보았을 때 아래와 같은 두 가지 문제가 남아있다.
- DAO의 기능에 따라 새로운 StatementStrategy 구현 클래스를 만들어야 한다.
DAO의 메소드 기능에 따라서 새로운 구현 클래스를 생성하다보면 클래스가 무지막지하게 늘어난다. 런타임 시 DI를 사용하는 것 외에 상속을 통해서 구현하는 것과 큰 차이가 없다.
- StatementStrategy의 필요한 정보 User와 같은 부가적인 정보를 저장하고, 전달해야 한다.
add() 메소드는 User 객체의 정보가 필요했다. AddStatement의 인스턴스 변수를 만들고, 생성자로 외부에서부터 객체를 주입해야 하는 번거로운 점이 있다.
🔎 중첩 클래스란?
중첩 클래스(nested class)에는 다음과 같이 네 가지 종류가 있다.
- 인스턴스 내부 클래스(Instance Inner Class):
- 외부 클래스의 인스턴스와 연결되어 사용된다.
- 외부 클래스의 인스턴스 메서드 내에서 생성될 수 있다.
- 외부 클래스의 인스턴스 멤버에 접근할 수 있다.
public class OuterClass { private int x; public class InnerClass { public void printValue() { System.out.println("The value of x is: " + x); } } public void someMethod() { InnerClass innerObj = new InnerClass(); innerObj.printValue(); } }
- 정적 내부 클래스(Static Inner Class):
- 외부 클래스의 인스턴스와 독립적으로 존재한다. static 클래스, 변수, 메서드는 독립적인 메모리를 할당 받는다.
- 정적(static) 멤버와 유사한 방식으로 사용할 수 있다.
- 외부 클래스의 정적(static) 멤버에만 접근할 수 있다. static이 아닌 다른 인스턴스 변수에는 접근할 수 없다.
public class OuterClass { private static int x; public static class InnerClass { public void printValue() { System.out.println("The value of x is: " + x); } } public static void someMethod() { InnerClass innerObj = new InnerClass(); innerObj.printValue(); } }
public class OuterClass { private int x; public static class InnerClass { public void printValue() { // 다음 라인은 오류이다. 정적 내부 클래스에서는 외부 클래스의 인스턴스 변수에 직접 접근할 수 없습니다. System.out.println("The value of x is: " + x); } } }
→ 외부의 인스턴스 변수에 접근하기 위해서는 해당 변수를 주입받아 사용해야 한다.
- 지역 내부 클래스(Local Inner Class):
- 메서드 내에 선언되고, 해당 메서드 내에서만 사용됩니다.
- 메서드 호출 시에만 접근할 수 있습니다.
public class OuterClass { public void someMethod() { int x = 10; class LocalClass { public void printValue() { System.out.println("The value of x is: " + x); } }//LocalClass의 끝 LocalClass localObj = new LocalClass(); //메서드 내에서만 인스턴스를 생성할 수 있다. localObj.printValue(); } }//OuterClass의 끝
- 익명 내부 클래스(Anonymous Inner Class):
- 이름이 없는 클래스로, 주로 인터페이스의 구현체로 사용됩니다.
- 클래스 정의와 동시에 인스턴스를 생성합니다.
public interface MyInterface { void doSomething(); } public class OuterClass { public void someMethod() { MyInterface innerObj = new MyInterface() { public void doSomething() { System.out.println("Doing something..."); } };//MyInterface의 생성자 innerObj.doSomething(); }//someMethod()의 끝 }//OuterClass의 끝
🔹지역 내부 클래스(Local Inner Class)
클래스가 많아지는 문제를 먼저 로컬 클래스로 해결해보자. DeleteAllStatement
와 AddStatement
는 UserDao
의 특정 메소드에서만 사용된다. 두 클래스와 UserDao
는 강하게 결합되어 있다. 특정 클래스에서만 사용된다면 로컬 클래스를 사용할 수도 있다. UserDao
의 add()
메소드에 적용해보자.
- UserDao, add()
public void add(User user) throws ClassNotFoundException, SQLException{ class AddStatementLocalClass implements StatementStrategy { User user; public AddStatementLocalClass(User user) { this.user = user; } @Override public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } }//내부 클래스의 끝 StatementStrategy st = new AddStatementLocalClass(user); jdbcContextWithStatementsStrategy(st); }//외부 클래스의 끝
조금 생소할 수 있지만 마치 지역 변수를 선언하듯이 만들면 된다.
AddStatement
가 사용될 곳이add()
메소드뿐이라면, 이렇게 사용하기 전에 바로 정의해서 쓸 수도 있다. 클래스 파일도 하나 줄고,add()
메소드 안에서PreparedStatement
생성 로직을 함께 볼 수 있어 코드를 이해하기도 좋다.또한 로컬 클래스인
AddStatementLocalClass
는add()
메소드의 내부 클래스이기 때문에add()
메소드가 가지고 있는 지역 변수인User
객체에 접근할 수 있다. 여기에선 메소드의 매개변수로 User 객체를 주입 받아 지역 변수로 사용한다. 그래서 로컬 클래스(AddStatementLocalClass
)에 생성자나 수정자를 사용해서User
객체를 주입하지 않아도 사용할 수 있다.add()
메소드가 내부 클래스인AddStatementLocalClass
의 외부 클래스 격의 역할을 하는 것이다.단, 외부 클래스의 지역 변수에
final
을 선언해주어야 한다.final
을 사용하는 이유는 다음과 같다.- 접근 가능 범위: 내부 클래스는 외부 클래스의 인스턴스와 관련이 있으므로, 내부 클래스에서는 외부 클래스의 멤버에 접근할 수 있어야 한다.
final
로 선언된 지역 변수를 사용하는 것은 내부 클래스가 해당 변수의 값을 보존하고 사용할 수 있도록 보장하는 역할을 한다.
- 내부 클래스의 수명: 내부 클래스는 외부 클래스의 인스턴스와 관련이 있으므로, 내부 클래스의 인스턴스는 외부 클래스의 인스턴스와 함께 사용될 수 있다.
final
로 선언된 변수는 값이 변경되지 않으므로, 내부 클래스의 인스턴스가 외부 클래스의 인스턴스보다 오래 존재하는 경우에도 외부 변수의 상태가 변하지 않아 안정성을 보장한다.
- 접근 가능 범위: 내부 클래스는 외부 클래스의 인스턴스와 관련이 있으므로, 내부 클래스에서는 외부 클래스의 멤버에 접근할 수 있어야 한다.
내부 클래스의 의해서 해당 변수의 값이 변하지 않게 유지해야 하고, 외부 클래스의 객체의 수명과 내부 클래스에서 참조하여 사용한 객체의 수명이 조화롭게 유지되어야 하기 때문이다. 만약 외부 클래스의 인스턴스가 수명이 끝났음에도 내부 클래스의 인스턴스가 계속 참조하게 된다면 해당 인스턴스는 가비지 컬렉션에 의해서 제거되지 않아 문제가 발생될 수 있다.
따라서
final
키워드를 사용하여 변수의 값을 보존하고 내부 클래스 객체의 수명과 외부 클래스의 객체의 수명을 조화롭게 유지하여 내부 클래스의 안전성과 외부 변수의 불변성을 보장해야 한다. Java 8부터는final
을 사용하지 않아도 외부 변수를 참조할 수 있지만, 내부 익명 클래스에서는 여전히final
이 요구된다.
- add(), final
public void add(final User user) throws ClassNotFoundException, SQLException{ class AddStatementLocalClass implements StatementStrategy { @Override public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } }//내부 클래스의 끝 StatementStrategy st = new AddStatementLocalClass(); jdbcContextWithStatementsStrategy(st); }//외부 클래스의 끝
🔹 익명 내부 클래스(Anonymous Inner Class)
익명 내부 클래스는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어진다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우 유용하다.
new 인터페이스이름() { 클래스 본문 }
이를 AddStatement에 적용해 보자.
- UserDao, add(), 익명 내부 클래스 적용
public void add(final User user) throws ClassNotFoundException, SQLException{ StatementStrategy st = new StatementStrategy() { public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } };//익명 내부 클래스의 끝 jdbcContextWithStatementsStrategy(st); }//외부 클래스의 끝
익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 이름이 없기 때문에 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다. 만들어진 익명 내부 클래스의 객체는 딱 한 번만 사용될테니 굳이 변수에 담지 않아도 된다. 메소드의 파라미터에서 생성해 보자.
- jdbcContextWithStatementStrategy()
public void add(final User user) throws ClassNotFoundException, SQLException{ jdbcContextWithStatementsStrategy(new StatementStrategy() { public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } }//익명 내부 클래스의 끝 );//jdbcContextWithStatementsStrategy() 메소드의 끝 }//외부 클래스의 끝
메소드의 파라미터에서 익명 내부 클래스를 생성했다. 익명 내부 클래스의 끝 “}”과 메소드의 끝인 “);”에 유의하며 코드를 작성하자. 동일하게
deleteAll()
메서드에도 적용할 수 있다.
- deleteAll()
public void deleteAll() throws SQLException { jdbcContextWithStatementsStrategy(new StatementStrategy() { @Override public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "DELETE FROM users"; return con.prepareStatement(sql); } }); }
✅ 컨텍스트와 DI
전략 패턴의 구조로 보면, UserDao
의 메소드가 클라이언트이고, 익명 내부 클래스가 개별적인 전략이고, jdbcContextWithStatementStrategy()
메소드가 DI를 담당하는 일종의 컨텍스트이다. jdbcContextWithStatementStrategy()
메소드를 UserDao
클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있도록 만들어보자.
- JdbcContext
package com.jhcode.spring.ch3.user.dao; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import javax.sql.DataSource; public class JdbcContext { private DataSource dataSource; public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; } public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException { Connection con = null; PreparedStatement pst = null; try { con = dataSource.getConnection(); pst = stmt.makePreparedStatement(con); pst.executeUpdate(); } catch (SQLException e) { throw e; } finally { if (pst != null) { try {pst.close(); } catch (SQLException e) {} } if (con != null) { try {con.close(); } catch (SQLException e) {} } } } }
이제
DataSource
객체가 필요한 것은JdbcContext
이지UserDao
가 아니다.UserDao
의DataSource
를 주입 받는 부분을 삭제해도 되지만 쿼리를 실행하고ResultSet
결과를 받아오는 부분은 새로 코드를 작성해야 하니 우선은 그냥 두자.클래스를 독립했으므로 메소드의 이름도
workWithStatementStrategy()
로 변경하였다.UserDao
도 이에 맞게 수정해보자.
- UserDao, add(), deleteAll()
//...생략 private JdbcContext jdbcContext; public void setJdbcContext(JdbcContext jdbcContext) { this.jdbcContext = jdbcContext; } public void add(final User user) throws ClassNotFoundException, SQLException{ jdbcContext.workWithStatementStrategy(new StatementStrategy() { public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "INSERT INTO users(id, name, password) values(?,?,?)"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, user.getId()); pst.setString(2, user.getName()); pst.setString(3, user.getPassword()); return pst; } }//익명 내부 클래스의 끝); );//jdbcContext.workWithStatementStrategy() 메소드의 끝 }//외부 클래스의 끝 //...생략 public void deleteAll() throws SQLException { jdbcContext.workWithStatementStrategy(new StatementStrategy() { @Override public PreparedStatement makePreparedStatement(Connection con) throws SQLException { String sql = "DELETE FROM users"; return con.prepareStatement(sql); } }); }
메소드를 통해
JdbcContext
를 주입 받아workWithStatementStrategy()
메소드를 사용할 수 있게 변경하였다.
- applicationContext.xml
새롭게 작성된 객체 간의 의존 관계를 파악해보자.
UserDao
는 이제JdbcContext
에 의존하고 있다. 그런데JdbcContext
는DataSource
와 다르게 클래스를 사용함으로써 그 자체로 구현 클래스가 된다. 스프링의 DI는 기본적으로 인터페이스를 중간에 두고 구현 클래스를 변경하며 사용하는게 목적이다. 하지만JdbcContext
는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.스프링의 빈 설정은 런타임 시에 만들어진다. 빈으로 정의되는 객체 사이의 관계를 그려보면 아래와 같다. userDao 빈이 dataSource 빈을 직접 의존했지만 이제 jdbcContext 빈이 그 사이에 끼게 된다.
스프링의 빈 설정을 담당하고 있는 applicationContext.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-3.0.xsd"> <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource"> <property name="driverClass" value="org.mariadb.jdbc.Driver" /> <property name="url" value="jdbc:mariadb://localhost:3306/toby_study?characterEncoding=UTF-8" /> <property name="username" value="root" /> <property name="password" value="1234" /> </bean> <bean id="userDao" class="com.jhcode.spring.ch3.user.dao.UserDao"> <property name="dataSource" ref="dataSource"/> <!-- 아직 dataSource를 사용하는 메소드가 있어 제거하지 않았다. --> <property name="jdbcContext" ref="jdbcContext"/> </bean> <bean id="jdbcContext" class="com.jhcode.spring.ch3.user.dao.JdbcContext"> <property name="dataSource" ref="dataSource"/> </bean> </beans>
“jdbcContext” 빈이 추가되었고, UserDao에 <property>를 통해 “jdbcContext” 빈을 주입 받도록 설정했다. 잘 되는지 테스트해보자.
- UserDaoTest
- 직접 DI 하기
@BeforeEach public void setUp() { System.out.println("setUp():" + this); DataSource dataSource = new SingleConnectionDataSource( "jdbc:mariadb://localhost:3306/toby?characterEncoding=UTF-8", "root", "1234", true); dao = new UserDao(); dao.setDataSource(dataSource); JdbcContext jdbcContext = new JdbcContext(); jdbcContext.setDataSource(dataSource); dao.setJdbcContext(jdbcContext); }
직접 DI로 확인해보고 싶다면
JdbcContext
를 생성하고DataSource
객체를JdbcContext
의 “setter”을 사용해서 주입한 후 다시UserDao
에 주입하면 된다.
- applicationContext.xml 사용하기
@ExtendWith(SpringExtension.class) @ContextConfiguration(locations = "/applicationContext.xml") public class UserDaoTest { @Autowired private UserDao dao;
@BeforeEach 부분도 필요가 없어서 모두 주석처리하고, @ExtendWith, @ContextConfiguration, @Autowired 어노테이션만 추가하면 된다. @ContextConfiguration은 applicationContext.xml의 위치를 제대로 설정해야 한다. 여기서 이야기하면 너무 길어지니 다른 블로그 참고하면 될 것 같다.
- 직접 DI 하기
✅ JdbcContext의 특별한 DI
- 스프링 IoC 컨테이너를 활용한 DI
UserDao
와JdbcContext
는 사이에 인터페이스를 사용하지 않고 DI를 적용했다. 지금까지는 구체적인 의존 관계가 만들어지지 않도록 인터페이스를 이용했는데, 인터페이스를 거치지 않고 사용해도 문제가 없을까?객체의 생성과 관계 설정을 IoC로 위임한 것을 볼 때 넓은 의미에서 DI를 충족한다고 볼 수 있다. 하지만 런타임 시에 의존할 객체 관계를 설정하기 위해선 인터페이스가 필요하기 때문에 온전한 DI라고는 볼 수 없다.
그렇다면 먼저 왜
JdbcContext
를 DI 구조로 만들어야 하는지 생각해보자.- JdbcContext가 싱글톤 빈이 되기 때문이다.
JdbcContext
는 객체 안에 변경되는 상태정보를 갖고 있지 않다. 오직 기능으로써의 역할만 수행한다. 따라서 일종의 서비스를 제공하는 객체라고 볼 수 있고, 여러 개를 생성하는 것보다 싱글톤 구조로 하나만 생성되어 사용하고자 하는 여러 객체에 공유되는 것이 경제적이다.
- JdbcContext가 DI를 통해 다른 빈(DataSource)에 의존하고 있기 때문이다.
JdbcContext
는 커넥션 객체가 필요하기 때문에DataSource
객체를 IoC 컨테이너에서 주입 받아 사용하고 있다. IoC 컨테이너에서 관리하는DataSource
객체를 주입 받기 위해선JdbcContext
도 빈이 되어야만 한다.
👉왜 인터페이스를 사용하지 않았을까?
인터페이스가 없다는 것은
UserDao
와JdbcContext
가 매우 긴말한 관계를 가지고 있다고 할 수 있다. 기능적으로 볼 때 두 객체 사이의 응집도도 높을 뿐더러, 결합도 또한 높기 때문에 인터페이스를 사용하지 않고 클래스끼리 DI하는 구조를 허용한 것이다. 다만 이러한 구조로 만드는 것은 가장 마지막 단계에서 고려해야할 사항이다. 인터페이스를 만들기 귀찮다고 클래스를 사용하는 것은 잘못된 생각인 것이다. - JdbcContext가 싱글톤 빈이 되기 때문이다.
- 코드를 이용하는 수동 DI
스프링의 빈으로 등록해서 DI를 하는 대신에 수동으로 DI를 할 수도 있다. 대신 이 방법을 쓰면 JdbcConext를 싱글톤으로 관리하는 것을 포기해야 한다. JdbcContext의 코드가 간단하기 때문에 싱글톤으로 관리하지 않아도 큰 문제가 없을 것이다.
그렇다면 더 이상 스프링의 빈이 아닌 JdbcContext에 DataSource 객체를 어떻게 DI해야할까?
답은 JdbcContext를 제어하는 UserDao에서 JdbcContext를 생성하고 DataSource까지 주입 하는 것이다. DataSource 객체는 스프링 컨테이너에서 주입 받고, JdbcContext에 다시 주입해주면 된다.
- applicationContext.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-3.0.xsd"> <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource"> <property name="driverClass" value="org.mariadb.jdbc.Driver" /> <property name="url" value="jdbc:mariadb://localhost:3306/toby_study?characterEncoding=UTF-8" /> <property name="username" value="root" /> <property name="password" value="1234" /> </bean> <bean id="userDao" class="com.jhcode.spring.ch3.user.dao.UserDao"> <property name="dataSource" ref="dataSource"/> <!-- 아직 dataSource를 사용하는 메소드가 있어 제거하지 않았다. --> </bean> </beans>
기존에 작성한 JdbcContext 빈 등록을 제거한다.
- UserDao
public class UserDao { private DataSource dataSource; private JdbcContext jdbcContext; public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; this.jdbcContext = new JdbcContext(); jdbcContext.setDataSource(dataSource); } public void setJdbcContext(JdbcContext jdbcContext) { this.jdbcContext = jdbcContext; }
스프링 컨테이너를 통해 DataSource 객체를 주입 받을 때 JdbcContext 객체를 생성하고, DataSource 객체도 JdbcContext 객체에 주입 한다.
이렇게 수정자 메소드 안에서 다른 객체를 초기화하고 코드를 이용해 DI하는 것은 스프링에서도 종종 사용되는 기법이다. 스프링의 빈으로 등록하는 경우와 코드를 통해 직접 DI하는 방법은 각각의 장단점이 있다.
- 스프링의 빈으로 등록하는 경우
- 오브젝트 사이의 의존 관계가 설정파일에 명확하게 드러나는 것이 장점
- DI 원칙에 부합하지 않는 구체적인 클래스와의 관계가 설정에서 직접 노출된다는 단점
- 코드를 통해 직접 DI 하는 경우
- 오브젝트 사이의 의존 관계가 외부에 드러나지 않는 장점
- 싱글톤으로 만들 수 없고, 부가적인 코드 작업이 필요하다는 단점.
- 스프링의 빈으로 등록하는 경우
- applicationContext.xml
👉어떤 방법이 더 낫다고 말할 수 없고 상황에 따라 적절히 판단하여 사용하면 된다.
📖토비 스프링 3.1 -p224~240
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T