
0. 들어가기 전에
지난 글에서는 Spring Boot가 왜 등장했고, 어떤 철학으로 스프링 프로젝트의 기본값을 정리했는지를 중심으로 정리해보았다. 그런데 막상 프로젝트를 시작하려고 하면, Boot의 자동 설정이 “무슨 일을 해주는지”보다 더 먼저 다가오는 고민이 있다. 바로 어디에 코드를 두어야 하는가, 그리고 어디까지가 한 요청의 책임인가 같은 질문이다.
컨트롤러에 코드를 쓰기 시작하면 가장 빠르게 결과는 나오지만, 그만큼 빠르게 비대해진다. 서비스는 비즈니스 로직을 모으는 곳이라고들 하지만, 막상 “비즈니스 로직”이 무엇인지 정의가 흐려지면 다시 컨트롤러로 되돌아간다. 저장소(Repository/DAO)는 DB만 보면 된다고 생각하지만, 트랜잭션과 예외가 섞이는 순간 경계는 다시 흔들린다.
그래서 이번 글은 특정 기술을 붙이는 방법을 다루는 글이 아니라, 프로젝트에 들어가기 직전 “경계”를 이론적으로 정리해두는 글이다. Controller–Service–Repository를 어떻게 나누는지, 트랜잭션은 왜 보통 서비스에 거는지, 예외는 어디서 잡고 어디서 번역하는지, 그리고 이 선택들이 결국 디버깅과 유지보수 비용을 어떻게 바꾸는지까지 한 번에 정리해보려 한다.
1. “레이어를 나눈다”는 말의 진짜 의미
처음에는 “컨트롤러-서비스-DAO로 나누면 된다”라는 말이 가장 쉬운 정답처럼 들린다. 하지만 레이어를 나누는 목적이 파일을 세 개로 쪼개는 것이라면, 그 구조는 오래 가지 못한다. 레이어링이 의미를 가지려면 “변경 이유가 다른 코드가 섞이지 않도록” 경계를 만든다는 목표가 먼저 있어야 한다.
예를 들어 어떤 기능이 바뀌는 이유가 화면 요구사항 때문인지, 도메인 규칙 때문인지, 데이터 저장 방식 때문인지가 다르다면, 코드는 그 이유에 따라 나뉘어야 한다. 이렇게 나누면 요구사항이 바뀌었을 때 수정 범위가 예측 가능해지고, 장애가 났을 때도 “어느 레이어에서 문제가 났는지”를 빠르게 좁힐 수 있다. 결국 레이어링은 팀이 공유하는 디버깅 지도에 가깝다.

💡 토막 개념: 관심사(Concern)와 책임(Responsibility)
관심사는 “무엇을 신경 쓰는가”의 단위이고, 책임은 “어디까지가 내 몫인가”의 경계다. 레이어가 의미 있으려면 관심사가 다르고 책임이 다른 것들이 한 곳에 뭉치지 않게 만들어야 한다.
실무 TIP
컨트롤러 메서드가 길어지는 순간을 단순한 “코딩 습관”으로만 보면 늦다. 보통 그 순간은 규칙(비즈니스)과 입출력(웹)이 섞였다는 신호다. 이때 빨리 서비스로 규칙을 옮기면, 다음 기능을 붙일 때 디버깅 비용이 확 줄어든다.
2. Controller는 어디까지 해야 얇은가
컨트롤러는 요청을 받는 문이다. 문은 넓어질수록 취약해진다. 컨트롤러가 해야 하는 일은 크게 세 가지로만 정리하는 편이 안전하다. 첫째, 입력을 해석한다. 둘째, 비즈니스 로직을 호출한다. 셋째, 응답 형태를 결정한다. 여기서 중요한 건 “비즈니스 로직을 수행하지 않는다”는 문장을 습관처럼 붙이는 것이다.
예를 들어 회원가입을 처리할 때, 컨트롤러에서 비밀번호 해시를 만들고, 이메일 중복을 검사하고, 저장까지 직접 한다면 처음엔 빨라 보인다. 하지만 그 순간부터 같은 규칙을 다른 컨트롤러에서도 쓰고 싶어지며, 복붙이 시작된다. 반대로 컨트롤러가 입력을 받아 서비스 메서드 하나로 위임한다면, 규칙은 한곳에 모인다. 컨트롤러는 HTTP와만 친해지고, 서비스는 규칙과 친해진다.
@Controller
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping
public String join(MemberJoinRequest req) {
memberService.join(req);
return "redirect:/members/login";
}
}
위 코드에서 컨트롤러는 입력 객체를 받고, 서비스 호출을 하고, 리다이렉트만 결정한다. 성공 시 무엇을 보여줄지 결정하는 역할까지만 한다. “얇은 컨트롤러”라는 말은 결국 이런 형태를 가리키는 경우가 많다. 그리고 이렇게 만들어두면 테스트 전략도 깔끔해진다. 컨트롤러 테스트는 매핑과 응답을, 서비스 테스트는 규칙을 본다.

