생성자 주입과 의존성 방향
StudySessionService가 ConcurrentHashMap이나 JPA 클래스를 직접 사용하면 저장 기술을 바꿀 때 업무 코드도 함께 고쳐야 합니다. 서비스가 정말 필요로 하는 저장·조회 기능만 interface로 표현하고, 메모리나 데이터베이스 구현은 그 계약 바깥에 둡니다.
이때 interface는 막연한 "추상화"가 아니라 업무 코드가 외부에 요구하는 **포트(port)**입니다. 포트를 구현해 기술 세계와 연결하는 객체를 **어댑터(adapter)**라고 부릅니다.
필수 협력자는 생성자에서 드러나야 한다
생성자 주입은 객체가 정상 동작하는 데 필요한 협력자를 생성 시점에 강제합니다. 필드 주입처럼 숨겨진 가변 상태가 없고, 순수 Java 코드에서도 필요한 가짜 구현을 바로 전달할 수 있습니다.
의존성 화살표는 Service → Repository interface ← Adapter가 됩니다. 런타임에는 어댑터 객체가 서비스 안으로 들어오지만, 소스 코드의 업무 계층은 구체 기술을 import하지 않습니다.
포트와 인메모리 어댑터를 연결한다
먼저 이후 장에서도 계속 사용할 StudySession 모델과 저장소 포트를 정의합니다. 첫 구현은 데이터베이스 설정 없이 동작을 확인할 수 있는 인메모리 어댑터입니다.
package com.andongmin.studylog.session;
public enum SessionStatus {
PLANNED,
COMPLETED,
CANCELLED
}package com.andongmin.studylog.session;
import java.time.LocalDate;
import java.util.UUID;
public record StudySession(
UUID id,
String subject,
String topic,
LocalDate studyDate,
int minutes,
SessionStatus status,
long version) {
public StudySession {
if (id == null || subject == null || subject.isBlank()
|| topic == null || topic.isBlank() || studyDate == null) {
throw new IllegalArgumentException("required field is missing");
}
if (minutes < 1 || minutes > 1440) {
throw new IllegalArgumentException("minutes must be between 1 and 1440");
}
}
}package com.andongmin.studylog.session;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface StudySessionRepository {
StudySession save(StudySession session);
Optional<StudySession> findById(UUID id);
List<StudySession> findAll();
}package com.andongmin.studylog.session;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryStudySessionRepository implements StudySessionRepository {
private final Map<UUID, StudySession> sessions = new ConcurrentHashMap<>();
@Override
public StudySession save(StudySession session) {
sessions.put(session.id(), session);
return session;
}
@Override
public Optional<StudySession> findById(UUID id) {
return Optional.ofNullable(sessions.get(id));
}
@Override
public List<StudySession> findAll() {
return List.copyOf(sessions.values());
}
}package com.andongmin.studylog.session;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class StudySessionService {
private final StudySessionRepository repository;
public StudySessionService(StudySessionRepository repository) {
this.repository = repository;
}
public List<StudySession> findAll() {
return repository.findAll();
}
}@Repository가 붙은 어댑터는 컴포넌트 스캔으로 Bean이 되고, 구현하는 interface가 하나뿐이므로 컨테이너는 StudySessionService 생성자에 이 객체를 주입합니다. List.copyOf는 저장소 내부 컬렉션을 호출자에게 그대로 노출하지 않습니다.
구현 교체가 서비스 코드를 흔들지 않는지 확인한다
컴파일 후 StudySessionService의 import를 살펴보세요. ConcurrentHashMap이나 Spring Data 타입이 없고 StudySessionRepository만 참조한다면 경계가 지켜진 것입니다. 3장에서 JPA 어댑터를 추가할 때도 서비스의 생성자 계약은 유지됩니다.
./mvnw -q -DskipTests compileService가 구체 저장소가 아닌 StudySessionRepository 포트만 참조한 상태로 컴파일되어야 합니다.포트에 특정 기술의 페이지 타입이나 SQL 조건 객체를 넣으면 interface만 분리했을 뿐 기술 의존성은 남습니다. 메서드 이름과 값 타입이 업무 언어로 설명되는지 확인하는 것이 중요합니다.
다음 절에서는 우리 클래스와 외부 라이브러리 객체를 각각 어떤 방식으로 Bean 등록할지 선택합니다.