1. 비유 — 레고 블록과 조립 설명서

스프링을 이해한다는 것은 레고 블록(객체)을 어떻게 조립하는지(의존관계), 그 설명서(설계 원칙)를 이해하는 것입니다. 토비의 스프링이 가르쳐주는 것은 “어떻게 쓰는가”가 아니라 “왜 이렇게 설계되었는가”입니다.


2. 오브젝트와 의존관계

2.1 관심사의 분리 (Separation of Concerns)

처음에는 모든 것이 하나의 클래스에 있습니다:

// 나쁜 예: 관심사가 뒤섞여 있음
public class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {
        // DB 연결 — 관심사 1
        Class.forName("com.mysql.cj.jdbc.Driver");
        Connection c = DriverManager.getConnection(
            "jdbc:mysql://localhost/springbook", "spring", "book");

        // SQL 실행 — 관심사 2
        PreparedStatement ps = c.prepareStatement(
            "INSERT INTO users(id, name, password) VALUES(?,?,?)");
        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ps.setString(3, user.getPassword());
        ps.executeUpdate();

        // 리소스 반납 — 관심사 3
        ps.close();
        c.close();
    }
}

관심사를 분리하면:

// DB 연결 — 관심사 1 분리
public interface ConnectionMaker {
    Connection makeConnection() throws ClassNotFoundException, SQLException;
}

public class DConnectionMaker implements ConnectionMaker {
    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.cj.jdbc.Driver");
        return DriverManager.getConnection("jdbc:mysql://...", "d", "d_password");
    }
}

// UserDao — SQL 실행에만 집중
public class UserDao {
    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker) {
        this.connectionMaker = connectionMaker;
    }

    public void add(User user) throws ClassNotFoundException, SQLException {
        Connection c = connectionMaker.makeConnection();
        // SQL 실행
        PreparedStatement ps = c.prepareStatement("INSERT INTO users...");
        // ...
    }
}
graph LR
    A[UserDao] -->|"의존"| B["ConnectionMaker 인터페이스"]
    C[DConnectionMaker] -->|"구현"| B
    D[NConnectionMaker] -->|"구현"| B

    E[UserDaoTest] -->|DI| A
    E -->|"선택"| C

2.2 개방-폐쇄 원칙 (OCP)

graph TD
    A["OCP: 확장에 열려있고, 변경에 닫혀있음"] --> B[UserDao]
    A --> C["ConnectionMaker 인터페이스"]

    B -->|"변경 없이"| D["새 DB로 전환 가능"]
    C -->|"새 구현체 추가"| E[NConnectionMaker]
    C -->|"새 구현체 추가"| F[TestConnectionMaker]
    B -->|"코드 수정 불필요"| G["UserDao는 그대로"]

3. 템플릿 메서드 패턴과 전략 패턴

3.1 JDBC 코드의 중복 문제

// 공통 패턴: 연결 → 쿼리 → 예외처리 → 닫기
// add(), get(), delete() 모두 동일한 구조
public void add(User user) throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;
    try {
        c = dataSource.getConnection();
        ps = c.prepareStatement("INSERT ...");  // 이 부분만 다름
        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if (ps != null) try { ps.close(); } catch (SQLException e) {}
        if (c != null) try { c.close(); } catch (SQLException e) {}
    }
}

3.2 전략 패턴으로 해결

// 전략 인터페이스
@FunctionalInterface
public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

// 템플릿 메서드 (변하지 않는 부분)
public class JdbcContext {

    private DataSource dataSource;

    public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;
        try {
            c = dataSource.getConnection();
            ps = stmt.makePreparedStatement(c); // 전략 실행
            ps.executeUpdate();
        } catch (SQLException e) {
            throw e;
        } finally {
            if (ps != null) try { ps.close(); } catch (SQLException e) {}
            if (c != null) try { c.close(); } catch (SQLException e) {}
        }
    }
}

// UserDao — 변하는 부분만 집중
public class UserDao {

