← Java 강의 목록으로
🌱
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:run
bash
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

자주 하는 실수

  1. 컨트롤러가 비즈니스 로직 다 처리 → 테스트 어려움
  2. 도메인을 그대로 노출 → 외부 API 가 내부 구조에 묶임
  3. `@Autowired` 필드 주입 사용 → 생성자 주입 사용
  4. 양방향 의존이 생긴 채 시작 → Spring 이 시작을 거부
  5. 서비스가 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 영속화를 추가합니다.

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