스프링으로 백엔드 개발을 시작하면 가장 먼저 자주 보게 되는 구조가 바로 계층 구조(Layered Architecture) 이다. 처음에는 그냥 Controller, Service, Repository를 나누는 정도로만 이해하기 쉽지만, 실제로는 코드의 책임을 분리하고 유지보수를 쉽게 만들기 위한 설계 방식이라고 보는 것이 맞다.
이번 글에서는 스프링에서 자주 사용하는 계층 구조가 무엇인지, 각 계층이 어떤 역할을 맡는지, 왜 이렇게 나누는지 정리해보겠다.
계층 구조는 애플리케이션을 역할별로 나누어 여러 층(layer)으로 분리하는 방식이다. 각 계층은 자신이 맡은 책임만 수행하고, 보통 위에서 아래 방향으로만 의존한다.
스프링에서는 보통 다음과 같이 나눈다.
Controller Service Repository Domain(Entity)
필요에 따라 DTO, Facade, Converter, Validator 같은 구성요소가 추가되기도 한다.
핵심은 단순하다.
Controller: HTTP 요청/응답 처리 Service: 비즈니스 로직 처리 Repository: DB 접근 처리 Entity/Domain: 핵심 데이터와 상태 표현
즉, “웹 요청을 받는 부분”, “실제 업무 규칙을 처리하는 부분”, “데이터를 저장하고 조회하는 부분”을 나누는 것이다.
계층을 나누지 않으면 처음에는 빠르게 개발할 수 있다. 하지만 프로젝트가 조금만 커져도 다음과 같은 문제가 생긴다.
컨트롤러에 로직이 몰린다 데이터베이스 조회 코드가 여기저기 흩어진다 테스트가 어려워진다 기능 변경 시 수정 범위가 커진다 코드 읽기가 힘들어진다
예를 들어 “회원 가입” 기능이 있다고 하자. 이 기능 안에는 다음이 섞일 수 있다.
HTTP 요청 파싱 이메일 형식 검증 중복 회원 체크 비밀번호 암호화 회원 저장 응답 JSON 생성
이걸 한 클래스에서 다 처리하면 코드가 금방 복잡해진다. 그래서 역할별로 나누는 것이다.
Controller는 클라이언트의 요청을 가장 먼저 받는 계층이다. 주로 다음 역할을 맡는다.
URL 매핑 요청 파라미터/JSON 바인딩 입력값 검증 Service 호출 응답 반환
즉, Controller는 웹과 직접 맞닿아 있는 입구라고 보면 된다.
예시:
@RestController @RequestMapping("/members") @RequiredArgsConstructor public class MemberController {
private final MemberService memberService;
@PostMapping
public ResponseEntity<MemberResponse> createMember(@RequestBody CreateMemberRequest request) {
MemberResponse response = memberService.createMember(request);
return ResponseEntity.ok(response);
}}
여기서 중요한 점은 Controller가 직접 DB에 접근하거나, 핵심 비즈니스 로직을 길게 처리하지 않는다는 것이다. 그런 일은 Service에 맡긴다.
Service는 비즈니스 로직의 중심이다. 실제 서비스의 규칙과 흐름을 담당한다.
예를 들면 다음과 같은 것들이다.
중복 회원 가입 방지 주문 금액 계산 재고 확인 결제 가능 여부 판단 트랜잭션 처리
예시:
@Service @RequiredArgsConstructor @Transactional public class MemberService {
private final MemberRepository memberRepository;
public MemberResponse createMember(CreateMemberRequest request) {
if (memberRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
}
Member member = new Member(request.getName(), request.getEmail());
Member savedMember = memberRepository.save(member);
return new MemberResponse(savedMember.getId(), savedMember.getName(), savedMember.getEmail());
}}
Service는 Controller처럼 HTTP를 신경 쓰지 않고, Repository처럼 SQL이나 DB 세부 구현에도 집중하지 않는다. 오직 “이 기능이 어떤 규칙으로 동작해야 하는가?”에 집중한다.
Repository는 데이터 저장소와 직접 통신하는 계층이다. 즉, DB 접근 전담 계층이다.
주요 역할은 다음과 같다.
데이터 저장 데이터 조회 데이터 수정/삭제 쿼리 수행
예시:
@Repository public interface MemberRepository extends JpaRepository<Member, Long> { boolean existsByEmail(String email); }
JPA를 사용하면 Repository는 인터페이스만으로도 상당 부분 구현할 수 있다. 복잡한 조회가 필요하면 JPQL, QueryDSL, Native Query 등을 활용할 수 있다.
Repository는 “회원 중복 체크를 해야 한다”를 판단하지 않는다. 그저 “이 이메일이 존재하는지 조회해주는 역할”만 한다.
Entity는 데이터베이스 테이블과 매핑되는 객체이기도 하고, 더 넓게 보면 서비스의 핵심 데이터를 표현하는 모델이다.
예시:
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
public Member(String name, String email) {
this.name = name;
this.email = email;
}}
Entity는 단순히 DB 컬럼 묶음으로만 쓰일 수도 있지만, 프로젝트가 발전할수록 상태 변화와 규칙을 일부 포함하는 방향으로 설계하기도 한다.
예를 들어 주문 엔티티가 스스로 cancel() 메서드를 가져서 취소 가능한 상태인지 검사한 뒤 상태를 변경하도록 만들 수 있다.
스프링 계층 구조의 전형적인 흐름은 다음과 같다.
클라이언트 → Controller → Service → Repository → DB
그리고 응답은 반대로 올라온다.
DB → Repository → Service → Controller → 클라이언트
예를 들어 회원 가입 요청이 들어오면:
클라이언트가 /members로 POST 요청 Controller가 요청 JSON을 받음 Service가 중복 검사, 회원 생성 로직 수행 Repository가 DB에 저장 저장 결과를 다시 Service에 전달 Controller가 응답 객체를 반환
이 흐름이 명확할수록 코드 추적이 쉬워지고, 문제 발생 시 어디를 봐야 하는지도 빨라진다.
초보자가 자주 하는 실수 중 하나가 Controller에서 Entity를 그대로 요청/응답에 사용하는 것이다. 작은 프로젝트에서는 가능해 보이지만, 보통은 DTO(Data Transfer Object) 를 따로 두는 것이 좋다.
DTO를 쓰는 이유
DB 구조와 외부 API 구조는 꼭 같을 필요가 없다.
예를 들어 비밀번호, 내부 상태값 같은 정보를 숨길 수 있다.
프론트엔드가 원하는 형태로 응답을 가공하기 쉽다.
예시:
@Getter @NoArgsConstructor public class CreateMemberRequest { private String name; private String email; } @Getter @AllArgsConstructor public class MemberResponse { private Long id; private String name; private String email; }
즉, Entity는 내부 모델, DTO는 외부와 주고받는 데이터 형식이라고 이해하면 된다.
계층 구조에서는 보통 위 계층이 아래 계층을 호출한다.
Controller → Service Service → Repository
반대로 아래 계층이 위 계층을 알게 되면 구조가 꼬이기 시작한다.
예를 들어 Repository가 Controller를 참조하거나, Entity가 Controller의 요청 객체를 직접 알게 되면 계층 간 경계가 무너진다.
그래서 보통 다음 원칙을 지킨다.
Controller는 Service를 의존한다 Service는 Repository를 의존한다 Repository는 DB 기술에 의존한다 아래 계층은 위 계층을 모른다
이 원칙이 깨지면 수정 범위가 커지고 테스트도 어려워진다.
각 클래스가 왜 존재하는지 분명해진다.
문제가 생겼을 때 어느 계층을 봐야 할지 빠르게 판단할 수 있다.
Service 로직은 Controller 없이도 테스트할 수 있고, Repository는 별도로 검증할 수 있다.
팀원끼리 “이건 Controller 책임”, “이건 Service 책임”처럼 공통 기준을 잡기 쉽다.
초기에는 단순 CRUD여도, 나중에 요구사항이 늘어나면 계층 분리의 효과가 커진다.
계층 구조가 항상 정답은 아니다. 잘못 쓰면 오히려 불편해질 수 있다.
작은 프로젝트에서는 과하게 느껴질 수 있다.
모든 비즈니스 로직이 Service에 몰리면 결국 “거대한 Service 클래스”가 된다.
클래스를 나눴더라도 책임이 애매하면 여전히 복잡하다.
예를 들어 이런 경우가 있다.
Controller가 너무 많은 검증을 한다 Service가 SQL 성격의 코드를 많이 안고 있다 Repository가 업무 규칙까지 판단한다
즉, 파일 수가 많다고 설계가 좋은 것은 아니다. 책임 분리가 진짜로 되었는지가 중요하다.
댓글 0