    private JdbcContext jdbcContext;

    public void add(final User user) throws SQLException {
        jdbcContext.workWithStatementStrategy(
            c -> {
                PreparedStatement ps = c.prepareStatement("INSERT INTO users(id, name) VALUES(?,?)");
                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                return ps;
            }
        );
    }

    public void deleteAll() throws SQLException {
        jdbcContext.workWithStatementStrategy(
            c -> c.prepareStatement("DELETE FROM users")
        );
    }
}

이것이 JdbcTemplate의 내부 동작 원리입니다!


4. 서비스 추상화

4.1 트랜잭션 서비스 추상화 문제

// JDBC 트랜잭션 — UserService가 JDBC에 의존
public class UserService {

    public void upgradeLevels() throws Exception {
        // JDBC Connection을 직접 다룸 — 문제!
        Connection c = dataSource.getConnection();
        c.setAutoCommit(false);
        try {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            c.commit();
        } catch (Exception e) {
            c.rollback();
            throw e;
        } finally {
            c.close();
        }
    }
}

JPA로 교체하면? Hibernate로 교체하면? UserService를 수정해야 합니다.

4.2 PlatformTransactionManager 추상화

// UserService — 트랜잭션 기술에 독립적
public class UserService {

    private PlatformTransactionManager transactionManager;

    public void upgradeLevels() {
        TransactionStatus status =
            transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            transactionManager.commit(status);
        } catch (RuntimeException e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}
graph TD
    A[UserService] -->|"의존"| B["PlatformTransactionManager 인터페이스"]
    C[DataSourceTransactionManager] -->|"구현"| B
    D[JpaTransactionManager] -->|"구현"| B
    E[HibernateTransactionManager] -->|"구현"| B
    F[JtaTransactionManager] -->|"구현"| B

    A -->|"코드 변경 없이"| G["DB 기술 교체 가능"]

5. 예외 전환 (Exception Translation)

5.1 체크 예외의 문제점

// 체크 예외를 강제 처리해야 하는 문제
public void add(User user) throws SQLException { // UserDao 구현 기술 노출!
    // ...
}

// 인터페이스에서도 throws가 필요 — 기술 종속
public interface UserDao {
    void add(User user) throws SQLException; // JDBC에 종속!
}

5.2 예외 전환 전략

// 1. 예외 포장 (Wrapping) — 체크 예외 → 언체크 예외
public void add(User user) {
    try {
        // ...
    } catch (SQLException e) {
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) {
            throw new DuplicateUserIdException(e); // 비즈니스 의미 있는 예외로 변환
        }
        throw new DataAccessException(e); // 언체크 예외로 포장
    }
}

// 2. 인터페이스는 깔끔하게
public interface UserDao {
    void add(User user); // throws 없음!
    User get(String id);
    List<User> getAll();
}

5.3 Spring의 DataAccessException 계층

graph TD
    A["DataAccessException\nRuntimeException"] --> B[NonTransientDataAccessException]
    A --> C[TransientDataAccessException]

    B --> D[DataIntegrityViolationException]
    B --> E[InvalidDataAccessApiUsageException]
    B --> F[BadSqlGrammarException]

    C --> G[QueryTimeoutException]
    C --> H[ConcurrencyFailureException]

    D --> I[DuplicateKeyException]

    J["MySQL SQLException\n에러코드 1062"] -->|"변환"| I
    K["Oracle SQLException\n에러코드 1"] -->|"변환"| I
    L["H2 SQLException\n에러코드 23505"] -->|"변환"| I

6. Spring 6 주요 변경점

6.1 Jakarta EE 9+ 마이그레이션

// Spring 5 (javax)
import javax.servlet.http.HttpServletRequest;
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;

// Spring 6 (jakarta) — 패키지명 변경!
import jakarta.servlet.http.HttpServletRequest;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;

6.2 Java 17 기준선

// Spring 6은 Java 17 최소 요구
// Records, Sealed Classes, Pattern Matching 활용 가능

