3. Template

3장 템플릿

3.1. 다시보는 초난감 DAO

초난감 DAO는 기능적으로는 동작하지만, 서버 환경에서 사용하기에는 여러 문제가 있습니다. DB 연결과 같은 제한된 리소스를 사용하는 코드임에도 불구하고, 예외 상황에 대한 고려가 부족해 안정성을 보장하지 못합니다. 특히 JDBC 코드에서는 정상 흐름이 아니라, 중간에 예외가 발생했을 때도 반드시 리소스를 반환해야 한다는 점이 중요합니다.

3.1.1. 예외처리 기능을 갖춘 DAO

JDBC 수정 기능의 예외 처리 문제 가장 단순한 deleteAll() 메서드는 다음과 같은 구조를 가집니다.

  • Connection 획득

  • PreparedStatement 생성

  • SQL 실행

  • 리소스 close

문제는 SQL 실행 도중 예외가 발생하면 close 코드가 실행되지 않는다는 점입니다. 이 경우 Connection과 PreparedStatent가 반환되지 않아, 커넥션 풀을 사용하는 서버 환경에서는 심각한 장애로 이어질 수 있습니다.

이 문제를 해결하기 위해 try/catch/finally 구조를 적용합니다. 핵심은 어디서 예외가 발생하더라도 finally 블록에서 리소스를 정리하는 것입니다.

예외 상황을 고려한 deleteAll()

fun deleteAll() {
    var connection: Connection? = null
    var preparedStatement: PreparedStatement? = null

    try {
        connection = dataSource.connection
        preparedStatement = connection.prepareStatement("delete from users")
        preparedStatement.executeUpdate()
    } catch (e: SQLException) {
        throw e
    } finally {
        if (preparedStatement != null) {
            try {
                preparedStatement.close()
            } catch (e: SQLException) {
                // close 실패 시 별도 처리 없음
            }
        }
        if (connection != null) {
            try {
                connection.close()
            } catch (e: SQLException) {
                // close 실패 시 별도 처리 없음
            }
        }
    }
}

이 구조를 통해 예외가 발생하더라도 리소스가 반드시 반환됩니다. 다만 코드가 장황해지고, 메서드마다 유사한 패턴이 반복되는 문제가 발생합니다.

JDBC 조회 기능의 예외 처리

c조회 기능에서는 ResultSet이라는 리소스가 추가로 등장합니다. 따라서 정리해야 할 대상이 하나 더 늘어납니다. 이로 인해 try/catch/finally 블록은 더욱 복잡해집니다.

getCount() 메서드의 예외 처리

이 코드는 안정성은 확보했지만, 한계를 갖고 있습니다.

  • 메서드마다 거의 동일한 try/catch/fainlly 코드가 반복됩니다.

  • 비즈니스 로직보다 예외 처리 코드가 더 많습니다.

  • 실수로 close 순서를 바꾸거나 누락할 수 있습니다.

3.2. 변하는 것과 변하지 않는 것

3.2.1. JDBC try/catch/finally 코드의 문제점

try/catch/finally를 적용한 DAO는 기능적으로는 안정적으로 실행할 수 있는 상태입니다. 예외가 발생하더라도 리소스는 반환되고, 서버 환경에서도 커넥션 누수 문제는 발생하지 않습니다.

하지만 코드를 조금만 확장해보면 문제가 발생: 중복 코드의 폭발적인 증가.

  • 모든 DAO 메서드는 거의 동일한 try/catch/finally 구조를 가지고,

  • ResultSet이 있느냐 없느냐 정도의 차이.

  • 개발자는 copy & paste에 의존하게 됩니다.

이 방식은 아래의 위험들을 갖고 있습니다.

  • close() 호출을 한 줄 빠뜨려도 컴파일 에러가 발생하지 않습니다.

  • 정상 동작하는 것처럼 보이지만, 특정 경로에서만 커넥션이 반환되지 않을 수 있습니다.

  • 서버에 배포된 뒤 일정 시간이 지나서야 DB 커넥션 풀이 고갈되는 장애로 이어질 수 있습니다.

실제 기업환경에서는 이런 문제로 서버 재가동이나 긴급 장애 대응이 발생하는 사례가 존재합니다.

