RESTful API & Swagger 완벽 가이드 📚¶
목차¶
REST API 기본 개념¶
REST란?¶
REST (Representational State Transfer)는 분산 시스템 설계를 위한 아키텍처 스타일입니다.
핵심 원칙¶
- 자원 (Resource): 모든 데이터는 URI를 통해 고유하게 식별
- 예:
/api/users/1
(1번 사용자 자원) - 행위 (Verb): HTTP Method로 자원에 대한 행위 표현
- GET, POST, PUT, DELETE 등
- 표현 (Representation): 자원의 상태를 JSON, XML 등으로 전달
전통적 방식 vs REST API 방식¶
graph TB
subgraph "전통적 방식 (SSR)"
A[클라이언트 요청] --> B[서버]
B --> C[완전한 HTML 페이지 생성]
C --> D[클라이언트로 전송]
end
subgraph "REST API 방식 (CSR)"
E[클라이언트 요청] --> F[REST API 서버]
F --> G[JSON 데이터만 응답]
G --> H[클라이언트에서 화면 구성]
end
Spring Boot REST 컨트롤러¶
@Controller vs @RestController¶
@Controller (전통적 방식)¶
@Controller
public class TraditionalController {
@GetMapping("/users")
public String getUsersPage(Model model) {
// model에 데이터 추가
model.addAttribute("users", userService.getAllUsers());
// 템플릿 파일명 반환 (예: users.html)
return "users";
}
@GetMapping("/api/users")
@ResponseBody // 이 애너테이션이 있어야 JSON 응답 가능
public List<User> getUsers() {
return userService.getAllUsers();
}
}
@RestController (REST API 전용)¶
@RestController // @Controller + @ResponseBody 조합
@RequestMapping("/api") // 기본 경로 설정
public class UserRestController {
@GetMapping("/users")
public List<UserDto.Response> getUsers() {
// 자동으로 JSON 형태로 직렬화되어 응답
return userService.getAllUsers();
}
}
데이터 처리 방식¶
요청 데이터 받기¶
@PathVariable (경로 변수)¶
@GetMapping("/users/{id}") // URL 경로의 일부
public UserDto.Response getUser(@PathVariable Long id) {
// URL: /api/users/123 -> id = 123
return userService.getUserById(id);
}
@GetMapping("/users/{id}/posts/{postId}")
public PostDto.Response getUserPost(
@PathVariable Long id, // 사용자 ID
@PathVariable Long postId) { // 게시글 ID
// URL: /api/users/123/posts/456 -> id=123, postId=456
return postService.getUserPost(id, postId);
}
@RequestParam (쿼리 스트링)¶
@GetMapping("/users")
public List<UserDto.Response> getUsers(
@RequestParam(defaultValue = "0") int page, // 페이지 번호
@RequestParam(defaultValue = "10") int size, // 페이지 크기
@RequestParam(required = false) String name) { // 이름으로 검색 (선택사항)
// URL: /api/users?page=1&size=20&name=김철수
// page=1, size=20, name="김철수"
return userService.getUsers(page, size, name);
}
@RequestBody (요청 본문)¶
@PostMapping("/users")
public UserDto.Response createUser(@RequestBody UserDto.CreateRequest request) {
// HTTP Body의 JSON 데이터를 Java 객체로 변환
// { "name": "김철수", "email": "kim@example.com" }
return userService.createUser(request);
}
Entity vs DTO 분리¶
문제상황: Entity 직접 노출¶
// ❌ 잘못된 방식
@RestController
public class BadController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// Entity를 직접 반환하면 위험!
return userRepository.findById(id);
// 문제점:
// 1. 비밀번호 같은 민감정보도 노출
// 2. Entity 구조 변경시 API도 함께 변경됨
// 3. 순환 참조 문제 발생 가능
}
}
해결책: DTO 사용¶
// ✅ 올바른 방식
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
// 생성자 주입 (Spring이 자동으로 의존성 주입)
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users/{id}")
public UserDto.Response getUser(@PathVariable Long id) {
// DTO만 반환하여 안전하고 명확한 API 명세
return userService.getUserById(id);
}
}
Java Record를 활용한 DTO¶
전통적인 DTO 방식¶
// 기존 방식: 많은 코드 필요
public class UserCreateRequest {
private String name;
private String email;
// 기본 생성자
public UserCreateRequest() {}
// 모든 필드 생성자
public UserCreateRequest(String name, String email) {
this.name = name;
this.email = email;
}
// Getter 메서드들
public String getName() { return name; }
public String getEmail() { return email; }
// Setter 메서드들
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
// equals, hashCode, toString 메서드들...
}
Java Record 방식 (Java 16+)¶
// ✨ 간결하고 현대적인 방식
public class UserDto {
// 사용자 생성 요청 DTO
public record CreateRequest(
String name, // 자동으로 private final로 생성
String email // getter, equals, hashCode, toString 자동 생성
) {
// 유효성 검증 로직 추가 가능
public CreateRequest {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("이름은 필수입니다");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("올바른 이메일을 입력하세요");
}
}
}
// 사용자 수정 요청 DTO
public record UpdateRequest(
String name,
String email
) {}
// 사용자 응답 DTO
public record Response(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
// Entity에서 DTO로 변환하는 정적 메서드
public static Response from(User user) {
return new Response(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
}
실습 예제 코드¶
1. Entity 클래스¶
@Entity // JPA Entity임을 표시
@Table(name = "users") // 데이터베이스 테이블명 지정
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자 (JPA 요구사항)
@AllArgsConstructor // 모든 필드를 받는 생성자
@Getter // Lombok: getter 메서드 자동 생성
@ToString // Lombok: toString 메서드 자동 생성
public class User {
@Id // 기본키 지정
@GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 증가
private Long id;
@Column(nullable = false, length = 100) // NOT NULL 제약, 최대 100자
private String name;
@Column(nullable = false, unique = true) // NOT NULL + UNIQUE 제약
private String email;
@CreationTimestamp // 생성 시간 자동 설정
private LocalDateTime createdAt;
@UpdateTimestamp // 수정 시간 자동 업데이트
private LocalDateTime updatedAt;
// 비즈니스 로직 메서드
public void updateInfo(String name, String email) {
this.name = name;
this.email = email;
// updatedAt은 @UpdateTimestamp에 의해 자동 업데이트
}
}
2. Repository 인터페이스¶
@Repository // Spring의 데이터 접근 계층임을 표시
public interface UserRepository extends JpaRepository<User, Long> {
// JpaRepository<Entity타입, ID타입>을 상속받으면
// 기본적인 CRUD 메서드들이 자동으로 제공됨:
// - save(entity): 저장/수정
// - findById(id): ID로 조회
// - findAll(): 전체 조회
// - deleteById(id): ID로 삭제
// - count(): 총 개수
// 커스텀 쿼리 메서드 (Spring Data JPA가 자동으로 구현)
Optional<User> findByEmail(String email); // 이메일로 사용자 찾기
List<User> findByNameContaining(String name); // 이름에 특정 문자열 포함
boolean existsByEmail(String email); // 이메일 중복 체크
}
3. Service 클래스¶
@Service // 비즈니스 로직 처리 계층임을 표시
@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션
public class UserService {
private final UserRepository userRepository;
// 생성자 주입 (권장 방식)
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 사용자 생성
@Transactional // 쓰기 작업이므로 readOnly=false (기본값)
public UserDto.Response createUser(UserDto.CreateRequest request) {
// 1. 이메일 중복 체크
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다");
}
// 2. Entity 생성
User user = new User(null, request.name(), request.email(), null, null);
// 3. 데이터베이스에 저장
User savedUser = userRepository.save(user);
// 4. Entity를 DTO로 변환하여 반환
return UserDto.Response.from(savedUser);
}
// 전체 사용자 조회
public List<UserDto.Response> getAllUsers() {
return userRepository.findAll() // 모든 User Entity 조회
.stream() // Stream API 사용
.map(UserDto.Response::from) // 각 Entity를 DTO로 변환
.toList(); // List로 수집
}
// 특정 사용자 조회
public UserDto.Response getUserById(Long id) {
User user = userRepository.findById(id) // Optional<User> 반환
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
return UserDto.Response.from(user);
}
// 사용자 정보 수정
@Transactional
public UserDto.Response updateUser(Long id, UserDto.UpdateRequest request) {
// 1. 기존 사용자 조회
User user = userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
// 2. 이메일 중복 체크 (자신 제외)
if (!user.getEmail().equals(request.email()) &&
userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다");
}
// 3. 정보 업데이트 (Dirty Checking으로 자동 저장)
user.updateInfo(request.name(), request.email());
// 4. DTO로 변환하여 반환
return UserDto.Response.from(user);
}
// 사용자 삭제
@Transactional
public void deleteUser(Long id) {
// 존재 여부 확인 후 삭제
if (!userRepository.existsById(id)) {
throw new EntityNotFoundException("사용자를 찾을 수 없습니다");
}
userRepository.deleteById(id);
}
}
4. REST Controller¶
@RestController // JSON 응답을 위한 REST 컨트롤러
@RequestMapping("/api/users") // 기본 경로 설정
@Validated // 유효성 검증 활성화
public class UserApiController {
private final UserService userService;
// 생성자 주입
public UserApiController(UserService userService) {
this.userService = userService;
}
// 새 사용자 등록
@PostMapping
public ResponseEntity<UserDto.Response> createUser(
@Valid @RequestBody UserDto.CreateRequest request) {
// @Valid: 요청 데이터 유효성 검증
// @RequestBody: HTTP Body의 JSON을 Java 객체로 변환
UserDto.Response response = userService.createUser(request);
// 201 Created 상태코드와 함께 생성된 사용자 정보 반환
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// 모든 사용자 조회
@GetMapping
public ResponseEntity<List<UserDto.Response>> getAllUsers() {
List<UserDto.Response> users = userService.getAllUsers();
// 200 OK 상태코드와 함께 사용자 목록 반환
return ResponseEntity.ok(users);
}
// 특정 사용자 조회
@GetMapping("/{id}")
public ResponseEntity<UserDto.Response> getUser(@PathVariable Long id) {
// @PathVariable: URL 경로에서 {id} 부분을 Long id로 바인딩
UserDto.Response user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
// 사용자 정보 수정
@PutMapping("/{id}")
public ResponseEntity<UserDto.Response> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserDto.UpdateRequest request) {
UserDto.Response updatedUser = userService.updateUser(id, request);
return ResponseEntity.ok(updatedUser);
}
// 사용자 삭제
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
// 204 No Content: 삭제 성공, 응답 본문 없음
return ResponseEntity.noContent().build();
}
// 예외 처리
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException e) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
ErrorResponse error = new ErrorResponse("BAD_REQUEST", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
// 에러 응답용 DTO
public record ErrorResponse(String code, String message) {}
API 아키텍처 시각화¶
전체 시스템 아키텍처¶
graph TB
subgraph "클라이언트 계층"
A[웹 브라우저]
B[모바일 앱]
C[Postman]
end
subgraph "Spring Boot 애플리케이션"
D["RestController - HTTP 요청/응답 처리"]
E["Service - 비즈니스 로직"]
F["Repository - 데이터 접근"]
end
subgraph "데이터 계층"
G["MySQL/PostgreSQL 데이터베이스"]
end
A --> D
B --> D
C --> D
D --> E
E --> F
F --> G
style D fill:#e1f5fe
style E fill:#f3e5f5
style F fill:#e8f5e8
REST API 요청 흐름¶
sequenceDiagram
participant C as 클라이언트
participant RC as @RestController
participant S as @Service
participant R as @Repository
participant DB as 데이터베이스
Note over C,DB: 사용자 생성 요청 예시
C->>+RC: POST /api/users<br/>{name:"김철수", email:"kim@test.com"}
Note over RC: @RequestBody로<br/>JSON → DTO 변환
RC->>+S: createUser(CreateRequest)
Note over S: 비즈니스 로직<br/>(유효성 검증, 중복 체크)
S->>+R: existsByEmail(email)
R->>+DB: SELECT COUNT(*) FROM users WHERE email=?
DB-->>-R: 0 (중복 없음)
R-->>-S: false
S->>+R: save(user)
R->>+DB: INSERT INTO users (name, email) VALUES (?, ?)
DB-->>-R: 저장된 User 엔티티
R-->>-S: User 엔티티
Note over S: Entity → DTO 변환
S-->>-RC: UserDto.Response
Note over RC: DTO → JSON 변환<br/>HTTP 201 Created
RC-->>-C: 201 Created<br/>{id:1, name:"김철수", email:"kim@test.com"}
HTTP 메서드별 용도¶
graph LR
subgraph "HTTP 메서드"
A[GET<br/>📖 조회]
B[POST<br/>➕ 생성]
C[PUT<br/>✏️ 전체 수정]
D[PATCH<br/>🔧 부분 수정]
E[DELETE<br/>🗑️ 삭제]
end
subgraph "특징"
F[안전함<br/>Safe]
G[멱등성<br/>Idempotent]
H[변경 발생<br/>Non-Safe]
end
A --> F
A --> G
B --> H
C --> G
C --> H
D --> H
E --> G
E --> H
style A fill:#e8f5e8
style B fill:#fff3e0
style C fill:#e3f2fd
style D fill:#f3e5f5
style E fill:#ffebee
Swagger 문서화¶
Swagger 설정¶
@Configuration
@EnableSwagger2 // Swagger 활성화
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2) // Swagger 2.0 사용
.select()
.apis(RequestHandlerSelectors.basePackage("com.example.controller")) // 스캔할 패키지
.paths(PathSelectors.ant("/api/**")) // 문서화할 경로 패턴
.build()
.apiInfo(apiInfo()); // API 정보 설정
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("사용자 관리 API") // API 제목
.description("사용자 CRUD 기능을 제공하는 RESTful API") // 설명
.version("1.0.0") // 버전
.contact(new Contact("개발팀", "https://example.com", "dev@example.com"))
.build();
}
}
Swagger 애너테이션 활용¶
@RestController
@RequestMapping("/api/users")
@Api(tags = "사용자 관리") // Swagger UI에서 그룹 이름
public class UserApiController {
@PostMapping
@ApiOperation(value = "사용자 생성", notes = "새로운 사용자를 등록합니다")
@ApiResponses({
@ApiResponse(code = 201, message = "사용자 생성 성공"),
@ApiResponse(code = 400, message = "잘못된 요청 데이터"),
@ApiResponse(code = 409, message = "이메일 중복")
})
public ResponseEntity<UserDto.Response> createUser(
@ApiParam(value = "사용자 생성 정보", required = true)
@Valid @RequestBody UserDto.CreateRequest request) {
UserDto.Response response = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
@ApiOperation(value = "사용자 목록 조회", notes = "등록된 모든 사용자 목록을 조회합니다")
public ResponseEntity<List<UserDto.Response>> getAllUsers(
@ApiParam(value = "페이지 번호", defaultValue = "0")
@RequestParam(defaultValue = "0") int page,
@ApiParam(value = "페이지 크기", defaultValue = "10")
@RequestParam(defaultValue = "10") int size) {
List<UserDto.Response> users = userService.getAllUsers(page, size);
return ResponseEntity.ok(users);
}
}
DTO에 Swagger 문서화¶
public class UserDto {
@ApiModel(description = "사용자 생성 요청")
public record CreateRequest(
@ApiModelProperty(value = "사용자 이름", required = true, example = "김철수")
@NotBlank(message = "이름은 필수입니다")
String name,
@ApiModelProperty(value = "이메일 주소", required = true, example = "kim@example.com")
@Email(message = "올바른 이메일 형식이 아닙니다")
@NotBlank(message = "이메일은 필수입니다")
String email
) {}
@ApiModel(description = "사용자 응답 정보")
public record Response(
@ApiModelProperty(value = "사용자 ID", example = "1")
Long id,
@ApiModelProperty(value = "사용자 이름", example = "김철수")
String name,
@ApiModelProperty(value = "이메일 주소", example = "kim@example.com")
String email,
@ApiModelProperty(value = "생성 시간", example = "2025-09-02T10:30:00")
LocalDateTime createdAt
) {
public static Response from(User user) {
return new Response(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
}
현업 트렌드 및 베스트 프랙티스¶
2025년 API 개발 트렌드¶
1. API-First 설계¶
현업에서는 API를 제품처럼 취급하는 API-as-a-Product (AaaP) 접근법이 주류가 되고 있습니다. 이는 API 소비자를 단순한 통합이 아닌 사용자로 바라보는 관점입니다.
2. 보안 강화¶
- Zero-Trust 아키텍처: 기본적으로 아무것도 신뢰하지 않고 지속적인 검증 수행
- OAuth 2.0 + JWT: 토큰 기반 인증/인가가 표준
- API 게이트웨이: 모든 API 호출의 중앙 집중 관리
3. 하이브리드 접근법¶
REST API와 GraphQL을 함께 사용하는 하이브리드 방식이 증가하고 있습니다. REST는 데이터 소스 역할, GraphQL은 게이트웨이 역할을 담당합니다.
현업에서 주로 사용하는 기술 스택¶
Backend Framework¶
// Spring Boot 3.x (현재 LTS)
@SpringBootApplication
public class RestApiApplication {
public static void main(String[] args) {
SpringApplication.run(RestApiApplication.class, args);
}
}
데이터베이스 접근¶
// Spring Data JPA (가장 보편적)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 복잡한 쿼리는 @Query 애너테이션 사용
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
Optional<User> findActiveUserByEmail(@Param("email") String email);
}
// QueryDSL (복잡한 동적 쿼리용)
@Repository
public class UserQueryRepository {
private final JPAQueryFactory queryFactory;
public UserQueryRepository(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
// 복잡한 검색 조건을 동적으로 구성
public List<User> findUsersWithConditions(String name, String email, Boolean active) {
QUser user = QUser.user; // QueryDSL이 생성한 Q클래스
BooleanBuilder builder = new BooleanBuilder(); // 동적 조건 빌더
if (name != null) {
builder.and(user.name.containsIgnoreCase(name)); // 이름 포함 검색
}
if (email != null) {
builder.and(user.email.eq(email)); // 이메일 정확 매치
}
if (active != null) {
builder.and(user.active.eq(active)); // 활성 상태 필터
}
return queryFactory
.selectFrom(user) // User 엔티티 선택
.where(builder) // 동적 조건 적용
.orderBy(user.createdAt.desc()) // 생성일 내림차순
.fetch(); // 실행 및 결과 반환
}
}
응답 형태 표준화¶
// 현업에서 자주 사용하는 공통 응답 형태
public record ApiResponse<T>(
boolean success, // 성공 여부
String message, // 응답 메시지
T data, // 실제 데이터
String timestamp // 응답 시간
) {
// 성공 응답 생성 헬퍼 메서드
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(
true,
"요청이 성공적으로 처리되었습니다",
data,
LocalDateTime.now().toString()
);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data, LocalDateTime.now().toString());
}
// 실패 응답 생성 헬퍼 메서드
public static <T> ApiResponse<T> failure(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now().toString());
}
}
// 컨트롤러에서 사용
@PostMapping
public ResponseEntity<ApiResponse<UserDto.Response>> createUser(
@Valid @RequestBody UserDto.CreateRequest request) {
UserDto.Response user = userService.createUser(request);
ApiResponse<UserDto.Response> response = ApiResponse.success("사용자가 생성되었습니다", user);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
페이징 처리¶
// Spring Data JPA의 Pageable 활용
@GetMapping
public ResponseEntity<ApiResponse<Page<UserDto.Response>>> getUsers(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable, // 페이징 정보 자동 바인딩
@RequestParam(required = false) String name) { // 검색 조건
Page<UserDto.Response> users = userService.getUsers(pageable, name);
return ResponseEntity.ok(ApiResponse.success(users));
}
// Service에서 페이징 처리
@Transactional(readOnly = true)
public Page<UserDto.Response> getUsers(Pageable pageable, String name) {
Page<User> userPage;
if (name != null && !name.trim().isEmpty()) {
// 이름으로 검색 + 페이징
userPage = userRepository.findByNameContaining(name.trim(), pageable);
} else {
// 전체 조회 + 페이징
userPage = userRepository.findAll(pageable);
}
// Page<Entity>를 Page<DTO>로 변환
return userPage.map(UserDto.Response::from);
}
현업 보안 적용 사례¶
JWT 토큰 기반 인증¶
// JWT 유틸리티 클래스
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}") // application.yml에서 비밀키 주입
private String jwtSecret;
@Value("${app.jwt.expiration}") // 토큰 만료 시간
private long jwtExpirationInMs;
// 토큰 생성
public String generateToken(String username) {
Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(username) // 사용자 식별자
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(expiryDate) // 만료 시간
.signWith(SignatureAlgorithm.HS256, jwtSecret) // 서명 알고리즘
.compact(); // 토큰 문자열 생성
}
// 토큰에서 사용자명 추출
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret) // 서명 검증용 키
.parseClaimsJws(token) // 토큰 파싱
.getBody(); // 페이로드 추출
return claims.getSubject(); // 사용자명 반환
}
// 토큰 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true; // 파싱 성공 = 유효한 토큰
} catch (JwtException | IllegalArgumentException e) {
return false; // 파싱 실패 = 무효한 토큰
}
}
}
// 인증이 필요한 API에 적용
@RestController
@RequestMapping("/api/users")
public class SecureUserController {
@PostMapping("/profile")
@PreAuthorize("hasRole('USER')") // USER 권한 필요
public ResponseEntity<UserDto.Response> updateProfile(
@AuthenticationPrincipal UserDetails userDetails, // 현재 로그인 사용자
@Valid @RequestBody UserDto.UpdateRequest request) {
// 현재 로그인한 사용자의 정보만 수정 가능
String currentUsername = userDetails.getUsername();
UserDto.Response response = userService.updateUserProfile(currentUsername, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
API 버전 관리¶
// URL 경로 기반 버전 관리 (가장 일반적)
@RestController
@RequestMapping("/api/v1/users") // v1 버전
public class UserControllerV1 {
// 기존 API 유지
}
@RestController
@RequestMapping("/api/v2/users") // v2 버전
public class UserControllerV2 {
// 새로운 기능 추가
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<UserDto.ResponseV2>>> getUsers(
@PageableDefault(size = 20) Pageable pageable) {
// v2에서는 더 풍부한 응답 제공
return ResponseEntity.ok(ApiResponse.success(userService.getUsersV2(pageable)));
}
}
// 헤더 기반 버전 관리 (선택적)
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(headers = "API-Version=1")
public ResponseEntity<List<UserDto.Response>> getUsersV1() {
return ResponseEntity.ok(userService.getAllUsers());
}
@GetMapping(headers = "API-Version=2")
public ResponseEntity<PagedResponse<UserDto.ResponseV2>> getUsersV2(Pageable pageable) {
return ResponseEntity.ok(userService.getUsersV2(pageable));
}
}
현업 모니터링 및 로깅¶
구조화된 로깅¶
@RestController
@Slf4j // Lombok의 로깅 애너테이션
public class UserApiController {
@PostMapping
public ResponseEntity<ApiResponse<UserDto.Response>> createUser(
@Valid @RequestBody UserDto.CreateRequest request) {
// 요청 시작 로그 (구조화된 형태)
log.info("사용자 생성 요청 시작 - email: {}", request.email());
try {
UserDto.Response response = userService.createUser(request);
// 성공 로그
log.info("사용자 생성 완료 - userId: {}, email: {}",
response.id(), response.email());
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("사용자가 생성되었습니다", response));
} catch (IllegalArgumentException e) {
// 비즈니스 예외 로그
log.warn("사용자 생성 실패 - email: {}, reason: {}",
request.email(), e.getMessage());
throw e;
} catch (Exception e) {
// 시스템 예외 로그
log.error("사용자 생성 중 예상치 못한 오류 - email: {}",
request.email(), e);
throw new RuntimeException("서버 내부 오류가 발생했습니다");
}
}
}
성능 모니터링¶
// AOP를 활용한 실행 시간 측정
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
"@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed(); // 실제 메서드 실행
long executionTime = System.currentTimeMillis() - startTime;
// 성능 로그
log.info("API 실행 완료 - method: {}, executionTime: {}ms",
joinPoint.getSignature().getName(), executionTime);
return result;
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
log.error("API 실행 실패 - method: {}, executionTime: {}ms",
joinPoint.getSignature().getName(), executionTime);
throw e;
}
}
}
API 문서화 현업 도구¶
OpenAPI 3.0 (Swagger의 후속)¶
// 최신 SpringDoc OpenAPI 사용
@OpenAPIDefinition(
info = @Info(
title = "사용자 관리 API",
version = "2.0",
description = "사용자 CRUD 및 인증 기능을 제공하는 RESTful API"
),
servers = {
@Server(url = "https://api.example.com", description = "프로덕션 서버"),
@Server(url = "https://staging-api.example.com", description = "스테이징 서버"),
@Server(url = "http://localhost:8080", description = "로컬 개발 서버")
}
)
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer"
)
public class OpenApiConfig {
}
// 컨트롤러에서 상세한 문서화
@RestController
@RequestMapping("/api/v2/users")
@Tag(name = "사용자 관리", description = "사용자 CRUD 및 프로필 관리 API")
public class UserControllerV2 {
@Operation(
summary = "사용자 생성",
description = "새로운 사용자 계정을 생성합니다. 이메일은 중복될 수 없습니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "사용자 생성 성공",
content = @Content(schema = @Schema(implementation = UserDto.Response.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"),
@ApiResponse(responseCode = "409", description = "이메일 중복")
})
@PostMapping
public ResponseEntity<ApiResponse<UserDto.Response>> createUser(
@Parameter(description = "사용자 생성 정보", required = true)
@Valid @RequestBody UserDto.CreateRequest request) {
UserDto.Response response = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("사용자가 생성되었습니다", response));
}
}
현업 테스팅 전략¶
계층별 테스트¶
// 1. Controller 테스트 (Web Layer)
@WebMvcTest(UserApiController.class) // 웹 계층만 테스트
class UserApiControllerTest {
@Autowired
private MockMvc mockMvc; // HTTP 요청 시뮬레이션
@MockBean
private UserService userService; // Service는 Mock으로 대체
@Test
@DisplayName("사용자 생성 API 테스트")
void createUser_Success() throws Exception {
// Given: 테스트 데이터 준비
UserDto.CreateRequest request = new UserDto.CreateRequest("김철수", "kim@test.com");
UserDto.Response expectedResponse = new UserDto.Response(1L, "김철수", "kim@test.com", LocalDateTime.now());
when(userService.createUser(any(UserDto.CreateRequest.class)))
.thenReturn(expectedResponse); // Mock 동작 정의
// When & Then: API 호출 및 검증
mockMvc.perform(post("/api/users") // POST 요청
.contentType(MediaType.APPLICATION_JSON) // Content-Type 헤더
.content("""
{
"name": "김철수",
"email": "kim@test.com"
}
""")) // 요청 Body
.andExpect(status().isCreated()) // 201 상태코드 기대
.andExpect(jsonPath("$.success").value(true)) // JSON 응답 검증
.andExpect(jsonPath("$.data.name").value("김철수"))
.andExpect(jsonPath("$.data.email").value("kim@test.com"));
// Service 메서드 호출 검증
verify(userService).createUser(any(UserDto.CreateRequest.class));
}
}
// 2. Service 테스트 (Business Logic)
@ExtendWith(MockitoExtension.class) // Mockito 확장
class UserServiceTest {
@Mock
private UserRepository userRepository; // Repository Mock
@InjectMocks
private UserService userService; // 테스트 대상 (Mock이 주입됨)
@Test
@DisplayName("이메일 중복시 예외 발생")
void createUser_EmailDuplicate_ThrowsException() {
// Given
UserDto.CreateRequest request = new UserDto.CreateRequest("김철수", "kim@test.com");
when(userRepository.existsByEmail("kim@test.com")).thenReturn(true); // 중복 상황
// When & Then
assertThatThrownBy(() -> userService.createUser(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("이미 존재하는 이메일입니다");
// Repository 호출 검증
verify(userRepository).existsByEmail("kim@test.com");
verify(userRepository, never()).save(any(User.class)); // save는 호출되지 않아야 함
}
}
// 3. Repository 테스트 (Data Layer)
@DataJpaTest // JPA 관련 설정만 로드
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager; // 테스트용 EntityManager
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("이메일로 사용자 조회 테스트")
void findByEmail_Success() {
// Given: 테스트 데이터 직접 생성
User user = new User(null, "김철수", "kim@test.com", null, null);
entityManager.persistAndFlush(user); // 테스트 DB에 저장
// When: Repository 메서드 호출
Optional<User> found = userRepository.findByEmail("kim@test.com");
// Then: 결과 검증
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("김철수");
assertThat(found.get().getEmail()).isEqualTo("kim@test.com");
}
}
현업 배포 및 운영¶
Docker 컨테이너화¶
# 현업에서 사용하는 멀티 스테이지 Dockerfile
FROM openjdk:17-jdk-slim as builder
# 작업 디렉토리 설정
WORKDIR /app
# Gradle 래퍼와 빌드 파일 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
# 의존성 다운로드 (캐시 최적화)
RUN ./gradlew dependencies --no-daemon
# 소스 코드 복사 및 빌드
COPY src src
RUN ./gradlew bootJar --no-daemon
# 실행 스테이지
FROM openjdk:17-jre-slim
# 애플리케이션 사용자 생성 (보안)
RUN addgroup --system spring && adduser --system spring --ingroup spring
USER spring:spring
# 빌드된 JAR 파일 복사
COPY --from=builder /app/build/libs/*.jar app.jar
# 컨테이너 실행 명령
ENTRYPOINT ["java", "-jar", "/app.jar"]
# 포트 노출
EXPOSE 8080
Kubernetes 배포 설정¶
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-api
labels:
app: user-api
spec:
replicas: 3 # 3개의 Pod으로 확장
selector:
matchLabels:
app: user-api
template:
metadata:
labels:
app: user-api
spec:
containers:
- name: user-api
image: your-registry/user-api:latest
ports:
- containerPort: 8080
env: # 환경변수 설정
- name: SPRING_PROFILES_ACTIVE
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef: # 민감정보는 Secret으로 관리
name: database-secret
key: url
resources: # 리소스 제한
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
livenessProbe: # 헬스체크
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
API 게이트웨이 패턴¶
Spring Cloud Gateway 설정¶
# application.yml
spring:
cloud:
gateway:
routes:
- id: user-service # 라우트 ID
uri: http://user-service:8080 # 실제 서비스 주소
predicates:
- Path=/api/users/** # 경로 매칭 조건
filters:
- name: RequestRateLimiter # 요청 제한
args:
rate-limiter: "#{@redisRateLimiter}"
key-resolver: "#{@userKeyResolver}"
- AddResponseHeader=X-Response-Default-Foo, Default-Bar # 헤더 추가
- id: auth-service
uri: http://auth-service:8080
predicates:
- Path=/api/auth/**
filters:
- name: CircuitBreaker # 서킷 브레이커 패턴
args:
name: auth-circuit-breaker
fallbackUri: forward:/fallback/auth
현업에서 자주 사용하는 라이브러리¶
필수 의존성 (build.gradle)¶
dependencies {
// Spring Boot 기본
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.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// 문서화 (OpenAPI 3.0)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// 데이터베이스
runtimeOnly 'com.mysql:mysql-connector-j' // MySQL
// 또는 runtimeOnly 'org.postgresql:postgresql' // PostgreSQL
// 개발 편의
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// 캐싱
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// 모니터링
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus' // 메트릭 수집
// 테스트
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.testcontainers:junit-jupiter' // 통합 테스트용
testImplementation 'org.testcontainers:mysql'
}
전체 API 처리 흐름¶
flowchart TD
A[클라이언트 요청] --> B{API Gateway}
B --> C[인증/인가 검증]
C --> D{유효한 토큰?}
D -->|No| E[401 Unauthorized]
D -->|Yes| F[Spring Boot 애플리케이션]
F --> G[@RestController]
G --> H[요청 데이터 바인딩<br/>@PathVariable, @RequestParam, @RequestBody]
H --> I[유효성 검증<br/>@Valid]
I --> J{검증 통과?}
J -->|No| K[400 Bad Request]
J -->|Yes| L[@Service 비즈니스 로직]
L --> M[@Repository 데이터 접근]
M --> N[(데이터베이스)]
N --> O[Entity 조회/저장]
O --> P[Entity → DTO 변환]
P --> Q[JSON 직렬화]
Q --> R[HTTP 응답]
style G fill:#e1f5fe
style L fill:#f3e5f5
style M fill:#e8f5e8
style N fill:#fff3e0
에러 처리 및 예외 상황¶
전역 예외 처리기¶
@RestControllerAdvice // 모든 컨트롤러의 예외를 처리
@Slf4j
public class GlobalExceptionHandler {
// 엔티티를 찾을 수 없는 경우
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleEntityNotFound(EntityNotFoundException e) {
log.warn("Entity not found: {}", e.getMessage());
ApiResponse<Void> response = ApiResponse.failure(e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
// 유효성 검증 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationErrors(
MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
// 모든 필드 에러를 수집
e.getBindingResult().getFieldErrors().forEach(error -> {
errors.put(error.getField(), error.getDefaultMessage());
});
log.warn("Validation failed: {}", errors);
ApiResponse<Map<String, String>> response =
ApiResponse.failure("입력 데이터가 올바르지 않습니다").withData(errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 데이터베이스 제약 조건 위반
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation(
DataIntegrityViolationException e) {
log.error("Data integrity violation", e);
String message = "데이터 무결성 제약 조건을 위반했습니다";
if (e.getMessage().contains("Duplicate entry")) {
message = "이미 존재하는 데이터입니다";
}
ApiResponse<Void> response = ApiResponse.failure(message);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
// 예상치 못한 서버 오류
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception e) {
log.error("Unexpected error occurred", e);
ApiResponse<Void> response = ApiResponse.failure("서버 내부 오류가 발생했습니다");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
현업 캐싱 전략¶
Redis를 활용한 캐싱¶
@Service
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final RedisTemplate<String, Object> redisTemplate;
// 자주 조회되는 데이터는 캐시 적용
@Cacheable(value = "users", key = "#id") // Spring Cache 추상화
public UserDto.Response getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
log.info("Database에서 사용자 조회: {}", id); // 캐시 미스시에만 로그 출력
return UserDto.Response.from(user);
}
// 수정시 캐시 무효화
@CacheEvict(value = "users", key = "#id") // 해당 키의 캐시 삭제
@Transactional
public UserDto.Response updateUser(Long id, UserDto.UpdateRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
user.updateInfo(request.name(), request.email());
return UserDto.Response.from(user);
}
// 수동 캐시 관리 (복잡한 로직이 필요한 경우)
public List<UserDto.Response> getActiveUsers() {
String cacheKey = "active_users";
// 캐시에서 먼저 조회
List<UserDto.Response> cachedUsers = (List<UserDto.Response>)
redisTemplate.opsForValue().get(cacheKey);
if (cachedUsers != null) {
log.info("캐시에서 활성 사용자 목록 반환");
return cachedUsers;
}
// 캐시 미스시 데이터베이스에서 조회
List<UserDto.Response> users = userRepository.findByActiveTrue()
.stream()
.map(UserDto.Response::from)
.toList();
// 캐시에 저장 (10분 TTL)
redisTemplate.opsForValue().set(cacheKey, users, Duration.ofMinutes(10));
log.info("데이터베이스에서 활성 사용자 목록 조회 후 캐시 저장");
return users;
}
}
현업 API 설계 가이드라인¶
RESTful URL 설계 원칙¶
✅ 좋은 URL 설계
GET /api/users # 사용자 목록 조회
GET /api/users/123 # 특정 사용자 조회
POST /api/users # 새 사용자 생성
PUT /api/users/123 # 사용자 전체 정보 수정
PATCH /api/users/123 # 사용자 부분 정보 수정
DELETE /api/users/123 # 사용자 삭제
# 중첩 리소스
GET /api/users/123/posts # 특정 사용자의 게시글 목록
POST /api/users/123/posts # 특정 사용자의 새 게시글 작성
❌ 피해야 할 URL 설계
GET /api/getUsers # 동사 사용 금지
POST /api/users/create # 불필요한 동사
GET /api/users/123/delete # GET으로 삭제 시도
POST /api/users/search # 조회는 GET 사용
HTTP 상태 코드 가이드¶
@RestController
public class StatusCodeExampleController {
// 200 OK: 성공적인 GET, PUT, PATCH
@GetMapping("/users/{id}")
public ResponseEntity<UserDto.Response> getUser(@PathVariable Long id) {
UserDto.Response user = userService.getUserById(id);
return ResponseEntity.ok(user); // 200 OK
}
// 201 Created: 성공적인 POST (리소스 생성)
@PostMapping("/users")
public ResponseEntity<UserDto.Response> createUser(
@Valid @RequestBody UserDto.CreateRequest request) {
UserDto.Response user = userService.createUser(request);
// Location 헤더에 생성된 리소스의 URI 포함
URI location = URI.create("/api/users/" + user.id());
return ResponseEntity.created(location).body(user); // 201 Created
}
// 204 No Content: 성공적인 DELETE (응답 본문 없음)
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
// 400 Bad Request: 잘못된 요청 데이터
@PostMapping("/users/invalid-example")
public ResponseEntity<ApiResponse<Void>> invalidRequest() {
// 유효성 검증 실패시 자동으로 400 반환
return ResponseEntity.badRequest()
.body(ApiResponse.failure("잘못된 요청입니다"));
}
// 404 Not Found: 리소스를 찾을 수 없음
@GetMapping("/users/{id}/not-found-example")
public ResponseEntity<ApiResponse<Void>> notFound(@PathVariable Long id) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.failure("사용자를 찾을 수 없습니다"));
}
// 409 Conflict: 비즈니스 규칙 위반 (중복 등)
@PostMapping("/users/conflict-example")
public ResponseEntity<ApiResponse<Void>> conflict() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.failure("이미 존재하는 이메일입니다"));
}
// 500 Internal Server Error: 서버 내부 오류
// GlobalExceptionHandler에서 자동 처리
}
현업 성능 최적화¶
데이터베이스 최적화¶
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_user_email", columnList = "email"), // 이메일 인덱스
@Index(name = "idx_user_created_at", columnList = "created_at") // 생성일 인덱스
})
public class User {
// N+1 문제 해결을 위한 페치 전략
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 지연 로딩
private List<Post> posts = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY) // 연관 엔티티도 지연 로딩
@JoinColumn(name = "department_id")
private Department department;
}
// N+1 문제 해결: 페치 조인 사용
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u LEFT JOIN FETCH u.posts WHERE u.id = :id")
Optional<User> findByIdWithPosts(@Param("id") Long id); // 한 번의 쿼리로 posts까지 조회
@Query("SELECT u FROM User u LEFT JOIN FETCH u.department WHERE u.active = true")
List<User> findActiveUsersWithDepartment(); // 부서 정보까지 한번에 조회
}
비동기 처리¶
@Service
public class AsyncUserService {
// 비동기 메서드 설정
@Async("taskExecutor") // 별도 스레드 풀에서 실행
@Transactional
public CompletableFuture<Void> sendWelcomeEmail(Long userId) {
try {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
// 이메일 발송 로직 (시간이 오래 걸리는 작업)
emailService.sendWelcomeEmail(user.getEmail(), user.getName());
log.info("환영 이메일 발송 완료: {}", user.getEmail());
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
log.error("환영 이메일 발송 실패: userId={}", userId, e);
return CompletableFuture.failedFuture(e);
}
}
}
// 비동기 설정
@Configuration
@EnableAsync // 비동기 처리 활성화
public class AsyncConfig {
@Bean(name = "taskExecutor")
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 기본 스레드 수
executor.setMaxPoolSize(10); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기 큐 크기
executor.setThreadNamePrefix("async-task-"); // 스레드 이름 접두사
executor.initialize();
return executor;
}
}
통합 테스트 및 실제 테스트¶
Testcontainers를 활용한 통합 테스트¶
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers // Docker 컨테이너 기반 테스트
class UserApiIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@Autowired
private TestRestTemplate restTemplate; // 실제 HTTP 요청 수행
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("사용자 생성부터 조회까지 전체 플로우 테스트")
void userFullCrudFlow() {
// 1. 사용자 생성
UserDto.CreateRequest createRequest = new UserDto.CreateRequest("김철수", "kim@test.com");
ResponseEntity<ApiResponse<UserDto.Response>> createResponse =
restTemplate.postForEntity("/api/users", createRequest,
new ParameterizedTypeReference<ApiResponse<UserDto.Response>>() {});
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(createResponse.getBody().success()).isTrue();
Long userId = createResponse.getBody().data().id();
// 2. 생성된 사용자 조회
ResponseEntity<ApiResponse<UserDto.Response>> getResponse =
restTemplate.exchange("/api/users/" + userId, HttpMethod.GET, null,
new ParameterizedTypeReference<ApiResponse<UserDto.Response>>() {});
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().data().name()).isEqualTo("김철수");
// 3. 데이터베이스에도 실제로 저장되었는지 확인
Optional<User> savedUser = userRepository.findById(userId);
assertThat(savedUser).isPresent();
assertThat(savedUser.get().getEmail()).isEqualTo("kim@test.com");
}
}
API 버전 관리 전략¶
현업에서 사용하는 버전 관리 방식¶
graph TB
subgraph "버전 관리 방식"
A[URL Path 버전닝<br/>/api/v1/users<br/>/api/v2/users]
B[Header 버전닝<br/>API-Version: 1<br/>Accept: application/vnd.api.v2+json]
C[Query Parameter<br/>/api/users?version=1]
end
subgraph "현업 선호도"
D[가장 일반적<br/>85%]
E[대기업/엔터프라이즈<br/>10%]
F[레거시 시스템<br/>5%]
end
A --> D
B --> E
C --> F
style A fill:#e8f5e8
style B fill:#fff3e0
style C fill:#ffebee
마이크로서비스 패턴¶
서비스 간 통신¶
// 외부 서비스 호출을 위한 FeignClient (현업 표준)
@FeignClient(name = "notification-service", url = "${services.notification.url}")
public interface NotificationServiceClient {
@PostMapping("/api/notifications/email")
void sendEmail(@RequestBody EmailRequest request);
@PostMapping("/api/notifications/sms")
void sendSms(@RequestBody SmsRequest request);
}
// 서비스에서 다른 서비스 호출
@Service
@Transactional
public class UserRegistrationService {
private final UserService userService;
private final NotificationServiceClient notificationClient;
public UserDto.Response registerUser(UserDto.CreateRequest request) {
// 1. 사용자 생성
UserDto.Response user = userService.createUser(request);
// 2. 환영 이메일 발송 (비동기)
CompletableFuture.runAsync(() -> {
try {
EmailRequest emailRequest = new EmailRequest(
user.email(),
"환영합니다!",
"회원가입을 축하드립니다."
);
notificationClient.sendEmail(emailRequest);
} catch (Exception e) {
log.warn("환영 이메일 발송 실패: userId={}", user.id(), e);
}
});
return user;
}
}
현업 모니터링 및 관찰 가능성¶
Actuator를 활용한 헬스체크¶
// 커스텀 헬스 체크
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final UserRepository userRepository;
@Override
public Health health() {
try {
// 데이터베이스 연결 상태 확인
long userCount = userRepository.count();
return Health.up()
.withDetail("database", "연결됨")
.withDetail("userCount", userCount)
.withDetail("timestamp", LocalDateTime.now())
.build();
} catch (Exception e) {
return Health.down()
.withDetail("database", "연결 실패")
.withDetail("error", e.getMessage())
.build();
}
}
}
// application.yml 설정
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus # 노출할 엔드포인트
endpoint:
health:
show-details: always # 헬스체크 상세 정보 표시
metrics:
export:
prometheus:
enabled: true # Prometheus 메트릭 노출
분산 추적 (Distributed Tracing)¶
// Zipkin/Jaeger를 활용한 분산 추적
@RestController
public class TracingController {
private final UserService userService;
private final Tracer tracer; // OpenTracing/OpenTelemetry
@GetMapping("/users/{id}")
public ResponseEntity<UserDto.Response> getUser(@PathVariable Long id) {
// 커스텀 스팬 생성
Span span = tracer.nextSpan()
.name("get-user") // 스팬 이름
.tag("user.id", id.toString()) // 태그 추가
.start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
UserDto.Response user = userService.getUserById(id);
span.tag("user.email", user.email()); // 추가 태그
span.annotate("user-found"); // 이벤트 기록
return ResponseEntity.ok(user);
} catch (Exception e) {
span.tag("error", e.getMessage()); // 에러 태그
throw e;
} finally {
span.end(); // 스팬 종료
}
}
}
실무 배포 파이프라인¶
CI/CD 파이프라인 구성¶
# .github/workflows/deploy.yml (GitHub Actions)
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql: # 테스트용 MySQL 서비스
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: testdb
ports:
- 3306:3306
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle dependencies # 의존성 캐싱
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
- name: Run tests # 테스트 실행
run: ./gradlew test
- name: Generate test report # 테스트 결과 리포트
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: 'Test Results'
path: '**/build/test-results/test/TEST-*.xml'
reporter: java-junit
build-and-deploy:
needs: test # 테스트 성공시에만 배포
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # main 브랜치만 배포
steps:
- uses: actions/checkout@v3
- name: Build Docker image # Docker 이미지 빌드
run: |
docker build -t ${{ secrets.DOCKER_REGISTRY }}/user-api:${{ github.sha }} .
docker tag ${{ secrets.DOCKER_REGISTRY }}/user-api:${{ github.sha }} \
${{ secrets.DOCKER_REGISTRY }}/user-api:latest
- name: Push to registry # 이미지 레지스트리에 푸시
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker push ${{ secrets.DOCKER_REGISTRY }}/user-api:${{ github.sha }}
docker push ${{ secrets.DOCKER_REGISTRY }}/user-api:latest
- name: Deploy to Kubernetes # 쿠버네티스 배포
run: |
kubectl set image deployment/user-api user-api=${{ secrets.DOCKER_REGISTRY }}/user-api:${{ github.sha }}
kubectl rollout status deployment/user-api
API 문서 자동화 워크플로¶
문서 생성 및 배포¶
graph LR
A[소스 코드<br/>OpenAPI 애너테이션] --> B[빌드 프로세스]
B --> C[OpenAPI Spec 생성<br/>openapi.json]
C --> D[Swagger UI 생성]
C --> E[Postman Collection 생성]
C --> F[SDK 자동 생성]
D --> G[개발자 포털 배포]
E --> H[QA 팀 배포]
F --> I[클라이언트 팀 배포]
style A fill:#e8f5e8
style G fill:#e1f5fe
style H fill:#f3e5f5
style I fill:#fff3e0
현업 보안 체크리스트¶
API 보안 필수 사항¶
// CORS 설정 (Cross-Origin Resource Sharing)
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 도메인 (프로덕션에서는 구체적으로 명시)
configuration.setAllowedOriginPatterns(Arrays.asList(
"https://*.example.com", // 회사 도메인
"http://localhost:3000", // 로컬 개발
"http://localhost:3001" // 로컬 스테이징
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true); // 쿠키 허용
configuration.setMaxAge(3600L); // Preflight 캐시 시간
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
// 요청 제한 (Rate Limiting)
@Component
public class RateLimitingFilter implements Filter {
private final RedisTemplate<String, String> redisTemplate;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String clientIp = getClientIp(httpRequest); // 클라이언트 IP 추출
String key = "rate_limit:" + clientIp;
String currentCount = redisTemplate.opsForValue().get(key);
if (currentCount == null) {
// 첫 요청: 카운터 초기화 (1분 TTL)
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(1));
} else if (Integer.parseInt(currentCount) >= 100) { // 분당 100회 제한
// 제한 초과시 429 Too Many Requests 응답
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(429);
httpResponse.getWriter().write("{\"error\":\"요청 제한을 초과했습니다\"}");
return;
} else {
// 카운터 증가
redisTemplate.opsForValue().increment(key);
}
chain.doFilter(request, response); // 다음 필터로 전달
}
private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null) {
return xForwardedFor.split(",")[0].trim(); // 첫 번째 IP
}
return request.getRemoteAddr();
}
}
실무 데이터 검증¶
복합 유효성 검증¶
// 커스텀 검증 애너테이션
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "이미 사용 중인 이메일입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 검증 로직 구현
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, UserDto.CreateRequest> {
private final UserRepository userRepository;
@Override
public boolean isValid(UserDto.CreateRequest request, ConstraintValidatorContext context) {
if (request == null || request.email() == null) {
return true; // null 검증은 @NotNull이 담당
}
// 데이터베이스에서 중복 확인
return !userRepository.existsByEmail(request.email());
}
}
// DTO에 적용
@UniqueEmail // 커스텀 검증 애너테이션
public record CreateRequest(
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
String name,
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
String email
) {}
현업 개발 도구 및 워크플로¶
개발 도구 스택¶
# 현업에서 가장 많이 사용하는 도구들
개발 환경:
IDE: IntelliJ IDEA Ultimate (70%), VS Code (25%), Eclipse (5%)
빌드 도구: Gradle (80%), Maven (20%)
버전 관리: Git + GitHub/GitLab
API 개발 및 테스트:
문서화: Swagger/OpenAPI 3.0, Postman
테스트: Postman, Insomnia, REST Client (VS Code)
모킹: WireMock, MockServer
모니터링 및 로깅:
APM: New Relic, Datadog, Elastic APM
로깅: ELK Stack (Elasticsearch, Logstash, Kibana)
메트릭: Prometheus + Grafana
클라우드 및 배포:
클라우드: AWS (60%), Azure (25%), GCP (15%)
컨테이너: Docker + Kubernetes
CI/CD: GitHub Actions, Jenkins, GitLab CI
성능 벤치마킹¶
JMeter를 활용한 성능 테스트¶
// 성능 테스트용 컨트롤러 설정
@RestController
@RequestMapping("/api/performance")
public class PerformanceTestController {
private final UserService userService;
private final MeterRegistry meterRegistry; // 메트릭 수집
@GetMapping("/users/{id}")
@Timed(value = "api.users.get", description = "사용자 조회 API 실행 시간")
public ResponseEntity<UserDto.Response> getUser(@PathVariable Long id) {
// 메트릭 카운터 증가
meterRegistry.counter("api.users.get.requests", "endpoint", "getUser").increment();
try {
UserDto.Response user = userService.getUserById(id);
// 성공 메트릭
meterRegistry.counter("api.users.get.success", "endpoint", "getUser").increment();
return ResponseEntity.ok(user);
} catch (EntityNotFoundException e) {
// 실패 메트릭
meterRegistry.counter("api.users.get.notfound", "endpoint", "getUser").increment();
throw e;
}
}
}
최신 개발 동향 (2025년)¶
1. AI 기반 API 개발¶
// AI 코드 생성 도구와의 통합이 일반화
// GitHub Copilot, Amazon CodeWhisperer 등을 활용한 개발
@RestController
public class AiAssistedController {
// AI가 제안하는 코드 패턴들이 표준이 되어가고 있음
@GetMapping("/users/search")
public ResponseEntity<PagedResponse<UserDto.Response>> searchUsers(
@RequestParam(required = false) String query,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir,
@PageableDefault(size = 20) Pageable pageable) {
// AI가 생성한 복잡한 검색 로직
PagedResponse<UserDto.Response> result = userService.searchUsers(
query, sortBy, sortDir, pageable);
return ResponseEntity.ok(result);
}
}
2. 클라우드 네이티브 패턴¶
// Spring Cloud와 Kubernetes 네이티브 통합
@RestController
@RefreshScope // 설정 변경시 자동 리프레시
public class CloudNativeController {
@Value("${app.feature.new-algorithm:false}") // 피처 플래그
private boolean useNewAlgorithm;
@Autowired
private ServiceDiscovery serviceDiscovery; // 서비스 디스커버리
@GetMapping("/users/{id}/recommendations")
public ResponseEntity<List<RecommendationDto>> getRecommendations(@PathVariable Long id) {
if (useNewAlgorithm) {
// 새로운 추천 알고리즘 사용
return ResponseEntity.ok(newRecommendationService.getRecommendations(id));
} else {
// 기존 알고리즘 사용
return ResponseEntity.ok(legacyRecommendationService.getRecommendations(id));
}
}
}
마무리: 현업 개발자가 되기 위한 학습 로드맵¶
단계별 학습 계획¶
```mermaid gantt title API 개발자 성장 로드맵 dateFormat YYYY-MM-DD section 기초 단계 REST 기본 개념 :done, basic1, 2025-09-01, 1w Spring Boot 기초 :done, basic2, 2025-09-08, 2w JPA/Hibernate :active, basic3, 2025-09-22, 2w
section 중급 단계
JWT 인증/인가 :inter1, 2025-10-06, 1w
테스트 작성 :inter2, 2025-10-13, 2w
Docker 컨테이너화 :inter3, 2025-10-27, 1w
section 고급 단계
마이크로서비스 :adv1, 2025-11-03, 3w
모니터링/로깅 :adv2, 2025-11-24, 2w
성능 최적화 :adv3, 2025-12-08, 2w