// Record 활용
public record UserDto(Long id, String name, String email) {}

// Pattern Matching
if (response instanceof ErrorResponse errorResponse) {
    log.error("에러: {}", errorResponse.message());
}

// Sealed Classes
public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T value) implements Result<T> {}
public record Failure<T>(String error) implements Result<T> {}

6.3 AOT (Ahead-of-Time) 처리

graph LR
    A[Spring 6 + GraalVM] --> B["AOT 컴파일"]
    B --> C["Native Image 생성"]
    C --> D["빠른 시작 시간"]
    C --> E["낮은 메모리 사용"]
    C --> F["JVM 불필요"]

    G["기존 JVM 방식"] --> H["JIT 컴파일"]
    H --> I["런타임 최적화"]
    I --> J["긴 시작 시간\n높은 메모리"]
// AOT 힌트 제공
@Component
@ImportRuntimeHints(MyRuntimeHintsRegistrar.class)
public class MyComponent {}

public class MyRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // 리플렉션이 필요한 클래스 등록
        hints.reflection().registerType(MyDto.class,
            MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
            MemberCategory.INVOKE_DECLARED_METHODS);

        // 리소스 파일 등록
        hints.resources().registerPattern("templates/*.html");
    }
}

6.4 HTTP Interface Client

// Spring 6 새 기능: 인터페이스로 HTTP 클라이언트 정의
public interface GithubClient {

    @GetExchange("/repos/{owner}/{repo}")
    GithubRepo getRepo(@PathVariable String owner, @PathVariable String repo);

    @PostExchange("/repos/{owner}/{repo}/issues")
    GithubIssue createIssue(@PathVariable String owner,
                             @PathVariable String repo,
                             @RequestBody CreateIssueRequest request);
}

// 설정
@Bean
public GithubClient githubClient() {
    WebClient webClient = WebClient.builder()
        .baseUrl("https://api.github.com")
        .defaultHeader(HttpHeaders.AUTHORIZATION, "token " + githubToken)
        .build();

    return HttpServiceProxyFactory
        .builderFor(WebClientAdapter.create(webClient))
        .build()
        .createClient(GithubClient.class);
}

// 사용
@Service
public class GithubService {

    private final GithubClient githubClient;

    public GithubRepo getSpringRepo() {
        return githubClient.getRepo("spring-projects", "spring-framework");
    }
}

6.5 Micrometer Tracing 통합

// Spring 6 + Micrometer Tracing (분산 추적)
@Service
public class OrderService {

    private final Tracer tracer;

    @Observed(name = "order.create", contextualName = "주문 생성")
    public Order createOrder(CreateOrderRequest request) {
        Span span = tracer.currentSpan();
        if (span != null) {
            span.tag("order.memberId", request.getMemberId().toString());
        }
        return orderRepository.save(Order.from(request));
    }
}

7. 스프링 핵심 설계 원칙

7.1 SOLID 원칙 적용

graph TD
    A[SOLID] --> B["SRP 단일 책임 원칙"]
    A --> C["OCP 개방-폐쇄 원칙"]
    A --> D["LSP 리스코프 치환 원칙"]
    A --> E["ISP 인터페이스 분리 원칙"]
    A --> F["DIP 의존관계 역전 원칙"]

    B -->|"Spring 적용"| G["레이어 분리: Controller, Service, Repository"]
    C -->|"Spring 적용"| H["인터페이스 + DI로 확장에 열려있음"]
    D -->|"Spring 적용"| I["구현체는 인터페이스 계약 준수"]
    E -->|"Spring 적용"| J["UserDao, UserReadDao, UserWriteDao 분리"]
    F -->|"Spring 적용"| K["추상화에 의존, 구현체에 의존 X"]

7.2 템플릿 콜백 패턴 — Spring 전반에서 사용

