5. Service Abstraction

5장. 서비스 추상화

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

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

  • 단순한 데이터 표현(int)의 한계 인식

  • 도메인 개념(enum Level) 도입

  • 테스트를 통한 설계 문제 발견

  • DAO와 Service 책임 분리

  • 객체에게 책임을 이동시키는 리팩터링

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

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

  • 사용자는 BASIC, SILVER, GOLD 중 하나의 레벨을 가집니다.

  • 신규 가입 사용자는 항상 BASIC 레벨입니다.

  • 로그인 횟수가 50회 이상이면 SILVER로 승급됩니다.

  • 추천 횟수가 30회 이상이면 GOLD로 승급됩니다.

  • 레벨 변경은 사용자의 행위가 아니라 시스템이 정기적으로 수행하는 작업입니다.

  • 이미 승급된 사용자는 다시 처리되지 않습니다.

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

5.1.1. 필드 추가

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

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

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

  • level = 1000 같은 값도 아무 문제 없이 들어갈 수 있습니다.

  • 컴파일러가 값의 의미를 전혀 보장해주지 않습니다.

  • 코드만 보고 1, 2, 3이 무엇을 의미하는지 알기 어렵습니다.

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

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

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

  • User.setLevel(100) 같은 코드가 문법적으로는 완벽히 유효합니다.

  • 이는 “레벨이라는 개념이 숫자로 취급되고 있다”는 신호입니다.

  • 비즈니스 개념이 원시 타입에 묻히면, 잘못된 값이 런타임까지 흘러가게 됩니다.

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

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

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

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

  • 애플리케이션 내부에서는 반드시 Level 타입만 사용합니다.

  • DB에는 여전히 int로 저장하지만, 그 사실은 외부로 드러나지 않습니다.

  • 잘못된 값은 즉시 예외로 드러나도록 설계됩니다.

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

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

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

반면 enum을 사용하면,

  • 허용 가능한 값의 집합이 타입 수준에서 고정

  • 잘못된 값은 아예 코드 작성 단계에서 차단

  • 레벨과 관련된 행위를 함께 묶을 수 있는 여지가 생깁니다

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

Level enum 도입

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

  • DB에는 여전히 int로 저장

  • 애플리케이션 내부에서는 반드시 Level 타입으로만 사용

  • 잘못된 값은 즉시 예외 발생

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


User 클래스에 Level 필드 추가

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

여기서 중요한 점은,

  • 레벨은 단순 속성이 아니라

  • 이후 비즈니스 로직의 핵심 판단 기준이 된다는 점입니다

5.1.2. 사용자 수정 기능 추가

왜 수정 기능이 필요한가

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

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

  • 사용자를 새로 생성하는 것이 아니라

  • 기존 사용자의 상태를 변경합니다


update() 테스트부터 작성

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

  • 사용자 하나를 저장

  • 일부 필드를 변경

  • 다시 조회해서 변경 여부 확인

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

  • WHERE 절이 빠지면 전체 데이터가 변경됩니다

  • 컬럼 하나라도 누락되면 데이터 불일치가 발생합니다

  • 컴파일 타임에 검증되지 않습니다

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

  1. 사용자 한 명을 DB에 저장한다

  2. 그 사용자의 일부 필드를 변경한다

  3. update()를 호출한다

  4. 다시 조회해서 모든 필드가 정확히 변경되었는지 확인한다


UserDaoJdbc의 update 구현

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

  • Level → DB 저장 시 intValue() 사용

  • id를 기준으로 정확히 한 명만 수정


테스트 보완: WHERE 절 검증

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

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

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

  • 수정 대상 사용자 외의 사용자는 절대 변경되지 않아야 함

  • 이 검증이 없다면 SQL 오류를 놓칠 수 있음

Where절이 없는 경우,

  • user1은 기대대로 변경됨

  • user2도 같은 값으로 덮어써짐

  • checkSameUser(user2, untouchedUser2)에서 테스트 실패

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

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

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

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

5.1.3. UserService.upgradeLevels()

비즈니스 로직의 위치 문제

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

  • 레벨 업그레이드는 명백한 비즈니스 로직

  • 그런데 이 로직을 DAO에 넣기 시작하면

    • 조건 판단

    • 레벨 변경

    • DB 업데이트 가 한 클래스에 몰립니다

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


UserService 도입

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

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

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

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

두 책임이 섞이면,

  • 정책 변경이 기술 변경으로 전파되고

  • 테스트가 복잡해지며

  • 구현 교체(JDBC → JPA)가 어려워집니다

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

5.1.4. UserService.add()

신규 사용자 기본 레벨 문제

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

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

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

  • User 생성자?

  • DAO add()?

  • Service?

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

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

  • “기본 레벨 정책”은 비즈니스 규칙

  • 따라서 Service가 책임진다

5.1.5. 코드 개선

기존 upgradeLevels()의 한계

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

  • if / else if 블록이 길고 읽기 어렵습니다

  • 레벨 순서가 코드에 하드코딩되어 있습니다

  • 레벨이 추가되면 조건문 전체를 수정해야 합니다

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


업그레이드 가능 여부 분리

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

이렇게 하면,

  • 판단 로직과 실행 로직이 분리되고

  • 테스트가 쉬워집니다


Level에게 순서 책임 이동

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


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

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

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

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

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

  • 상태 불변 조건을 내부에서 보호할 수 있고

  • 잘못된 사용을 즉시 예외로 차단할 수 있으며

  • 객체의 응집도가 높아집니다

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

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

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

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

  • 트랜잭션의 필요성

  • 트랜잭션 경계 설정 책임

  • JDBC 트랜잭션의 한계

  • 스프링의 트랜잭션 동기화

  • 트랜잭션 서비스 추상화

