Back to writing

토비의 스프링 - 오브젝트와 의존관계

토비의 스프링 - 오브젝트와 의존관계

초난감 DAO

User

data class User(  
    val id: String,  
    val name: String,  
    val password: String,  
)

UserDao

JDBC를 이용하는 작업의 일반적인 순서

  1. DB 연결을 위한 Connection을 가져온다.
  2. SQL을 담은 Statement 를 만든다.
  3. 만들어진 Statement를 실행한다.
  4. 조회의 경우 SQL 쿼리의 실행결과를 ResultSet 으로 받아서 정보를 저장할 오브젝트에 옮겨준다.
  5. 작업 중에 생성된 Connection, Statement, ResultSet 같은 리소스는 작업을 마친 후 반드시 닫아준다.
  6. JDBC API가 만들어내는 예외를 잡아서 직접 처리하거나, 메소드에 throws를 선언해서 예외가 발생하면 메소드 밖으로 던지게 한다.
class UserDao {  
    @Throws(SQLException::class, ClassNotFoundException::class)  
    fun add(user: User) {  
        Class.forName("org.h2.Driver")  
        val connection = java.sql.DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")  
        val ps = connection.prepareStatement("INSERT INTO users(id, name, password) VALUES(?, ?, ?)")  
        ps.setString(1, user.id)  
        ps.setString(2, user.name)  
        ps.setString(3, user.password)  
        ps.executeUpdate()  
        ps.close()  
        connection.close()  
    }  
  
    @Throws(SQLException::class, ClassNotFoundException::class)  
    fun get(id: String): User {  
        Class.forName("org.h2.Driver")  
        val connection = java.sql.DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")  
        val ps = connection.prepareStatement("SELECT * FROM users WHERE id = ?")  
        ps.setString(1, id)  
  
        val rs = ps.executeQuery()  
        rs.next()  
        val user = User(  
            rs.getString("id"),  
            rs.getString("name"),  
            rs.getString("password")  
        )  
  
        rs.close()  
        ps.close()  
        connection.close()  
  
        return user  
    }  
}

DAO의 분리

관심사의 분리

  • 분리와 확장을 고려한 설계를 통해 변경이 일어날 때 필요한 작업을 최소화하고 변경이 다른 곳에 문제를 일으키지 않게 해야함.
  • 관심사가 같은 것 끼리 모으고 다른 것은 분리해줌으로써 같은 관심에 효과적으로 집중할 수 있게 만들어 줌

커넥션 만들기의 추출

UserDao의 관심사

  • DB와 연결을 위한 커넥션을 어떻게 가져올까?
  • User 등록을 위해 SQL문장을 만들 Statement를 만들고 실행
  • 사용한 리소스를 반환

중복 코드의 메소드 추출

  • DB 연결과 관련된 부분에 변경이 일어났을 경우 수정이 간단해짐.

DB 커넥션 만들기의 독립

UserDao를 N사와 D사에 제공하여 서로 다른 DB를 사용할 수 있게 하는 방법

상속을 통한 확장

  • 템플릿 메소드 패턴: 슈퍼클래스에 기본적인 로직의 흐름을 만들고, 그 기능의 일부를 추상 메소드나 오버라이딩이 가능한 protected 메소드 등으록 만든 뒤 서브클래스에서 이런 메소드를 필요에 맞게 구현해서 사용하도록 하는 방법
  • 팩토리 메소드 패턴: 서브 클래스에서 구체적인 오브젝트 생성 방법을 결정하는 방법

위 코드의 문제

  • 다중 상속의 문제
  • 클래스의 밀접한 관계(서브클래스가 슈퍼클래스의 기능을 사용할 수 있음)
  • 다른 Dao가 만들어진다면 중복되는 getConnection()을 모두 만들어 주어야함

DAO의 확장

클래스의 분리

다른 관심사를 독립된 메소드를 만들어 분리, 상하위 클래스로 분리 했음. 이번엔 완전히 독립된 클래스로 만들기

  • N사와 D사 UserDao 분리가 다시 불가능해짐
  • DB 커넥션을 제공하는 클래스가 어떤 것인지를 UserDao가 구체적으로 알고있어야 함.

인터페이스의 도입

  • 오브젝트 사이에 관계가 만들어지려면 직접 생성자를 호출해서 만드는 방법도 있지만 외부에서 만들어준 것을 가져오는 방법도 있다.
  • 외부에서 만든 오브젝트를 전달받으려면 메소드 파라미터나 생성자 파라미터를 이용하면 된다.
  • 클래스 사이의 관계가 아닌 오브젝트 사이의 다이나믹한 관계가 만들어짐

원칙과 패턴

개방 폐쇄 원칙

  • 클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다.
  • UserDao는 DB 연결 방법 기능에 대한 확장이 열려있다. -> UserDao 변경없이 연결 방법을 늘릴 수 있음

