개발 가이드 백엔드 서비스 작성 가이드
최종 수정:

서비스 작성 가이드

Spring Service 레이어 작성 규칙 및 트랜잭션 관리

서비스 작성 가이드

기본 원칙

  • Interface 작성 안 함 (단일 구현체 기준)
  • @Service, @RequiredArgsConstructor, @Slf4j 필수 어노테이션
  • 클래스 레벨에 @Transactional(readOnly = true) 기본 적용
  • 쓰기 메서드에만 @Transactional 별도 선언
  • null 반환 금지 — Optional 또는 예외 처리

기본 구조

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class PostService {

    private final PostRepository postRepository;

    // 쓰기 작업 — 별도 @Transactional 선언
    @Transactional
    public PostDetailRes regPost(PostCreateReq req) {
        log.info("노트 등록: title={}", req.getTitle());
        Post post = Post.builder()
                .title(req.getTitle())
                .content(req.getContent())
                .category(req.getCategory())
                .build();
        Post saved = postRepository.save(post);
        return PostDetailRes.from(saved);
    }

    // 읽기 작업 — readOnly 상속
    public PostDetailRes getPost(Long id) {
        return postRepository.findById(id)
                .map(PostDetailRes::from)
                .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다: " + id));
    }

    @Transactional
    public PostDetailRes uptPost(Long id, PostUpdateReq req) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다: " + id));
        post.update(req.getTitle(), req.getContent());
        return PostDetailRes.from(post);
    }

    @Transactional
    public void delPost(Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다: " + id));
        postRepository.delete(post);
    }
}

트랜잭션 관리 규칙

@Service
@Transactional(readOnly = true)     // 클래스 기본값: 읽기 전용
public class DigestNewsService {

    @Transactional                   // 쓰기 작업에만 별도 선언
    public void saveDigest(DigestNews news) { ... }

    // readOnly = true 상속
    public List<DigestNewsListRes> getDigestList() { ... }

    @Transactional(rollbackFor = Exception.class)   // 체크 예외도 롤백
    public void processAndSave() { ... }
}
  • 트랜잭션 경계는 Service 레이어에서만 관리
  • 외부 API 호출(Claude AI, Slack 등)은 트랜잭션 범위 밖에서 수행
  • 체크 예외 롤백이 필요한 경우 rollbackFor = Exception.class 명시

외부 API 호출 패턴

@Service
@RequiredArgsConstructor
@Slf4j
public class DigestOrchestrator {

    private final AiProcessingService aiProcessingService;
    private final DigestNewsService digestNewsService;
    private final MailService mailService;

    // 트랜잭션 없이 실행 — 외부 API 포함
    public void runDigest() {
        try {
            // 1. AI 처리 (외부 Claude API 호출)
            List<DigestNews> digests = aiProcessingService.process();

            // 2. DB 저장 (별도 트랜잭션)
            digestNewsService.saveAll(digests);

            // 3. 메일 발송 (외부 SMTP)
            mailService.sendDigest(digests);

        } catch (Exception e) {
            log.error("다이제스트 처리 실패", e);
            throw new IllegalStateException("다이제스트 처리 중 오류 발생", e);
        }
    }
}

Entity → DTO 변환 패턴

변환 로직은 VO 클래스의 정적 팩터리 메서드에 위치한다.

// PostDetailRes.java
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PostDetailRes {
    private Long id;
    private String title;
    private String content;
    private String category;
    private LocalDateTime createdAt;

    // Entity → VO 변환 메서드
    public static PostDetailRes from(Post post) {
        return PostDetailRes.builder()
                .id(post.getId())
                .title(post.getTitle())
                .content(post.getContent())
                .category(post.getCategory())
                .createdAt(post.getFrstRegistDt())
                .build();
    }
}

Service에서 호출:

return postRepository.findById(id)
        .map(PostDetailRes::from)
        .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다: " + id));

페이징 처리

public Page<PostDetailRes> getPostPage(int page, String category) {
    Pageable pageable = PageRequest.of(page, PagingConstant.DEFAULT_SIZE,
            Sort.by("frstRegistDt").descending());

    if (category != null && !category.isBlank()) {
        return postRepository.findByCategory(category, pageable)
                .map(PostDetailRes::from);
    }
    return postRepository.findAll(pageable)
            .map(PostDetailRes::from);
}

서비스 체크리스트

  • [ ] Interface 없이 구현 클래스만 작성
  • [ ] @Service, @RequiredArgsConstructor, @Slf4j 선언
  • [ ] 클래스 레벨 @Transactional(readOnly = true) 적용
  • [ ] 쓰기 메서드에 @Transactional 별도 선언
  • [ ] null 반환 없음 (Optional 또는 예외 처리)
  • [ ] 외부 API 호출은 트랜잭션 범위 밖에서 수행
  • [ ] 메서드 30줄 이하 유지
AI 문서 검색

현재 페이지 내용을 기반으로 질문하세요.