5.2.1. 모 아니면 도

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

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

  1. 예외가 발생하기 전까지 변경된 사용자 레벨은 그대로 둔다

  2. 작업 전체를 취소하고 모든 사용자를 이전 상태로 되돌린다

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

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


테스트를 통한 문제 제기

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

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

  • 업그레이드 도중 예외가 발생했을 때

  • 이미 업그레이드된 사용자 레벨이 그대로 DB에 남는 문제

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

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


UserService


테스트용 UserService 대역


강제 예외 발생 테스트

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

이 테스트가 필요한 이유?

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

5.2.2. 트랜잭션 경계 설정

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

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


전형적인 JDBC 트랜잭션 코드


문제의 원인

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

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


Connection 전달 방식의 한계

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

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

  • 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란?

즉,

  • ThreadLocal 기반 저장소

  • “현재 스레드에서 사용하는 리소스들”을 저장

  • key: DataSource

  • value: Connection

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

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

Service에서 트랜잭션 시작

이 줄의 의미는:

  • “이 스레드에서 트랜잭션 동기화를 시작한다”

  • 내부적으로 ThreadLocal Map을 준비함

아직 커넥션은 없습니다.


Service에서 커넥션을 하나 생성

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

DataSourceUtils.getConnection()의 동작 순서

  1. TransactionSynchronizationManager

    • 이 DataSource에 바인딩된 커넥션이 있는지 확인

  2. 없으면

    • dataSource.getConnection() 호출해서 새 커넥션 생성

  3. 트랜잭션 동기화가 활성화된 상태라면

    • 이 커넥션을 ThreadLocal에 저장

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

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


커넥션을 ThreadLocal에 바인딩

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

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

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

이 함수는:

  1. TransactionSynchronizationManager 확인

  2. 현재 스레드에

    • dataSource에 바인딩된 커넥션이 있으면

  3. 그 커넥션을 그대로 반환

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

Service 코드

내부 흐름

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

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

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

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

  • JDBC 트랜잭션

  • JTA 트랜잭션

  • Hibernate / JPA 트랜잭션

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


PlatformTransactionManager

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

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


설정을 통한 기술 교체

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

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

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

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

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

  1. Service가 Connection을 직접 다룸

  2. JDBC API를 명확히 알고 있어야 함

  3. 트랜잭션 기술이 바뀌면 → Service 코드 수정이 불가피

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

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

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

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

Hibernate / JPA는 또 다릅니다.

정리하면:

기술
트랜잭션 시작

JDBC

Connection.setAutoCommit(false)

JTA

UserTransaction.begin()

JPA

EntityTransaction.begin()

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

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

3. 스프링의 해결 전략

질문을 이렇게 바꿉니다

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

정답은 아닙니다.

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

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

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

4. PlatformTransactionManager의 역할

핵심 개념

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

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

  • JDBC용 구현체가 따로 있고

  • JTA용 구현체가 따로 있고

  • JPA용 구현체가 따로 있습니다

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


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

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

이 한 줄에서 벌어지는 일

JDBC 환경이라면

  • DataSourceTransactionManager가 호출됨

  • 내부에서

    • DataSource에서 Connection을 얻음

    • autoCommit을 false로 설정

    • ThreadLocal에 Connection을 묶어둠

  • 그 결과를 TransactionStatus로 감쌈

JTA 환경이라면

  • JtaTransactionManager가 호출됨

  • 내부에서

    • WAS의 TransactionManager를 통해

    • 글로벌 트랜잭션을 시작

  • 역시 결과를 TransactionStatus로 감쌈

Service 입장에서는

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


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

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

  • JDBC의 경우 → DataSourceUtils.getConnection()이 ThreadLocal에 묶인 같은 Connection을 반환

  • JPA의 경우 → 트랜잭션에 바인딩된 EntityManager 사용

즉,

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

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


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

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

  • 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. 가장 단순한 구현

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

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

  • 사용자 레벨을 변경하고

  • DB에 반영한 뒤

  • 메일을 발송합니다

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

JavaMail API를 직접 사용하는 코드

이 코드는 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. 메일 발송 책임을 인터페이스로 분리

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

  • UserService는 더 이상 JavaMail을 알지 못합니다

  • “메일을 보낸다”는 역할만 알고

  • “어떻게 보내는지”는 관심 대상이 아닙니다


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

이제 UserService는:

  • JavaMail API를 전혀 사용하지 않고

  • try/catch 블록도 사라졌으며

  • 메일 전송 실패 시 예외 처리는 MailSender 구현체의 책임이 됩니다


3. 실제 구현: JavaMailSenderImpl

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

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

5.4.4. 테스트 대역

1. DummyMailSender

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

이 구현체를 사용하면:

  • 테스트 중 메일 발송 없음

  • 메일 서버 필요 없음

  • 테스트 실패 원인 제거


2. MockMailSender

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


테스트 코드

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

  • 업그레이드 대상 사용자만 메일을 받았는지

  • 메일 발송 순서가 업그레이드 순서와 일치하는지

  • 메일 발송이 비즈니스 로직과 함께 실행되는지

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

정리

핵심 메시지

  • 외부 서비스 연동은 항상 추상화 대상이다

  • 테스트하기 어려운 API는 구조적으로 격리해야 한다

  • 비즈니스 로직은 외부 기술로부터 보호되어야 한다

5.3과의 연결

  • 트랜잭션 → PlatformTransactionManager

  • DB 연결 → DataSource

  • 메일 발송 → MailSender

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

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

Last updated