Spring 6 이해와 원리
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을 “왜” 사용하는지 이해하려면 의존성 역전과 관심사 분리의 필요성부터 이해해야 한다.
실무에서 자주 하는 실수
-
인터페이스 없이 구체 클래스에 직접 의존 —
OrderService가EmailServiceImpl에 직접 의존하면 이메일 서비스 교체 시OrderService도 수정해야 한다.EmailService인터페이스에 의존하면 구현체를 자유롭게 교체할 수 있다(OCP 위반 방지). -
거대한 서비스 클래스 (God Object) — 하나의 서비스 클래스에 주문, 결제, 알림, 통계 로직이 모두 있으면 단일 책임 원칙(SRP)을 위반한다. 변경 이유가 여러 가지인 클래스는 반드시 분리해야 한다.
-
설정 클래스에서 비즈니스 로직 처리 —
@Configuration클래스에 비즈니스 로직을 넣거나,@Bean메서드 안에서 데이터 처리를 하면 관심사가 뒤섞인다. 설정 클래스는 순수하게 빈 등록만 담당해야 한다. -
상속으로 코드 재사용 시도 — 공통 로직을
BaseService로 만들어 상속받으면 부모-자식 결합도가 높아진다. 부모 변경이 모든 자식에 영향을 준다. 컴포지션(has-a)과 인터페이스 활용이 상속(is-a)보다 유연하다. -
테스트하기 어려운 코드를 “나중에” 리팩토링 계획 —
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)로 분리하면 각 레이어를 독립적으로 변경하고 테스트할 수 있다.
댓글