컨트롤러 만들어보기
레이어드 아키텍처를 적용하기 전에, 먼저 가장 단순한 형태로 컨트롤러를 만들어보면
스프링이 요청과 응답을 어떻게 처리하는지 감을 잡기 좋다.
ProductController를 만들고, POST /products 요청을 받아 Product 객체를 그대로 반환한다고 해보자.
@RestController
public class ProductController {
@RequestMapping(value = "/products", method = RequestMethod.POST)
public Product createProduct(@RequestBody Product product){
// product 저장하는 부분
return product;
}
}
이 코드는 클라이언트가 보낸 요청 바디를 @RequestBody를 통해 Product 객체로 변환해서 받고,
다시 그 Product 객체를 응답으로 돌려주는 아주 단순한 예제다.

이 과정에서 Postman으로 테스트했을 때 406 에러가 발생할 수 있는데,
이 에러는 단순히 저장 로직이 없어서 나는 것이 아니다.
핵심은 응답을 JSON 문자열로 만들 때, Product가 가지고 있는 필드 값을 읽어와야 하는데,
그 값을 꺼내기 위한 getter 메서드가 없으면 스프링이 제대로 응답 객체를 직렬화하지 못할 수 있다는 점이다.
즉, 아래와 같은 getter 메서드가 필요하다.
(Product 클래스에 getter 메서드를 추가한다.)
public Long getId() {
return id;
}
public Integer getAmount() {
return amount;
}
public String getName() {
return name;
}
public Integer getPrice() {
return price;
}
getter를 추가하고 다시 요청을 보내면, 이번에는 에러 없이 요청으로 보냈던 값이 응답으로 그대로 오는 것을 확인할 수 있다.
이때 id가 null로 보이는 이유는 아주 단순하다. 아직 코드 어디에서도 id 값을 직접 지정해준 적이 없기 때문이다.

레이어드 아키텍처
처음에는 컨트롤러 안에서 모든 것을 처리해도 크게 문제 없어 보일 수 있다.
예를 들어 상품 저장 기능을 만드는 컨트롤러를 만들고,
그 안에서 리스트에 저장까지 해버리면 당장은 동작할 수 있다.
하지만 애플리케이션이 조금만 커져도 이런 방식은 금방 불편해진다.
예를 들어 상품을 저장하는 로직이 어떤 컨트롤러 안에 들어 있는데,
나중에 다른 컨트롤러에서도 같은 저장 로직이나 조회 로직을 사용해야 하는 상황이 생길 수 있다.
이 경우 한 컨트롤러가 다른 컨트롤러를 참조하거나, 같은 로직이 여러 곳에 중복될 가능성이 커진다.
그러면 컨트롤러는 원래 맡아야 할 “외부와 데이터 교환하는 역할”을 넘어서 너무 많은 책임을 가지게 된다.
그래서 보통은 데이터를 저장하거나 조회하는 주체를 따로 분리하는 구조를 사용한다.
이것이 바로 레이어드 아키텍처를 적용하는 이유다.
레이어드 아키텍처는 말 그대로 애플리케이션 코드를 역할별 계층으로 나누어 배치하는 방식이다.
어떤 식으로 나눌지는 여러 방법이 있지만,
여기서는 도메인 주도 설계(DDD)에서 자주 이야기하는 기본적인 네 가지 계층 구조를 기준으로 설명한다.