문제는:

  • 코드 자체가 틀렸다고 바로 드러나지는 앖다는 것이고,

  • 테스트로 검증하려 해도 예외 상황을 만들기 어렵고,

  • DAO 메서드마다 모든 예외 케이스를 테스트하는 것은 현실적으로 힘듭니다.

3.2.2. 분리와 재사용을 위한 디자인 패턴 적용

이 문제를 해결하기 위해 "변하는 것과 변하지 않는 것을 식별"합니다.

deleteAll()을 기준으로 보면 아래와 같이 나눌 수 있습니다.

  • 변하지 않는 부분

    • Connection 획득

    • PreparedStatement 실행

    • 예외 처리

    • 리소스 반환 (try/catch/finally)

  • 변하는 부분

    • PreparedStatement 생성 로직

    • SQL 문자열

    • 파라미터 바인딩 방식

메서드 추출

이렇게 분리하게 되면 SQL 생성 로직은 분리되지만, 실제로는 재사용성 측면에서 이득은 없습니다. 왜냐하면 재사용이 필요한 쪽은 try/catch/finally 구조인데, 이 구조는 완전히 DAO 메서드 안에 고정되어 있기 때문입니다.

템플릿 메서드 패턴

변하지 않는 흐름을 슈퍼클래스에 두고, 변하는 부분을 추상 메서드로 정의해 서브클래스에서 구현합니다.

이 방식은 중복 제거에는 성공하지만, 상속 기반 확장이라는 한계를 가집니다.

  • DAO 기능 하나 추가할 때마다 서브클래스가 필요합니다.

  • 클래스 개수가 빠르게 증가합니다.

  • 런타임에 유연학헤 동작을 바꾸기 어렵습니다.

즉, 구조가 컴파일 시점에 고정되어 버립니다.

전략 패턴

이를 해결하기 위해 등장한 것이 전략 패턴입니다. 변하는 부분을 인터페이스로 분리하고, 이를 외부에서 주입받도록 합니다.

이제 try/catch/finally 구조는 컨텍스트로 분리됩니다.

deleteAll()은 더 이상 JDBC구조를 알 필요가 없습니다.

3.3 JDBC 전략 패턴의 최적화

deleteAll()을 전략 패턴으로 분리한 이후, JDBC 작업의 공통 흐름은 jdbcContextWithStatementStrategy() 라는 컨텍스트 메서드로 모였습니다. DAO 메서드는 이제 SQL과 파라미터 설정같은 "변하는 로직"만 책임지게 되었고, 예외 처리와 리소스 관리는 컨텍스트가 담당합니다.

  • 컨텍스트: JDBC 작업의 고정된 흐름 담당

  • 전략: PreparedStatement 생성이라는 가변 로직 담당

이제 이 구조를 add() 메서드에도 적용하면서, 실제 사용 과정에서 드러나는 문제를 하나씩 해결해 나갑니다.

3.3.1. 전략 클래스의 추가 정보

deletreAll()과 달리 add()는 PreparedStatement를 만들 때 User라는 추가 정보가 필요합니다. 전략 클래스를 분리하면 가장 먼저 해결해야하는 문제는:

"전략 클래스 안에서 user 객체를 어떻게 사용할 것인가?" 입니다.

해결 방법은, 전략 클래스가 필요한 정보를 생성자를 통해 전달받도록 만드는 것입니다.

그리고 DAO 메서드는 전략 객체를 생성해서 컨텍스트에 전달합니다.

이 방식으로 기능은 정상 동작하지만, 또 다른 문제가 생깁니다.

  • DAO 메서드마다 전략 클래스를 하나씩 만들어야 합니다.

  • 클래스 파일 수가 늘어납니다.

  • 전략 클래스는 특정 DAO 메서드에만 강하게 결합되어 있습니다.

3.3.2. 전략과 클라이언트의 동거

이 문제를 해결하는 첫 번째 개선은 전략 클래스를 외부 파일로 분리하지 않는 것입니다. 전략이 특정 DAO 메서드에서만 사용된다면, 굳이 독립 클래스로 둘 필요가 없습니다.

로컬 클래스 적용

