토비의 스프링 정복하기 14편 - 과거부터 이어져 온 설계 패턴 4가지

들어가며

토비의 스프링 7장 후반부는 SQL 레지스트리를 런타임에 수정 가능하게 만드는 과정을 다룹니다. 솔직히 말하면, SQL을 런타임에 교체하는 시나리오 자체는 현대 스프링에서 거의 사용되지 않습니다. MyBatis를 쓰면 매퍼 XML을 수정하고 재배포하면 되고, JPA를 쓰면 SQL을 직접 관리하지 않는다.

하지만 이 과정에서 적용되는 설계 패턴들은 현대 스프링에서도 매일 마주치게 됩니다. 이 글에서는 현재적인 스프링에서는 그 개념을 어떻게 활용하는지에 대해 설명하려고 합니다.


1. 인터페이스 분리 원칙(ISP) - 기존 코드를 건드리지 않는 기능 확장

토비의 스프링에서의 맥락

기존 SqlRegistry는 등록과 검색만 제공했습니다. 여기에 SQL 수정 기능이 필요해졌을 때, SqlRegistry에 직접 updateSql()을 추가하면 등록/검색만 필요한 BaseSqlService까지 영향을 받게 됩니다.

그래서 SqlRegistry를 상속한 UpdatableSqlRegistry를 별도로 만들어, 각 클라이언트가 자신에게 필요한 인터페이스로만 접근하게 구현하였습니다.

BaseSqlService ──→ SqlRegistry (등록/검색만)
                        ↑ (상속)
SqlAdminService ──→ UpdatableSqlRegistry (등록/검색 + 수정)
                        ↑ (구현)
                   ConcurrentHashMapSqlRegistry

핵심은 하나의 오브젝트를 공유하되, 클라이언트마다 다른 인터페이스로 접근한다는 것입니다.

현대 스프링에서 매일 마주치는 ISP

이 패턴은 스프링 데이터의 Repository 상속 구조에서 그대로 살아있습니다. JPA Repository를 계속 타고 들어가다보면 아래의 리포지토리를 보게 될 것입니다.

Repository (마커 인터페이스)
    ↑
CrudRepository (기본 CRUD)
    ↑
PagingAndSortingRepository (페이징/정렬 추가)
    ↑
JpaRepository (JPA 특화 기능 추가)

서비스 레이어에서 페이징이 필요 없으면 CrudRepository로 접근하게 되고, 페이징이 필요하면 PagingAndSortingRepository로 접근합니다. 토비의 스프링에서 SqlRegistry → UpdatableSqlRegistry로 확장한 것과 동일한 원리로 볼 수 있습니다.

실무에서 ISP를 적용해야 하는 신호: 인터페이스를 구현할 때 UnsupportedOperationException을 던지거나 빈 메소드를 만들고 있다면, 인터페이스가 너무 크다는 뜻이니 분리를 고려해야 합니다.

트레이드오프

  • ISP를 지키면 기존 코드에 영향 없이 확장할 수 있지만, 인터페이스가 늘어나면서 구조 파악 비용이 증가합니다.
  • 스프링 데이터의 Repository 계층만 해도 4단계인데, 커스텀 Repository까지 추가하면 어떤 클라이언트가 어떤 레벨을 쓰는지 추적이 어려워집니다.

2. ConcurrentHashMap vs synchronized - 동시성 제어 전략 선택

SQL 레지스트리의 디폴트 구현체가 HashMap 기반이었는데, 멀티스레드 환경에서 동시 접근 시 데이터가 깨질 수 있어 ConcurrentHashMap으로 교체하였습니다.

이 선택은 현대 애플리케이션에서도 빈번하게 발생합니다. 인메모리 캐시, 설정 값 저장, 커넥션 풀 관리 등 공유 데이터를 다룰 때마다 같은 판단이 필요합니다.

// 1. HashMap - 단일 스레드에서만 사용
Map<String, Object> localCache = new HashMap<>();

// 2. Collections.synchronizedMap - 간단하지만 모든 접근에 락
Map<String, Object> syncCache = Collections.synchronizedMap(new HashMap<>());