높은 응집도와 낮은 결합도

  • 하나의 모듈, 하나의 클래스가 하나의 책임 또는 관심사에 집중되어 있다.
  • 변경이 일어날 때 모듈의 많은 부분이 함께 바뀐다면 응집도가 높다고 말할 수 있다.
  • 커넥션 만드는 기능을 추가한다 한다면, ConnectionMaker로 DB 연결을 독립시켰기 때문에 커넥션 풀은 활용하는 ConnectionMaker 구현 클래스를 추가하면 된다.

전략 패턴

  • UserDaoTest(main) - UserDao - ConnectionMaker 구조를 전략 패턴이라 볼 수 있다.
  • 자신의 기능 맥락에서 필요에 따라 변경이 필요한 알고리즘을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있게 하는 디자인 패턴이다.

제어의 역전(IoC)

오브젝트 팩토리

  • UserDaoTest(main) 이 어떤 ConnectionMaker 를 사용할지 결정하는 기능을 떠맡았다.
  • 뭔가 문제가 있음

팩토리

오브젝트 팩토리의 활용

제어권의 이전을 통한 제어관계 역전

  • 제어의 역전이라는 건, 프로그램의 제어 흐름 구조가 뒤바뀌는 것
  • main() 메소드와 같이 프로그램의 시작 지점에서 사용할 오브젝트 결정, 생성, 호출.. 작업이 반복된다.
  • 제어의 역전에서는 오브젝트가 자신이 사용할 오브젝틀르 스스로 선택하지 않는다.
  • 추상 UserDao 에서 getConnection() 을 구현하면 슈퍼클래스의 템플릿 메소드에 의해 필요할 때 호출된다. 제어권을 상위에 넘기고 자신은 필요할 때 호출되어 사용되도록 한다. -> 제어의 역전
  • 자연스럽게 관심을 분리하고 책임을 나누고 유연하게 확장 가능한 구조로 만들기 위해 DaoFactory 를 도입했던 과정이 IoC를 적용하는 작업이었다고 볼 수 있다.

스프링의 IoC

오브젝트 팩토리를 이용한 스프링 IoC

애플리케이션 컨텍스트와 설정정보

  • 스프링에서는 스프링이 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트를 bean이라고 부른다. -> 제어 역전이 적용된 오브젝트
  • 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리 라고 부른다.
  • 보통 빈 팩토리보다는 애플리케이션 전반에 걸쳐 모든 구성요소의 제어 작업 담당하는 application context 를 주로 사용한다.

애플리케이션 컨텍스트 적용

애플리케이션 컨텍스트의 동작방식

DaoFactory 와 같은 오브젝트 팩토리에서 사용했던 IoC 원리를 그대로 적용하는 데 애플리케이션 컨텍스트를 사용하는 이유는 범용적이고 유연한 방법으로 IoC 기능을 확장하기 위해서다.

DaoFactory 를 오브젝트 팩토리로 직접 사용했을 때와 비교해서 애플리케이션 컨텍스트를 사용했을 때 얻을 수 있는 장점은 다음과 같다.

  • 클라이언트는 구체적인 팩토리 클래스를 알 필요가 없다.
    • 애플리케이션 컨텍스트를 사용하면 일관된 방식으로 원하는 오브젝트를 가져올 수 있다.
    • xml이나 yaml 처럼 단순한 방법을 사용해 설정정보를 만들 수도 있다.
  • 애플리케이션 컨텍스트는 종합 IoC 서비스를 제공해준다.
    • 오브젝트가 만들어지는 방식, 시점, 전략을 다르게 가져갈 수도 있다. 기타 등등..
  • 애플리케이션 컨텍스트는 빈을 검색하는 다양한 방법을 제공한다.
    • getBean 으로 빈을 찾아주고 타입만으로 검색하거나 애노테이션 설정이 되어있는 빈을 찾을 수도 있다.

스프링 IoC의 용어 정리

  • Bean: 스프링이 IoC 방식으로 관리하는 오브젝트
  • Bean Factory: IoC를 담당하는 핵심 컨테이너
  • Application Context: 빈 팩토리를 확장한 IoC 컨테이너 (상속)

싱글톤 레지스트리와 오브젝트 스코프

  • userDao() 를 호출했을 때 리턴되는 오브젝트는 매번 달라진다. (동일성 x, 동등성 o)
  • 애플리케이션 컨텍스트를 통해 가져온 오브젝트는 같은 오브젝트이다.

싱글톤 레지스트리로서의 애플리케이션 컨텍스트

  • 애플리케이션 컨텍스트는 IoC 컨테이너이자 싱글톤을 저장하고 관리하는 싱글톤 레지스트리 다.
  • 스프링은 기본적으로 별다른 설정을 하지 않으면 내부에서 생성하는 빈 오브젝트를 모두 싱클톤으로 만든다.