전략 클래스를 DAO 메서드 내부의 로컬 클래스로 옮길 수 있습니다.

이렇게 하면 아래의 효과를 얻습니다.

  • 클래스 파일 수 감소

  • 전략의 사용 범위가 명확해짐

  • 코드의 응집도가 높아짐

하지만 여전히 코드가 깁니다.

익명 내부 클래스

로컬 클래스에서 한 단계 더 나아가면 익명 내부 클래스를 사용할 수 있습니다. 전략 인터페이스를 구현하는 객체를 즉석에서 만들어 전달하는 방식입니다.

deleteAll()도 같은 방식으로 정리할 수 있습니다.

이제 전략 패턴의 구조는 유지하면서도 클래스 폭증 문제와 가독성 문제를 동시에 해결했습니다.

3.4. 컨텍스트와 DI

3.4.1. JdbcContext의 분리

이전 상태의 문제점

  • UserDao

    • SQL을 결정한다

    • StatementStrategy를 만든다

    • JDBC 컨텍스트 메서드도 가지고 있다

즉, UserDao는 여전히 두 가지 책임을 동시에 가지고 있습니다.

  • DAO 본연의 책임: 사용자 관련 데이터 접근 로직

  • JDBC 컨텍스트 책임: 커넥션 획득, 예외 처리, 리소스 반납

이 상태에서도 동작에는 문제가 없지만, 설계 관점에서는 역할이 섞여 있는 상태입니다.

JdbcContext를 분리하는 이유

jdbcContextWithStatementStrategy는 다음 성격을 가집니다.

  • 특정 DAO에 종속되지 않는다.

  • 어떤 테이블, 어떤 도메인과도 무관하다.

  • "PreparedStatement를 실행하는 JDBC 작업의 표준 흐름"이다.

이 코드는 UserDao의 전용 코드가 아니라, 재사용 가능한 JDBC 컨텍스트입니다. 따라서 UserDao 밖으로 분리하는 것이 자연스럽습니다.

JdbcContext 클래스

이제 JDBC 컨텍스트는 완전히 독립된 객체가 됩니다.

UserDao는 어떻게 바뀌는가

UserDao는 이제 아래 책임을 가집니다.

  • 어떤 SQL을 실행할지

  • 어떤 파라미터를 바인딩 할지

JDBC 흐름과 리소스 관리는 와전히 JdbcContext로 이동했습니다.

의존관계 변화

  • 이전: UserDao -> DataSource

  • 변경: UserDao -> JdbcContext -> DataSource

이 변화는:

  • UserDao는 더 이상 DB 연결 방식에 관심이 없습니다.

  • JdbcContext만 DataSource를 알고 있다.

  • JDBC 관련 변경은 JdbcContext만 수정하면 된다.

3.4.2. JdbcContext의 특별한 DI

왜 인터페이를 쓰지 않았는가? 보통 DI를 설명할떄는 "인터페이스에 의존하라" 라고 합니다. 하지만 여긱서는 JdbcContext 인터페이스를 만들지 않습니다. 이유는:

  • JdbcContext는 교체 대상이 아니다.

  • 구현이 하나뿐이다

  • 역할이 명확하고 단순하다

  • 테스트 대역이 필요한 객체가 아니다.

즉, 변경 가능성이 낮은 서비스 객체이기 때문에, 인터페이스를 도입하는 이점이 거의 없습니다.

그럼에도 DI를 사용하는 이유

인터페이스가 없어도 DI는 여전히 의미가 있습니다.

  • 객체 생성 책임을 UserDao에서 제거한다.

  • DataSource 설정을 외부에서 통제할 수 있다.

  • 객체 간 관계를 코드가 아닌 설정으로 관리한다.

이것이 제어의 역전입니다.

스프링 설정 관점에서의 의미

  • JdbcContext는 스프링 빈이 된다.

  • DataSrouce를 주입받는다.

  • UserDao는 JdbcContext를 주입받는다.

결과적으로,

  • UserDao는 JDBC 세부 구현에서 완전히 분리된다.

  • JdbcContext는 여러 DAO에서 재사용 가능해진다.

3.5. 템플릿과 롤백