// 3. ConcurrentHashMap - 읽기 락 없음, 쓰기만 세그먼트 락
Map<String, Object> concurrentCache = new ConcurrentHashMap<>();

// 4. Caffeine Cache - TTL, 최대 크기, 통계 등 캐시 기능 포함
Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();

실무에서의 선택 기준:

  • 단일 스레드 → HashMap
  • 멀티스레드 + 단순 key-value → ConcurrentHashMap
  • 멀티스레드 + TTL, 크기 제한 등 캐시 정책 필요 → Caffeine, Spring Cache 추상화
  • 분산 환경 → Redis

트레이드오프

  • ConcurrentHashMap은 개별 연산은 스레드 안전하지만, 복합 연산(check-then-act)은 원자적이지 않다. 토비의 스프링에서도 updateSql(Map)에서 여러 SQL을 수정하다 중간에 실패하면 일부만 수정되는 문제가 발생했하였습니다.
  • ConcurrentHashMap의 compute(), merge() 같은 원자적 복합 연산을 활용하면 일부 해결되지만, 여러 키에 걸친 원자성은 여전히 보장되지 않습니다. 이 경우 트랜잭션이 필요합니다.

3. 내장형 DB - 테스트와 경량 저장소

ConcurrentHashMap의 동시성/트랜잭션 한계를 넘기 위해 내장형 DB(HSQL)를 도입했다. 외부 DB의 운영 부담 없이 트랜잭션과 격리수준을 확보하는 것이 목적이였습니다.

EmbeddedDatabase db = new EmbeddedDatabaseBuilder()
        .setType(HSQL)
        .addScript("/schema.sql")
        .build();

SQL 레지스트리용으로는 쓰이지 않지만, 테스트 환경에서는 매일 사용한다. 스프링 부트는 H2를 테스트용 내장형 DB로 자동 설정합니다.

// 스프링 부트 테스트 - H2 자동 설정
@SpringBootTest
@AutoConfigureTestDatabase  // 내장형 DB로 자동 전환
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveAndFind() {
        User user = new User("test", "password");
        userRepository.save(user);

        User found = userRepository.findById("test").orElseThrow();
        assertThat(found.getName()).isEqualTo("test");
    }
}
# application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop

토비의 스프링에서 EmbeddedDatabaseBuilder로 직접 빌더를 호출하고, schema.sql을 수동으로 지정하고, shutdown()을 직접 호출했던 것이 스프링 부트에서는 자동화된 것입니다.

내장형 DB가 테스트 외에 쓰이는 경우: 임베디드 시스템, 데스크톱 애플리케이션, 또는 SQLite를 로컬 캐시/설정 저장소로 사용하는 경우. 서버 애플리케이션에서 런타임 저장소로 내장형 DB를 쓰는 일은 드물다.

트레이드오프

  • 테스트에서 H2를 쓰면 빠르고 격리된 환경을 얻지만, 실제 DB(MySQL, PostgreSQL)와 SQL 호환성 차이로 테스트가 통과해도 운영에서 실패하는 경우가 있음
  • Testcontainers로 실제 DB를 컨테이너로 띄우는 방식이 이 문제를 해결하지만, 테스트 속도가 느려지는 트레이드오프가 있음

4. TransactionTemplate vs @Transactional - 트랜잭션 경계 설정 전략

SQL 레지스트리에서 여러 SQL을 한 번에 수정할 때, 중간에 실패하면 일부만 수정되는 문제가 있었습니다. AOP 기반 @Transactional은 범위가 넓은 서비스 레이어에 적합하고, SQL 레지스트리처럼 제한된 오브젝트 내에서의 간단한 트랜잭션에는 TransactionTemplate을 직접 사용했습니다.

// 토비의 스프링 - TransactionTemplate 직접 사용
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
    @Override
    protected void doInTransactionWithoutResult(TransactionStatus status) {
        for (Map.Entry<String, String> entry : sqlmap.entrySet())
            updateSql(entry.getKey(), entry.getValue());
    }
});