graph TD
    A["템플릿 콜백 패턴"] --> B[JdbcTemplate]
    A --> C[RestTemplate]
    A --> D[TransactionTemplate]
    A --> E[RedisTemplate]
    A --> F[RabbitTemplate]
    A --> G[KafkaTemplate]

    B -->|"콜백"| H[RowMapper, PreparedStatementCreator]
    C -->|"콜백"| I[RequestCallback, ResponseExtractor]
    D -->|"콜백"| J[TransactionCallback]
// TransactionTemplate 사용
@Service
public class UserService {

    private final TransactionTemplate transactionTemplate;

    public void upgradeLevels() {
        transactionTemplate.execute(status -> {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            return null;
        });
    }
}

8. 스프링 테스트 전략

8.1 단위 테스트 vs 통합 테스트

// 단위 테스트 — 빠름, Mock 사용
class UserServiceTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserDao userDao;

    @Mock
    private MailSender mailSender;

    @Test
    void upgradeLevels() {
        List<User> users = Arrays.asList(
            new User("1", "A", Level.BASIC, 49, 0),
            new User("2", "B", Level.BASIC, 50, 0),  // 업그레이드 대상
            new User("3", "C", Level.SILVER, 60, 29),
            new User("4", "D", Level.SILVER, 60, 30) // 업그레이드 대상
        );

        given(userDao.getAll()).willReturn(users);

        userService.upgradeLevels();

        verify(userDao, times(2)).update(any(User.class));
    }
}

// 통합 테스트 — 느림, 실제 DB 사용
@SpringBootTest
@Transactional // 테스트 후 롤백
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserDao userDao;

    @Test
    void upgradeLevelsWithRealDb() {
        // 테스트 데이터 준비
        userDao.deleteAll();
        userDao.add(new User("1", "A", Level.BASIC, 50, 0));

        userService.upgradeLevels();

        User upgraded = userDao.get("1");
        assertThat(upgraded.getLevel()).isEqualTo(Level.SILVER);
    }
}

극한 시나리오

실제 Spring AOP가 동작하는 방식을 직접 구현:

// 부가 기능: 메서드 실행 시간 측정
public class PerformanceAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        StopWatch stopWatch = new StopWatch(invocation.getMethod().getName());
        stopWatch.start();

        try {
            return invocation.proceed(); // 실제 메서드 실행
        } finally {
            stopWatch.stop();
            if (stopWatch.getTotalTimeMillis() > 100) {
                log.warn("느린 메서드 감지: {} ({}ms)",
                    invocation.getMethod().getName(),
                    stopWatch.getTotalTimeMillis());
            }
        }
    }
}

// ProxyFactory로 프록시 생성
ProxyFactory factory = new ProxyFactory(new UserServiceImpl());
factory.addAdvice(new PerformanceAdvice());
UserService proxied = (UserService) factory.getProxy();

// 이것이 @Transactional의 내부 동작 원리

10. Spring 6 변경점 요약

항목 Spring 5 Spring 6
최소 Java 버전 Java 8 Java 17
Jakarta EE javax.* jakarta.*
HTTP Client RestTemplate (deprecated) WebClient, HTTP Interface
관찰성 직접 구현 Micrometer Tracing 통합
AOT/Native 제한적 GraalVM Native Image 공식 지원
Spring Security 5.x 6.x (SecurityFilterChain 방식)
최소 Tomcat Tomcat 9 Tomcat 10

11. 요약

개념 핵심 교훈 현대적 적용
관심사 분리 하나의 클래스는 하나의 책임 @Controller, @Service, @Repository
전략 패턴 변하는 것과 변하지 않는 것 분리 JdbcTemplate, TransactionTemplate
서비스 추상화 인터페이스로 기술 종속성 제거 PlatformTransactionManager
예외 전환 체크 예외 → 언체크 예외 DataAccessException 계층
템플릿 콜백 공통 코드를 템플릿으로 분리 JdbcTemplate, RestTemplate
Spring 6 Jakarta EE 9+, Java 17 최신 생태계 대응

카테고리:

업데이트:

댓글