이 예제에서 "변하는 것/변하지 않는 것 분리"를 파일 처리 + 계산 로직이라는 전혀 다른 문제에도 그대로 적용할 수 있습니다.

  • try/catch/finally -> 템플릿

  • 실제 작업 로직 -> 콜백

  • 전략 패턴 -> 메서드 하나짜리 콜백으로 단순화

  • 내부 클래스 -> 익명 내부 클래스

  • 마지막으로 -> 제네릭을 이용해 재사용 범위를 극대화

try/catch/finally의 중복

처음 코드는 아래와 같았습니다.

  • 파일을 연다

  • 한줄씩 읽는다

  • 계산한다

  • 예외를 처리한다

  • 리소스를 닫는다.

여기서 문제는:

  • 파일 여는 방식은 항상 동일

  • 닫는 방식도 항상 동일

  • 매번 달라지는 건 계산 로직뿐

템플릿/콜백의 기본 구조

템플릿이 하는일

  • 리소스 생성 (bufferedReader)

  • 반복 구조 관리 (while readLine)

  • 예외처리

  • 리소스 반환

콜백이 하는 일

  • '한 줄을 읽었을 때 무엇을 할 것인가'

  • 누적값을 어떻게 갱신할 것인가

이 분리가 바로 템플릿/콜백 패턴입니다.

3.5.1. 템플릿과 콜백의 역할 분리

콜백 인터페이스 (초기 형태)

이 단계에서는:

  • 템플릿이 bufferedReader를 직접 콜백에 넘김.

  • 콜백이 while 루프까지 모두 책임짐.

하지만 문제는:

  • '라인 단위 처리'가 반복됨

  • sum, multiply, concatenate 모두 구조가 거의 동일

3.5.2. 라인 단위로 더 잘개 쪼개기

그래서 콜백을 더욱 작게 만듭니다.

그리고 템플릿은 이렇게 바뀝니다.

  • while(readLine)은 템플릿이 담당

  • 콜백은 '한 줄 + 현재 누적값"만 처리

이 단계에서 역할 분리가 가장 깔끔해집니다.

3.5.3. 제네릭 도입의 이유

하나의 또 문제가 발생합니다.

  • sum -> Integer

  • multiply -> Integer

  • concatenate -> String

템플릿 구조는 완전히 동일한데 타입만 다릅니다. 그래서 등장한 코드:

이 메서드의 의미는:

  • T는 '누적 결과의 타입'

  • initVal은 누적값의 초기값

  • 템플릿은 T의 의미를 전혀 모름

  • 오직 콜백이 T를 어떻게 다룰지만 알고 있음

즉,

  • Integer면 합/곱

  • String이면 문자열 연결

  • 다른 타입도 얼마든지 가능

concatenate 예제가 왜 중요한가

이 예제의 포인트:

  • 기존 숫자 계산 문제를 완전히 벗어남

  • '파일 처리 + 누적 계산'이라는 추상 문제로 승격

  • 템플릿/콜백 패턴이 특정 도메인(JDBC)에 묶여 있지 않음

3.6. 스프링의 JdbcTempalte

3.6. JdbcTemplate 도입 배경

3.5까지는 UserDao는 아래의 특징을 가졌습니다.

  • JDBC API에 대한 직접 의존은 제거됨

하지만 여전히

  • JdbcContext,

  • ExecuteSql

  • StatementStrategy

  • RowMapper 비슷한 콜백

들을 직접 유지보수 해야합니다.

스프링은 이미 다음을 제공합니다.

  • JDBC 템플릿

  • PreparedStatement 생성 콜백

  • ResultSet 처리 콜백

  • 예외 반환

  • 리소스 반환

따라서, JdbcContext를 유지할 이유가 없습니다.

3.6.1. JdbcContext -> JdbcTemplate로 변경

기존 코드

문제점

  • 템플릿의 의도는 좋지만

  • 유지보수 대상 코드가 늘어남

  • 예외 변환, 트랜잭션 연계 기능 없음


변경 후 코드 (JdbcTemplate 적용)

변화 요약

항목
기존
변경

템플릿

JdbcContext

JdbcTemplate

SQL 실행

executeSql

update

리소스 관리

직접

스프링 내부

예외 처리

SQLException

DataAccessException

