SpringBoot 3-Tier Architecture (Controller / Service / Repository) + DTO

최병현·2026년 2월 26일

spring boot

목록 보기
8/34

SpringBoot에서 3-Tier Architecture는 유지보수성과 확장성을 높이기 위한 표준 설계 방식이다. 각 계층은 “자기 책임만” 수행하며, 계층 간 데이터 이동은 Entity가 아닌 DTO를 통해 이루어진다. 이번 정리는 TodoList 예제를 기준으로, 주의할 부분까지 수정한 최종 구조다.


1. 3-Tier 구조의 핵심 개념

1) Controller (Presentation Layer)

  • HTTP 요청을 가장 먼저 받는 계층
  • 요청 데이터를 받고 Service 호출
  • 응답을 ResponseEntity로 감싸 상태코드와 함께 반환

2) Service (Business Logic Layer)

  • 핵심 비즈니스 로직 수행
  • 트랜잭션 관리 (@Transactional)
  • Entity ↔ DTO 변환 담당
  • Repository 여러 개를 조합하여 업무 흐름 완성

3) Repository (Data Access Layer)

  • DB 접근 전담
  • JPA 기반 CRUD 처리
  • 비즈니스 판단은 하지 않는다

2. 요청/응답 전체 흐름

Client → Controller → Service → Repository → DB → Repository 
→ Service (Entity → DTO 변환) → Controller (ResponseEntity 생성) → Client

이 구조를 유지해야 코드가 커져도 유지보수가 가능해진다.


3. DTO를 반드시 사용하는 이유

  • 보안: Entity 그대로 반환하면 민감 필드(password 등) 노출 위험
  • 결합도 감소: DB 구조 변경이 API 스펙에 영향 주지 않음
  • 유효성 분리: 요청용 DTO와 응답용 DTO를 나누면 책임 분리 가능

Request DTO와 Response DTO는 목적이 다르므로 분리하는 것이 정석이다.


4. TodoList 패키지 구조

com.todo.todolist 
├─ controller 
├─ service 
├─ repository 
├─ entity 
└─ dto

5. DTO 정의 (record 사용, 불변 객체)

주의: 필드명은 소문자로 통일한다. (JSON 매핑 안정성)

public record TodoRequest(String content) {
}

public record TodoResponse(Long id, String content, boolean isCompleted) {
}

6. Service 계층 (비즈니스 로직 + 트랜잭션 + 변환 담당)

주의할 부분 수정:

  • completeToggle은 실제로 toggle 동작하도록 수정
  • RuntimeException 대신 명확한 메시지 유지 (추후 GlobalExceptionHandler 확장 가능)
  • DTO 변환은 Service에서 처리
@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;
    private final UserRepository userRepository;

    @Transactional
    public TodoResponse createTodo(Long userId, TodoRequest request) {

        User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));

        Todo todo = new Todo(request.content(), user);
        Todo savedTodo = todoRepository.save(todo);

        return toResponse(savedTodo);
    }

    @Transactional(readOnly = true)
    public List<TodoResponse> getTodoList() {
        return todoRepository.findAll().stream()
                .map(this::toResponse)
                .collect(Collectors.toList());
    }

    @Transactional
    public TodoResponse completeToggle(Long todoId) {

        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new RuntimeException("할 일을 찾을 수 없습니다."));

        todo.setCompleted(!todo.isCompleted());

        return toResponse(todo);
    }

    @Transactional
    public void deleteTodo(Long todoId) {

        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new RuntimeException("할 일을 찾을 수 없습니다."));

        todoRepository.delete(todo);
    }

    private TodoResponse toResponse(Todo todo) {
        return new TodoResponse(
                todo.getId(),
                todo.getContent(),
                todo.isCompleted()
        );
    }
}

7. Controller 계층 (HTTP 입구)

Controller는 로직을 직접 처리하지 않는다. Service를 호출하고 HTTP 응답만 구성한다.

@RestController
@RequestMapping("/api/todos")
@RequiredArgsConstructor
public class TodoController {

    private final TodoService todoService;

    @PostMapping("/{userId}")
    public ResponseEntity<TodoResponse> addTodo(
            @PathVariable Long userId,
            @RequestBody TodoRequest request) {

        TodoResponse response = todoService.createTodo(userId, request);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<TodoResponse>> getAllTodos() {
        return ResponseEntity.ok(todoService.getTodoList());
    }

    @PutMapping("/{todoId}")
    public ResponseEntity<TodoResponse> completeTodo(@PathVariable Long todoId) {
        return ResponseEntity.ok(todoService.completeToggle(todoId));
    }

    @DeleteMapping("/{todoId}")
    public ResponseEntity<Void> deleteTodo(@PathVariable Long todoId) {
        todoService.deleteTodo(todoId);
        return ResponseEntity.noContent().build();
    }
}

8. 중요한 설계 포인트 정리

  • Controller는 로직을 직접 처리하지 않는다.
  • Service는 Repository를 직접 호출하지만, Repository는 Service를 모른다.
  • DTO는 계층 간 데이터 전달용이며 Entity를 직접 노출하지 않는다.
  • Stream + map()은 Entity → DTO 변환에 매우 자주 사용된다.
  • @Transactional(readOnly = true)는 조회 성능 최적화에 중요하다.

9. Frontend ↔ Backend 통신 관점 정리

React(Frontend)는 fetch/axios로 /api/todos를 호출한다. JSON 요청이 Controller로 들어오고, Service에서 Entity를 만들고 DB에 저장한다. 저장 결과는 DTO로 변환되어 JSON으로 다시 React로 전달된다.

즉 이 구조는 SPA(React) + REST API 기반 설계의 기본 골격이다.


10. 최종 핵심 요약

  • 3-Tier는 역할 분리를 통한 유지보수성 확보 전략이다.
  • Entity는 DB 매핑 전용, 외부 노출은 DTO로 한다.
  • Service는 업무 흐름의 중심이다.
  • Controller는 HTTP 레벨 책임만 가진다.
  • 이 구조를 지키는 것이 실무형 Spring 프로젝트의 기본이다.

이 구조를 제대로 이해하면 이후 JWT, Role-based Security, 예외 처리, Validation 확장까지 자연스럽게 연결된다. 이제 이 설계를 기반으로 기능을 확장해 나가면 된다.

profile
Develop

0개의 댓글