아래 코드는 “Author 도메인”을 예시로 해서, 스프링 서비스 레이어에서 자주 다루는 주제(의존성 주입, 레포지토리 교체, DTO↔Entity 변환, 예외 처리, 스트림 변환)를 한 파일 안에서 실습/정리해둔 형태다.
AuthorService는 Controller에서 넘어온 요청 DTO를 받아서,
즉, “컨트롤러는 요청/응답”, “레포지토리는 저장소 접근”, “서비스는 그 사이의 비즈니스 규칙과 흐름”을 담당한다.
클래스 상단의 @Service는 컴포넌트 스캔 대상으로 등록되어 스프링 컨테이너가 빈으로 관리한다.
기본 스코프가 싱글톤이기 때문에 애플리케이션 컨텍스트 안에서 1개 인스턴스로 관리되며, 여러 요청이 들어와도 같은 서비스 인스턴스가 재사용되는 구조를 전제로 설계한다. developer-jinnie.tistory
이 전제 때문에 서비스는 “요청마다 변하는 상태(state)”를 내부 필드로 들고 있지 않도록 설계하는 습관이 중요하다(스레드 안정성 관점).
주석에서 크게 3가지를 비교하고 있는데, 실무 관점에서도 거의 동일하게 정리된다.
@Autowired
private AuthorMemoryRepository authorRepository;
final 적용이 어렵고(생성 시점 강제 주입이 불가), 테스트/리팩토링 관점에서 불리하다는 이유로 보통 “실습용/레거시”로 취급한다. developer-jinnie.tistory현재 코드가 채택한 방식.
private final AuthorMybatisRepository authorRepository;
@Autowired
public AuthorService(AuthorMybatisRepository authorRepository) {
this.authorRepository = authorRepository;
}
final로 “필수 의존성”을 강제할 수 있어 불완전한 객체 생성 방지에 유리하다.@Autowired는 생략 가능한 패턴으로 많이 쓴다.주석에 적어둔 것처럼 final(또는 @NonNull) 필드를 대상으로 생성자를 자동 생성해서 생성자 주입을 더 간단하게 만든다.
다만 코드에서 “다형성 설계가 불가”라고 적어둔 부분은 표현이 조금 애매한데, 실제로는 필드 타입을 interface로 두면 @RequiredArgsConstructor로도 다형성 설계가 가능하다(결국 “필드 타입을 무엇으로 잡느냐” 문제).
코드가 의도하는 학습 포인트는 “서비스 로직은 유지한 채, 저장 방식만 바꿔끼울 수 있다”는 것.
AuthorMemoryRepository (in-memory)AuthorJdbcRepository (JDBC)AuthorMybatisRepository (MyBatis)지금은 구현체 타입을 직접 바꿔 끼우는 형태로 실습했는데, 주석에서도 말하듯 진짜 다형성 설계를 하려면 보통 아래처럼 간다.
AuthorRepository(interface) 정의AuthorJdbcRepositoryImpl, AuthorMybatisRepositoryImpl 같은 구현체를 @Repository로 등록이렇게 하면 Service는 구현체 변경에 더 둔감해지고(결합도 감소), 테스트 더블(mock)도 쉬워진다.
save() 메서드 주석이 가장 풍부한데, 정리하면 아래 3가지다.
Author author = new Author(null, dto.getName(), dto.getEmail(), dto.getPassword());
Author author = Author.builder()
.email(dto.getEmail())
.name(dto.getName())
.password(dto.getPassword())
.build();
코드 최종 형태는 DTO가 변환 책임을 갖는 방식.
Author author = dto.toEntity();
fromEntity는 “DTO 인스턴스가 아직 없으니 static 메서드로 설계”라고 주석에 적었는데, 이건 실무에서도 흔한 패턴이다.이 서비스는 예외를 “잡아서 처리”하지 않고, 서비스 정책에 맞게 던지고, 상위(Controller)나 전역 예외 처리에서 일괄 처리하도록 설계했다는 점이 핵심이다.
if (authorRepository.findByEmail(author.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
Author author = authorRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Entity is not found"));
authorRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Entity is not found"));
authorRepository.delete(id);
주석 그대로 요약하면:
최종 코드는 stream으로 정리돼 있다.
return authorRepository.findAll().stream()
.map(a -> AuthorListDto.fromEntity(a))
.collect(Collectors.toList());
여기서 학습 포인트는 2개: