> For the complete documentation index, see [llms.txt](https://wonjoon.gitbook.io/joons-til/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wonjoon.gitbook.io/joons-til/books/tobys-spring-vol1/5.-service-abstraction.md).

# 5. Service Abstraction

**비즈니스 로직을 어디에 둘 것인가**, 그리고 **그 결정이 코드 구조와 테스트에 어떤 영향을 미치는가**를 단계적으로 체험하는 데 목적이 있습니다.

초반에는 `UserDao`에 레벨 관리 로직을 추가하면서 시작하지만, 점차 코드가 복잡해지고 책임이 뒤섞이면서 문제가 드러납니다.\
이를 해결하는 과정에서 다음과 같은 흐름을 거치게 됩니다.

* 단순한 데이터 표현(`int`)의 한계 인식
* 도메인 개념(`enum Level`) 도입
* 테스트를 통한 설계 문제 발견
* DAO와 Service 책임 분리
* 객체에게 책임을 이동시키는 리팩터링

## 5.1. 사용자 레벨 관리 기능 추가

기존 UserDao는 사용자 정보를 단순히 저장하고 조회하는 CRUD 기능만 제공하고 있었습니다.\
여기에 다음과 같은 **비즈니스 요구사항**이 추가됩니다.

* 사용자는 `BASIC`, `SILVER`, `GOLD` 중 하나의 레벨을 가집니다.
* 신규 가입 사용자는 항상 `BASIC` 레벨입니다.
* 로그인 횟수가 50회 이상이면 `SILVER`로 승급됩니다.
* 추천 횟수가 30회 이상이면 `GOLD`로 승급됩니다.
* 레벨 변경은 사용자의 행위가 아니라 **시스템이 정기적으로 수행하는 작업**입니다.
* 이미 승급된 사용자는 다시 처리되지 않습니다.

여기서 중요한 점은,\
\&#xNAN;**“레벨”은 단순한 숫자나 상태값이 아니라, 명확한 규칙과 정책을 가진 비즈니스 개념**이라는 사실입니다.

### 5.1.1. 필드 추가

**int 타입으로 레벨을 표현할 때의 문제**

가장 먼저 떠올릴 수 있는 방식은 사용자 레벨을 `int`로 표현하는 것입니다.

```kotlin
class User {
    companion object {
        const val BASIC = 1
        const val SILVER = 2
        const val GOLD = 3
    }

    var level: Int = BASIC
}
```

이 방식은 구현이 간단해 보이지만, 설계적으로는 매우 위험합니다.

* `level = 1000` 같은 값도 아무 문제 없이 들어갈 수 있습니다.
* 컴파일러가 값의 의미를 전혀 보장해주지 않습니다.
* 코드만 보고 `1`, `2`, `3`이 무엇을 의미하는지 알기 어렵습니다.

즉, **타입이 비즈니스 규칙을 전혀 보호해주지 못하는 상태**입니다.

#### int 상수로도 충분해 보이는데, 왜 위험한가?

int 상수 방식은 컴파일 시점에 의미를 검증할 수 없다는 치명적인 문제가 있습니다.

* `User.setLevel(100)` 같은 코드가 문법적으로는 완벽히 유효합니다.
* 이는 “레벨이라는 개념이 숫자로 취급되고 있다”는 신호입니다.
* 비즈니스 개념이 원시 타입에 묻히면, 잘못된 값이 런타임까지 흘러가게 됩니다.

결국 이는 **테스트로도 막기 어려운 버그**로 이어집니다.

#### enum(Level) 도입과 도메인 모델링

이 문제를 해결하기 위해 `enum`을 도입합니다.

```kotlin
enum class Level(val value: Int) {
    BASIC(1),
    SILVER(2),
    GOLD(3);

    companion object {
        fun valueOf(value: Int): Level =
            values().find { it.value == value }
                ?: throw IllegalArgumentException("Unknown value: $value")
    }
}
```

이 설계의 핵심은 다음과 같습니다.

* 애플리케이션 내부에서는 **반드시 Level 타입만 사용**합니다.
* DB에는 여전히 `int`로 저장하지만, 그 사실은 외부로 드러나지 않습니다.
* 잘못된 값은 즉시 예외로 드러나도록 설계됩니다.

여기서 중요한 포인트는,\
**DB 설계와 도메인 모델을 동일시하지 않았다는 점**입니다.

#### 어차피 DB에는 int로 저장하는데, enum을 쓰는 실질적인 이점은?

상수 방식은 “값의 범위를 사람이 기억해야 하는 방식”입니다.\
컴파일러가 도와주지 않기 때문에, 잘못된 값은 항상 **런타임까지 흘러갑니다**.

반면 enum을 사용하면,

* 허용 가능한 값의 집합이 **타입 수준에서 고정**
* 잘못된 값은 **아예 코드 작성 단계에서 차단**
* 레벨과 관련된 행위를 함께 묶을 수 있는 여지가 생깁니다

이 차이는 **규모가 커질수록 치명적으로 벌어집니다**.

#### **Level enum 도입**

```kotlin
enum class Level(val value: Int) {
    BASIC(1),
    SILVER(2),
    GOLD(3);

    companion object {
        fun valueOf(value: Int): Level =
            when (value) {
                1 -> BASIC
                2 -> SILVER
                3 -> GOLD
                else -> throw AssertionError("Unknown value: $value")
            }
    }
}
```

이 설계의 핵심은 다음과 같습니다.

* DB에는 여전히 `int`로 저장
* 애플리케이션 내부에서는 **반드시 Level 타입으로만 사용**
* 잘못된 값은 즉시 예외 발생

즉, **DB 표현과 도메인 표현을 분리**한 구조입니다.

***

#### **User 클래스에 Level 필드 추가**

이제 `User` 클래스에 레벨 관련 필드를 추가합니다.

```kotlin
class User(
    val id: String,
    val name: String,
    val password: String,
    var level: Level,
    var login: Int,
    var recommend: Int
)
```

여기서 중요한 점은,

* 레벨은 단순 속성이 아니라
* 이후 **비즈니스 로직의 핵심 판단 기준**이 된다는 점입니다

### 5.1.2. 사용자 수정 기능 추가

**왜 수정 기능이 필요한가**

레벨 관리 로직을 테스트하려면,\
기존 사용자 정보를 **변경할 수 있어야** 합니다.

특히 레벨 업그레이드는 다음과 같은 특성을 가집니다.

* 사용자를 새로 생성하는 것이 아니라
* **기존 사용자의 상태를 변경**합니다

***

**update() 테스트부터 작성**

책에서는 이 시점에서도 **테스트를 먼저 수정**합니다.

* 사용자 하나를 저장
* 일부 필드를 변경
* 다시 조회해서 변경 여부 확인

이 테스트는 단순해 보이지만 update는 SELECT나 INSERT보다 훨씬 위험한 SQL입니다.

* WHERE 절이 빠지면 전체 데이터가 변경됩니다
* 컬럼 하나라도 누락되면 데이터 불일치가 발생합니다
* 컴파일 타임에 검증되지 않습니다

이 테스트가 검증하려는 시나리오는 다음과 같습니다.

1. 사용자 한 명을 DB에 저장한다
2. 그 사용자의 일부 필드를 변경한다
3. `update()`를 호출한다
4. 다시 조회해서 **모든 필드가 정확히 변경되었는지** 확인한다

```kotlin
@Test
fun update() {
    // given
    userDao.deleteAll()

    val user = User(
        id = "user1",
        name = "원래이름",
        password = "password1",
        level = Level.BASIC,
        login = 1,
        recommend = 0
    )

    userDao.add(user)

    // when - 일부 필드 변경
    user.name = "변경된이름"
    user.password = "password2"
    user.level = Level.GOLD
    user.login = 100
    user.recommend = 999

    userDao.update(user)

    // then - 다시 조회해서 검증
    val updatedUser = userDao.get(user.id)

    checkSameUser(user, updatedUser)
}

private fun checkSameUser(expected: User, actual: User) {
    assertThat(actual.id).isEqualTo(expected.id)
    assertThat(actual.name).isEqualTo(expected.name)
    assertThat(actual.password).isEqualTo(expected.password)
    assertThat(actual.level).isEqualTo(expected.level)
    assertThat(actual.login).isEqualTo(expected.login)
    assertThat(actual.recommend).isEqualTo(expected.recommend)
}
```

***

**UserDaoJdbc의 update 구현**

```kotlin
fun update(user: User) {
    jdbcTemplate.update(
        """
        update users
        set name = ?, password = ?, level = ?, login = ?, recommend = ?
        where id = ?
        """.trimIndent(),
        user.name,
        user.password,
        user.level.value,
        user.login,
        user.recommend,
        user.id
    )
}
```

이 코드에서 핵심은 다음 두 가지입니다.

* Level → DB 저장 시 `intValue()` 사용
* id를 기준으로 정확히 한 명만 수정

***

**테스트 보완: WHERE 절 검증**

초기 테스트는 다음 문제를 잡아내지 못합니다.

* `where id = ?`가 빠져도 테스트 통과 가능

그래서 책에서는 테스트를 보완합니다.

* 수정 대상 사용자 외의 사용자는 **절대 변경되지 않아야 함**
* 이 검증이 없다면 SQL 오류를 놓칠 수 있음

```kotlin
@Test
fun update_should_update_only_target_user() {
    // given
    userDao.deleteAll()

    val user1 = User(
        id = "user1",
        name = "user1",
        password = "password1",
        level = Level.BASIC,
        login = 1,
        recommend = 0
    )

    val user2 = User(
        id = "user2",
        name = "user2",
        password = "password2",
        level = Level.SILVER,
        login = 55,
        recommend = 10
    )

    userDao.add(user1)
    userDao.add(user2)

    // when - user1만 수정
    user1.name = "modified-name"
    user1.password = "modified-password"
    user1.level = Level.GOLD
    user1.login = 999
    user1.recommend = 999

    userDao.update(user1)

    // then
    val updatedUser1 = userDao.get(user1.id)
    val untouchedUser2 = userDao.get(user2.id)

    checkSameUser(user1, updatedUser1)
    checkSameUser(user2, untouchedUser2)
}
```

Where절이 없는 경우,

* user1은 기대대로 변경됨
* user2도 **같은 값으로 덮어써짐**
* `checkSameUser(user2, untouchedUser2)`에서 테스트 실패

#### update 테스트에서 왜 "다른 사용자가 변경되지 않았는지" 검증해야 하나?

SQL은 컴파일 타임에 검증되지 않습니다.\
WHERE 절이 빠진 UPDATE는 **모든 데이터를 변경**합니다.

이 오류는 기능 테스트만으로는 절대 잡히지 않으며,\
**의도하지 않은 데이터 파괴**로 이어질 수 있습니다.

DAO 테스트는 단순 성공 검증이 아니라\
**데이터 무결성 검증**이 목적입니다.

### 5.1.3. UserService.upgradeLevels()

**비즈니스 로직의 위치 문제**

이제 본격적인 문제가 드러납니다.

* 레벨 업그레이드는 명백한 비즈니스 로직
* 그런데 이 로직을 DAO에 넣기 시작하면
  * 조건 판단
  * 레벨 변경
  * DB 업데이트\
    가 한 클래스에 몰립니다

이 시점에서 **DAO의 책임이 무너지기 시작**합니다.

***

**UserService 도입**

책에서는 이 문제를 해결하기 위해 **UserService 클래스를 도입**합니다.

```kotlin
class UserService(
    private val userDao: UserDao
) {
    fun upgradeLevels() {
        val users = userDao.getAll()
        for (user in users) {
            if (user.level == Level.BASIC && user.login >= 50) {
                user.level = Level.SILVER
                userDao.update(user)
            } else if (user.level == Level.SILVER && user.recommend >= 30) {
                user.level = Level.GOLD
                userDao.update(user)
            }
        }
    }
}
```

이 구현은 아직 깔끔하지 않지만,\
**비즈니스 로직을 DAO 밖으로 끌어냈다는 점이 핵심**입니다.

#### 왜 이 로직을 DAO 두면 안되는가?

DAO는 “어떻게 저장하고 가져올 것인가”라는 **기술적 책임**만 가져야 합니다.\
레벨 업그레이드는 “언제 상태를 바꿀 것인가”라는 **정책 책임**입니다.

두 책임이 섞이면,

* 정책 변경이 기술 변경으로 전파되고
* 테스트가 복잡해지며
* 구현 교체(JDBC → JPA)가 어려워집니다

그래서 이 로직은 반드시 Service 계층에 위치해야 합니다.

### 5.1.4. UserService.add()

**신규 사용자 기본 레벨 문제**

새로운 요구사항이 등장합니다.

* 신규 가입자는 항상 BASIC 레벨이어야 한다

이 정책을 어디에 둘 것인지가 문제입니다.

* User 생성자?
* DAO add()?
* Service?

책에서는 **Service의 add() 메서드에서 처리**합니다.

```kotlin
fun add(user: User) {
    if (user.level == null) {
        user.level = Level.BASIC
    }
    userDao.add(user)
}
```

이 설계의 의미는 명확합니다.

* “기본 레벨 정책”은 **비즈니스 규칙**
* 따라서 Service가 책임진다

### 5.1.5. 코드 개선

**기존 upgradeLevels()의 한계**

기존 코드의 문제는 다음과 같습니다.

* if / else if 블록이 길고 읽기 어렵습니다
* 레벨 순서가 코드에 하드코딩되어 있습니다
* 레벨이 추가되면 조건문 전체를 수정해야 합니다

즉, **변경에 매우 취약한 구조**입니다.

***

**업그레이드 가능 여부 분리**

먼저 “업그레이드 가능한가”를 판단하는 메서드를 분리합니다.

```kotlin
private fun canUpgradeLevel(user: User): Boolean {
    return when (user.level) {
        Level.BASIC -> user.login >= 50
        Level.SILVER -> user.recommend >= 30
        Level.GOLD -> false
    }
}
```

이렇게 하면,

* 판단 로직과 실행 로직이 분리되고
* 테스트가 쉬워집니다

***

**Level에게 순서 책임 이동**

레벨의 순서를 `Level` enum이 스스로 알도록 변경합니다.

```kotlin
enum class Level(val value: Int, val next: Level?) {
    BASIC(1, SILVER),
    SILVER(2, GOLD),
    GOLD(3, null);

    fun nextLevel(): Level =
        next ?: throw IllegalStateException("업그레이드 불가")
}
```

***

**User에게 업그레이드 책임 이동**

```kotlin
fun upgradeLevel() {
    this.level = this.level.nextLevel()
}
```

Service는 이제 **결정만 하고 실행은 위임**합니다.

```kotlin
private fun upgradeLevel(user: User) {
    user.upgradeLevel()
    userDao.update(user)
}
```

#### 왜 Service가 직접 level을 변경하지 않고 User에게 위임하나?

User의 레벨은 User 자신의 상태입니다.\
외부에서 직접 변경하면 User는 단순한 데이터 구조가 됩니다.

상태 변경 책임을 객체에 두면,

* 상태 불변 조건을 내부에서 보호할 수 있고
* 잘못된 사용을 즉시 예외로 차단할 수 있으며
* 객체의 응집도가 높아집니다

이는 객체지향 설계의 핵심 원칙입니다.

## 5.2. 트랜잭션 서비스 추상화

사용자 레벨 업그레이드는 여러 사용자를 대상으로 한 번에 수행되는 작업이며,\
작업 도중 장애가 발생했을 때 **이미 반영된 일부 결과를 그대로 둘 것인지**,\
아니면 **모든 변경을 취소할 것인지**를 결정해야 합니다.

사용자 레벨 업그레이드 작업을\
\&#xNAN;**‘모 아니면 도(All or Nothing)’** 방식으로 처리해야 한다는 결론에 이르고,\
이를 보장하기 위해

* 트랜잭션의 필요성
* 트랜잭션 경계 설정 책임
* JDBC 트랜잭션의 한계
* 스프링의 트랜잭션 동기화
* 트랜잭션 서비스 추상화

### 5.2.1. 모 아니면 도

사용자 레벨 업그레이드는 정기적으로 실행되는 작업입니다.\
여러 사용자의 레벨을 순차적으로 변경하는 과정에서,\
중간에 예외가 발생할 가능성을 배제할 수 없습니다.

이때 다음 두 가지 선택지가 존재합니다.

1. 예외가 발생하기 전까지 변경된 사용자 레벨은 그대로 둔다
2. 작업 전체를 취소하고 모든 사용자를 이전 상태로 되돌린다

책에서는 이 작업의 성격상 **두 번째 선택지가 더 적절하다고 판단**합니다.\
사용자 레벨 업그레이드는 개별 사용자 요청이 아니라\
**정책에 따른 일괄 처리 작업**이기 때문입니다.

따라서 이 작업은 반드시 **부분 성공을 허용하지 않는 작업**,\
즉 “모 아니면 도” 방식으로 처리되어야 합니다.

***

**테스트를 통한 문제 제기**

기존의 `upgradeLevels()` 테스트는\
모든 조건을 만족하는 사용자의 레벨이 정상적으로 업그레이드되는지만 검증합니다.

하지만 다음과 같은 상황은 검증하지 못합니다.

* 업그레이드 도중 예외가 발생했을 때
* 이미 업그레이드된 사용자 레벨이 그대로 DB에 남는 문제

실제 장애(DB 장애, 네트워크 오류 등)를 테스트로 재현하기는 어렵기 때문에,\
책에서는 **의도적으로 예외를 발생시키는 테스트용 UserService 대역**을 만들어\
이 문제를 검증합니다.

이를 위해 `upgradeLevel()` 메서드를 오버라이드할 수 있도록\
접근 제어자를 조정합니다.

***

**UserService**

```kotlin
open class UserService(
    protected val userDao: UserDao
) {

    open fun upgradeLevels() {
        val users = userDao.getAll()
        for (user in users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user)
            }
        }
    }

    protected open fun upgradeLevel(user: User) {
        user.upgradeLevel()
        userDao.update(user)
    }

    private fun canUpgradeLevel(user: User): Boolean =
        when (user.level) {
            Level.BASIC -> user.login >= MIN_LOGCOUNT_FOR_SILVER
            Level.SILVER -> user.recommend >= MIN_RECOMMEND_FOR_GOLD
            Level.GOLD -> false
        }

    companion object {
        const val MIN_LOGCOUNT_FOR_SILVER = 50
        const val MIN_RECOMMEND_FOR_GOLD = 30
    }
}
```

***

**테스트용 UserService 대역**

```kotlin
class TestUserService(
    userDao: UserDao,
    private val failId: String
) : UserService(userDao) {

    override fun upgradeLevel(user: User) {
        if (user.id == failId) {
            throw TestUserServiceException()
        }
        super.upgradeLevel(user)
    }
}

class TestUserServiceException : RuntimeException()
```

***

**강제 예외 발생 테스트**

```kotlin
@Test
fun upgradeAllOrNothing() {
    userDao.deleteAll()
    users.forEach { userDao.add(it) }

    val testUserService =
        TestUserService(userDao, users[3].id)

    try {
        testUserService.upgradeLevels()
        fail("TestUserServiceException expected")
    } catch (e: TestUserServiceException) {
        // expected
    }

    val user = userDao.get(users[1].id)
    assertThat(user.level).isEqualTo(Level.BASIC)
}
```

이 테스트는 **실패합니다**.\
네 번째 사용자 처리 중 예외가 발생했지만,\
그 이전에 업그레이드된 두 번째 사용자의 레벨은\
이미 DB에 반영되어 있기 때문입니다.

#### 이 테스트가 필요한 이유?

이 테스트는 “업그레이드가 잘 되는가”를 검증하는 테스트가 아니라,\
\&#xNAN;**“실패했을 때 데이터가 어떻게 되는가”를 검증하는 테스트**입니다.\
트랜잭션이 없으면 중간까지의 변경이 그대로 커밋되는 문제가 발생합니다.

### 5.2.2. 트랜잭션 경계 설정

이 테스트가 실패하는 이유는\
`upgradeLevels()` 메서드 전체가 **하나의 트랜잭션으로 묶여 있지 않기 때문**입니다.

JDBC에서 트랜잭션은 **Connection 단위**로 관리됩니다.\
여러 SQL을 하나의 트랜잭션으로 묶으려면\
반드시 동일한 Connection을 사용해야 합니다.

***

**전형적인 JDBC 트랜잭션 코드**

```kotlin
val connection = dataSource.connection
connection.autoCommit = false

try {
    // 여러 SQL 실행
    connection.commit()
} catch (e: Exception) {
    connection.rollback()
    throw e
} finally {
    connection.close()
}
```

***

**문제의 원인**

`JdbcTemplate`을 사용하는 DAO는\
메서드 호출 시마다 `DataSource`로부터 Connection을 얻어 사용합니다.

따라서 `upgradeLevels()` 안에서 여러 번 호출되는 `update()`는\
각각 **서로 다른 트랜잭션**으로 실행됩니다.

***

**Connection 전달 방식의 한계**

이 문제를 해결하기 위해\
UserService에서 Connection을 생성하고 DAO에 전달하는 방식이 가능합니다.

```kotlin
fun upgradeLevels() {
    val connection = dataSource.connection
    connection.autoCommit = false

    try {
        val users = userDao.getAll(connection)
        for (user in users) {
            if (canUpgradeLevel(user)) {
                user.upgradeLevel()
                userDao.update(connection, user)
            }
        }
        connection.commit()
    } catch (e: Exception) {
        connection.rollback()
        throw e
    } finally {
        connection.close()
    }
}
```

하지만 이 방식은 다음과 같은 문제를 낳습니다.

* Service 계층이 JDBC API에 종속됨
* DAO 인터페이스가 Connection에 종속됨
* 데이터 접근 기술 변경 시 연쇄 수정 발생

#### Connection을 왜 직접 전달하면 안될까?

이 방식은 트랜잭션은 해결하지만,\
Service와 DAO를 특정 기술(JDBC)에 강하게 결합시킵니다.\
이는 데이터 접근 기술 변경 시 비즈니스 로직까지 수정하게 만드는 구조입니다.

### 5.2.3. 트랜잭션 동기화

스프링은 이 문제를 해결하기 위해\
**트랜잭션 동기화(Transaction Synchronization)** 를 제공합니다.

트랜잭션 시작 시 생성한 Connection을\
스레드 단위 저장소에 보관하고,\
DAO에서는 이를 직접 전달받지 않아도\
같은 Connection을 사용하도록 합니다.

**즉, 커넥션을 파라미터로 넘기지 말고, 현재 스레드에 몰래 보관합니다.**

`JdbcTemplate`은 내부적으로\
`DataSourceUtils.getConnection()`을 사용하여\
동기화된 Connection이 있으면 이를 재사용합니다.

이를 통해 다음이 가능해집니다.

* Service는 트랜잭션 경계만 관리
* DAO는 기존 구조 유지
* Connection 파라미터 제거

#### TransactionSynchronizationManager란?

```kotlin
object TransactionSynchronizationManager {
    private val resources = ThreadLocal<Map<Any, Any>>()
}
```

즉,

* **ThreadLocal 기반 저장소**
* “현재 스레드에서 사용하는 리소스들”을 저장
* key: DataSource
* value: Connection

이라는 구조. **스레드마다 하나씩 있는 개인 보관함**.

#### 트랜잭션 동기화시 어떤 일이 발생하나?

#### Service에서 트랜잭션 시작

```kotlin
TransactionSynchronizationManager.initSynchronization()
```

이 줄의 의미는:

* “이 스레드에서 트랜잭션 동기화를 시작한다”
* 내부적으로 ThreadLocal Map을 준비함

아직 커넥션은 없습니다.

***

#### Service에서 커넥션을 하나 생성

```kotlin
val conn = DataSourceUtils.getConnection(dataSource)
```

여기서 **중요한 포인트**가 나옵니다.

**DataSourceUtils.getConnection()의 동작 순서**

1. `TransactionSynchronizationManager`에
   * 이 DataSource에 바인딩된 커넥션이 있는지 확인
2. **없으면**
   * `dataSource.getConnection()` 호출해서 새 커넥션 생성
3. 트랜잭션 동기화가 활성화된 상태라면
   * 이 커넥션을 **ThreadLocal에 저장**

즉, 이 순간에 실제로 이런 일이 벌어집니다.

```
ThreadLocal {
    dataSource -> conn
}
```

**“이 스레드에서 dataSource를 쓰면 이 conn을 써라”** 라는게 만들어집니다.

***

#### 커넥션을 ThreadLocal에 바인딩

```kotlin
TransactionSynchronizationManager.bindResource(dataSource, conn)
```

이제 상태는 이렇게 됩니다.

```
현재 스레드
 └─ dataSource → conn (autoCommit=false)
```

이게 **트랜잭션 동기화의 핵심 상태**입니다.

#### DAO에서는 무슨일이 벌어지나?

```kotlin
DataSourceUtils.getConnection(dataSource)
```

이 함수는:

1. `TransactionSynchronizationManager` 확인
2. 현재 스레드에
   * `dataSource`에 바인딩된 커넥션이 있으면
3. **그 커넥션을 그대로 반환**

새 커넥션을 만들지 않습니다.

#### Service 코드

```kotlin
val conn = DataSourceUtils.getConnection(dataSource)
conn.autoCommit = false

userService.upgradeLevels()
```

#### 내부 흐름

```
[같은 스레드]

Service
 └─ dataSource → conn  (ThreadLocal에 저장)

DAO 호출
 └─ DataSourceUtils.getConnection()
     → ThreadLocal에서 conn 발견
     → 동일 conn 사용

DAO 호출
 └─ 동일 conn 사용
```

**DAO가 커넥션을 전달받지 않았는데도, 같은 커넥션을 쓰는 이유?**

-> **ThreadLocal 트랜잭션 동기화 때문**

### 5.2.4. 트랜잭션 서비스 추상화

여전히 남아 있는 문제는 **트랜잭션 기술 종속성**입니다.

* JDBC 트랜잭션
* JTA 트랜잭션
* Hibernate / JPA 트랜잭션

각 기술마다 API가 다르기 때문에,\
Service 코드에 트랜잭션 경계 설정이 들어가면\
기술 변경에 취약해집니다.

***

**PlatformTransactionManager**

스프링은 트랜잭션 경계 설정을 추상화한\
`PlatformTransactionManager` 인터페이스를 제공합니다.

PlatformTransactionManager는\
“트랜잭션을 어떻게 시작하고 끝낼지”라는 기술 차이를 감추고,\
Service가 비즈니스 로직에만 집중할 수 있게 만드는 추상화 계층입니다.

```kotlin
class UserService(
    private val userDao: UserDao,
    private val transactionManager: PlatformTransactionManager
) {

    fun upgradeLevels() {
        val status =
            transactionManager.getTransaction(DefaultTransactionDefinition())

        try {
            val users = userDao.getAll()
            for (user in users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user)
                }
            }
            transactionManager.commit(status)
        } catch (e: RuntimeException) {
            transactionManager.rollback(status)
            throw e
        }
    }

    protected fun upgradeLevel(user: User) {
        user.upgradeLevel()
        userDao.update(user)
    }
}
```

***

**설정을 통한 기술 교체**

```xml
<bean id="transactionManager"
      class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>
```

JTA로 변경할 경우 설정만 교체합니다.

```xml
<bean id="transactionManager"
      class="org.springframework.transaction.jta.JtaTransactionManager"/>
```

UserService 코드는 변경되지 않습니다.

#### 1. 기존 JDBC 트랜잭션 코드가 왜 문제였는가

JDBC 기반 Service 코드는 보통 이런 흐름이었습니다.

```kotlin
val conn = dataSource.connection
conn.autoCommit = false

try {
    // 여러 DAO 작업
    conn.commit()
} catch (e: Exception) {
    conn.rollback()
    throw e
} finally {
    conn.close()
}
```

이 코드의 문제는 다음과 같습니다.

1. Service가 **Connection을 직접 다룸**
2. JDBC API를 **명확히 알고 있어야 함**
3. 트랜잭션 기술이 바뀌면\
   → Service 코드 수정이 불가피

즉, Service의 책임이 “비즈니스 로직” + “트랜잭션 기술 제어” 두 가지가 되어버린 상태입니다.

#### 2. 기술이 바뀌면 왜 Service가 깨지는가

#### JDBC → JTA로 바뀌는 경우를 보겠습니다

JTA에서는 이렇게 트랜잭션을 다룹니다.

```java
UserTransaction ut = ...
ut.begin()
...
ut.commit()
```

Hibernate / JPA는 또 다릅니다.

```java
EntityTransaction tx = em.getTransaction()
tx.begin()
...
tx.commit()
```

정리하면:

| 기술   | 트랜잭션 시작                         |
| ---- | ------------------------------- |
| JDBC | Connection.setAutoCommit(false) |
| JTA  | UserTransaction.begin()         |
| JPA  | EntityTransaction.begin()       |

**메서드 이름도, 객체도, 흐름도 다릅니다.**

그래서 Service에 트랜잭션 코드가 있으면\
기술이 바뀔 때마다 Service를 고쳐야 합니다.

#### 3. 스프링의 해결 전략

#### 질문을 이렇게 바꿉니다

“Service가 정말 알아야 할 게\
JDBC냐, JTA냐, JPA냐 인가?”

정답은 아닙니다.

Service가 알아야 할 것은 단 하나입니다.

“트랜잭션을 시작하고,\
성공하면 커밋하고,\
실패하면 롤백한다”

**어떻게 하는지는 중요하지 않습니다.**

#### 4. PlatformTransactionManager의 역할

#### 핵심 개념

`PlatformTransactionManager`는\
“트랜잭션 경계 설정 역할만 담당하는 인터페이스”입니다.

```kotlin
interface PlatformTransactionManager {
    fun getTransaction(definition: TransactionDefinition): TransactionStatus
    fun commit(status: TransactionStatus)
    fun rollback(status: TransactionStatus)
}
```

여기서 중요한 점은 다음입니다.

* JDBC용 구현체가 따로 있고
* JTA용 구현체가 따로 있고
* JPA용 구현체가 따로 있습니다

하지만 **Service는 이를 전혀 모릅니다.**

***

#### 5. UserService 코드에서 실제로 무슨 일이 일어나는가

질문 주신 코드를 기준으로 하나씩 보겠습니다.

```kotlin
val status =
    transactionManager.getTransaction(DefaultTransactionDefinition())
```

#### 이 한 줄에서 벌어지는 일

**JDBC 환경이라면**

* `DataSourceTransactionManager`가 호출됨
* 내부에서
  * DataSource에서 Connection을 얻음
  * autoCommit을 false로 설정
  * ThreadLocal에 Connection을 묶어둠
* 그 결과를 `TransactionStatus`로 감쌈

**JTA 환경이라면**

* `JtaTransactionManager`가 호출됨
* 내부에서
  * WAS의 TransactionManager를 통해
  * 글로벌 트랜잭션을 시작
* 역시 결과를 `TransactionStatus`로 감쌈

Service 입장에서는

“트랜잭션이 시작되었다”\
라는 사실만 알면 됩니다.

***

#### 이후 DAO 호출 시 무슨 일이 일어나는가

```kotlin
val users = userDao.getAll()
```

여기서 중요한 연결 고리가 하나 있습니다.

* JDBC의 경우\
  → `DataSourceUtils.getConnection()`이\
  ThreadLocal에 묶인 **같은 Connection**을 반환
* JPA의 경우\
  → 트랜잭션에 바인딩된 EntityManager 사용

즉,

DAO는 커넥션을 새로 만드는 것처럼 보이지만\
실제로는 **현재 트랜잭션에 묶인 자원**을 사용합니다.

이게 바로 \*\*트랜잭션 동기화(Transaction Synchronization)\*\*입니다.

***

#### 6. commit / rollback 은 어떻게 기술별로 달라지는가

```kotlin
transactionManager.commit(status)
```

이 한 줄의 의미는 다음과 같습니다.

* JDBC
  * Connection.commit()
* JTA
  * UserTransaction.commit()
* JPA
  * EntityTransaction.commit()

하지만 Service는 이 차이를 전혀 모릅니다.

Service는 단지 “정상 종료니까 commit”

이라고 말합니다.

## 5.3. 서비스 추상화와 단일 책임 원칙

트랜잭션 기술을 추상화했더니,\
서비스 계층이 ‘비즈니스 로직만’ 책임질 수 있게 되었고,\
그 결과 단일 책임 원칙(SRP)이 자연스럽게 지켜졌습니다.

그러면 왜 이 구조가 좋은가?

### 수직, 수평 계층구조와 의존관계

#### 수직적 계층 구조

* 애플리케이션 계층
  * UserService
  * UserDao
* 서비스 추상화 계층
  * TransactionManager
  * DataSource
* 기술 서비스 계층
  * JDBC, JTA, Connection Pool, WAS, Database

여기서 중요한 점은:

**UserService와 UserDao는 ‘애플리케이션 로직’ 계층에 있고,**\
**트랜잭션과 DB 연결은 그 아래의 ‘기술 계층’에 있다**

#### 수평적 분리

수평 분리는 “역할에 따른 분리”입니다.

* UserService\
  → 사용자 관리 **비즈니스 로직**
* UserDao\
  → 사용자 데이터 **저장/조회 로직**
* TransactionManager\
  → 트랜잭션 **시작/종료 정책**
* DataSource\
  → DB **연결 방식**

이렇게 나뉘면, 같은 애플리케이션 계층 안에서도 **책임이 섞이지 않습니다**.

### 트랜잭션 추상화 이전의 UserService 문제점

UserService에 JDBC Connection을 직접 사용하는\
트랜잭션 코드가 들어 있다면,\
UserService가 수정되는 이유는 몇 개인가?

답은 **두 가지**입니다.

1. 사용자 관리 정책이 바뀔 때
2. 트랜잭션 기술이 바뀔 때\
   (JDBC → JTA → Hibernate → JPA 등)

즉,

* 비즈니스 로직 변경
* 기술 변경

이 **두 가지 이유**로 UserService가 바뀝니다. -> **단일 책임 원칙 위반**

### 의존관계 역전과 DI의 역할

책에서 반복해서 강조하는 지점입니다.

#### 원래 구조

* UserService → JDBC Connection
* UserService → 특정 트랜잭션 API

즉, **고수준 로직이 저수준 기술에 의존**했습니다.

***

#### 추상화 이후 구조

* UserService → PlatformTransactionManager (인터페이스)
* 실제 구현:
  * DataSourceTransactionManager
  * JpaTransactionManager
  * JtaTransactionManager

이 구현체들은 \*\*스프링 설정(DI)\*\*에서 교체됩니다.

UserService는:

* 어떤 트랜잭션 기술을 쓰는지
* DB가 몇 개인지
* JTA인지 JDBC인지

**전혀 모릅니다.**

### 왜 “서비스 추상화”인가

책에서는 “트랜잭션 추상화”가 아니라\
“서비스 추상화”라고 부릅니다.

이유는:

* 트랜잭션은 애플리케이션 전체에서 사용되는 **공통 서비스**
* 특정 기술(JDBC 등)에 종속되지 않아야 함
* 여러 기술을 **동일한 방식으로 사용**할 수 있어야 함

그래서:

* 기술 API를 직접 쓰지 않고
* 추상화된 서비스 인터페이스를 사용

하는 구조가 됩니다.

### 단일 책임 원칙의 진짜 효과

책 후반부에서 강조하는 핵심은 이 부분입니다.

단일 책임 원칙의 장점은 단순히 “깔끔하다”가 아닙니다.

#### 실제 효과

1. 코드 수정 범위가 줄어든다
2. 테스트가 쉬워진다
3. 기술 교체 비용이 낮아진다
4. 실수 가능성이 줄어든다

**“변경 작업이 많아질수록, 단일 책임 원칙을 지키지 않은 코드는 수정 자체가 두려워진다”**

## 5.4. 메일 서비스 추상화

사용자 레벨이 업그레이드되면\
**해당 사용자에게 안내 메일을 발송한다**

### 5.4.1. JavaMail을 이용한 메일 발송 기능

#### 1. 가장 단순한 구현

책은 먼저 **가장 직관적인 방식**으로 메일 발송 기능을 추가합니다.

```java
protected void upgradeLevel(User user) {
    user.upgradeLevel();
    userDao.update(user);
    sendUpgradeEmail(user);
}
```

이 코드는 기능적으로 아무 문제가 없습니다.

* 사용자 레벨을 변경하고
* DB에 반영한 뒤
* 메일을 발송합니다

이 시점에서는 “메일 발송”이\
**사용자 레벨 업그레이드의 부가 기능**으로 자연스럽게 보입니다.

#### JavaMail API를 직접 사용하는 코드

```java
private void sendUpgradeEmail(User user) {
    Properties props = new Properties();
    props.put("mail.smtp.host", "mail.ksug.org");

    Session s = Session.getInstance(props, null);
    MimeMessage message = new MimeMessage(s);

    try {
        message.setFrom(new InternetAddress("useradmin@ksug.org"));
        message.addRecipient(
            Message.RecipientType.TO,
            new InternetAddress(user.getEmail())
        );
        message.setSubject("Upgrade 안내");
        message.setText(
            "사용자님의 등급이 " + user.getLevel().name() +
            "로 업그레이드되었습니다."
        );

        Transport.send(message);
    }
    catch (MessagingException e) {
        throw new RuntimeException(e);
    }
}
```

이 코드는 JavaMail을 사용하는 **전형적인 예제 코드**이며,\
실제 운영 환경에서도 문제없이 동작할 수 있습니다.

하지만 이 구현에는 **아직 드러나지 않은 심각한 문제가 숨어 있습니다.**

### 5.4.2. JavaMail이 포함한 코드의 테스트 문제

#### 문제 1. 테스트가 외부 메일 서버에 의존함

`upgradeLevels()` 메서드를 테스트하면 다음 문제가 발생합니다.

* 메일 서버가 준비되어 있지 않으면 테스트 실패
* SMTP 설정이 잘못되면 테스트 실패
* 네트워크 장애가 발생하면 테스트 실패

즉,\
**UserService의 비즈니스 로직 테스트가 외부 메일 서버 상태에 의존**하게 됩니다.

이는 테스트의 가장 중요한 원칙을 깨뜨립니다: **테스트는 외부 환경과 무관하게 항상 동일한 결과를 보장해야 한다**

#### 문제 2. 테스트 실행 시 실제 메일 발송

더 심각한 문제는 테스트를 실행할 때마다\
실제 메일이 사용자에게 발송될 수 있다는 점입니다.

* 테스트 코드 실행
* 사용자 레벨 업그레이드 발생
* 실제 메일 전송

이는 테스트 환경에서 절대 허용되어서는 안 되는 동작입니다.

#### 문제 3. JavaMail은 테스트 대체가 거의 불가능함

이 문제가 해결되지 않는 근본적인 이유는 JavaMail API의 구조에 있습니다.

* `Session`은 인터페이스가 아닌 클래스
* 생성자가 private
* 내부 구현이 외부에 노출되지 않음

즉,

* Mockito로 Mock 객체를 만들 수 없고
* Stub이나 Fake 구현으로 교체할 수도 없습니다

JavaMail은 **확장이나 대체를 고려하지 않고 설계된 API**입니다.

### 5.4.3. 테스트를 위한 서비스 추상화

이 문제를 해결하기 위해 이전 장에서 사용했던 전략을 그대로 적용합니다.

* DB 연결 → `DataSource`
* 트랜잭션 관리 → `PlatformTransactionManager`
* 메일 발송 → **추상화 필요**

즉,\
**JavaMail을 직접 사용하지 말고, 그 위에 추상 계층을 둡니다.**

***

#### 1. 메일 발송 책임을 인터페이스로 분리

```java
public interface MailSender {
    void send(SimpleMailMessage mailMessage) throws MailException;
}
```

이 인터페이스가 도입되면서 구조가 바뀝니다.

* UserService는 더 이상 JavaMail을 알지 못합니다
* “메일을 보낸다”는 역할만 알고
* “어떻게 보내는지”는 관심 대상이 아닙니다

***

#### 2. UserService의 메일 발송 코드 수정

```java
private MailSender mailSender;

public void setMailSender(MailSender mailSender) {
    this.mailSender = mailSender;
}

private void sendUpgradeEmail(User user) {
    SimpleMailMessage mailMessage = new SimpleMailMessage();
    mailMessage.setTo(user.getEmail());
    mailMessage.setFrom("useradmin@ksug.org");
    mailMessage.setSubject("Upgrade 안내");
    mailMessage.setText(
        "사용자님의 등급이 " + user.getLevel().name()
    );

    this.mailSender.send(mailMessage);
}
```

이제 UserService는:

* JavaMail API를 전혀 사용하지 않고
* try/catch 블록도 사라졌으며
* 메일 전송 실패 시 예외 처리는 MailSender 구현체의 책임이 됩니다

***

#### 3. 실제 구현: JavaMailSenderImpl

스프링은 JavaMail을 감싸는 구현체를 이미 제공합니다.

```xml
<bean id="mailSender"
      class="org.springframework.mail.javamail.JavaMailSenderImpl">
    <property name="host" value="mail.server.com"/>
</bean>
```

UserService는 이 구현체를 DI로 주입받아 사용합니다.

### 5.4.4. 테스트 대역

#### 1. DummyMailSender

아무 일도 하지 않는 구현체입니다.

```java
public class DummyMailSender implements MailSender {
    public void send(SimpleMailMessage mailMessage) {
        // 아무 작업도 하지 않음
    }
}
```

이 구현체를 사용하면:

* 테스트 중 메일 발송 없음
* 메일 서버 필요 없음
* 테스트 실패 원인 제거

***

#### 2. MockMailSender

메일 발송 여부를 검증하기 위한 구현체입니다.

```java
static class MockMailSender implements MailSender {
    private List<String> requests = new ArrayList<>();

    public List<String> getRequests() {
        return requests;
    }

    public void send(SimpleMailMessage mailMessage) {
        requests.add(mailMessage.getTo()[0]);
    }
}
```

***

#### 테스트 코드

```java
MockMailSender mockMailSender = new MockMailSender();
userService.setMailSender(mockMailSender);

userService.upgradeLevels();

List<String> requests = mockMailSender.getRequests();
assertThat(requests.size(), is(2));
assertThat(requests.get(0), is(users.get(1).getEmail()));
assertThat(requests.get(1), is(users.get(3).getEmail()));
```

이 테스트는 다음을 검증합니다.

* 업그레이드 대상 사용자만 메일을 받았는지
* 메일 발송 순서가 업그레이드 순서와 일치하는지
* 메일 발송이 비즈니스 로직과 함께 실행되는지

외부 메일 서버 없이,\
비즈니스 규칙만 검증합니다.

## 정리

#### 핵심 메시지

* 외부 서비스 연동은 항상 추상화 대상이다
* 테스트하기 어려운 API는 구조적으로 격리해야 한다
* 비즈니스 로직은 외부 기술로부터 보호되어야 한다

#### 5.3과의 연결

* 트랜잭션 → `PlatformTransactionManager`
* DB 연결 → `DataSource`
* 메일 발송 → `MailSender`

모두 동일한 설계 원칙을 따릅니다.

**비즈니스 로직은 변경되지 않게 두고 기술과 환경만 교체 가능하도록 만든다**
