본 문서는 REST API 시리즈 중 2편입니다. 1편에서는 REST API의 기본 개념과 설계 원칙을 다루었으며, 이번 편에서는 Spring Boot를 활용한 구현 방법을 설명합니다.
1. Spring Boot 소개
Spring Boot는 Java 기반의 애플리케이션 개발을 단순화하는 Spring Framework의 확장으로, 복잡한 설정 없이도 빠르게 프로덕션 수준의 애플리케이션을 개발할 수 있게 해줍니다.
특징 설명
| 자동 설정 | 의존성을 기반으로 애플리케이션 설정을 자동화 |
| 내장 서버 | Tomcat, Jetty, Undertow 등의 서버를 내장 |
| 스타터 의존성 | 목적별로 필요한 의존성을 묶어 제공 |
| 모니터링 | Actuator를 통한 애플리케이션 모니터링 지원 |
| 프로덕션 준비 | 메트릭, 상태 확인, 외부화된 설정 등 지원 |
2. 개발 환경 설정
2.1 필수 도구
- JDK 17 이상
- Maven 3.6+ 또는 Gradle 7.0+
- IDE (IntelliJ IDEA, Eclipse, VS Code 등)
2.2 프로젝트 생성
Spring Initializr(https://start.spring.io/)를 통해 프로젝트를 생성할 수 있습니다:
설정 항목 권장 설정
| Project | Maven 또는 Gradle |
| Language | Java |
| Spring Boot | 3.2.x (최신 안정 버전) |
| Packaging | Jar |
| Dependencies | Spring Web, Spring Data JPA, H2 Database, Lombok, Validation |
3. REST API 프로젝트 구조
Spring Boot 프로젝트의 표준 구조:
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ └── demo/
│ │ ├── DemoApplication.java
│ │ ├── controller/
│ │ │ └── UserController.java
│ │ ├── service/
│ │ │ └── UserService.java
│ │ ├── repository/
│ │ │ └── UserRepository.java
│ │ ├── entity/
│ │ │ └── User.java
│ │ ├── dto/
│ │ │ ├── UserRequestDto.java
│ │ │ └── UserResponseDto.java
│ │ ├── exception/
│ │ │ ├── GlobalExceptionHandler.java
│ │ │ ├── ResourceNotFoundException.java
│ │ │ └── ErrorResponse.java
│ │ └── config/
│ │ └── SwaggerConfig.java
│ └── resources/
│ ├── application.properties
│ ├── application-dev.properties
│ └── application-prod.properties
└── test/
└── java/
└── com/
└── example/
└── demo/
├── controller/
│ └── UserControllerTest.java
└── service/
└── UserServiceTest.java
4. 기본 의존성 설정
4.1 Maven (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>rest-api-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rest-api-demo</name>
<description>Demo REST API project with Spring Boot</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- SpringDoc OpenAPI (Swagger) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.2 Gradle (build.gradle)
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
5. 애플리케이션 설정
5.1 application.properties
# 서버 설정
server.port=8080
server.servlet.context-path=/api
# 데이터베이스 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# H2 콘솔 설정
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# JPA 설정
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# 로깅 설정
logging.level.org.springframework=INFO
logging.level.com.example=DEBUG
# OpenAPI 설정
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method
6. 모델 및 엔티티 정의
6.1 User 엔티티
package com.example.demo.entity;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(length = 20)
private String phone;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
6.2 DTO (Data Transfer Objects)
UserRequestDto.java
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDto {
@NotBlank(message = "이름은 필수 입력 항목입니다")
@Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
private String name;
@NotBlank(message = "이메일은 필수 입력 항목입니다")
@Email(message = "유효한 이메일 형식이 아닙니다")
private String email;
@Pattern(regexp = "^[0-9]{10,15}$", message = "전화번호는 10~15자의 숫자여야 합니다")
private String phone;
}
UserResponseDto.java
package com.example.demo.dto;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserResponseDto {
private Long id;
private String name;
private String email;
private String phone;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
7. Repository 계층
7.1 UserRepository.java
package com.example.demo.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.entity.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
boolean existsByEmail(String email);
}
8. Service 계층
8.1 UserService 인터페이스
package com.example.demo.service;
import java.util.List;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.dto.UserResponseDto;
public interface UserService {
UserResponseDto createUser(UserRequestDto userRequestDto);
UserResponseDto getUserById(Long id);
List<UserResponseDto> getAllUsers();
List<UserResponseDto> searchUsersByName(String name);
UserResponseDto updateUser(Long id, UserRequestDto userRequestDto);
void deleteUser(Long id);
}
8.2 UserServiceImpl.java
package com.example.demo.service.impl;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.dto.UserResponseDto;
import com.example.demo.entity.User;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.exception.ResourceAlreadyExistsException;
import com.example.demo.repository.UserRepository;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Override
@Transactional
public UserResponseDto createUser(UserRequestDto userRequestDto) {
// 이메일 중복 검사
if (userRepository.existsByEmail(userRequestDto.getEmail())) {
throw new ResourceAlreadyExistsException("이미 등록된 이메일입니다: " + userRequestDto.getEmail());
}
User user = User.builder()
.name(userRequestDto.getName())
.email(userRequestDto.getEmail())
.phone(userRequestDto.getPhone())
.build();
User savedUser = userRepository.save(user);
return mapToResponseDto(savedUser);
}
@Override
@Transactional(readOnly = true)
public UserResponseDto getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("ID에 해당하는 사용자를 찾을 수 없습니다: " + id));
return mapToResponseDto(user);
}
@Override
@Transactional(readOnly = true)
public List<UserResponseDto> getAllUsers() {
return userRepository.findAll().stream()
.map(this::mapToResponseDto)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<UserResponseDto> searchUsersByName(String name) {
return userRepository.findByNameContainingIgnoreCase(name).stream()
.map(this::mapToResponseDto)
.collect(Collectors.toList());
}
@Override
@Transactional
public UserResponseDto updateUser(Long id, UserRequestDto userRequestDto) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("ID에 해당하는 사용자를 찾을 수 없습니다: " + id));
// 이메일이 변경되었고, 그 이메일이 이미 존재하는 경우
if (!user.getEmail().equals(userRequestDto.getEmail()) &&
userRepository.existsByEmail(userRequestDto.getEmail())) {
throw new ResourceAlreadyExistsException("이미 등록된 이메일입니다: " + userRequestDto.getEmail());
}
user.setName(userRequestDto.getName());
user.setEmail(userRequestDto.getEmail());
user.setPhone(userRequestDto.getPhone());
User updatedUser = userRepository.save(user);
return mapToResponseDto(updatedUser);
}
@Override
@Transactional
public void deleteUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("ID에 해당하는 사용자를 찾을 수 없습니다: " + id));
userRepository.delete(user);
}
private UserResponseDto mapToResponseDto(User user) {
return UserResponseDto.builder()
.id(user.getId())
.name(user.getName())
.email(user.getEmail())
.phone(user.getPhone())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build();
}
}
9. Controller 계층
9.1 UserController.java
package com.example.demo.controller;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.dto.UserResponseDto;
import com.example.demo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Tag(name = "User Controller", description = "사용자 관리 API")
public class UserController {
private final UserService userService;
@PostMapping
@Operation(summary = "사용자 생성", description = "새로운 사용자를 생성합니다.")
@ApiResponse(responseCode = "201", description = "사용자 생성 성공")
public ResponseEntity<UserResponseDto> createUser(@Valid @RequestBody UserRequestDto userRequestDto) {
UserResponseDto createdUser = userService.createUser(userRequestDto);
return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
}
@GetMapping("/{id}")
@Operation(summary = "사용자 조회", description = "ID로 사용자를 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
@ApiResponse(responseCode = "404", description = "사용자 없음", content = @Content)
public ResponseEntity<UserResponseDto> getUserById(
@Parameter(description = "사용자 ID") @PathVariable Long id) {
UserResponseDto user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@GetMapping
@Operation(summary = "사용자 목록 조회", description = "모든 사용자 목록을 조회하거나 이름으로 검색합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
public ResponseEntity<List<UserResponseDto>> getUsers(
@Parameter(description = "검색할 사용자 이름 (선택사항)")
@RequestParam(required = false) String name) {
List<UserResponseDto> users = (name != null)
? userService.searchUsersByName(name)
: userService.getAllUsers();
return ResponseEntity.ok(users);
}
@PutMapping("/{id}")
@Operation(summary = "사용자 정보 수정", description = "ID로 사용자 정보를 수정합니다.")
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "404", description = "사용자 없음", content = @Content)
public ResponseEntity<UserResponseDto> updateUser(
@Parameter(description = "사용자 ID") @PathVariable Long id,
@Valid @RequestBody UserRequestDto userRequestDto) {
UserResponseDto updatedUser = userService.updateUser(id, userRequestDto);
return ResponseEntity.ok(updatedUser);
}
@DeleteMapping("/{id}")
@Operation(summary = "사용자 삭제", description = "ID로 사용자를 삭제합니다.")
@ApiResponse(responseCode = "204", description = "삭제 성공")
@ApiResponse(responseCode = "404", description = "사용자 없음", content = @Content)
public ResponseEntity<Void> deleteUser(
@Parameter(description = "사용자 ID") @PathVariable Long id) {
userService.deleteUser(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
10. 예외 처리
10.1 사용자 정의 예외 클래스
ResourceNotFoundException.java
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
ResourceAlreadyExistsException.java
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.CONFLICT)
public class ResourceAlreadyExistsException extends RuntimeException {
public ResourceAlreadyExistsException(String message) {
super(message);
}
}
10.2 글로벌 예외 핸들러
ErrorResponse.java
package com.example.demo.exception;
import java.time.LocalDateTime;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
private List<ValidationError> validationErrors;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ValidationError {
private String field;
private String message;
}
}
GlobalExceptionHandler.java
package com.example.demo.exception;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import lombok.extern.slf4j.Slf4j;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error(HttpStatus.NOT_FOUND.getReasonPhrase())
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ResourceAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleResourceAlreadyExistsException(
ResourceAlreadyExistsException ex, WebRequest request) {
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.CONFLICT.value())
.error(HttpStatus.CONFLICT.getReasonPhrase())
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.CONFLICT);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex, WebRequest request) {
List<ErrorResponse.ValidationError> validationErrors = new ArrayList<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
ErrorResponse.ValidationError validationError = ErrorResponse.ValidationError.builder()
.field(fieldError.getField())
.message(fieldError.getDefaultMessage())
.build();
validationErrors.add(validationError);
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error(HttpStatus.BAD_REQUEST.getReasonPhrase())
.message("입력값 검증에 실패했습니다")
.path(request.getDescription(false).replace("uri=", ""))
.validationErrors(validationErrors)
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(
Exception ex, WebRequest request) {
log.error("Unexpected error occurred", ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.message("서버 내부 오류가 발생했습니다. 관리자에게 문의하세요.")
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
11. API 문서화 설정 (Swagger/OpenAPI)
11.1 OpenAPI 설정
package com.example.demo.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Spring Boot REST API")
.description("Spring Boot을 이용한 REST API 예제")
.version("v1.0.0")
.contact(new Contact()
.name("API Support")
.url("https://www.example.com")
.email("support@example.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0")))
.servers(List.of(
new Server()
.url("http://localhost:8080/api")
.description("Local Development Server")
));
}
}
12. 단위 테스트
12.1 Service 계층 테스트 (UserServiceTest.java)
package com.example.demo.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.dto.UserResponseDto;
import com.example.demo.entity.User;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.exception.ResourceAlreadyExistsException;
import com.example.demo.repository.UserRepository;
import com.example.demo.service.impl.UserServiceImpl;
import java.time.LocalDateTime;
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserServiceImpl userService;
private User user;
private UserRequestDto userRequestDto;
@BeforeEach
void setUp() {
LocalDateTime now = LocalDateTime.now();
user = User.builder()
.id(1L)
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.createdAt(now)
.build();
userRequestDto = UserRequestDto.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
}
@Test
@DisplayName("사용자 생성 성공 테스트")
void createUserSuccess() {
// Given
when(userRepository.existsByEmail(anyString())).thenReturn(false);
when(userRepository.save(any(User.class))).thenReturn(user);
// When
UserResponseDto result = userService.createUser(userRequestDto);
// Then
assertNotNull(result);
assertEquals(user.getId(), result.getId());
assertEquals(user.getName(), result.getName());
assertEquals(user.getEmail(), result.getEmail());
verify(userRepository, times(1)).existsByEmail(anyString());
verify(userRepository, times(1)).save(any(User.class));
}
@Test
@DisplayName("중복 이메일로 사용자 생성 실패 테스트")
void createUserFailDuplicateEmail() {
// Given
when(userRepository.existsByEmail(anyString())).thenReturn(true);
// When & Then
assertThrows(ResourceAlreadyExistsException.class, () -> {
userService.createUser(userRequestDto);
});
verify(userRepository, times(1)).existsByEmail(anyString());
verify(userRepository, never()).save(any(User.class));
}
@Test
@DisplayName("ID로 사용자 조회 성공 테스트")
void getUserByIdSuccess() {
// Given
when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));
// When
UserResponseDto result = userService.getUserById(1L);
// Then
assertNotNull(result);
assertEquals(user.getId(), result.getId());
assertEquals(user.getName(), result.getName());
verify(userRepository, times(1)).findById(anyLong());
}
@Test
@DisplayName("존재하지 않는 ID로 사용자 조회 실패 테스트")
void getUserByIdNotFound() {
// Given
when(userRepository.findById(anyLong())).thenReturn(Optional.empty());
// When & Then
assertThrows(ResourceNotFoundException.class, () -> {
userService.getUserById(1L);
});
verify(userRepository, times(1)).findById(anyLong());
}
@Test
@DisplayName("모든 사용자 조회 테스트")
void getAllUsers() {
// Given
User user2 = User.builder()
.id(2L)
.name("Jane Doe")
.email("jane@example.com")
.phone("9876543210")
.createdAt(LocalDateTime.now())
.build();
when(userRepository.findAll()).thenReturn(Arrays.asList(user, user2));
// When
List<UserResponseDto> result = userService.getAllUsers();
// Then
assertNotNull(result);
assertEquals(2, result.size());
verify(userRepository, times(1)).findAll();
}
}
12.2 Controller 계층 테스트 (UserControllerTest.java)
package com.example.demo.controller;
import static org.hamcrest.CoreMatchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.dto.UserResponseDto;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
private UserRequestDto userRequestDto;
private UserResponseDto userResponseDto;
@BeforeEach
void setUp() {
LocalDateTime now = LocalDateTime.now();
userRequestDto = UserRequestDto.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
userResponseDto = UserResponseDto.builder()
.id(1L)
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.createdAt(now)
.build();
}
@Test
@DisplayName("사용자 생성 API 테스트")
void createUserTest() throws Exception {
when(userService.createUser(any(UserRequestDto.class))).thenReturn(userResponseDto);
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userRequestDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.name", is("John Doe")))
.andExpect(jsonPath("$.email", is("john@example.com")));
}
@Test
@DisplayName("ID로 사용자 조회 API 테스트")
void getUserByIdTest() throws Exception {
when(userService.getUserById(anyLong())).thenReturn(userResponseDto);
mockMvc.perform(get("/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.name", is("John Doe")))
.andExpect(jsonPath("$.email", is("john@example.com")));
}
@Test
@DisplayName("존재하지 않는 ID로 사용자 조회 API 테스트")
void getUserByIdNotFoundTest() throws Exception {
when(userService.getUserById(anyLong())).thenThrow(new ResourceNotFoundException("ID에 해당하는 사용자를 찾을 수 없습니다: 1"));
mockMvc.perform(get("/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("모든 사용자 조회 API 테스트")
void getAllUsersTest() throws Exception {
UserResponseDto user2 = UserResponseDto.builder()
.id(2L)
.name("Jane Doe")
.email("jane@example.com")
.phone("9876543210")
.createdAt(LocalDateTime.now())
.build();
List<UserResponseDto> users = Arrays.asList(userResponseDto, user2);
when(userService.getAllUsers()).thenReturn(users);
mockMvc.perform(get("/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()", is(2)))
.andExpect(jsonPath("$[0].id", is(1)))
.andExpect(jsonPath("$[0].name", is("John Doe")))
.andExpect(jsonPath("$[1].id", is(2)))
.andExpect(jsonPath("$[1].name", is("Jane Doe")));
}
@Test
@DisplayName("사용자 수정 API 테스트")
void updateUserTest() throws Exception {
when(userService.updateUser(anyLong(), any(UserRequestDto.class))).thenReturn(userResponseDto);
mockMvc.perform(put("/users/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userRequestDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.name", is("John Doe")))
.andExpect(jsonPath("$.email", is("john@example.com")));
}
@Test
@DisplayName("사용자 삭제 API 테스트")
void deleteUserTest() throws Exception {
doNothing().when(userService).deleteUser(anyLong());
mockMvc.perform(delete("/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNoContent());
}
@Test
@DisplayName("이름으로 사용자 검색 API 테스트")
void searchUsersByNameTest() throws Exception {
List<UserResponseDto> users = Arrays.asList(userResponseDto);
when(userService.searchUsersByName(anyString())).thenReturn(users);
mockMvc.perform(get("/users?name=John")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()", is(1)))
.andExpect(jsonPath("$[0].id", is(1)))
.andExpect(jsonPath("$[0].name", is("John Doe")));
}
}
13. 통합 테스트
13.1 리포지토리 통합 테스트 (UserRepositoryIntegrationTest.java)
package com.example.demo.repository;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;
import com.example.demo.entity.User;
@DataJpaTest
public class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("사용자 저장 테스트")
void saveUserTest() {
// Given
User user = User.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
// When
User savedUser = userRepository.save(user);
// Then
assertNotNull(savedUser);
assertNotNull(savedUser.getId());
assertEquals(user.getName(), savedUser.getName());
assertEquals(user.getEmail(), savedUser.getEmail());
assertNotNull(savedUser.getCreatedAt());
}
@Test
@DisplayName("이메일로 사용자 조회 테스트")
void findByEmailTest() {
// Given
User user = User.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
userRepository.save(user);
// When
Optional<User> foundUser = userRepository.findByEmail("john@example.com");
// Then
assertTrue(foundUser.isPresent());
assertEquals(user.getName(), foundUser.get().getName());
}
@Test
@DisplayName("이름으로 사용자 검색 테스트")
void findByNameContainingIgnoreCaseTest() {
// Given
User user1 = User.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
User user2 = User.builder()
.name("Jane Doe")
.email("jane@example.com")
.phone("9876543210")
.build();
User user3 = User.builder()
.name("Bob Smith")
.email("bob@example.com")
.phone("5555555555")
.build();
userRepository.saveAll(List.of(user1, user2, user3));
// When
List<User> result = userRepository.findByNameContainingIgnoreCase("doe");
// Then
assertEquals(2, result.size());
assertTrue(result.stream().anyMatch(user -> user.getName().equals("John Doe")));
assertTrue(result.stream().anyMatch(user -> user.getName().equals("Jane Doe")));
}
@Test
@DisplayName("이메일 존재 여부 확인 테스트")
void existsByEmailTest() {
// Given
User user = User.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
userRepository.save(user);
// When & Then
assertTrue(userRepository.existsByEmail("john@example.com"));
assertFalse(userRepository.existsByEmail("nonexistent@example.com"));
}
}
13.2 컨트롤러 통합 테스트 (UserControllerIntegrationTest.java)
package com.example.demo.controller;
import static org.hamcrest.CoreMatchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.dto.UserRequestDto;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
@DisplayName("사용자 생성 통합 테스트")
void createUserIntegrationTest() throws Exception {
UserRequestDto userRequestDto = UserRequestDto.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userRequestDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name", is("John Doe")))
.andExpect(jsonPath("$.email", is("john@example.com")));
}
@Test
@DisplayName("중복 이메일로 사용자 생성 실패 통합 테스트")
void createUserWithDuplicateEmailIntegrationTest() throws Exception {
// 첫 번째 사용자 생성
UserRequestDto user1 = UserRequestDto.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user1)))
.andExpect(status().isCreated());
// 동일한 이메일로 두 번째 사용자 생성 시도
UserRequestDto user2 = UserRequestDto.builder()
.name("Jane Doe")
.email("john@example.com") // 동일한 이메일
.phone("9876543210")
.build();
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user2)))
.andExpect(status().isConflict());
}
@Test
@DisplayName("사용자 조회 및 수정 통합 테스트")
void getUserAndUpdateIntegrationTest() throws Exception {
// 사용자 생성
UserRequestDto createDto = UserRequestDto.builder()
.name("John Doe")
.email("john@example.com")
.phone("1234567890")
.build();
String createResponse = mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createDto)))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
int userId = objectMapper.readTree(createResponse).get("id").asInt();
// 사용자 조회
mockMvc.perform(get("/users/" + userId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("John Doe")));
// 사용자 정보 수정
UserRequestDto updateDto = UserRequestDto.builder()
.name("John Updated")
.email("john.updated@example.com")
.phone("5555555555")
.build();
mockMvc.perform(put("/users/" + userId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("John Updated")))
.andExpect(jsonPath("$.email", is("john.updated@example.com")));
// 수정된 사용자 조회
mockMvc.perform(get("/users/" + userId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("John Updated")));
}
}
14. API 배포 및 모니터링
14.1 Docker를 이용한 애플리케이션 컨테이너화
Dockerfile
FROM eclipse-temurin:17-jdk-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/userdb
- SPRING_DATASOURCE_USERNAME=postgres
- SPRING_DATASOURCE_PASSWORD=postgres
depends_on:
- db
db:
image: postgres:14.1-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_DB=userdb
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
14.2 애플리케이션 모니터링
Spring Boot Actuator를 사용하여 모니터링 구성:
의존성 추가 (pom.xml에 추가)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Prometheus 메트릭 내보내기 (선택 사항) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
application.properties 설정
# Actuator 설정
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=always
management.health.diskspace.enabled=true
management.health.db.enabled=true
# 애플리케이션 정보
info.app.name=REST API Demo
info.app.description=Spring Boot으로 구현한 REST API
info.app.version=1.0.0
15. 보안 구현
15.1 Spring Security 설정
의존성 추가 (pom.xml에 추가)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT 지원 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
SecurityConfig.java
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**", "/h2-console/**", "/swagger-ui/**", "/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.headers().frameOptions().sameOrigin(); // H2 콘솔 사용을 위한 설정
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
16. 결론
Spring Boot를 사용하여 RESTful API를 개발하는 방법에 대해 알아보았습니다. 이 문서에서는 프로젝트 설정부터 구현, 테스트, 배포까지의 전체 과정을 다루었습니다.
Spring Boot는 다음과 같은 장점을 제공합니다:
- 자동 설정을 통한 빠른 개발 환경 구성
- 다양한 스타터 패키지를 통한 손쉬운 의존성 관리
- 내장 서버를 통한 간편한 배포
- 풍부한 테스트 지원
- 다양한 모니터링 및 보안 기능
REST API를 구현할 때는 다음 사항을 고려해야 합니다:
- 적절한 계층 구조 설계 (Controller, Service, Repository)
- DTO 패턴을 통한 데이터 전송 객체 분리
- 일관된 예외 처리 및 응답 포맷 정의
- 단위 테스트 및 통합 테스트를 통한 품질 보증
- API 문서화를 통한 개발자 경험 향상
17. 후속 시리즈 안내
본 문서는 REST API 구현 시리즈의 두 번째 편입니다. 시리즈의 다음 주제는 다음과 같습니다:
시리즈 제목 주요 내용
| 1편 | REST API의 기본 개념과 설계 원칙 | REST 아키텍처, HTTP 메서드, 상태 코드, 설계 모범 사례 등 |
| 3편 | Nest.JS에서의 REST API 작성 예시 | Node.js 기반 Nest.JS 프레임워크를 이용한 REST API 구현 |
| 4편 | 그 외 플랫폼에서의 REST API 작성 예시 | Django, Flask, Express.js 등 다양한 프레임워크에서의 구현 |
각 편에서는 해당 프레임워크의 특성을 살린 REST API 설계 및 구현 방법, 보안 처리, 테스트 방법 등을 실제 코드 예제와 함께 제공할 예정입니다.
18. 참고 자료
- Spring Boot 공식 문서
- Spring Data JPA 공식 문서
- Spring Security 공식 문서
- Spring REST API 모범 사례
- SpringDoc OpenAPI
- Docker 공식 문서
- JUnit 5 사용자 가이드
19. 마무리
Spring Boot는 Java 기반의 백엔드 개발에서 가장 인기 있는 프레임워크 중 하나로, RESTful API 개발에 특히 적합합니다. 이 문서에서 살펴본 것처럼 Spring Boot는 강력한 기능과 유연성을 제공하면서도, 개발자가 비즈니스 로직에 집중할 수 있도록 많은 보일러플레이트 코드를 제거해 줍니다.
REST API 개발에 있어서 Spring Boot의 주요 강점은 다음과 같습니다:
- 개발 생산성 - 자동 설정과 스타터 의존성으로 개발 속도를 높입니다.
- 확장성 - 마이크로서비스 아키텍처에 적합한 독립적인 서비스 구성이 가능합니다.
- 통합 용이성 - 다양한 데이터베이스, 캐시, 메시징 시스템 등과 쉽게 통합됩니다.
- 보안 - Spring Security를 통한 강력한 보안 기능을 제공합니다.
- 테스트 지원 - 단위 테스트와 통합 테스트를 위한 다양한 도구를 제공합니다.
- 모니터링 - Actuator를 통한 애플리케이션 상태 모니터링이 가능합니다.
이 문서에서 제공한 예제 코드와 설명을 통해 Spring Boot로 견고하고 확장 가능한 REST API를 개발하는 데 필요한 기본 지식을 얻으셨기를 바랍니다. REST API 개발은 단순히 기술적 구현을 넘어 좋은 설계와 사용자 경험을 고려하는 것이 중요합니다. 1편에서 다룬 REST 설계 원칙과 모범 사례를 함께 적용하면, 더욱 효과적인 API를 구축할 수 있을 것입니다.
다음 편에서는 Node.js 생태계의 Nest.JS 프레임워크를 활용한 REST API 개발 방법을 살펴보겠습니다. 이를 통해 다양한 백엔드 기술 스택에서의 REST API 구현 방식을 비교하고, 각 프레임워크의 장단점을 이해할 수 있을 것입니다.
감사합니다.
'Study' 카테고리의 다른 글
| 그 외 플랫폼에서의 REST API 작성 예시 (4/4) (0) | 2025.04.26 |
|---|---|
| Nest.JS에서의 REST API 작성 예시 (3/4) (0) | 2025.04.26 |
| REST API 란 무엇인가? (1/4) (0) | 2025.04.26 |
| Spring JPA에서 PostgreSQL JSONB 타입 활용하기 (1) | 2025.04.21 |