💡 토막 개념: PRG(Post-Redirect-Get)
POST로 상태를 변경한 뒤 곧바로 화면을 렌더링하지 않고, redirect로 GET 페이지로 이동시키는 패턴이다. 새로고침 시 동일 POST가 반복되는 문제를 줄이고, 화면 URL이 의미를 갖게 만든다.
실무 TIP
컨트롤러에서 가장 자주 생기는 “욕심”은 검증과 규칙을 같이 넣는 것이다. 단순한 형식 검증(널, 길이, 패턴)은 컨트롤러/DTO 레벨에서 해도 되지만, “DB를 봐야 알 수 있는 검증”(중복, 상태 전이 가능 여부)은 서비스로 보내는 편이 책임이 깔끔하다.
3. Service는 무엇을 ‘소유’해야 하는가
서비스는 흔히 “비즈니스 로직을 담는 곳”이라고 말한다. 그런데 그 문장만으로는 서비스가 무엇을 해야 하는지 여전히 모호할 수 있다. 실무에서 서비스가 소유해야 하는 것은 규칙 자체도 있지만, 더 중요한 것은 규칙이 적용되는 순서와 경계다. 예를 들어 “회원가입은 중복 검사 후 저장한다” 같은 흐름을 누가 소유하는가의 문제다.
서비스는 여러 저장소 호출을 조합하고, 한 요청 안에서 일관된 상태를 만들도록 조율한다. 그래서 서비스가 얇아지면 오히려 구조가 불안해지기도 한다. 저장소에 규칙이 흩어지거나, 컨트롤러가 다시 조합을 맡기 때문이다. 서비스는 단순 중계자가 아니라, 애플리케이션 정책을 실행하는 계층으로 두는 편이 장기적으로 안전하다.
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
this.memberRepository = memberRepository;
this.passwordEncoder = passwordEncoder;
}
public void join(MemberJoinRequest req) {
if (memberRepository.existsById(req.id())) {
throw new DuplicateMemberException(req.id());
}
String hashed = passwordEncoder.encode(req.password());
memberRepository.save(new Member(req.id(), hashed, req.name()));
}
}
서비스가 규칙을 소유하면 테스트도 명확해진다. “중복이면 실패한다”, “비밀번호는 해시된다” 같은 규칙은 HTTP와 무관하다. 그리고 이 규칙은 화면이 바뀌어도, API가 추가되어도 그대로 유지되어야 한다. 즉 서비스는 UI 변화에 덜 흔들리도록 설계하는 계층이다.