첫 번째는 표현(Presentation) 계층이다.
이 계층은 클라이언트의 요청을 받고, 응답을 돌려주는 역할을 한다.
웹 애플리케이션에서는 보통 Controller가 여기에 해당한다.
즉, 외부와 직접 맞닿아 있는 계층이다.
두 번째는 응용(Application) 계층이다.
이 계층은 표현 계층의 호출을 받아 실제 애플리케이션 기능을 수행한다.
예를 들어 상품을 저장하거나, 저장된 상품을 조회하거나, 삭제하는 로직을 조합하는 역할을 맡는다.
보통 Service가 여기에 해당한다.
세 번째는 도메인(Domain) 계층이다.
이 계층은 애플리케이션이 다루는 핵심 개념이 위치하는 곳이다.
지금 만들고 있는 것은 상품 관리 애플리케이션이므로, 핵심 도메인은 상품이다.
이 관점에서 Product 같은 클래스는 도메인 객체라고 볼 수 있다.
네 번째는 인프라스트럭처(Infrastructure) 계층이다.
이 계층은 저장소나 외부 시스템 연동처럼, 특정 구현 기술에 의존하는 코드를 두는 곳이다.
예를 들어 지금은 상품을 리스트에 저장할 수도 있지만, 나중에는 데이터베이스에 저장할 수도 있다.
이처럼 저장 방식이 바뀔 수 있는 부분은 인프라스트럭처 계층으로 분리해두면 변경 영향을 최소화할 수 있다.
보통 Repository가 여기에 해당한다.
이 구조가 절대적인 정답은 아니다. 하지만 중요한 것은 왜 레이어를 나누는가다.
역할이 다른 코드를 분리해두면 책임이 명확해지고,
재사용과 테스트가 쉬워지며, 나중에 구현 방식을 바꿀 때도 변경 범위를 줄일 수 있다.
결국 레이어드 아키텍처는 “예쁘게 나누기 위한 구조”가 아니라, 유지보수와 확장성을 높이기 위한 구조라고 이해하면 된다.
패키지 나누기
레이어드 아키텍처를 실제 코드에 적용하려면, 우선 패키지부터 역할별로 나누는 것이 좋다.
이번 실습에서는 controller, service, domain, repository 네 가지 패키지가 필요하다.

