서비스 작성 가이드
기본 원칙
- 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줄 이하 유지