Spring Boot로 REST API 만들기: JPA, Spring Security, Validation, 예외 처리, 테스트, 배포
이 글의 핵심
Spring Boot는 자동 설정 덕분에 시작은 쉽지만, DataSource 설정 에러나 @RequestBody 누락, CORS 차단처럼 원인을 모르면 오래 헤매는 문제가 초반에 몰려 있습니다. 자동 설정이 조건부로 빈을 등록하는 방식과 AOP 프록시 때문에 같은 클래스 안의 메서드 호출에는 @Transactional이 적용되지 않는 이유까지 내부 동작으로 설명합니다.
이 글의 핵심
Spring Boot로 엔터프라이즈 애플리케이션을 구축하는 글입니다. REST API, JPA, Spring Security, Actuator, 테스트, 배포까지 실전 예제로 정리했으며, 자동 설정·ApplicationContext 생명주기·DI·AOP 프록시·프로덕션 패턴까지 내부 동작을 심화해서 다룹니다.
레거시 Spring을 Boot로 옮길 때 체감이 가장 큰 부분은 XML과 톰캣 설정이 사라지는 것이지만, 옮기고 나서 시간을 가장 많이 잡아먹는 건 “자동 설정이 뭘 켰는지 모르겠다”는 문제입니다. 그래서 이 글은 사용법과 함께 10장에서 자동 설정·프록시가 실제로 어떻게 동작하는지까지 다룹니다.
들어가며: “Spring 설정이 복잡해요”
실무에서 마주치는 문제들
XML 설정이 수백 줄이에요
레거시 Spring은 XML이 복잡합니다. Spring Boot는 자동 설정합니다.
서버 설정이 번거로워요
Tomcat을 따로 설치해야 합니다. Spring Boot는 내장 서버를 제공합니다.
의존성 관리가 어려워요
버전 충돌이 자주 발생합니다. Spring Boot Starter가 해결합니다.
Spring Boot란?
핵심 특징
Spring Boot는 Spring 기반 애플리케이션을 빠르게 개발하는 프레임워크입니다. 주요 장점:
- 자동 설정: Convention over Configuration
- 내장 서버: Tomcat, Jetty 내장
- Starter: 의존성 간편 관리
- Actuator: 모니터링 내장
- 프로덕션 준비: 즉시 배포 가능
프로젝트 생성
Spring Initializr
# https://start.spring.io/
# 또는 CLI
curl https://start.spring.io/starter.zip \
-d dependencies=web,data-jpa,postgresql,security \
-d type=maven-project \
-d language=java \
-d bootVersion=3.2.0 \
-d baseDir=myapp \
-o myapp.zip
unzip myapp.zip
cd myapp
./mvnw spring-boot:run
개발용 설정: H2 인메모리 DB
처음에는 PostgreSQL을 띄우지 않고 H2 인메모리 DB로 시작하면 설정이 가장 적습니다. dependencies=web,data-jpa,h2로 프로젝트를 만든 뒤 src/main/resources/application.properties를 이렇게 둡니다.
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
# 개발용: 시작할 때 테이블 생성, 종료 시 삭제 (.properties는 줄 끝 주석이 없음 — #은 줄 맨 앞에만)
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 콘솔: http://localhost:8080/h2-console
spring.h2.console.enabled=true
ddl-auto=create-drop은 재시작할 때마다 데이터가 사라지므로 개발 전용입니다. 운영 DB로 옮길 때는 validate나 none으로 두고 Flyway·Liquibase 같은 마이그레이션 도구로 스키마를 관리해야 합니다. update는 편해 보이지만 컬럼 삭제·타입 변경을 반영하지 않아 운영에서 스키마가 조용히 어긋나는 원인이 됩니다.
실행과 curl로 확인하기
./mvnw spring-boot:run
# 로그에 "Tomcat started on port 8080"이 보이면 준비 완료
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "[email protected]"}'
curl http://localhost:8080/api/users
curl -X DELETE http://localhost:8080/api/users/1
REST API
Controller
// src/main/java/com/example/demo/controller/UserController.java
package com.example.demo.controller;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// 생성자가 하나면 @Autowired 없이도 주입됨 (필드 주입보다 테스트·불변성에 유리, 10.3 참고)
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
User created = userService.save(user);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.findById(id)
.map(existing -> {
user.setId(id);
return ResponseEntity.ok(userService.save(user));
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return ResponseEntity.noContent().build();
}
}
JPA
Entity
// src/main/java/com/example/demo/model/User.java
package com.example.demo.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
// Getters and Setters
}
Repository
// src/main/java/com/example/demo/repository/UserRepository.java
package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Service
// src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<User> findAll() {
return userRepository.findAll();
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public User save(User user) {
return userRepository.save(user);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
Spring Security
설정
// src/main/java/com/example/demo/config/SecurityConfig.java
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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 filterChain(HttpSecurity http) throws Exception {
http
// 토큰 기반의 상태 없는(stateless) API일 때만 CSRF를 끔. 세션·쿠키 인증이면 켜 둘 것
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/**").authenticated()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults()); // Security 6.1+: 체이닝 방식(.csrf().disable())은 deprecated
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Validation
// src/main/java/com/example/demo/dto/CreateUserDto.java
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class CreateUserDto {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
// Getters and Setters
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserDto dto) {
// ...
}
예외 처리
// src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(ResourceNotFoundException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleGeneral(Exception ex) {
Map<String, String> error = new HashMap<>();
error.put("error", "Internal server error");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
테스트
// src/test/java/com/example/demo/service/UserServiceTest.java
// 패키지 선언
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
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 java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void findById_shouldReturnUser() {
User user = new User();
user.setId(1L);
user.setName("John");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
Optional<User> result = userService.findById(1L);
assertTrue(result.isPresent());
assertEquals("John", result.get().getName());
}
}
배포
JAR 빌드
./mvnw clean package
java -jar target/myapp-0.0.1-SNAPSHOT.jar
Docker
FROM eclipse-temurin:21-jdk-alpine as builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Spring Boot 내부 동작 심화 (엔지니어 관점)
실무에서 “왜 이렇게 동작하지?”를 설명하려면 자동 설정, 컨텍스트 생명주기, 빈과 DI, AOP 프록시가 한 줄로 이어진다고 보면 됩니다. 아래는 운영·장애 분석·성능 튜닝에 바로 연결되는 내부 모델입니다.
자동 설정(Auto-configuration) 메커니즘
@SpringBootApplication은 실제로 세 가지를 한 덩어리로 묶은 진입점 메타 애너테이션입니다. @Configuration(구성 클래스), @ComponentScan(컴포넌트 스캔), @EnableAutoConfiguration(자동 설정 로딩)이 합쳐진 형태로 이해하면 이후 동작이 선명해집니다.
자동 설정 클래스는 클래스패스와 프로퍼티를 읽어 조건부로 빈을 등록합니다. Spring Boot 3.x에서는 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports에 등록된 클래스 목록이 로딩 대상이며, 과거 방식인 META-INF/spring.factories의 EnableAutoConfiguration 키는 레거시 호환 경로로만 기억하면 됩니다.
핵심은 조건 평가(Conditional) 입니다. 예를 들어 @ConditionalOnClass는 특정 클래스가 클래스패스에 있을 때만 설정을 활성화하며, @ConditionalOnBean은 이미 특정 빈이 등록된 경우에만 추가 빈을 붙입니다. @ConditionalOnProperty는 spring.* 같은 외부 설정 값으로 기능을 켜고 끕니다. 이 조합 덕분에 “웹이면 서블릿 스택을, JPA면 Hibernate를” 같은 관례 기반 기본값이 자동으로 맞춰집니다.
자동 설정끼리의 순서는 @AutoConfigureBefore / @AutoConfigureAfter / @AutoConfigureOrder로 조정합니다. 충돌이나 중복 등록이 의심될 때는 spring.autoconfigure.exclude로 특정 자동 설정 클래스를 제외하거나, @SpringBootApplication(exclude = ...)로 좁혀서 검증하는 것이 일반적인 운영 패턴입니다.
디버깅 팁: --debug 또는 logging.level.org.springframework.boot.autoconfigure=DEBUG로 어떤 자동 설정이 매칭/비매칭되었는지 로그로 확인할 수 있습니다. “로컬에서는 되는데 서버에서만 실패”하는 류의 이슈는 대부분 클래스패스 차이나 프로파일·환경 변수 차이가 조건을 바꾼 경우입니다.
ApplicationContext 생명주기
스프링 애플리케이션 런타임의 중심은 ApplicationContext입니다. Spring Boot 웹 애플리케이션에서는 보통 AnnotationConfigServletWebServerApplicationContext(서블릿 기반) 계열이 사용되며, 컨텍스트 refresh 과정에서 빈 팩토리가 완성되고 이벤트가 발행됩니다.
refresh()의 큰 흐름을 엔지니어 관점에서 짚으면 다음과 같습니다. (세부 단계명은 버전에 따라 세분화되지만 개념은 동일합니다.)
- 환경 준비:
Environment(프로파일, 프로퍼티 소스)가 바인딩됩니다. - BeanFactory 후처리:
BeanFactoryPostProcessor가 빈 정의(Definition) 를 바꿉니다.PropertySourcesPlaceholderConfigurer같은 것이 여기서 동작합니다. - 빈 후처리기 등록:
BeanPostProcessor가 준비됩니다. 이후 단계에서 빈 인스턴스를 감쌉니다(AOP 포함). - 싱글톤 빈 초기화: 의존성 그래프를 따라 빈을 생성합니다.
- 특화 단계: 웹이라면
onRefresh()에서 내장 웹 서버가 시작되는 흐름이 이어집니다(Servlet 컨텍스트).
생명주기 이벤트로는 ContextRefreshedEvent, 부트의 경우 ApplicationReadyEvent 등이 대표적입니다. @PostConstruct는 빈 생성 직후, ApplicationRunner / CommandLineRunner는 컨텍스트가 준비된 뒤 실행된다는 점이 다릅니다. “초기화 순서 버그” 를 볼 때는 이 단계를 기준으로 원인을 쪼개면 해결 속도가 빨라집니다.
빈 생성과 의존성 주입(DI)의 실체
스프링 DI는 빈 정의 → 빈 팩토리 → 빈 인스턴스의 파이프라인으로 이해하는 것이 정확합니다. @Component, @Bean, 자동 설정이 만드는 팩토리 메서드는 모두 결국 BeanDefinition으로 수렴합니다.
주입 지점에는 생성자, 필드, 세터가 있으며, Spring은 불변성과 테스트 용이성 측면에서 가능한 한 필수 의존성을 생성자로 주입하도록 권장합니다. 생성자 주입은 의존성이 명시적으로 드러나고 순환 의존을 조기에 드러내는 장점이 있습니다. 반면 순환 의존이 실제로 필요한 설계인지(대개는 아닙니다)부터 재검토해야 합니다.
@Autowired 해석은 타입을 기준으로 후보를 찾으며, 후보가 여럿일 때는 @Primary, @Qualifier, 컬렉션 주입 규칙으로 결정됩니다. 스코프가 다른 빈(예: 요청 스코프를 싱글톤에 주입)을 섞을 때는 프록시가 개입할 수 있으므로, 그때는 @Lazy나 ObjectProvider, Provider 같은 지연 조회 패턴을 고려합니다.
내부적으로 BeanPostProcessor는 트랜잭션, 검증, AOP 같은 횡단 관심사를 빈 인스턴스에 덧입히는 지점입니다. 따라서 “내가 만든 순수 객체”와 “컨테이너가 꺼내 주는 객체”가 다를 수 있다는 점을 염두에 두어야 합니다.
AOP 프록시 생성과 자기 호출(Self-invocation) 문제
스프링 AOP는 기본적으로 프록시 기반입니다. 인터페이스 기반 빈에는 JDK 동적 프록시, 클래스 기반에는 CGLIB 서브클래스 프록시가 쓰이는 경우가 많습니다. Spring Boot 2.x 이후 설정에 따라 클래스 프록시를 우선하는 경향이 강해졌고(spring.aop.proxy-target-class 기본이 사실상 true에 가깝습니다), 이는 구체 클래스에 직접 @Transactional 등을 붙이는 코드 스타일과도 맞물립니다.
프록시가 붙는 이유는 메서드 호출을 가로채 부가 기능(트랜잭션, 보안, 로깅, 재시도) 을 적용하기 위해서입니다. 따라서 같은 클래스 안에서 this로 메서드를 호출하면 프록시를 거치지 않는 자기 호출이 되어 @Transactional이 기대대로 동작하지 않을 수 있습니다. 해결은 트랜잭션 경계를 외부 컴포넌트로 분리하거나, 필요 시 TransactionTemplate/EntityManager 같은 명시 API로 경계를 옮기는 식으로 경계 설계를 바로잡는 것이 근본입니다.
운영 관점에서는 “프록시 때문에 스택이 깊다”는 현상이 관측(APM)에서 흔히 보입니다. 이는 이상이 아니라 설계상 자연스러운 비용이며, 불필요하게 AOP 포인트컷을 과다 적용하는 것은 피하는 것이 좋습니다.
프로덕션 Spring Boot 패턴 (현장 체크리스트)
설정과 비밀: 12-Factor 관점에서 설정은 이미지에 박지 않고 환경 변수·시크릿 스토어로 주입합니다. @ConfigurationProperties로 타입 안전 바인딩을 하고 @Validated로 제약을 걸면 잘못된 설정을 시작 시점에 조기 실패시킬 수 있습니다.
프로파일: spring.profiles.active로 dev / staging / prod를 분리하되, 프로덕션 전용 차이는 코드 분기보다 설정으로 해결하는 편이 안전합니다.
관측성: Actuator 엔드포인트는 공개 범위를 최소화하고, Micrometer 메트릭과 분산 트레이싱(예: OpenTelemetry)을 붙여 지연의 원인을 프레임워크 경계에서부터 추적할 수 있게 합니다.
데이터 접근: 커넥션 풀(HikariCP)의 최대 풀 크기, 타임아웃, DB 드라이버의 네트워크 타임아웃을 함께 튜닝합니다. 풀만 키우고 DB max_connections를 무시하면 오히려 장애가 커집니다.
종료 처리: Kubernetes 환경에서는 유예 종료(graceful shutdown) 와 헬스 프로브 설정을 맞춰 진행 중인 트래픽이 끊기지 않게 합니다. Spring Boot는 서버 종료 시 처리 중인 요청을 마무리하는 옵션을 제공하므로, 버전별 문서의 Graceful Shutdown 항목을 기준으로 조정합니다.
아키텍처: 컨트롤러는 얇게 두고, 도메인 규칙은 서비스/도메인 계층에 둡니다. 트랜잭션 경계는 일관성이 필요한 유스케이스 단위로 유지합니다. “어디까지가 프록시 경계인지, 어디서 트랜잭션이 열리는지”를 팀 규칙으로 명시하면 장애 대응 비용이 줄어듭니다.
처음 만날 때 막히는 에러와 실수
| 증상 | 원인 | 해결 |
|---|---|---|
Port 8080 was already in use | 다른 프로세스(이전에 띄운 앱 포함)가 포트 점유 | 기존 프로세스 종료, 또는 server.port=8081 |
Failed to configure a DataSource: 'url' attribute is not specified | spring-boot-starter-data-jpa는 넣었는데 DB 드라이버나 spring.datasource.url이 없음 | H2·PostgreSQL 드라이버 의존성 추가 후 URL 설정 |
POST로 보낸 JSON이 전부 null로 들어옴 | 파라미터에 @RequestBody 누락 → Spring이 쿼리 파라미터에서 값을 찾음 | create(@RequestBody User user) |
| 프론트엔드에서 호출하면 CORS 에러 | 다른 오리진 요청 허용 안 됨 | 개발 중엔 @CrossOrigin(origins = "http://localhost:3000"), 운영은 WebMvcConfigurer의 addCorsMappings나 Security의 CORS 설정으로 한곳에서 관리 |
| Security 넣자마자 모든 요청이 401 | spring-boot-starter-security는 추가만 해도 전 엔드포인트를 잠금 | 5장처럼 SecurityFilterChain에서 공개 경로를 명시 |
Could not find or load main class | 빌드 산출물이 꼬임, IDE와 Maven 설정 불일치 | ./mvnw clean package 후 다시 실행 |
@RequestBody 누락은 에러 없이 조용히 null이 들어온다는 점 때문에 특히 오래 헤맵니다. 요청이 컨트롤러까지 오는데 값이 비어 있다면 가장 먼저 확인할 부분입니다. 로그로 원인을 좁힐 때는 logging.level.org.springframework.web=DEBUG를 켜면 어떤 핸들러에 매핑됐고 요청 본문을 어떻게 읽었는지까지 보입니다.
정리 및 체크리스트
핵심 요약
- Spring Boot: 빠른 Spring 개발
- 자동 설정: 최소한의 설정
- 내부 동작: 조건부 자동 설정, 컨텍스트 생명주기, DI 파이프라인, AOP 프록시 경계
- REST API: @RestController
- JPA: 데이터베이스 추상화
- Spring Security: 인증/인가
- Actuator: 모니터링
구현 체크리스트
- Spring Boot 프로젝트 생성
- REST API 구현
- JPA Entity 정의
- Spring Security 설정
- Validation 구현
- 테스트 작성
- Docker 배포
- 자동 설정 매칭 로그(
--debug)로 환경별 차이 점검 - 프로덕션: 프로파일·설정 바인딩·관측성·종료 처리 점검
같이 보면 좋은 글
- NestJS로 백엔드 만들기
- FastAPI로 Python API 만들기
- PostgreSQL 고급 가이드
자주 묻는 질문 (FAQ)
Q. Spring vs Spring Boot, 차이가 뭔가요?
A. Spring Boot는 Spring을 더 쉽게 사용하도록 만든 프레임워크입니다. 자동 설정과 내장 서버를 제공합니다.
Q. Kotlin으로도 사용할 수 있나요?
A. 네, Spring Boot는 Kotlin을 완벽하게 지원합니다.
Q. 마이크로서비스에 적합한가요?
A. 네, Spring Cloud와 함께 사용하면 마이크로서비스 아키텍처를 쉽게 구축할 수 있습니다.
Q. 자동 설정은 어떻게 켜지고 꺼지나요?
A. 클래스패스와 @Conditional* 조건, spring.* 프로퍼티를 조합해 빈 정의가 등록됩니다. 제외는 spring.autoconfigure.exclude 등으로 제어합니다.
Q. @Transactional이 같은 클래스 안 호출에서 안 먹는 이유는?
A. 프록시 밖에서 호출될 때만 부가 기능이 적용되는 경우가 많습니다. 자기 호출(this)은 프록시를 거치지 않아 경계 설계를 점검해야 합니다.
Q. ApplicationContext refresh는 왜 중요한가요?
A. 빈 정의 후처리·빈 생성·웹 서버 기동 등이 이 흐름에서 일어납니다. 초기화 순서 이슈는 이 단계를 기준으로 분해하면 원인 파악이 빨라집니다.