[Spring] Author API 실습: Service 계층의 역할

이지연·2026년 1월 20일

아래 코드는 “Author 도메인”을 예시로 해서, 스프링 서비스 레이어에서 자주 다루는 주제(의존성 주입, 레포지토리 교체, DTO↔Entity 변환, 예외 처리, 스트림 변환)를 한 파일 안에서 실습/정리해둔 형태다.


AuthorService의 역할(서비스 레이어의 책임)

AuthorService는 Controller에서 넘어온 요청 DTO를 받아서,

  • DTO를 Domain(Entity)로 조립하고
  • Repository를 통해 저장/조회/삭제 같은 영속화 작업을 수행한 뒤
  • 결과를 다시 DTO로 변환해 Controller로 반환한다.

즉, “컨트롤러는 요청/응답”, “레포지토리는 저장소 접근”, “서비스는 그 사이의 비즈니스 규칙과 흐름”을 담당한다.


@Service와 싱글톤 스코프

클래스 상단의 @Service는 컴포넌트 스캔 대상으로 등록되어 스프링 컨테이너가 빈으로 관리한다.
기본 스코프가 싱글톤이기 때문에 애플리케이션 컨텍스트 안에서 1개 인스턴스로 관리되며, 여러 요청이 들어와도 같은 서비스 인스턴스가 재사용되는 구조를 전제로 설계한다. developer-jinnie.tistory

이 전제 때문에 서비스는 “요청마다 변하는 상태(state)”를 내부 필드로 들고 있지 않도록 설계하는 습관이 중요하다(스레드 안정성 관점).


DI(의존성 주입) 3가지 방식 정리

주석에서 크게 3가지를 비교하고 있는데, 실무 관점에서도 거의 동일하게 정리된다.

1) 필드 주입(@Autowired)

@Autowired
private AuthorMemoryRepository authorRepository;
  • 간단하지만 final 적용이 어렵고(생성 시점 강제 주입이 불가), 테스트/리팩토링 관점에서 불리하다는 이유로 보통 “실습용/레거시”로 취급한다. developer-jinnie.tistory

2) 생성자 주입(권장)

현재 코드가 채택한 방식.

private final AuthorMybatisRepository authorRepository;

@Autowired
public AuthorService(AuthorMybatisRepository authorRepository) {
    this.authorRepository = authorRepository;
}
  • final로 “필수 의존성”을 강제할 수 있어 불완전한 객체 생성 방지에 유리하다.
  • 주석에서 말한 “서비스 메서드 호출할 때마다 new로 레포지토리를 만들면 데이터가 리셋되는 문제”도 DI로 자연스럽게 해결된다(스프링이 관리하는 인스턴스를 주입받아 재사용).
  • 생성자가 1개라면 @Autowired는 생략 가능한 패턴으로 많이 쓴다.

3) Lombok @RequiredArgsConstructor

주석에 적어둔 것처럼 final(또는 @NonNull) 필드를 대상으로 생성자를 자동 생성해서 생성자 주입을 더 간단하게 만든다.
다만 코드에서 “다형성 설계가 불가”라고 적어둔 부분은 표현이 조금 애매한데, 실제로는 필드 타입을 interface로 두면 @RequiredArgsConstructor로도 다형성 설계가 가능하다(결국 “필드 타입을 무엇으로 잡느냐” 문제).


Repository 교체 실습 포인트

코드가 의도하는 학습 포인트는 “서비스 로직은 유지한 채, 저장 방식만 바꿔끼울 수 있다”는 것.

  • AuthorMemoryRepository (in-memory)
  • AuthorJdbcRepository (JDBC)
  • AuthorMybatisRepository (MyBatis)
  • (추후) JPA로 교체 가능

지금은 구현체 타입을 직접 바꿔 끼우는 형태로 실습했는데, 주석에서도 말하듯 진짜 다형성 설계를 하려면 보통 아래처럼 간다.

  • AuthorRepository(interface) 정의
  • AuthorJdbcRepositoryImpl, AuthorMybatisRepositoryImpl 같은 구현체를 @Repository로 등록
  • 스프링이 주입해주도록 구성

이렇게 하면 Service는 구현체 변경에 더 둔감해지고(결합도 감소), 테스트 더블(mock)도 쉬워진다.


DTO → Entity 조립 전략(객체 조립 방식)

save() 메서드 주석이 가장 풍부한데, 정리하면 아래 3가지다.

1) 생성자로 직접 조립

Author author = new Author(null, dto.getName(), dto.getEmail(), dto.getPassword());
  • 간단하지만 파라미터가 늘어날수록 생성자 오버로딩이 증가하거나, 순서 실수 위험이 커진다.

2) Builder 패턴

Author author = Author.builder()
    .email(dto.getEmail())
    .name(dto.getName())
    .password(dto.getPassword())
    .build();
  • 매개변수 순서/선택에 유연하고 가독성이 좋다.
  • 다만 빌더 역시 결국 “필드가 많아지면 많아지는대로 코드가 길어진다”.

3) toEntity / fromEntity 패턴(추천한 형태)

코드 최종 형태는 DTO가 변환 책임을 갖는 방식.

Author author = dto.toEntity();
  • 변환 로직을 DTO로 “공통화”해두면 서비스 메서드가 깔끔해지고, 변환 규칙이 한 곳에 모인다.
  • fromEntity는 “DTO 인스턴스가 아직 없으니 static 메서드로 설계”라고 주석에 적었는데, 이건 실무에서도 흔한 패턴이다.

예외 처리 흐름(서비스 정책을 코드로 고정)

이 서비스는 예외를 “잡아서 처리”하지 않고, 서비스 정책에 맞게 던지고, 상위(Controller)나 전역 예외 처리에서 일괄 처리하도록 설계했다는 점이 핵심이다.

save(): 이메일 중복 정책

if (authorRepository.findByEmail(author.getEmail()).isPresent()) {
    throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
  • “중복이면 저장하지 않는다”는 서비스 정책이 명확히 코드에 남는다.

findById(): 없으면 예외

Author author = authorRepository.findById(id)
    .orElseThrow(() -> new NoSuchElementException("Entity is not found"));
  • Optional을 서비스에서 해소하면서 “없으면 예외”라는 정책을 강제한다.

delete(): 2-step 삭제

authorRepository.findById(id)
    .orElseThrow(() -> new NoSuchElementException("Entity is not found"));

authorRepository.delete(id);

주석 그대로 요약하면:

  • 바로 delete를 날리면(방법 1) DB 에러가 나고 그 부담이 DB/서버에 그대로 간다.
  • 먼저 존재 확인 후 삭제(방법 2)를 하면 “존재하지 않는 요청을 명확히 예외로 처리”할 수 있고, DB 레벨 에러를 줄일 수 있다.

findAll(): 엔티티 리스트를 DTO 리스트로 변환

최종 코드는 stream으로 정리돼 있다.

return authorRepository.findAll().stream()
    .map(a -> AuthorListDto.fromEntity(a))
    .collect(Collectors.toList());

여기서 학습 포인트는 2개:

  • “서비스는 엔티티를 그대로 반환하지 않고, 화면/응답에 맞는 DTO로 변환해서 반환”한다.
  • 반복 변환은 stream으로 간결하게 만들 수 있다.
profile
Eazy하게

0개의 댓글