현대 스프링에서의 트랜잭션 전략

@Transactional이 표준이지만, TransactionTemplate이 더 적합한 상황이 여전히 존재합니다.

// 1. @Transactional - 서비스 레이어의 표준
@Service
public class OrderService {

    @Transactional
    public void placeOrder(OrderRequest request) {
        orderRepository.save(request.toOrder());
        inventoryService.decrease(request.getItemId(), request.getQuantity());
        paymentService.charge(request.getPaymentInfo());
    }
}

// 2. TransactionTemplate - 메소드 내에서 부분적 트랜잭션이 필요할 때
@Service
public class BatchService {
    private final TransactionTemplate transactionTemplate;

    public BatchService(PlatformTransactionManager txManager) {
        this.transactionTemplate = new TransactionTemplate(txManager);
    }

    public void processBatch(List<Item> items) {
        for (Item item : items) {
            // 건별로 트랜잭션 - 하나 실패해도 나머지는 처리
            transactionTemplate.executeWithoutResult(status -> {
                processItem(item);
            });
        }
    }
}

TransactionTemplate이 더 적합한 상황:

  • 메소드 내에서 트랜잭션 경계를 세밀하게 제어해야 할 때 (위의 배치 처리 예시)
  • 같은 클래스 내부 메소드 호출에서 트랜잭션이 필요할 때 (@Transactional은 프록시 기반이라 내부 호출 시 적용되지 않음)
  • 트랜잭션 성공/실패에 따라 분기 로직이 필요할 때

@Transactional의 내부 호출 문제

현대 스프링에서도 자주 발생하는 실수 입니다. 이전에 정리했던 @Transactional 관련된 글을 참고하시면 좋습니다.

@Service
public class UserService {

    // 프록시를 통해 호출되므로 트랜잭션 적용됨
    @Transactional
    public void updateUser(User user) {
        userRepository.save(user);
    }

    // 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 트랜잭션 미적용
    public void updateMultipleUsers(List<User> users) {
        for (User user : users) {
            updateUser(user);  // 트랜잭션이 적용되지 않음!
        }
    }
}

// TransactionTemplate으로 해결
public void updateMultipleUsers(List<User> users) {
    transactionTemplate.executeWithoutResult(status -> {
        for (User user : users) {
            userRepository.save(user);
        }
    });
}

트레이드오프

  • @Transactional은 선언적이라 가독성이 좋지만, 프록시 기반의 한계(내부 호출, private 메소드 등)를 이해하지 못하면 트랜잭션이 적용되지 않는 버그가 발생함
  • TransactionTemplate은 명시적이라 동작이 확실하지만, 코드가 장황해지고 트랜잭션 경계가 @Transactional처럼 한눈에 보이지 않음
  • 토비의 스프링에서 트랜잭션 매니저를 내부에서 직접 생성했는데, 현대 스프링에서는 빈으로 등록된 PlatformTransactionManager를 DI 받는 것이 표준. 외부 트랜잭션과의 전파가 필요할 수 있기 때문

마무리

토비의 스프링 7장 후반부의 SQL 레지스트리 예제는 현대 스프링에서 직접 쓸 일이 없습니다. 하지만 그 안에 녹아있는 4가지 패턴은 여전히 실무의 핵심입니다.

핵심 개념 토비의 스프링 예시 현대 스프링 방식
ISP SqlRegistry → UpdatableSqlRegistry CrudRepository → JpaRepository 상속 구조
동시성 제어 HashMap → ConcurrentHashMap 인메모리 캐시 선택 (ConcurrentHashMap / Caffeine / Redis)
내장형 DB EmbeddedDatabaseBuilder + HSQL 스프링 부트 H2 자동 설정, Testcontainers
프로그래밍 트랜잭션 TransactionTemplate 직접 사용 배치 처리, 내부 호출 등 @Transactional 한계 상황에서 활용

예제가 낡았다고 패턴까지 낡은 것은 아닙니다. 중요한 것은 구체적인 코드가 아니라, 어떤 문제 상황에서 어떤 설계 판단을 했는지를 읽어내는 것입니다.