Spring Boot

Spring Boot 계층형 구조(Controller/Service/Repository) 잘 나누는 법 — 책임 분리와 DTO/엔티티 경계

IT Lab 2026. 3. 11. 10:00

Spring Boot 3에서 Controller/Service/Repository를 깔끔하게 분리하는 기준과 DTO/엔티티 경계, 서비스 레이어 규칙을 실무 관점에서 정리합니다.

도입 (문제 상황)

기능이 몇 개 없을 때는 Controller에서 Repository를 바로 호출해도 잘 돌아가지만, 요구사항이 늘어나면 “이 로직은 어디에 둬야 하지?”가 빠르게 문제가 됩니다. 특히 DTO와 엔티티를 섞어 쓰기 시작하면, 작은 변경에도 여러 계층이 같이 흔들리면서 유지보수가 어려워져요.

핵심 개념: Spring Boot 레이어드 아키텍처에서 “경계”를 지키는 기준

Controller/Service/Repository를 나누는 목적은 “코드를 예쁘게 분류”하는 게 아니라 변경의 파급 범위를 줄이는 것입니다. 각 계층이 책임을 넘지 않도록 경계를 세워두면, 요구사항이 커져도 구조가 무너지지 않습니다.

Spring Boot Controller 책임: HTTP를 도메인 호출로 번역하기

Controller는 웹 어댑터입니다. 아래 역할에 집중하면 됩니다.

  • 요청을 DTO로 받기(@RequestBody, @ModelAttribute)
  • 검증(Validation)과 인증/인가 결과를 바탕으로 서비스 호출
  • 서비스 결과를 응답 DTO로 변환해 반환
  • HTTP 상태 코드/헤더/에러 응답 형태 결정

반대로, Controller가 하면 곤란한 일은 다음과 같습니다.

  • 트랜잭션 경계 관리(대부분 Service에서)
  • 비즈니스 규칙(“재고가 0이면 주문 불가” 같은 규칙)
  • JPA 엔티티를 그대로 응답으로 내보내기(지연 로딩, 순환 참조, 내부 필드 노출 위험)

Spring Boot Service 책임: 유스케이스(업무 흐름)와 규칙의 집합

Service는 “무엇을 할지”를 표현하는 계층입니다. 흔히 유스케이스 단위로 메서드가 생기고, 내부에서 여러 Repository/외부 연동을 조합합니다.

  • 비즈니스 규칙과 흐름(유스케이스) 구현
  • 트랜잭션 경계 설정(@Transactional)
  • 도메인 모델(엔티티) 조작 및 상태 변경
  • 외부 시스템 호출이 있다면 실패/재시도/보상 같은 정책을 한 곳에 모으기

Service 레이어 규칙을 한 문장으로 정리하면 이렇습니다.
“Controller는 얇게, Service는 규칙을 갖고, Repository는 저장만 한다.”

Spring Boot Repository 책임: 저장소 접근을 캡슐화하기

Repository는 데이터 접근을 숨기는 레이어입니다.

  • 엔티티 단위 CRUD, 조회 쿼리 제공
  • 쿼리 최적화(fetch join, projection 등) 책임
  • 비즈니스 규칙을 넣지 않기(“삭제 가능 여부 판단” 같은 로직은 Service로)

DTO/엔티티 경계: “택배 상자(DTO)”와 “실제 상품(엔티티)”를 섞지 않기

DTO는 계층 사이를 오가는 “운반용 상자”이고, 엔티티는 “실제 도메인 상태”입니다. 상자(DTO)를 그대로 창고(Repository/JPA)에 넣거나, 상품(엔티티)을 그대로 고객(HTTP 응답)에 보내면 문제가 생깁니다.

  • 엔티티를 API 응답으로 직접 노출하면
    • 내부 필드가 새는 보안 이슈
    • LAZY 로딩으로 N+1/예외가 발생
    • 양방향 연관관계로 JSON 무한 루프
  • 반대로 요청 DTO를 엔티티처럼 여기면
    • 검증/정규화/규칙 적용이 누락되기 쉽고
    • “API 스펙 변경”이 “도메인 변경”으로 전염됩니다

한눈에 비교: 계층별 책임과 금지사항

계층 주 책임 주로 다루는 타입 하면 안 되는 것(대표)
Controller HTTP ↔ 유스케이스 연결 Request/Response DTO 비즈니스 규칙, 엔티티 직접 반환
Service 유스케이스/규칙/트랜잭션 엔티티, 도메인 값 객체 HTTP 세부사항(상태코드 등), DB 쿼리 최적화 세부
Repository 데이터 접근/쿼리 엔티티, Projection 비즈니스 규칙, DTO 중심 설계(무분별한 DTO 저장)

코드 예제: 회원 가입/조회로 보는 Controller-Service-Repository 분리 (Spring Boot 3.x)

아래 예제는 “회원 가입”과 “회원 단건 조회”를 통해 DTO/엔티티 경계와 서비스 규칙을 보여줍니다. 그대로 복붙해서 실행할 수 있게 H2 기준으로 구성했습니다.

// build.gradle (Gradle Groovy)
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }
# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug

1) 엔티티: 도메인 상태와 규칙은 엔티티/서비스에서 관리

package com.example.demo.member;

import jakarta.persistence.*;