3.6.2. update() 메서드 적용

기존 add 메서드 (콜백 기반)

변경 후 add 메서드

핵심 변화

  • PreparedStatement 콜백 제거

  • SQL + 파라미터만 남김

  • 파라미터 바인딩 순서가 명확해짐

3.6.3. queryForInt -> queryForObject

기존 getCount

JdbcTemplate은 내부적으로 다음을 처리합니다.

  • PreparedStatement 생성

  • ResultSet.next()

  • 단일 컬럼 값 추출

  • 예외 처리

개발자는 결과 타입만 지정하면 됩니다.

3.6.4. RowMapper 적용 (get, getAll)

RowMapper 정의 (공통)


get 메서드

변경 전 개념

  • ResultSetExtractor

  • 직접 rs.next()

  • 예외 처리 직접 수행

변경 후 코드

  • 조회 결과가 없으면 EmptyResultDataAccessException

  • 스프링이 의미 있는 예외로 변환


getAll 메서드

  • RowMapper는 재사용 가능

  • 여러 로우를 List로 자동 수집

3.6.5. 최종 UserDao


정리

  • JdbcTemplate은

    • 우리가 만든 템플릿/콜백 구조의 완성형

    • 예외 변환, 리소스 관리, 트랜잭션 연계까지 포함

  • DAO의 책임은

    • SQL 정의

    • 파라미터 전달

    • 결과 매핑 으로 명확히 축소됨

  • 설계는 우리가 이해해야 하지만, 구현은 스프링에 맡긴다

이로써 초난감 DAO에서 시작한 개선 여정은 실무에서도 그대로 사용할 수 있는 JDBC DAO 구조로 완성됩니다.

마무리

JDBC → Template → Callback → JdbcTemplate

UserDao 코드 진화 전체 과정 정리


요약

이 장의 핵심은 기술을 바꾸는 것이 아니라 **“변하지 않는 것과 변하는 것을 분리해 가는 사고 과정”**입니다.

  • 변하지 않는 것: DB 연결, 예외 처리, 리소스 반환, 작업 흐름

  • 변하는 것: SQL, 파라미터 바인딩, 결과 매핑

이 둘을 분리하는 과정이 템플릿 → 전략 → 콜백 → JdbcTemplate로 이어집니다.


0단계. 초난감 DAO

특징

  • JDBC 코드가 DAO 메서드에 그대로 노출

  • 예외 발생 시 리소스 누수

  • 모든 DAO 메서드가 동일한 구조를 반복

코드


1단계. try / finally 도입

목적

  • 리소스 반환을 보장

  • 예외 상황에서도 안정성 확보

코드

문제점

  • deleteAll, add, getCount 등 모든 메서드가 동일한 구조

  • SQL만 다른데 코드 대부분이 중복


2단계. 변하는 부분 분리 (메서드 추출)

목적

  • SQL 생성 부분을 분리

코드

한계

  • SQL만 분리되었을 뿐

  • 여전히 DAO 메서드마다 동일한 try/finally


3단계. Template Method 패턴 (상속 기반)

목적

  • 작업 흐름을 부모 클래스에 고정

  • SQL 생성만 서브클래스에서 변경

코드

문제점

  • 상속 강제

  • SQL 하나 바꾸려면 클래스가 늘어남

  • 런타임 조합 불가


4단계. Strategy 패턴 도입 (상속 → 조합)

전략 인터페이스

템플릿 메서드

deleteAll 적용


5단계. 익명 내부 클래스 → 람다

코드

의미

  • 전략 객체는 일회용

  • 람다가 가장 적합한 표현


6단계. JdbcContext 클래스로 템플릿 분리

JdbcContext

UserDao


7단계. 콜백 더 단순화 (executeSql)

JdbcContext

UserDao


8단계. query / RowMapper / ResultSetExtractor 추가

getCount (ResultSetExtractor)

get (RowMapper)


9단계. JdbcTemplate 도입 (모든 템플릿 제거)

핵심 전환점

우리가 만든 JdbcContext + 콜백 구조를 Spring이 JdbcTemplate으로 이미 제공


최종 UserDao (Kotlin, JdbcTemplate)


전체 변화 한 줄 요약

Last updated