그다음 이해해야 하는 개념이 빈(Bean) 등록과 의존성 주입(DI) 이다.
일반적인 자바 코드에 익숙하다면, 클래스를 사용할 때 new 키워드로 직접 인스턴스를 생성하는 방식에 익숙할 수 있다.
하지만 스프링 프레임워크에서는 모든 객체를 직접 new로 만들기보다,
필요한 클래스들을 스프링이 대신 생성하고 관리하도록 맡기는 경우가 많다.
왜 이런 방식이 필요할까. 가장 큰 이유는 재사용과 관리의 일관성 때문이다.
controller, service, repository 같은 클래스는 보통 필드에 상태를 저장하지 않고, 메서드 로직 중심으로 동작하는 경우가 많다.
이런 클래스는 요청이 올 때마다 매번 새로 만들 필요가 없고, 한 번 만들어두고 계속 재사용해도 되는 경우가 많다.
스프링은 이런 객체를 빈(Bean) 으로 등록해서 애플리케이션이 실행되는 동안 관리한다.
즉, 빈은 스프링이 관리하는 객체라고 이해하면 된다.
보통은 @RestController, @Service, @Repository 같은 어노테이션을 클래스에 붙이면,
스프링이 애플리케이션 실행 시점에 해당 클래스를 빈으로 등록한다.
그리고 다른 클래스가 그 빈을 필요로 하면, 생성자나 필드 등을 통해 자동으로 주입해준다.
이것이 의존성 주입이다.
이 덕분에 클래스들은 자신이 “무엇을 사용할지”만 선언하면 되고,
“그 객체를 어떻게 만들지”까지 직접 책임지지 않아도 된다.
상품을 리스트에 추가하기
이제 실제로 상품을 리스트에 추가하는 기능을 레이어드 구조로 구현해보면,
각 계층이 어떤 역할을 하는지가 확실히 보인다.
① 먼저 컨트롤러는 외부 요청을 받는 역할만 담당한다.
@RestController
public class ProductController {
private final SimpleProductService simpleProductService;
@Autowired
ProductController(SimpleProductService simpleProductService){
this.simpleProductService = simpleProductService;
}
@RequestMapping(value = "/products", method = RequestMethod.POST)
public Product createProduct(@RequestBody Product product){
simpleProductService.add(product);
return product;
}
}
여기서 컨트롤러는 /products로 들어온 POST 요청을 받고, 요청 바디의 Product 객체를 서비스에 전달한다.
즉, 컨트롤러는 요청을 해석하고 적절한 서비스 메서드를 호출하는 데 집중한다.
실제 저장 방법은 컨트롤러가 알지 못한다.
② 서비스는 응용 계층으로서, 컨트롤러와 레포지토리 사이를 연결해주는 역할을 한다.
@Service
public class SimpleProductService {
private ListProductRepository listProductRepository;
@Autowired
SimpleProductService(ListProductRepository listProductRepository){
this.listProductRepository = listProductRepository;
}
public Product add(Product product){
Product savedProduct = listProductRepository.add(product);
return savedProduct;
}
}
지금은 서비스 로직이 단순히 레포지토리 메서드를 한 번 호출하는 수준이라서 “굳이 필요한가?” 싶을 수 있다.
하지만 나중에 유효성 검사, 중복 체크, 비즈니스 규칙 적용 등이 들어가면 이런 계층이 훨씬 중요해진다.
③ 레포지토리는 실제 저장을 담당한다.
@Repository
public class ListProductRepository {
private List<Product> products = new CopyOnWriteArrayList<>();
private AtomicLong sequence = new AtomicLong(1L);
public Product add(Product product){
product.setId(sequence.getAndAdd(1L));
products.add(product);
return product;
}
}
여기서는 데이터베이스 대신 리스트를 사용해서 상품을 저장하고 있다.
products 리스트에 상품을 넣기 전에, AtomicLong을 이용해 상품 번호를 하나씩 증가시키며 id를 부여한다.
즉, 레포지토리는 “어디에 어떻게 저장할 것인가”를 담당하는 계층이라고 볼 수 있다.
④ 도메인 객체인 Product는 상품 데이터를 담고 있다.
public class Product {
private Long id;
private String name;
private Integer price;
private Integer amount;
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return id;
}
public Integer getAmount() {
return amount;
}
public String getName() {
return name;
}
public Integer getPrice() {
return price;
}
}
+ CopyOnWriteArrayList와 AtomicLong
레포지토리 코드에서 눈에 띄는 부분이 CopyOnWriteArrayList와 AtomicLong이다.
그냥 ArrayList와 long을 쓸 수도 있을 것 같은데, 여기서 이런 클래스를 사용하는 이유는
동시성 문제를 조금 더 안전하게 다루기 위해서다.
먼저 CopyOnWriteArrayList는 기능적으로는 ArrayList와 비슷하지만,
멀티스레드 환경에서 더 안전하게 동작하도록 만들어진 리스트 구현체다.
여러 스레드가 동시에 데이터를 읽거나 수정할 가능성이 있을 때,
일반 ArrayList는 예상치 못한 문제가 생길 수 있다.
반면 CopyOnWriteArrayList는 내부적으로 변경 시 복사 전략을 사용해 좀 더 안전하게 동작한다.
또 AtomicLong은 long 값을 스레드 안전하게 증가시키기 위한 클래스다.
상품 번호를 1씩 증가시키는 로직은 단순해 보이지만,
여러 요청이 동시에 들어오면 일반적인 증가 연산은 충돌 문제가 생길 수 있다.
AtomicLong은 이런 증가 연산을 원자적으로 처리해주기 때문에, 중복 id가 생길 가능성을 줄여준다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP16. List를 이용한 상품 관리 애플리케이션 만들기 4편 (0) | 2026.04.10 |
|---|---|
| EP15. List를 이용한 상품 관리 애플리케이션 만들기 3편 (0) | 2026.04.09 |
| EP13. List를 이용한 상품 관리 애플리케이션 만들기 1편 (0) | 2026.04.07 |
| EP12. HTTP 요청과 응답 (0) | 2026.04.04 |
| EP11. HTTP 트랜잭션 개요 (0) | 2026.04.04 |