💡 토막 개념: 애플리케이션 서비스 vs 도메인 서비스
애플리케이션 서비스는 “유스케이스를 실행하는 조율자”에 가깝고, 도메인 서비스는 “도메인 모델에 속하기 애매한 규칙”을 담는 개념이다. 학습 단계에서는 우선 애플리케이션 서비스가 컨트롤러를 얇게 만드는 역할을 한다는 점만 잡아도 충분하다.
실무 TIP
서비스가 점점 커질 때 “그냥 서비스도 쪼개면 되지”로 끝내기 쉬운데, 보통은 유스케이스 기준(가입/로그인/주문/결제 등)으로 쪼개는 편이 이해하기 쉽다. 반대로 엔티티 기준(MemberService, ProductService)만으로 쪼개면 결국 큰 서비스가 다시 생기는 경우도 많다.
4. Repository/DAO는 어디까지 책임져야 하는가
Repository 또는 DAO는 흔히 “DB 접근 계층”으로 설명된다. 중요한 건 이 계층이 비즈니스 규칙을 몰라도 되게 만드는 데 있다. DB가 Oracle이든 MySQL이든, MyBatis든 JPA든, 서비스는 “데이터를 가져오고 저장한다”라는 추상적인 행동만 보면 되게 만드는 것이 목표다. 저장소가 기술 디테일을 숨기면, 서비스는 덜 흔들린다.
하지만 저장소가 너무 단순한 CRUD만 하게 두면, 서비스가 지나치게 데이터 조립을 떠안기도 한다. 그래서 실무에서는 “어떤 쿼리를 묶어서 제공할지”를 저장소의 API로 설계하는 일이 중요해진다. 예를 들어 존재 여부를 조회하는 existsById 같은 메서드는 그 자체로 정책은 아니지만, 서비스가 규칙을 구현하기 쉽게 만드는 도구가 된다.
public interface MemberRepository {
boolean existsById(String id);
void save(Member member);
Optional<Member> findById(String id);
}
이 인터페이스는 데이터 접근의 방향만 노출한다. 구현이 MyBatis의 mapper든, JdbcTemplate이든, JPA든 상관없이 서비스는 같은 코드를 유지할 수 있다. 학습 단계에서는 “저장소는 기술을, 서비스는 규칙을”이라는 문장으로 경계를 잡아두는 것이 도움이 된다.

💡 토막 개념: 리포지토리(Repository)와 DAO의 차이
DAO는 데이터 접근 객체라는 기술 중심 용어이고, Repository는 도메인 관점에서 저장소를 표현하는 용어로 설명되곤 한다. 둘을 엄격히 구분하기보다 “데이터 접근을 한곳에 모으는 계층”이라는 공통점을 먼저 잡아두면 된다.
실무 TIP
저장소 메서드가 “너무 범용적”이면 서비스에서 데이터 조립이 늘고, 저장소 메서드가 “너무 상세”하면 저장소가 정책처럼 보이기 시작한다. 보통은 유스케이스에서 자주 쓰는 조회 형태(존재 여부, 목록, 페이징, 상태별 조회) 정도는 저장소가 제공하고, 규칙은 서비스가 소유하는 균형을 맞춘다.
5. Transaction은 왜 대부분 Service에 거는가
트랜잭션은 “DB 작업을 묶는 기술”이기도 하지만, 더 넓게 보면 “한 요청에서 무엇을 하나의 작업 단위로 볼 것인가”를 정하는 선언이다. 그리고 그 작업 단위는 보통 유스케이스와 일치한다. 유스케이스는 컨트롤러가 아니라 서비스가 소유한다. 그래서 트랜잭션은 자연스럽게 서비스에 붙는다.
컨트롤러에 트랜잭션을 붙이면, 웹 계층이 DB 계층의 경계를 직접 들고 있는 모양이 된다. 반대로 저장소에 트랜잭션을 붙이면, 여러 저장소 호출을 하나로 묶기 어려워진다. 서비스는 여러 저장소 호출을 조합하는 곳이므로 “여기부터 여기까지는 한 덩어리”라는 선언을 서비스에 둔다. 이것이 흔한 이유다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
public OrderService(OrderRepository orderRepository, InventoryRepository inventoryRepository) {
this.orderRepository = orderRepository;
this.inventoryRepository = inventoryRepository;
}
@Transactional
public void placeOrder(PlaceOrderRequest req) {
inventoryRepository.decrease(req.productId(), req.quantity());
orderRepository.save(req.toOrder());
}
}
여기서 중요한 건 @Transactional이 “편의 기능”이 아니라 “경계 선언”이라는 점이다. 이 경계를 어디에 두느냐에 따라 장애 시 롤백 범위가 달라지고, 동시성 문제를 마주치는 지점도 달라진다. 그러니 트랜잭션은 단순히 붙이고 끝낼 게 아니라, 유스케이스 단위로 고민해야 한다.
또 하나 자주 놓치는 건 “어떤 예외에서 롤백되는가”다. 기본 설정은 런타임 예외에서 롤백되는 경우가 많고, 체크 예외는 롤백되지 않는 경우도 있다. 이 차이를 모르고 개발하면, 실패했는데도 일부 데이터가 남는 상황을 만들기 쉽다. 결국 트랜잭션은 범위뿐 아니라, 실패의 의미까지 같이 정의하는 장치라는 점을 기억해두는 편이 안전하다.

