🌱
Spring Boot
Spring Boot · 선수: 이전 단원
25. 서비스와 레이어
작은 예제에서는 컨트롤러 한 클래스에 모든 로직을 두어도 괜찮지만, 규모가 커지면 **계층 분리** 가 유지보수에 결정적입니다. Spring 진영의 관습적인 3 계층 구조와, 그 사이에서 안전하게 데이터를 전달하는 **DTO** 개념을 익힙니다.
Spring BootJavaREST서비스와 레이어
소요 시간
⏱ 약 1.5~2시간
난이도
📊 중급-고급
선수 조건
🎯 이전 단원 또는 동등 지식
결과물
작은 예제에서는 컨트롤러 한 클래스에 모든 로직을 두어도 괜찮지만, 규모가 커지면 **계층 분리** 가 유지보수에 결정적입니다. Spring 진영의 관습적인 3 계층 구조와, 그 사이에서 안전하게 데이터를 전달하는 **DTO** 개념을 익힙니다.
이 강의에서 배우는 것
- 1Controller / Service / Repository 의 역할 차이를 안다
- 2`@Service`, `@Repository` 어노테이션을 사용한다
- 3**생성자 주입(constructor injection)** 패턴을 안다
- 4DTO 와 도메인 객체를 구분한다
- 5"비즈니스 로직은 서비스" 라는 원칙을 적용한다
소개
작은 예제에서는 컨트롤러 한 클래스에 모든 로직을 두어도 괜찮지만, 규모가 커지면 **계층 분리** 가 유지보수에 결정적입니다. Spring 진영의 관습적인 3 계층 구조와, 그 사이에서 안전하게 데이터를 전달하는 **DTO** 개념을 익힙니다.
핵심 개념
1) 3 계층 그림
text
HTTP 요청 → Controller → Service → Repository → DB
(얇음) (두꺼움) (얇음)- Controller : HTTP 입출력만 (입력 검증·DTO 변환)
- Service : 비즈니스 로직 (검증·트랜잭션·도메인 행위)
- Repository : 데이터 저장소 접근 (DB · 인메모리)
2) 생성자 주입
java
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}- `final` 필드로 불변 보장
- 테스트 시 mock 주입이 쉬움
- 순환 의존이 있으면 컴파일/시작 시점에 노출됨
3) DTO vs 도메인
| 종류 | 위치 | 특징 |
|---|---|---|
| **도메인** | Service/Repository 안 | 비즈니스 행위 포함, 외부 노출 X |
| **DTO** | Controller 경계 | 입출력 모양에 맞춤, 직렬화 친화 |
도메인을 그대로 컨트롤러가 노출하면 내부 구조가 외부에 묶여 변화에 약합니다.
4) `@Repository`
java
@Repository
public class MemberRepository { ... }`@Service` / `@Repository` / `@Controller` 는 의미만 다른 `@Component` 입니다. Spring 이 컴포넌트 스캔으로 자동 등록합니다.
핵심 예제
예제 1 — `Member.java` : 도메인 객체
java
package com.codingnow.lecture.spring25.domain;
public class Member {
private Long id;
private String name;
private String email;
public Member(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters...
}예제 2 — `MemberRepository.java` : 메모리 저장소
java
@Repository
public class MemberRepository {
private final Map<Long, Member> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
public Member save(Member m) { ... }
public Optional<Member> findById(Long id) { ... }
public List<Member> findAll() { ... }
public boolean deleteById(Long id) { ... }
}예제 3 — `MemberService.java` : 비즈니스 로직
java
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) { this.repository = repository; }
public MemberResponse register(MemberCreateRequest req) {
if (req.name() == null || req.name().isBlank())
throw new IllegalArgumentException("name required");
Member saved = repository.save(new Member(null, req.name(), req.email()));
return MemberResponse.from(saved);
}
}예제 4 — `MemberController.java` : DTO 사용
java
@RestController
@RequestMapping("/api/members")
public class MemberController {
private final MemberService service;
public MemberController(MemberService service) { this.service = service; }
@PostMapping
public MemberResponse create(@RequestBody MemberCreateRequest req) {
return service.register(req);
}
@GetMapping
public List<MemberResponse> all() { return service.findAll(); }
}실행
bash
cd 06_Spring_Boot/25_서비스와_레이어
mvn spring-boot:runbash
curl -X POST http://localhost:8085/api/members \
-H "Content-Type: application/json" \
-d '{"name":"지수","email":"jisu@codingnow.com"}'
# {"id":1,"name":"지수","email":"jisu@codingnow.com"}
curl http://localhost:8085/api/members
# [{"id":1,"name":"지수","email":"jisu@codingnow.com"}]전체 예제 코드 (src/)
src/main/java/com/codingnow/lecture/spring25/Application.java
java
package com.codingnow.lecture.spring25;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
src/main/java/com/codingnow/lecture/spring25/controller/MemberController.java
java
package com.codingnow.lecture.spring25.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.codingnow.lecture.spring25.dto.MemberCreateRequest;
import com.codingnow.lecture.spring25.dto.MemberResponse;
import com.codingnow.lecture.spring25.service.MemberService;
@RestController
@RequestMapping("/api/members")
public class MemberController {
private final MemberService service;
public MemberController(MemberService service) {
this.service = service;
}
@PostMapping
public MemberResponse create(@RequestBody MemberCreateRequest req) {
return service.register(req);
}
@GetMapping
public List<MemberResponse> all() {
return service.findAll();
}
}
src/main/java/com/codingnow/lecture/spring25/domain/Member.java
java
package com.codingnow.lecture.spring25.domain;
/** 회원 도메인 객체. */
public class Member {
private Long id;
private final String name;
private final String email;
public Member(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public void assignId(Long id) {
this.id = id;
}
}
src/main/java/com/codingnow/lecture/spring25/dto/MemberCreateRequest.java
java
package com.codingnow.lecture.spring25.dto;
/** 회원 가입 요청 DTO. */
public record MemberCreateRequest(String name, String email) {}
src/main/java/com/codingnow/lecture/spring25/dto/MemberResponse.java
java
package com.codingnow.lecture.spring25.dto;
import com.codingnow.lecture.spring25.domain.Member;
/** 회원 응답 DTO. */
public record MemberResponse(Long id, String name, String email) {
public static MemberResponse from(Member m) {
return new MemberResponse(m.getId(), m.getName(), m.getEmail());
}
}
src/main/java/com/codingnow/lecture/spring25/repository/MemberRepository.java
java
package com.codingnow.lecture.spring25.repository;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
import com.codingnow.lecture.spring25.domain.Member;
/** 메모리 기반 회원 저장소. */
@Repository
public class MemberRepository {
private final Map<Long, Member> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
public Member save(Member m) {
if (m.getId() == null) m.assignId(seq.incrementAndGet());
store.put(m.getId(), m);
return m;
}
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public List<Member> findAll() {
return List.copyOf(store.values());
}
public boolean deleteById(Long id) {
return store.remove(id) != null;
}
}
src/main/java/com/codingnow/lecture/spring25/service/MemberService.java
java
package com.codingnow.lecture.spring25.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.codingnow.lecture.spring25.domain.Member;
import com.codingnow.lecture.spring25.dto.MemberCreateRequest;
import com.codingnow.lecture.spring25.dto.MemberResponse;
import com.codingnow.lecture.spring25.repository.MemberRepository;
/** 회원 비즈니스 로직. */
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
public MemberResponse register(MemberCreateRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new IllegalArgumentException("name required");
}
Member saved = repository.save(new Member(null, req.name(), req.email()));
return MemberResponse.from(saved);
}
public List<MemberResponse> findAll() {
return repository.findAll().stream().map(MemberResponse::from).toList();
}
}
src/main/resources/application.properties
properties
server.port=8085
spring.application.name=lecture-spring25
자주 하는 실수
- 컨트롤러가 비즈니스 로직 다 처리 → 테스트 어려움
- 도메인을 그대로 노출 → 외부 API 가 내부 구조에 묶임
- `@Autowired` 필드 주입 사용 → 생성자 주입 사용
- 양방향 의존이 생긴 채 시작 → Spring 이 시작을 거부
- 서비스가 HTTP 정보를 알게 됨 → 계층 침범
정리
- 3 계층 분리는 규모가 커질수록 효과 큼
- DTO 로 경계를 만들면 변화에 강해짐
- 의존은 생성자 주입 + final 권장
과제
# 과제 - 25. 서비스와 레이어
## 문제 — `Product` 등록·조회 API
- 위치: `answer/`
- 핵심 개념: 3 계층 분리, DTO 변환, 생성자 주입
요구사항
- 도메인: `Product(id, name, price)`
- DTO 입력: `ProductCreateRequest(name, price)`, 출력: `ProductResponse(id, name, price)`
- 서비스 검증: `price` 가 0 이하면 `IllegalArgumentException`
- 컨트롤러: `/api/products` 에 GET(list), POST(create)
- 저장: in-memory (`ConcurrentHashMap`)
예상 동작
bash
$ curl -X POST localhost:8086/api/products -H "Content-Type: application/json" \
-d '{"name":"Pen","price":1500}'
{"id":1,"name":"Pen","price":1500}
$ curl localhost:8086/api/products
[{"id":1,"name":"Pen","price":1500}]## 정답 확인 [`answer/`](./answer/) 폴더의 Maven 프로젝트를 보세요.
정답 코드 (homework/answer/)
answer/pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.6</version>
<relativePath/>
</parent>
<groupId>com.codingnow</groupId>
<artifactId>lecture-spring25-hw</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
answer/src/main/java/com/codingnow/lecture/spring25hw/Application.java
java
package com.codingnow.lecture.spring25hw;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
answer/src/main/java/com/codingnow/lecture/spring25hw/controller/ProductController.java
java
package com.codingnow.lecture.spring25hw.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.codingnow.lecture.spring25hw.dto.ProductCreateRequest;
import com.codingnow.lecture.spring25hw.dto.ProductResponse;
import com.codingnow.lecture.spring25hw.service.ProductService;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@PostMapping
public ProductResponse create(@RequestBody ProductCreateRequest req) {
return service.create(req);
}
@GetMapping
public List<ProductResponse> all() {
return service.all();
}
}
answer/src/main/java/com/codingnow/lecture/spring25hw/domain/Product.java
java
package com.codingnow.lecture.spring25hw.domain;
public class Product {
private Long id;
private final String name;
private final long price;
public Product(Long id, String name, long price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() { return id; }
public String getName() { return name; }
public long getPrice() { return price; }
public void assignId(Long id) { this.id = id; }
}
answer/src/main/java/com/codingnow/lecture/spring25hw/dto/ProductCreateRequest.java
java
package com.codingnow.lecture.spring25hw.dto;
public record ProductCreateRequest(String name, long price) {}
answer/src/main/java/com/codingnow/lecture/spring25hw/dto/ProductResponse.java
java
package com.codingnow.lecture.spring25hw.dto;
import com.codingnow.lecture.spring25hw.domain.Product;
public record ProductResponse(Long id, String name, long price) {
public static ProductResponse from(Product p) {
return new ProductResponse(p.getId(), p.getName(), p.getPrice());
}
}
answer/src/main/java/com/codingnow/lecture/spring25hw/repository/ProductRepository.java
java
package com.codingnow.lecture.spring25hw.repository;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
import com.codingnow.lecture.spring25hw.domain.Product;
@Repository
public class ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
public Product save(Product p) {
if (p.getId() == null) p.assignId(seq.incrementAndGet());
store.put(p.getId(), p);
return p;
}
public List<Product> findAll() {
return List.copyOf(store.values());
}
}
answer/src/main/java/com/codingnow/lecture/spring25hw/service/ProductService.java
java
package com.codingnow.lecture.spring25hw.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.codingnow.lecture.spring25hw.domain.Product;
import com.codingnow.lecture.spring25hw.dto.ProductCreateRequest;
import com.codingnow.lecture.spring25hw.dto.ProductResponse;
import com.codingnow.lecture.spring25hw.repository.ProductRepository;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public ProductResponse create(ProductCreateRequest req) {
if (req.price() <= 0) throw new IllegalArgumentException("price > 0");
Product saved = repository.save(new Product(null, req.name(), req.price()));
return ProductResponse.from(saved);
}
public List<ProductResponse> all() {
return repository.findAll().stream().map(ProductResponse::from).toList();
}
}
answer/src/main/resources/application.properties
properties
server.port=8086
spring.application.name=lecture-spring25-hw
직접 해 보기
bash
cd 06_Spring_Boot/25_서비스와_레이어
mvn spring-boot:run다음 단원
[26_데이터_저장](../26_데이터_저장/) — Spring Data JPA 와 H2 로 실제 DB 영속화를 추가합니다.