서버 애플리케이션과 싱글톤

  • 스프링이 싱글톤으로 빈을 만드는 이유는 대량의 요청이 있는 엔터프라이즈 시스템에서 매번 새로운 객체를 만드는 것이 서버에 부담이 되기 때문이다.

싱글톤 패턴의 한계

  • private 생성자를 갖고 있기 때문에 상속할 수 없다. (객체지향의 장점인 다형성 이용 불가)
  • 싱글톤은 만들어지는 방식이 제한적이기 때문에 테스트 하기 힘들다.
  • 서버 환경에서는 싱글톤이 하나만 만들어지는 것은 보장하지 못한다.
  • 싱글톤의 사용은 전역 상태를 만들 수 있기 때문에 바람직하지 못하다.

싱글톤 레지스트리

  • 스프링은 서버환경에서 싱글톤이 만들어져서 서비스 오브젝트 방식으로 사용되는 것은 적극 지지한다.
  • 하지만 구현 방식의 단점 때문에 기능을 제공하고 그게 싱글톤 레지스트리 다.

싱글톤과 오브젝트의 상태

  • 멀티스레드 환경이라면 무상태 방식으로 만들어야 한다.

스프링 빈의 스코프

  • 빈 스코프는 빈이 생성되고, 존재하고, 적용되는 범위이다.
  • 기본 스코프는 싱글톤이다.
  • prototype scope 가 있는데 컨테이너에 빈을 요청할 때마다 새로운 오브젝트를 만들어준다.
  • 요청마다 생성하는 request scope 가 있고, 웹의 세션과 스코프가 유사한 session scope가 있다.

의존관계 주입(DI)

제어의 역전과 의존관계 주입

  • 스프링 IoC 기능의 대표적인 동작원리는 주로 의존관계 주입이라고 불린다.

런타임 의존관계 설정

  • A 가 B에 의존하고 있다면 B가 변한다면 A에 영향을 미친다.
  • B의 메소드나 로직이 바뀐다면 A도 수정해야할 수 있다.
// B 클래스: A가 의존하는 대상
class B {
    fun doWork() {
        println("B: 기본 작업 수행")
    }
}

// A 클래스: B에 의존
class A(private val b: B) {
    fun perform() {
        println("A: 작업 수행 시작")
        b.doWork()
        println("A: 작업 수행 완료")
    }
}

// 실행 예제
fun main() {
    val b = B()
    val a = A(b)

    println("=== 첫 번째 실행 ===")
    a.perform()

    // B를 수정 – doWork 메서드의 동작이 바뀜
    val bModified = object : B() {
        override fun doWork() {
            println("B (수정됨): 변경된 작업 수행")
        }
    }
    val aWithModifiedB = A(bModified)

    println("=== 수정된 B로 실행 ===")
    aWithModifiedB.perform()
}

UserDao의 의존관계

class UserDao(  
    private val connectionMaker: ConnectionMaker,  
) {  
    ...
}
  • UserDaoConnectionMaker 에 의존한다.
  • 인터페이스를 적용하면 구현 클래스와의 결합은 느슨해진다. -> 구현 클래스의 내부로직이 바뀌어도 변경할 필요없다.
  • 의존관계 주입이란 다음과 같은 세 가지 조건을 충족하는 작업을 말한다.
    • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다.
    • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
    • 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.
  • DI는 자신이 사용할 오브젝트에 대한 선택과 생성 제어권을 외부로 넘기고 자신을 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC에 개념에 잘 들어맞는다.

의존관계 검색과 주입

  • 의존관계 검색은 애플리케이션 컨텍스트의 getBean() 메소드를 사용해 의존관계 검색을 한다.
class UserDao {
    private val connectionMaker: ConnectionMaker
          
      
    init {  
        val context = AnnotationConfigApplicationContext(DaoFactory::class.java)  
        connectionMaker = context.getBean("connectionMaker", ConnectionMaker::class.java)  
    }
  • 하지만 스프링 API가 나타나기 때문에 의존관계 주입이 훨씬 단순하고 깔끔하다.

메소드를 이용한 의존관계 주입

  • 의존관계 주입 시 반드시 생성자를 사용해야 하는 것은 아니다.

XML 을 이용한 설정

XML 설정보다 Java 설정을 사용해야 하는 이유

DataSource 인터페이스로 변환

DataSource 인터페이스 적용

  • ConnectionMaker 는 커넥션을 생성해주는 기능 하나만을 정의한 매우 단순한 인터페이스다.
  • 자바에서는 DB 커넥션을 가져오는 오브젝트의 기능을 추상화해서 비슷한 용도로 사용할 수 있게 만들어진 DataSource 라는 인터페이스가 이미 존재한다.

정리

스프링이란 어떻게 오브젝트가 설계되고, 만들어지고, 어떻게 관계를 맺고 사용되는지에 관심을 갖는 프레임워크