💡 토막 개념: ACID와 격리 수준(Isolation)
트랜잭션은 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 지속성(Durability)을 목표로 한다. 학습 초반에는 격리 수준을 모두 외우기보다, “동시성 문제는 트랜잭션 경계와 격리 수준 선택의 결과로 나타난다”는 연결만 잡아도 도움이 된다.
실무 TIP
트랜잭션을 길게 가져가면 안전해 보이지만, 동시에 락이 오래 잡혀 병목이 생길 수 있다. 그래서 “정말 DB 일관성을 보장해야 하는 구간”만 트랜잭션으로 묶고, 외부 API 호출처럼 오래 걸릴 수 있는 작업은 트랜잭션 밖으로 빼는 설계가 자주 등장한다.
6. 예외는 어디서 잡고, 어디서 번역해야 하는가
프로젝트가 커질수록 “예외 처리”는 단순히 try-catch를 어디에 둘지의 문제가 아니라, 사용자에게 어떤 메시지와 어떤 상태 코드를 줄지, 그리고 로그에는 무엇을 남길지의 문제로 바뀐다. 이때 가장 자주 생기는 혼란은 예외가 컨트롤러마다 제각각 처리되어 응답이 흔들리는 것이다. 그래서 예외는 가능한 한 한곳에서 번역하는 방식이 안정적이다.
서비스는 규칙을 실행하다가 실패하면 “의도 있는 예외”를 던질 수 있다. 반면 컨트롤러는 그 예외를 받아 사용자에게 적절한 응답으로 바꿔야 한다. 그런데 컨트롤러마다 바꾸기 시작하면 표준이 깨지므로, 스프링에서는 @ControllerAdvice 같은 장치를 제공한다. 이것이 “예외 번역”의 대표적인 위치가 된다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateMemberException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateMemberException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("DUPLICATE_MEMBER", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다."));
}
}
public record ErrorResponse(String code, String message) {}
이렇게 번역 지점을 고정해두면, 서비스는 “실패를 표현하는 언어(예외)”를 던지고, 웹 계층은 “클라이언트 언어(HTTP 응답)”로 바꾼다. 그리고 팀은 장애 대응 시 로그의 위치와 형태를 예측하기 쉬워진다.