@Entity
@Table(name = "members", uniqueConstraints = {
        @UniqueConstraint(name = "uk_member_email", columnNames = "email")
})
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String email;

    @Column(nullable = false, length = 30)
    private String name;

    protected Member() { }

    private Member(String email, String name) {
        this.email = email;
        this.name = name;
    }

    public static Member create(String email, String name) {
        // 엔티티는 "상태를 가진 객체"이므로 생성 시점 불변조건을 지키는 데 유리합니다.
        return new Member(email, name);
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public String getName() { return name; }
}

2) DTO: API 스펙은 DTO가 책임지고, 엔티티와 분리

package com.example.demo.member.api;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class MemberDtos {

    public record CreateRequest(
            @NotBlank @Email String email,
            @NotBlank @Size(max = 30) String name
    ) {}

    public record CreateResponse(Long id) {}

    public record DetailResponse(Long id, String email, String name) {}
}

3) Repository: 저장/조회만 담당

package com.example.demo.member;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}

4) Service: 유스케이스 + 규칙 + 트랜잭션 경계

package com.example.demo.member;

import com.example.demo.member.api.MemberDtos;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public MemberDtos.CreateResponse signUp(MemberDtos.CreateRequest req) {
        // 비즈니스 규칙: 이메일은 유일해야 한다
        if (memberRepository.existsByEmail(req.email())) {
            throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
        }

        Member saved = memberRepository.save(Member.create(req.email(), req.name()));
        return new MemberDtos.CreateResponse(saved.getId());
    }

    @Transactional(readOnly = true)
    public MemberDtos.DetailResponse getMember(Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다. id=" + id));

        // 엔티티를 직접 반환하지 않고 응답 DTO로 변환
        return new MemberDtos.DetailResponse(member.getId(), member.getEmail(), member.getName());
    }
}

5) Controller: HTTP 처리 + DTO 바인딩/검증 + 서비스 호출

package com.example.demo.member.api;

import com.example.demo.member.MemberService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@RestController
@RequestMapping("/api/members")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public ResponseEntity<MemberDtos.CreateResponse> signUp(@Valid @RequestBody MemberDtos.CreateRequest req) {
        MemberDtos.CreateResponse res = memberService.signUp(req);
        return ResponseEntity
                .created(URI.create("/api/members/" + res.id()))
                .body(res);
    }

    @GetMapping("/{id}")
    public ResponseEntity<MemberDtos.DetailResponse> getMember(@PathVariable Long id) {
        return ResponseEntity.ok(memberService.getMember(id));
    }
}
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

요청 흐름 다이어그램(Controller/Service/Repository)

sequenceDiagram
  participant C as "Controller"
  participant S as "Service"
  participant R as "Repository"
  participant DB as "Database"

  C->>S: "DTO로 요청 전달"
  S->>R: "엔티티 조회/저장 요청"
  R->>DB: "SQL 실행"
  DB-->>R: "결과 반환"
  R-->>S: "엔티티 반환"
  S-->>C: "응답 DTO 반환"
  C-->>C: "HTTP 응답 생성"

Controller는 HTTP를 처리하고, Service는 규칙/흐름을, Repository는 DB 접근을 담당한다는 흐름을 보여줍니다.

[IMAGE_PROMPT] 위치: "요청 흐름 다이어그램" 바로 아래 alt: Controller-Service-Repository 책임 분리 개념도 prompt: clean minimal tech blog style white background layered architecture diagram showing Controller Service Repository Database with arrows, emphasize responsibilities separation, no dense text style: diagram [/IMAGE_PROMPT]

실무 팁

💡 실무에서는: “Service가 비대해질 때” 유스케이스 단위로 쪼개 보세요

  • MemberService에 모든 회원 관련 기능을 넣기 시작하면 금방 1,000줄이 됩니다. 이때는 MemberSignUpService, MemberQueryService처럼 유스케이스/관심사 기준으로 분리하면 테스트도 쉬워지고 충돌도 줄어듭니다.
  • 단, 너무 잘게 쪼개서 “서비스가 서비스 호출” 형태로 꼬이면 오히려 추적이 어려워질 수 있으니, 팀의 복잡도에 맞는 적정 분리가 중요합니다.

DTO와 Entity 경계(택배 상자와 상품) 비유 일러스트

💡 실무에서는: DTO↔엔티티 매핑 위치를 팀 규칙으로 고정해 두는 게 효과가 큽니다

  • 권장: Controller는 요청 DTO를 그대로 Service로 전달, Service에서 엔티티 생성/변환을 담당(유스케이스 규칙과 함께 관리).
  • 조회 성능이 중요할 때는 Repository에서 Projection으로 DTO를 바로 조회하는 전략도 가능하지만, 이 경우 “읽기 모델”로 명확히 구분해 두지 않으면 계층 경계가 흐려지기 쉽습니다(예: MemberQueryRepository/MemberReadModel 같은 네이밍).

핵심 요약: Controller는 HTTP에 집중하고, Service는 유스케이스/규칙/트랜잭션을 책임지며, Repository는 저장소 접근만 담당합니다.
핵심 요약: DTO와 엔티티 경계를 지키면 API 변경이 도메인/DB까지 번지는 일을 줄일 수 있습니다.
핵심 요약: 서비스가 커지면 “도메인 기준”이 아니라 “유스케이스 기준”으로 분리해 보세요.

다음 글: #14 @Transactional 제대로 쓰기(전파/읽기전용)