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 LR
    A["OCP: 확장에 열려있고, 변경에"] --> B[UserDao]
    A --> C["ConnectionMaker 인터"]
    B -->|"변경 없이"| D["새 DB로 전환 가능"]
    C -->|"새 구현체 추가"| E[NConnectionMaker]
    C -->|"새 구현체 추가"| F[TestConnMaker]
    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 LR
    A[UserService] -->|"의존"| B["PlatformTransactio"]
    C[DataSourceTxMgr] -->|"구현"| B
    D[JpaTxManager] -->|"구현"| B
    E[HibernateTxManager] -->|"구현"| B
    F[JtaTxManager] -->|"구현"| 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 LR
    A["DataAccessExceptio"] --> B["NonTransient"]
    A --> C["Transient"]
    B --> D["DuplicateKeyExcept"]
    E["MySQL 1062"] -->|"변환"| D

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[GraalVM] --> B[AOT]
    B --> C[NativeImage]
    C --> D[빠른시작]
    E[JVM] --> F[JIT]
    F --> G[긴시작]
// 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 LR
    A[SOLID] --> B["SRP: 레이어 분리"]
    A --> C["OCP: 인터페이스+DI"]
    A --> D["LSP: 구현체 계약 준수"]
    A --> E["ISP: DAO 인터페이스 분리"]
    A --> F["DIP: 추상화 의존"]

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

graph LR
    A["템플릿 콜백 패턴"] --> B[JdbcTemplate]
    A --> C[RestTemplate]
    A --> D[TxTemplate]
    B -->|"콜백"| H[RowMapper]
    C -->|"콜백"| I[RequestCallback]
    D -->|"콜백"| J[TxCallback]
// 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 최신 생태계 대응

왜 이 기술인가?

방식 결합도 테스트 용이성 유연성 적합한 상황
절차적 코드 (관심사 미분리) 높음 낮음 낮음 프로토타입, 1회성 스크립트
OOP + SOLID (수동 DI) 중간 중간 높음 프레임워크 없는 순수 Java
Spring IoC/DI + SOLID 낮음 높음 높음 실무 표준

결론: Spring의 IoC/DI는 SOLID 원칙, 특히 DIP(의존성 역전 원칙)와 OCP(개방-폐쇄 원칙)를 자동화한다. Spring을 “왜” 사용하는지 이해하려면 의존성 역전과 관심사 분리의 필요성부터 이해해야 한다.


실무에서 자주 하는 실수

  1. 인터페이스 없이 구체 클래스에 직접 의존OrderServiceEmailServiceImpl에 직접 의존하면 이메일 서비스 교체 시 OrderService도 수정해야 한다. EmailService 인터페이스에 의존하면 구현체를 자유롭게 교체할 수 있다(OCP 위반 방지).

  2. 거대한 서비스 클래스 (God Object) — 하나의 서비스 클래스에 주문, 결제, 알림, 통계 로직이 모두 있으면 단일 책임 원칙(SRP)을 위반한다. 변경 이유가 여러 가지인 클래스는 반드시 분리해야 한다.

  3. 설정 클래스에서 비즈니스 로직 처리@Configuration 클래스에 비즈니스 로직을 넣거나, @Bean 메서드 안에서 데이터 처리를 하면 관심사가 뒤섞인다. 설정 클래스는 순수하게 빈 등록만 담당해야 한다.

  4. 상속으로 코드 재사용 시도 — 공통 로직을 BaseService로 만들어 상속받으면 부모-자식 결합도가 높아진다. 부모 변경이 모든 자식에 영향을 준다. 컴포지션(has-a)과 인터페이스 활용이 상속(is-a)보다 유연하다.

  5. 테스트하기 어려운 코드를 “나중에” 리팩토링 계획new 직접 생성, 정적 메서드 의존, 전역 상태 사용은 단위 테스트를 어렵게 만든다. 테스트 작성이 어렵다면 그 자체가 설계 문제의 신호다. 코드를 먼저 올바르게 설계하면 테스트는 자연스럽게 쉬워진다.


면접 포인트

Q1. SOLID 원칙 중 Spring이 직접적으로 구현하는 원칙은?

DIP(의존성 역전 원칙): @Autowired로 구체 클래스 대신 인터페이스에 의존. Spring이 구현체를 주입한다. OCP(개방-폐쇄 원칙): @Conditional과 인터페이스 교체로 기존 코드 수정 없이 기능 확장. SRP는 개발자가 클래스 설계 시 직접 지켜야 한다.

Q2. IoC(제어의 역전)란 무엇인가?

전통적으로 객체가 자신의 의존성을 직접 생성(new)했다. IoC는 이 제어권을 컨테이너(Spring)로 넘기는 원칙이다. 객체는 의존성이 어떻게 생성되는지 알 필요 없이 외부(컨테이너)에서 주입받는다. 할리우드 원칙(“Don’t call us, we’ll call you”)으로도 불린다.

Q3. DI(의존성 주입)의 세 가지 방식과 권장 방식은?

생성자 주입(권장): 불변성, 테스트 용이성, 순환 참조 감지. Setter 주입: 선택적 의존성. 필드 주입(@Autowired): 간편하지만 테스트 어려움, 불변성 보장 불가. Spring 팀과 실무에서는 생성자 주입이 표준이다. Lombok의 @RequiredArgsConstructor로 간결하게 사용한다.

Q4. OCP(개방-폐쇄 원칙)를 Spring에서 어떻게 구현하는가?

인터페이스를 정의하고 @Bean 또는 @Component로 구현체를 등록한다. 새 기능이 필요하면 기존 코드를 수정하지 않고 새 구현체를 추가하고 빈으로 등록한다. @Conditional로 환경에 따라 다른 구현체를 선택적으로 활성화한다.

Q5. 관심사의 분리(SoC)가 실무에서 중요한 이유는?

DB 로직, 비즈니스 로직, HTTP 처리가 하나의 클래스에 있으면, DB를 교체하거나 HTTP 프레임워크를 변경할 때 비즈니스 로직도 함께 수정해야 한다. 관심사를 Repository(DB), Service(비즈니스), Controller(HTTP)로 분리하면 각 레이어를 독립적으로 변경하고 테스트할 수 있다.


함께 읽으면 좋은 글

카테고리:

업데이트:

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →