본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 21. · 3 Views
Spring Boot 상품 서비스 구축 완벽 가이드
실무 RESTful API 설계부터 테스트, 배포까지 Spring Boot로 상품 서비스를 만드는 전 과정을 다룹니다. JPA 엔티티 설계, OpenAPI 문서화, Docker Compose 배포 전략을 초급 개발자도 쉽게 따라할 수 있도록 스토리텔링으로 풀어냅니다.
목차
1. 프로젝트 요구사항
신입 개발자 김개발 씨는 오늘 첫 프로젝트 회의에 참석했습니다. PM이 말합니다.
"이번에 상품 관리 서비스를 만들어야 해요. CRUD는 기본이고, API 문서도 자동 생성되면 좋겠어요." 김개발 씨는 어디서부터 시작해야 할지 막막했습니다.
프로젝트 요구사항 정의는 개발의 출발점입니다. 마치 건물을 짓기 전에 설계도를 그리는 것과 같습니다.
무엇을 만들어야 하는지 명확히 해야 불필요한 재작업을 줄이고 팀원들과 같은 방향을 바라볼 수 있습니다. 요구사항을 정리하면 개발 우선순위도 자연스럽게 결정됩니다.
다음 코드를 살펴봅시다.
// 프로젝트 요구사항 정리
/**
* 상품 서비스 핵심 기능
* 1. 상품 등록 (POST /api/products)
* 2. 상품 조회 (GET /api/products/{id})
* 3. 상품 목록 (GET /api/products)
* 4. 상품 수정 (PUT /api/products/{id})
* 5. 상품 삭제 (DELETE /api/products/{id})
*
* 비기능 요구사항
* - OpenAPI 3.0 문서 자동 생성
* - 단위 테스트 커버리지 80% 이상
* - Docker Compose로 로컬 환경 구성
*/
김개발 씨는 회의가 끝나고 선배 개발자 박시니어 씨를 찾아갔습니다. "선배님, 프로젝트를 어떻게 시작하면 좋을까요?" 박시니어 씨는 노트북을 열며 말했습니다.
"먼저 요구사항을 정리해야 해요." 요구사항 정리란 무엇일까요? 쉽게 비유하자면, 요리를 시작하기 전에 레시피를 확인하는 것과 같습니다.
어떤 재료가 필요한지, 어떤 순서로 조리할지 미리 알아야 실패하지 않습니다. 프로젝트도 마찬가지입니다.
무엇을 만들어야 하는지 명확히 해야 개발 중 길을 잃지 않습니다. 요구사항을 정리하지 않으면 어떤 일이 벌어질까요?
개발자마다 다른 방식으로 구현하게 됩니다. A 개발자는 상품명을 필수로 만들고, B 개발자는 선택으로 만듭니다.
API 경로도 제각각입니다. 누군가는 /product를 쓰고, 다른 사람은 /products를 씁니다.
나중에 이런 코드를 통합하려면 엄청난 시간이 낭비됩니다. 박시니어 씨는 요구사항을 기능과 비기능으로 나누어 설명했습니다.
기능 요구사항은 "무엇을 만들 것인가"입니다. 상품을 등록하고, 조회하고, 수정하고, 삭제하는 기능이 필요합니다.
이것이 핵심입니다. 비기능 요구사항은 "어떻게 만들 것인가"입니다.
API 문서는 자동으로 생성되어야 하고, 테스트 코드가 충분해야 하며, 로컬 개발 환경은 Docker로 쉽게 구축할 수 있어야 합니다. REST API 설계에는 원칙이 있습니다.
리소스는 명사로 표현하고 복수형을 사용합니다. /products가 좋은 예입니다.
HTTP 메서드는 동작을 나타냅니다. POST는 생성, GET은 조회, PUT은 수정, DELETE는 삭제입니다.
상품 등록은 POST /api/products가 됩니다. 특정 상품 조회는 GET /api/products/1입니다.
이렇게 설계하면 API만 봐도 무슨 동작인지 직관적으로 알 수 있습니다. 실무에서는 어떻게 요구사항을 정리할까요?
대부분의 팀은 Jira나 Notion 같은 도구를 사용합니다. 각 기능을 티켓으로 만들고 우선순위를 매깁니다.
예를 들어 상품 등록과 조회는 필수 기능이므로 우선순위가 높습니다. 상품 이미지 업로드는 나중에 추가해도 됩니다.
이렇게 하면 핵심 기능부터 빠르게 구현할 수 있습니다. API 버전 관리도 미리 고려해야 합니다.
처음에는 /api/v1/products처럼 버전을 명시하는 것이 좋습니다. 나중에 API 구조를 변경해야 할 때 /api/v2/products를 추가하면 기존 클라이언트는 영향을 받지 않습니다.
많은 기업들이 이런 방식으로 API를 관리합니다. 문서화 요구사항도 빼먹으면 안 됩니다.
API를 만들어도 프론트엔드 개발자나 다른 팀이 어떻게 쓰는지 모르면 소용없습니다. OpenAPI(Swagger) 같은 도구를 사용하면 코드에서 자동으로 문서가 생성됩니다.
개발자는 코드만 작성하면 되고, 문서는 항상 최신 상태를 유지합니다. 김개발 씨는 박시니어 씨의 설명을 듣고 메모장을 꺼냈습니다.
"그럼 먼저 기능 목록을 정리하고, API 경로를 설계하고, 문서화 방법을 정하면 되겠네요!" 요구사항을 명확히 정리하면 개발이 훨씬 수월해집니다. 무엇을 만들어야 하는지 알고 있으니 집중할 수 있고, 팀원들과 같은 방향을 바라보니 협업도 원활합니다.
여러분도 프로젝트를 시작할 때 가장 먼저 요구사항을 정리해 보세요.
실전 팁
💡 - API 경로는 복수 명사를 사용하고 일관성을 유지하세요 (products, orders, users)
- 기능 요구사항과 비기능 요구사항을 분리해서 정리하면 우선순위가 명확해집니다
- 처음부터 완벽한 요구사항은 없습니다. 개발하면서 지속적으로 업데이트하세요
2. 상품 엔티티 설계
요구사항 정리를 마친 김개발 씨는 이제 데이터베이스 설계를 시작하려고 합니다. "상품 정보를 어떻게 저장해야 할까요?" 박시니어 씨가 답했습니다.
"JPA 엔티티부터 만들어 봅시다. 엔티티가 곧 테이블 설계도니까요."
JPA 엔티티는 데이터베이스 테이블과 매핑되는 자바 클래스입니다. 마치 설계도면이 실제 건물이 되는 것처럼 엔티티 클래스는 데이터베이스 테이블이 됩니다.
@Entity 어노테이션을 붙이면 JPA가 자동으로 테이블을 생성하고 관리해줍니다. 잘 설계된 엔티티는 유지보수를 쉽게 만들어줍니다.
다음 코드를 살펴봅시다.
// Product.java
@Entity
@Table(name = "products")
@Getter @Setter
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(nullable = false)
private Integer stock;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
김개발 씨는 화면을 보며 고개를 갸우뚱했습니다. "어노테이션이 정말 많네요.
하나씩 뭘 하는 건가요?" 박시니어 씨는 미소 지으며 설명을 시작했습니다. JPA 엔티티란 정확히 무엇일까요?
쉽게 비유하자면, 엔티티는 부동산 등기부등본 같은 것입니다. 등기부등본에는 건물의 소유자, 면적, 주소 같은 정보가 기록됩니다.
엔티티에는 상품의 이름, 가격, 재고 같은 정보가 담깁니다. 등기부등본을 보면 건물의 모든 것을 알 수 있듯이, 엔티티를 보면 데이터 구조를 한눈에 파악할 수 있습니다.
엔티티가 없던 시절에는 어땠을까요? 개발자들은 SQL을 직접 작성해야 했습니다.
"CREATE TABLE products..."로 시작하는 긴 SQL 문을 손으로 타이핑했습니다. 테이블 구조가 바뀌면 모든 SQL을 찾아서 수정해야 했습니다.
휴먼 에러가 자주 발생했고, 데이터베이스마다 SQL 문법이 달라서 MySQL에서 PostgreSQL로 바꾸려면 코드를 대대적으로 고쳐야 했습니다. JPA 엔티티는 이런 고통을 해결해줍니다.
@Entity 어노테이션만 붙이면 JPA가 알아서 테이블을 만들어줍니다. 필드를 추가하면 컬럼이 추가됩니다.
데이터베이스가 바뀌어도 엔티티 코드는 그대로입니다. JPA가 각 데이터베이스에 맞는 SQL을 자동으로 생성해주기 때문입니다.
코드를 한 줄씩 살펴보겠습니다. 먼저 @Entity와 @Table 어노테이션이 클래스를 테이블로 만들어줍니다.
@Table(name = "products")는 테이블 이름을 지정합니다. 다음으로 @Id와 @GeneratedValue는 기본 키를 정의합니다.
IDENTITY 전략은 데이터베이스의 AUTO_INCREMENT를 사용합니다. 데이터를 저장할 때마다 ID가 자동으로 1씩 증가합니다.
@Column 어노테이션은 컬럼의 속성을 지정합니다. nullable = false는 NOT NULL 제약조건입니다.
상품 이름과 가격은 반드시 있어야 하므로 필수입니다. length = 100은 문자열 길이 제한입니다.
상품명은 100자를 넘을 수 없습니다. columnDefinition = "TEXT"는 긴 텍스트를 저장할 수 있게 합니다.
상품 설명은 길어질 수 있으니 TEXT 타입이 적합합니다. BigDecimal을 가격 타입으로 사용한 이유가 있습니다.
float나 double은 소수점 계산에서 오차가 발생할 수 있습니다. 999.99원짜리 상품 3개를 계산하면 2999.97원이 나올 수도 있습니다.
금융 데이터에서는 절대 용납할 수 없는 오류입니다. BigDecimal은 정확한 계산을 보장합니다.
실무에서 돈과 관련된 필드는 항상 BigDecimal을 사용합니다. @CreatedDate와 @LastModifiedDate는 감사(Audit) 기능입니다.
데이터가 언제 생성되었고 언제 수정되었는지 자동으로 기록됩니다. 이 필드들은 디버깅할 때 매우 유용합니다.
"이 상품은 언제 등록되었지?" 같은 질문에 바로 답할 수 있습니다. 많은 기업들이 모든 엔티티에 이 필드를 추가합니다.
실무에서 엔티티를 설계할 때 주의할 점이 있습니다. 연관관계를 너무 복잡하게 만들지 마세요.
처음 배울 때는 @OneToMany, @ManyToOne을 남발하기 쉽습니다. 하지만 연관관계가 많아질수록 쿼리 성능이 떨어지고 순환 참조 문제가 발생합니다.
필요한 경우에만 신중하게 사용하세요. Lombok을 사용하면 코드가 간결해집니다.
@Getter와 @Setter는 getter/setter 메서드를 자동 생성합니다. @NoArgsConstructor는 기본 생성자를 만들어줍니다.
JPA는 리플렉션을 사용하기 때문에 기본 생성자가 필수입니다. 이런 어노테이션들 덕분에 보일러플레이트 코드를 줄일 수 있습니다.
박시니어 씨는 마지막으로 당부했습니다. "엔티티는 비즈니스 로직을 담지 마세요.
순수하게 데이터 구조만 표현해야 합니다." 김개발 씨는 이해했다는 듯 끄덕였습니다. 잘 설계된 엔티티는 프로젝트의 기초가 됩니다.
마치 튼튼한 기초 위에 건물을 짓는 것처럼, 명확한 엔티티 위에 서비스와 컨트롤러를 만들면 안정적인 애플리케이션이 완성됩니다. 여러분도 엔티티를 설계할 때 각 필드의 의미를 신중하게 고민해 보세요.
실전 팁
💡 - 금액 필드는 반드시 BigDecimal을 사용하세요 (float/double은 오차 발생)
- 생성일시/수정일시는 모든 엔티티에 추가하는 것이 좋습니다
- 연관관계는 꼭 필요한 경우에만 추가하고, 양방향보다는 단방향을 선호하세요
3. REST API 구현
엔티티 설계를 마친 김개발 씨는 이제 API를 만들 차례입니다. "컨트롤러랑 서비스를 어떻게 나눠야 하나요?" 박시니어 씨가 화이트보드를 가리키며 답했습니다.
"컨트롤러는 요청을 받고, 서비스는 비즈니스 로직을 처리하고, 리포지토리는 데이터를 저장합니다. 각자 역할이 명확해야 해요."
REST API는 HTTP 프로토콜을 사용하는 웹 서비스입니다. 마치 식당에서 주문서를 작성하는 것과 같습니다.
손님(클라이언트)이 메뉴판(API 명세)을 보고 주문서(HTTP 요청)를 보내면 주방(서버)에서 요리(데이터)를 만들어 제공합니다. Spring Boot에서는 @RestController와 @Service, @Repository로 계층을 분리합니다.
다음 코드를 살펴봅시다.
// ProductController.java
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping
public ResponseEntity<Product> createProduct(@RequestBody @Valid ProductRequest request) {
Product product = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED).body(product);
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
Product product = productService.getProduct(id);
return ResponseEntity.ok(product);
}
@GetMapping
public ResponseEntity<List<Product>> getAllProducts() {
List<Product> products = productService.getAllProducts();
return ResponseEntity.ok(products);
}
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@RequestBody @Valid ProductRequest request) {
Product product = productService.updateProduct(id, request);
return ResponseEntity.ok(product);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
김개발 씨는 코드를 보며 질문했습니다. "왜 컨트롤러와 서비스를 나눠야 하나요?
컨트롤러에서 다 처리하면 안 되나요?" 박시니어 씨는 고개를 저었습니다. "계층을 나누는 데는 이유가 있어요." 계층 분리란 무엇일까요?
쉽게 비유하자면, 병원의 역할 분담과 같습니다. 접수 데스크는 환자를 맞이하고 기본 정보를 받습니다.
의사는 진단하고 치료 방법을 결정합니다. 약국은 처방전에 따라 약을 줍니다.
각자 자기 역할에만 집중하니 효율적입니다. 접수 직원이 진료까지 하면 혼란스럽겠죠?
계층을 나누지 않으면 어떤 문제가 생길까요? 모든 코드가 컨트롤러에 들어갑니다.
데이터 검증, 비즈니스 로직, 데이터베이스 접근이 한 곳에 섞입니다. 처음에는 괜찮아 보이지만 기능이 추가될수록 컨트롤러가 수백 줄로 늘어납니다.
버그를 찾기도 어렵고, 테스트 코드 작성도 힘들어집니다. 다른 개발자가 이해하기도 어렵습니다.
계층 분리는 이런 혼란을 막아줍니다. 컨트롤러는 HTTP 요청과 응답만 처리합니다.
@PostMapping, @GetMapping 같은 어노테이션으로 URL을 매핑하고, 요청 데이터를 받아서 서비스로 넘깁니다. 서비스는 비즈니스 로직을 담당합니다.
상품 재고가 충분한지 확인하고, 가격 계산을 하고, 데이터를 저장합니다. 리포지토리는 데이터베이스 CRUD만 처리합니다.
코드를 상세히 분석해보겠습니다. @RestController는 이 클래스가 REST API 컨트롤러임을 나타냅니다.
@Controller와 @ResponseBody를 합친 것입니다. 반환값이 자동으로 JSON으로 변환됩니다.
@RequestMapping("/api/products")는 모든 메서드가 /api/products로 시작하는 경로를 사용하게 합니다. @RequiredArgsConstructor는 Lombok의 의존성 주입 기능입니다.
final 필드에 대한 생성자를 자동으로 만들어줍니다. ProductService를 주입받을 수 있습니다.
생성자 주입 방식이 필드 주입보다 테스트하기 쉽고 불변성을 보장합니다. Spring 공식 문서도 생성자 주입을 권장합니다.
각 메서드는 하나의 HTTP 동작을 담당합니다. createProduct는 POST 요청을 받아 상품을 생성합니다.
@RequestBody는 JSON 데이터를 자바 객체로 변환합니다. @Valid는 입력값 검증을 수행합니다.
ResponseEntity.status(HttpStatus.CREATED)는 201 상태 코드를 반환합니다. REST 표준에서 생성 성공은 201입니다.
getProduct는 경로 변수를 사용합니다. @PathVariable Long id는 URL의 {id} 부분을 파라미터로 받습니다.
/api/products/1을 요청하면 id가 1이 됩니다. 서비스에서 해당 상품을 찾아 반환합니다.
상품이 없으면 예외가 발생하고, 클라이언트는 404 응답을 받습니다. updateProduct는 PUT 메서드를 사용합니다.
전체 데이터를 교체하는 것이 PUT의 의미입니다. PATCH는 일부만 수정할 때 사용합니다.
실무에서는 PUT과 PATCH를 명확히 구분해서 사용하는 것이 좋습니다. 클라이언트가 API의 의도를 쉽게 파악할 수 있기 때문입니다.
deleteProduct는 204 상태 코드를 반환합니다. 삭제 성공 시 응답 본문이 필요 없으므로 noContent()를 사용합니다.
클라이언트는 상태 코드만 확인하면 됩니다. 이렇게 HTTP 상태 코드를 적절히 사용하면 API가 RESTful 해집니다.
예외 처리는 어떻게 할까요? 실무에서는 @ControllerAdvice를 사용해 전역 예외 처리를 합니다.
모든 컨트롤러에서 발생한 예외를 한곳에서 처리할 수 있습니다. 코드 중복이 줄고 일관된 에러 응답을 제공할 수 있습니다.
박시니어 씨는 마지막으로 강조했습니다. "컨트롤러는 얇게 유지하세요.
비즈니스 로직은 서비스에 두어야 테스트하기 쉽습니다." 김개발 씨는 이제 계층 분리의 중요성을 이해했습니다. 잘 설계된 REST API는 직관적이고 유지보수하기 쉽습니다.
HTTP 메서드와 상태 코드를 올바르게 사용하고, 계층을 명확히 분리하면 프론트엔드 개발자도 쉽게 사용할 수 있는 API가 완성됩니다. 여러분도 API를 만들 때 각 계층의 책임을 명확히 해보세요.
실전 팁
💡 - 컨트롤러는 HTTP 처리만, 서비스는 비즈니스 로직만 담당하도록 책임을 분리하세요
- HTTP 상태 코드를 정확히 사용하세요 (200 OK, 201 Created, 204 No Content, 404 Not Found)
- 전역 예외 처리는 @ControllerAdvice를 사용하면 코드가 깔끔해집니다
4. OpenAPI 문서화
API 구현을 마친 김개발 씨는 뿌듯했습니다. 그런데 프론트엔드 팀에서 메시지가 왔습니다.
"API 문서 어디 있나요? 어떤 파라미터를 보내야 하는지 모르겠어요." 김개발 씨는 당황했습니다.
문서를 따로 작성해야 한다는 것을 깜빡했습니다.
OpenAPI는 REST API를 표준화된 방식으로 문서화하는 명세입니다. 마치 제품 사용 설명서처럼 API의 모든 것을 설명합니다.
Springdoc을 사용하면 코드에서 자동으로 문서가 생성되고 Swagger UI로 바로 테스트할 수 있습니다. 수동으로 문서를 작성할 필요가 없어 항상 코드와 동기화됩니다.
다음 코드를 살펴봅시다.
// build.gradle에 의존성 추가
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// ProductController.java에 어노테이션 추가
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
@Tag(name = "Product", description = "상품 관리 API")
public class ProductController {
@Operation(summary = "상품 생성", description = "새로운 상품을 등록합니다")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "생성 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청")
})
@PostMapping
public ResponseEntity<Product> createProduct(
@Parameter(description = "상품 정보", required = true)
@RequestBody @Valid ProductRequest request) {
Product product = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED).body(product);
}
}
박시니어 씨가 김개발 씨의 화면을 보며 말했습니다. "API 문서는 개발의 필수 요소예요.
하지만 수동으로 작성하면 금방 낡아요. 자동화가 답입니다." API 문서 자동화란 무엇일까요?
쉽게 비유하자면, 사진을 찍으면 자동으로 앨범이 만들어지는 것과 같습니다. 일일이 사진을 정리하고 설명을 적을 필요가 없습니다.
API 문서도 마찬가지입니다. 코드를 작성하면 문서가 자동으로 생성됩니다.
엔드포인트가 추가되면 문서에도 자동으로 나타납니다. 수동으로 문서를 작성하면 어떤 문제가 생길까요?
코드는 계속 변하는데 문서는 업데이트되지 않습니다. API가 변경되었는데 문서에는 예전 내용이 남아있습니다.
프론트엔드 개발자가 문서를 믿고 개발했다가 에러가 발생합니다. "왜 안 되죠?"라고 물어보면 "아, 그거 바뀌었어요"라고 답하게 됩니다.
신뢰가 무너집니다. OpenAPI와 Springdoc이 이 문제를 해결합니다.
Springdoc은 Spring Boot 애플리케이션의 컨트롤러를 분석해서 자동으로 OpenAPI 명세를 생성합니다. 어노테이션만 추가하면 됩니다.
서버를 실행하고 /swagger-ui.html에 접속하면 예쁜 UI로 모든 API를 볼 수 있습니다. 심지어 바로 테스트도 할 수 있습니다.
코드를 자세히 살펴보겠습니다. 먼저 build.gradle에 springdoc-openapi-starter-webmvc-ui 라이브러리를 추가합니다.
Spring Boot 3.x에서는 이 라이브러리를 사용합니다. 의존성을 추가하고 서버를 재시작하면 자동으로 문서가 생성됩니다.
별도 설정이 필요 없습니다. @Tag 어노테이션은 API 그룹을 정의합니다.
같은 도메인의 API를 묶어줍니다. "Product"라는 이름으로 상품 관련 API를 그룹화합니다.
Swagger UI에서 보면 Product 섹션 아래 모든 상품 API가 모여 있습니다. 문서를 보는 사람이 쉽게 원하는 API를 찾을 수 있습니다.
@Operation은 각 API의 상세 정보를 제공합니다. summary는 짧은 요약입니다.
"상품 생성"처럼 한눈에 무슨 API인지 알 수 있게 작성합니다. description은 좀 더 자세한 설명입니다.
"새로운 상품을 등록합니다"처럼 동작을 설명합니다. 프론트엔드 개발자가 이 정보만 봐도 API 사용법을 이해할 수 있습니다.
@ApiResponses는 응답 상태 코드를 문서화합니다. 어떤 상황에 어떤 상태 코드가 반환되는지 명시합니다.
201은 생성 성공, 400은 잘못된 요청입니다. 프론트엔드에서 에러 처리를 할 때 이 정보가 매우 유용합니다.
"400이 오면 유효성 검사 실패 메시지를 보여줘야겠다"고 판단할 수 있습니다. @Parameter는 요청 파라미터를 설명합니다.
description에 "상품 정보"라고 적으면 문서에 표시됩니다. required = true는 필수 파라미터임을 나타냅니다.
Swagger UI에서 이 정보를 보고 어떤 데이터를 보내야 하는지 알 수 있습니다. 실무에서는 DTO에도 어노테이션을 추가합니다.
ProductRequest 클래스의 각 필드에 @Schema 어노테이션을 붙이면 더 자세한 문서가 생성됩니다. 필드 타입, 필수 여부, 예시 값까지 표시됩니다.
이렇게 하면 API 사용자가 정확히 어떤 형태의 JSON을 보내야 하는지 알 수 있습니다. Swagger UI는 단순히 문서만 보는 게 아닙니다.
"Try it out" 버튼을 누르면 바로 API를 호출할 수 있습니다. 파라미터를 입력하고 Execute를 누르면 실제 서버에 요청이 전송됩니다.
응답도 바로 확인할 수 있습니다. 개발 중에 Postman 대신 Swagger UI를 사용하면 편리합니다.
보안 설정도 문서화할 수 있습니다. JWT 인증을 사용한다면 @SecurityRequirement 어노테이션을 추가합니다.
Swagger UI에서 토큰을 입력할 수 있는 버튼이 생깁니다. 인증이 필요한 API를 테스트할 때 매번 헤더를 수동으로 설정할 필요가 없습니다.
김개발 씨는 Swagger UI를 열어보고 감탄했습니다. "우와, 제가 만든 API가 이렇게 예쁘게 문서화되네요!" 박시니어 씨가 웃으며 말했습니다.
"이제 프론트엔드 팀에게 이 링크만 보내면 됩니다." 자동 문서화는 팀 협업을 원활하게 만듭니다. 개발자는 코드만 작성하면 되고, API 사용자는 항상 최신 문서를 볼 수 있습니다.
문서와 코드가 따로 노는 일이 없어집니다. 여러분도 API를 만들 때 문서화를 습관화해보세요.
실전 팁
💡 - 모든 컨트롤러에 @Tag와 @Operation을 추가하는 습관을 들이세요
- DTO 클래스에도 @Schema를 추가하면 더 친절한 문서가 됩니다
- Swagger UI는 개발 환경에서만 노출하고, 프로덕션에서는 비활성화하세요
5. 테스트 코드 작성
문서화까지 완료한 김개발 씨는 이제 끝났다고 생각했습니다. 하지만 박시니어 씨가 물었습니다.
"테스트 코드는 작성했나요?" 김개발 씨는 난처한 표정을 지었습니다. "수동으로 Swagger UI에서 다 테스트했는데요..." 박시니어 씨는 고개를 저었습니다.
"자동화된 테스트가 있어야 합니다."
테스트 코드는 코드가 의도대로 동작하는지 자동으로 검증하는 프로그램입니다. 마치 품질 검사원이 제품을 하나하나 확인하는 것과 같습니다.
JUnit과 MockMvc를 사용하면 HTTP 요청을 보내지 않고도 API를 테스트할 수 있습니다. 코드를 수정해도 테스트가 통과하면 안심할 수 있습니다.
다음 코드를 살펴봅시다.
// ProductControllerTest.java
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
@DisplayName("상품 생성 API 테스트")
void createProduct_Success() throws Exception {
// Given
ProductRequest request = new ProductRequest("노트북", "고성능 노트북",
new BigDecimal("1500000"), 10);
Product product = new Product(1L, "노트북", "고성능 노트북",
new BigDecimal("1500000"), 10);
when(productService.createProduct(any())).thenReturn(product);
// When & Then
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("노트북"))
.andExpect(jsonPath("$.price").value(1500000));
}
}
김개발 씨는 의아했습니다. "수동 테스트랑 뭐가 다른가요?" 박시니어 씨는 커피를 한 모금 마시고 설명을 시작했습니다.
테스트 자동화가 왜 필요할까요? 쉽게 비유하자면, 자동차 공장의 품질 검사와 같습니다.
수동 검사는 사람이 일일이 확인합니다. 피곤하면 실수할 수 있고, 시간도 오래 걸립니다.
자동 검사는 기계가 정해진 기준대로 확인합니다. 빠르고 정확하며 언제든 반복할 수 있습니다.
테스트 코드가 없으면 어떤 일이 벌어질까요? 코드를 수정할 때마다 불안합니다.
"이 수정이 다른 기능을 망가뜨리지는 않을까?" 걱정됩니다. 모든 기능을 수동으로 테스트하려면 시간이 엄청나게 걸립니다.
결국 일부만 테스트하게 되고, 숨어있던 버그가 프로덕션에 배포됩니다. 사용자가 버그를 발견하면 이미 늦었습니다.
테스트 코드는 이런 불안을 없애줍니다. 코드를 수정하고 테스트를 실행하면 몇 초 안에 결과가 나옵니다.
모든 테스트가 통과하면 안심하고 배포할 수 있습니다. 테스트가 실패하면 어디가 문제인지 바로 알 수 있습니다.
버그를 사용자가 발견하기 전에 개발자가 먼저 찾습니다. 코드를 단계별로 분석해보겠습니다.
@WebMvcTest는 컨트롤러 레이어만 테스트합니다. 전체 애플리케이션을 실행하지 않으므로 테스트가 빠릅니다.
ProductController만 로드하고 나머지는 무시합니다. 단위 테스트의 핵심은 격리입니다.
테스트하려는 부분만 집중하는 것입니다. @MockBean은 가짜 객체를 주입합니다.
ProductService의 실제 구현을 사용하지 않습니다. 데이터베이스에 접근하지도 않습니다.
Mock 객체는 미리 정의한 대로만 동작합니다. 이렇게 하면 컨트롤러 로직만 순수하게 테스트할 수 있습니다.
Given-When-Then 패턴은 테스트를 구조화합니다. Given은 테스트 준비 단계입니다.
어떤 데이터가 필요한지, Mock 객체가 무엇을 반환할지 정의합니다. When은 실제 동작입니다.
API를 호출합니다. Then은 검증입니다.
결과가 예상과 맞는지 확인합니다. 이 패턴을 따르면 테스트 코드를 읽기 쉽습니다.
MockMvc는 실제 HTTP 요청을 시뮬레이션합니다. 서버를 띄우지 않고도 컨트롤러를 테스트할 수 있습니다.
perform()으로 요청을 보내고, andExpect()로 응답을 검증합니다. status().isCreated()는 201 상태 코드를 기대합니다.
jsonPath()는 JSON 응답의 특정 필드를 검증합니다. when().thenReturn()은 Mock의 동작을 정의합니다.
productService.createProduct()가 호출되면 미리 준비한 product 객체를 반환합니다. 실제 데이터베이스에 저장하지 않습니다.
테스트가 끝나도 데이터베이스는 깨끗합니다. 테스트는 항상 독립적이어야 합니다.
실무에서는 여러 시나리오를 테스트합니다. 정상 케이스만 테스트하면 부족합니다.
잘못된 입력이 들어왔을 때, 존재하지 않는 ID를 조회할 때, 권한이 없을 때 등 다양한 경우를 테스트해야 합니다. 이런 엣지 케이스를 테스트하면 예상치 못한 버그를 미리 발견할 수 있습니다.
테스트 커버리지도 중요합니다. 코드의 몇 퍼센트가 테스트되고 있는지 측정하는 지표입니다.
JaCoCo 같은 도구를 사용하면 자동으로 측정됩니다. 일반적으로 80% 이상을 목표로 합니다.
하지만 100%가 목표는 아닙니다. 중요한 비즈니스 로직을 먼저 테스트하는 것이 효율적입니다.
통합 테스트도 필요합니다. @WebMvcTest는 단위 테스트입니다.
@SpringBootTest를 사용하면 전체 애플리케이션을 테스트할 수 있습니다. 실제 데이터베이스에 연결하고 전체 플로우를 검증합니다.
단위 테스트는 빠르고, 통합 테스트는 정확합니다. 둘 다 필요합니다.
박시니어 씨는 강조했습니다. "테스트 코드는 문서의 역할도 합니다.
테스트를 보면 API가 어떻게 동작해야 하는지 알 수 있어요." 김개발 씨는 이제 테스트의 가치를 이해했습니다. 테스트 코드는 처음에는 귀찮게 느껴질 수 있습니다.
하지만 프로젝트가 커질수록 그 가치가 빛납니다. 리팩토링할 때도, 새 기능을 추가할 때도 테스트가 있으면 자신감이 생깁니다.
여러분도 코드를 작성할 때 테스트를 함께 작성하는 습관을 들여보세요.
실전 팁
💡 - Given-When-Then 패턴으로 테스트를 구조화하면 읽기 쉽습니다
- 정상 케이스뿐 아니라 예외 상황도 테스트하세요 (404, 400 등)
- 단위 테스트(@WebMvcTest)와 통합 테스트(@SpringBootTest)를 적절히 섞어서 사용하세요
6. Docker Compose 배포
모든 코드를 완성한 김개발 씨는 로컬에서 잘 동작하는 것을 확인했습니다. 이제 팀원들과 공유하려고 하는데 문제가 생겼습니다.
"제 컴퓨터에서는 되는데요..." 박시니어 씨가 웃으며 말했습니다. "Docker를 사용하면 누구 환경에서나 똑같이 동작해요."
Docker Compose는 여러 컨테이너를 하나의 설정 파일로 관리하는 도구입니다. 마치 레고 블록을 조립하듯이 애플리케이션과 데이터베이스를 하나로 묶습니다.
개발자마다 다른 환경 설정 때문에 고생할 필요가 없습니다. docker-compose up 명령 하나면 전체 환경이 실행됩니다.
다음 코드를 살펴봅시다.
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15
container_name: product-db
environment:
POSTGRES_DB: productdb
POSTGRES_USER: admin
POSTGRES_PASSWORD: admin123
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
app:
build: .
container_name: product-service
depends_on:
- postgres
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/productdb
SPRING_DATASOURCE_USERNAME: admin
SPRING_DATASOURCE_PASSWORD: admin123
ports:
- "8080:8080"
volumes:
postgres-data:
김개발 씨는 궁금했습니다. "왜 다들 Docker를 사용하나요?" 박시니어 씨는 예전 이야기를 꺼냈습니다.
"옛날에는 '내 컴퓨터에서는 되는데'가 개발자의 악몽이었어요." 환경 차이는 어떤 문제를 일으킬까요? 쉽게 비유하자면, 악보와 악기의 차이 같은 것입니다.
같은 악보라도 피아노와 바이올린에서는 다르게 들립니다. 코드도 마찬가지입니다.
Windows에서 작성한 코드가 Linux에서 다르게 동작할 수 있습니다. Java 버전이 다르면 컴파일 에러가 납니다.
데이터베이스 버전이 다르면 쿼리가 실패합니다. Docker가 없던 시절에는 어땠을까요?
신입 개발자가 입사하면 환경 설정에 하루 이틀이 걸렸습니다. "Java 설치하고, PostgreSQL 설치하고, 환경변수 설정하고..." 복잡한 매뉴얼을 따라 했습니다.
한 단계라도 잘못하면 동작하지 않았습니다. 선배 개발자가 옆에서 도와줘야 했습니다.
생산성이 떨어졌습니다. Docker는 이 모든 과정을 자동화합니다.
Docker 이미지는 실행에 필요한 모든 것을 담고 있습니다. 운영체제, 라이브러리, 애플리케이션까지 패키징됩니다.
어떤 컴퓨터에서든 이미지만 실행하면 똑같이 동작합니다. 신입 개발자도 docker-compose up 명령 하나면 개발 환경이 준비됩니다.
docker-compose.yml 파일을 분석해보겠습니다. version은 Docker Compose 파일 형식 버전입니다.
3.8은 현재 많이 사용되는 버전입니다. services는 실행할 컨테이너들을 정의합니다.
여기서는 postgres와 app 두 개의 서비스를 정의했습니다. postgres 서비스는 데이터베이스입니다.
image: postgres:15는 PostgreSQL 15 버전을 사용한다는 의미입니다. Docker Hub에서 자동으로 이미지를 내려받습니다.
environment는 환경변수를 설정합니다. 데이터베이스 이름, 사용자, 비밀번호를 정의합니다.
실제 프로덕션에서는 비밀번호를 코드에 직접 쓰면 안 됩니다. 별도의 시크릿 관리 도구를 사용해야 합니다.
ports는 포트 매핑입니다. "5432:5432"는 호스트의 5432 포트를 컨테이너의 5432 포트와 연결합니다.
로컬 컴퓨터에서 localhost:5432로 데이터베이스에 접속할 수 있습니다. DBeaver 같은 클라이언트 도구로 직접 데이터를 확인할 수 있어 디버깅에 유용합니다.
volumes는 데이터를 영구 저장합니다. 컨테이너를 삭제하면 그 안의 데이터도 사라집니다.
볼륨을 사용하면 데이터가 호스트에 저장됩니다. 컨테이너를 재시작해도 데이터가 유지됩니다.
postgres-data라는 이름의 볼륨에 데이터베이스 파일이 저장됩니다. app 서비스는 Spring Boot 애플리케이션입니다.
build: .는 현재 디렉토리의 Dockerfile로 이미지를 빌드합니다. Dockerfile에는 Java 설치, 애플리케이션 빌드, 실행 명령이 들어갑니다.
depends_on은 실행 순서를 정의합니다. postgres가 먼저 실행되고 나서 app이 실행됩니다.
환경변수로 데이터베이스 연결 정보를 전달합니다. SPRING_DATASOURCE_URL에서 호스트 이름이 postgres인 것에 주목하세요.
Docker Compose는 서비스 이름으로 자동 DNS를 제공합니다. app 컨테이너에서 postgres라는 이름으로 데이터베이스에 접근할 수 있습니다.
실제 배포는 어떻게 할까요? 로컬 개발에서는 docker-compose를 사용합니다.
프로덕션에서는 Kubernetes 같은 오케스트레이션 도구를 사용하는 경우가 많습니다. 하지만 소규모 서비스는 Docker Compose만으로도 충분합니다.
AWS ECS나 Digital Ocean App Platform에서 Docker Compose를 지원합니다. 모니터링과 로깅도 고려해야 합니다.
docker-compose logs 명령으로 로그를 볼 수 있습니다. 서비스 이름을 지정하면 특정 컨테이너의 로그만 볼 수 있습니다.
Prometheus와 Grafana를 추가하면 메트릭을 수집하고 대시보드로 모니터링할 수 있습니다. 박시니어 씨는 마지막으로 조언했습니다.
"Docker는 처음에 어렵게 느껴질 수 있어요. 하지만 한번 익히면 개발 생산성이 엄청나게 올라갑니다." 김개발 씨는 이제 Docker의 가치를 이해했습니다.
Docker Compose는 현대 개발의 필수 도구입니다. 환경 설정 문제로 고생할 시간에 실제 기능 개발에 집중할 수 있습니다.
팀원들과 같은 환경에서 작업하니 협업도 원활해집니다. 여러분도 프로젝트를 시작할 때 Docker Compose를 활용해보세요.
실전 팁
💡 - .env 파일로 환경변수를 분리하면 민감한 정보를 안전하게 관리할 수 있습니다
- docker-compose down -v로 볼륨까지 삭제하면 깨끗하게 초기화됩니다
- 프로덕션 환경에서는 보안을 위해 비밀번호를 하드코딩하지 마세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.