💡 토막 개념: 예외 번역(Exception Translation)
내부에서 발생한 실패를 외부 계약(HTTP 응답, 화면 메시지)으로 바꾸는 과정이다. 같은 실패라도 사용자의 관점에서는 “무엇을 해야 하는가”가 중요하므로, 번역은 기술보다 제품 관점에 더 가깝다.
실무 TIP
운영에서 제일 위험한 예외는 “사용자에게 내부 정보가 그대로 노출되는 예외”다. SQL 에러 메시지, 경로, 스택 트레이스 같은 정보는 공격자에게 힌트가 될 수 있다. 사용자는 짧은 안내를 받고, 상세는 로그로 남기는 패턴이 흔하다.
7. DTO/Model/Entity는 어디까지 흘러가야 안전한가
레이어가 나뉘어도 데이터가 뒤섞이면 경계는 다시 무너진다. 가장 흔한 예는 DB 엔티티를 그대로 컨트롤러 응답으로 내보내는 것이다. 처음엔 편하지만, 화면 요구사항이 바뀌는 순간 엔티티가 UI에 끌려다니기 시작한다. 반대로 요청/응답에 쓰는 DTO를 서비스와 저장소까지 그대로 흘려보내면, 웹 계층의 형태가 도메인 규칙을 오염시키기도 한다.
그래서 학습 단계에서 가장 단순하고도 안전한 규칙은 이것이다. 컨트롤러는 요청/응답 DTO를 다루고, 서비스는 도메인 모델(또는 도메인에 가까운 형태)을 다루며, 저장소는 DB 매핑을 다룬다. 이 규칙을 절대적으로 지키는 것이 목표가 아니라, 이 규칙에서 벗어날 때는 “왜 벗어났는지”를 말할 수 있게 만드는 것이 목표다.
// 요청 DTO (웹 계층)
public record MemberJoinRequest(String id, String password, String name) {}
// 도메인 모델(단순 예시)
public record Member(String id, String hashedPassword, String name) {}
DTO와 도메인 모델을 분리하면 코드가 늘어나는 것처럼 보일 수 있다. 하지만 이 분리가 생기면 “이 변화는 UI 요구사항 변화인가, 비즈니스 규칙 변화인가”를 더 명확히 가를 수 있다. 결국 코드를 늘리는 대신 변경 비용을 줄이는 선택이다.

💡 토막 개념: API 계약(Contract)
클라이언트와 서버가 합의한 입력/출력의 형태다. 계약은 “지금 당장 구현이 쉬운가”보다 “바뀔 때 어디가 깨지는가”가 더 중요하다. 그래서 DTO는 계약을 표현하는 데 자주 쓰인다.
실무 TIP
DTO 변환 코드가 반복될 때는 그 반복 자체가 “경계가 의미 있다”는 신호일 때가 많다. 물론 반복이 너무 과하면 매퍼를 도입하기도 하지만, 학습 단계에서는 먼저 경계의 이유를 체감하는 편이 더 중요하다.
8. 마무리하며
이번 글에서는 프로젝트를 시작하기 전에 꼭 한 번은 정리해둘 만한 “경계”를 중심으로, Controller–Service–Repository의 책임 분리, 트랜잭션 경계를 서비스에 두는 이유, 예외를 번역하는 위치, 그리고 DTO/도메인 모델이 흘러가는 범위를 정리해 보았다. 이 내용은 특정 기술을 배우는 글이라기보다, 앞으로 어떤 기술을 붙이더라도 흔들리지 않을 기준을 잡는 글에 가깝다.
다음 글부터는 실전이 되는 프로젝트가 된다. 여태까지의 이론을 다루는 글과는 많이 다를 예정이다. 프로젝트가 정말 기대된다.
이미지 출처
※ 모든 이미지는 직접 제작하거나 저작권 문제가 없는 이미지로 제작되었습니다.
'부트캠프 일지 > 멀티캠퍼스 TIL' 카테고리의 다른 글
| [Java 풀스택 개발자] 팀 프로젝트 편: 설계 (0) | 2026.05.12 |
|---|---|
| [Java 풀스택 개발자] 팀 프로젝트 편: 기획 (0) | 2026.04.28 |
| [Java 풀스택 개발자] 백엔드 프로그래밍 편:스프링 부트 (0) | 2026.04.14 |
| [Java 풀스택 개발자] 백엔드 프로그래밍 편: 스프링 MVC (0) | 2026.04.07 |
| [Java 풀스택 개발자] 백엔드 프로그래밍 편: 스프링 편 (0) | 2026.03.30 |