N타워 백엔드 개발자
온보딩 가이드
Java/Spring Boot 백엔드 + React 프론트엔드 풀스택 포지션. 이 문서는 실무 즉시 투입 가능한 수준까지 빠르게 올리기 위한 셀프 온보딩 종합 가이드입니다.
경기 성남시 분당구 정자일로 190 (정자동) N타워
정자역 도보권
중급: 풀스택 API 연동, MyBatis/Oracle
고급: React/Spring Boot 아키텍트
핵심 요구 기술 스택
| 카테고리 | 기술 | 요구 수준 | 비고 |
|---|---|---|---|
| 백엔드 | Java / Spring Boot | 5년+ 또는 동급 | Architect 레벨 우대 |
| 프론트 | React (FE 연동) | 중급 이상 | async/await, API 연동 |
| DB | MyBatis + Oracle | 실무 필수 | 동적 SQL, 데이터 액세스 |
| 인프라 | Redis / 메시지 큐 | 연동 경험 | 외부 API 포함 |
이 문서 활용 방법
준비 완료 확인
항목을 클릭해 체크하면 진행률이 자동으로 업데이트됩니다. 브라우저를 닫아도 상태가 유지됩니다.
개발환경 구성
Oracle XE, Redis, Spring Boot, React를 로컬에서 한 번에 띄우는 Docker Compose 기반 환경 가이드입니다.
docker-compose.yml — 전체 스택
# docker-compose.ymlversion: '3.9'
services:
oracle:
image: gvenzl/oracle-xe:21-slim
environment:
ORACLE_PASSWORD: oracle123
APP_USER: appuser
APP_USER_PASSWORD: apppass
ports:
- "1521:1521"
volumes:
- oracle-data:/opt/oracle/oradata
healthcheck:
test: ["CMD", "healthcheck.sh"]
interval: 30s
timeout: 10s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis-data:/data
backend:
build: ./backend
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle:1521/XEPDB1
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: apppass
SPRING_REDIS_HOST: redis
depends_on:
oracle:
condition: service_healthy
redis:
condition: service_started
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
oracle-data:
redis-data:
Spring Boot application.yml
# src/main/resources/application.ymlspring:
datasource:
url: jdbc:oracle:thin:@localhost:1521/XEPDB1
username: appuser
password: apppass
driver-class-name: oracle.jdbc.OracleDriver
hikari:
maximum-pool-size: 10
connection-timeout: 30000
idle-timeout: 600000
data:
redis:
host: localhost
port: 6379
timeout: 3000
cache:
type: redis
redis:
time-to-live: 300000 # 5분
mybatis:
mapper-locations: classpath:mapper/**/\*.xml
configuration:
map-underscore-to-camel-case: true
default-fetch-size: 100
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
server:
port: 8080
logging:
level:
com.yourcompany.mapper: DEBUG # MyBatis SQL 로그
Vite 프록시 설정 (React)
// vite.config.tsimport { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
// /api/users → http://localhost:8080/api/users
}
}
}
})
Spring Boot 핵심 패턴
실무에서 반드시 알아야 하는 Spring Boot 3.x 패턴. 각 예제는 복사해서 바로 실행 가능한 수준으로 작성했습니다.
1. 글로벌 예외 처리 — @ControllerAdvice
// 표준 에러 응답 DTO
@Getter
@Builder
public class ErrorResponse {
private String code;
private String message;
private LocalDateTime timestamp;
private String path;
public static ErrorResponse of(String code, String message, String path) {
return ErrorResponse.builder()
.code(code).message(message).path(path)
.timestamp(LocalDateTime.now()).build();
}
}
// 글로벌 예외 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex, HttpServletRequest req) {
return ResponseEntity.status(ex.getStatus())
.body(ErrorResponse.of(ex.getCode(), ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest req) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(ErrorResponse.of("VALIDATION_ERROR", msg, req.getRequestURI()));
}
}
2. Spring Security 6.x + JWT (람다 DSL)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
3. @Async 비동기 처리 + 외부 API 연동
@Service
public class ExternalApiService {
private final WebClient webClient;
public ExternalApiService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://api.external.com")
.defaultHeader("Accept", "application/json")
.build();
}
// 동기 호출 — 응답을 기다려야 할 때
public ExternalData fetchData(String id) {
return webClient.get()
.uri("/data/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
res -> Mono.error(new BusinessException("EXTERNAL_API_ERROR")))
.bodyToMono(ExternalData.class)
.timeout(Duration.ofSeconds(5))
.block();
}
// 비동기 병렬 호출 — 여러 API를 동시에 호출
@Async
public CompletableFuture<ExternalData> fetchDataAsync(String id) {
ExternalData data = fetchData(id);
return CompletableFuture.completedFuture(data);
}
}
MyBatis + Oracle 실무 패턴
JD에서 직접 명시한 기술. Oracle 방언과 MyBatis 동적 SQL 패턴을 코드로 완벽히 숙지해야 합니다.
1. Mapper 인터페이스 + XML
// UserMapper.java@Mapper
public interface UserMapper {
List<UserDto> findByCondition(UserSearchParam param);
Optional<UserDto> findById(Long id);
void insert(UserDto user);
void update(UserDto user);
void delete(Long id);
int countByCondition(UserSearchParam param);
}
<!-- mapper/UserMapper.xml --><mapper namespace="com.example.mapper.UserMapper">
<resultMap id="UserResultMap" type="UserDto">
<id property="id" column="USER_ID"/>
<result property="name" column="USER_NM"/>
<result property="email" column="EMAIL_ADDR"/>
<result property="status" column="STATUS_CD"/>
<result property="createdAt" column="REG_DT"/>
<!-- 연관 객체 -->
<association property="dept" javaType="DeptDto">
<result property="deptId" column="DEPT_ID"/>
<result property="deptName" column="DEPT_NM"/>
</association>
</resultMap>
<!-- Oracle 페이징: ROW_NUMBER() OVER 권장 (ROWNUM은 서브쿼리 필수) -->
<select id="findByCondition" resultMap="UserResultMap">
SELECT *
FROM (
SELECT A.*, ROW_NUMBER() OVER (ORDER BY A.REG_DT DESC) AS RNUM
FROM (
SELECT U.USER_ID, U.USER_NM, U.EMAIL_ADDR, U.STATUS_CD, U.REG_DT,
D.DEPT_ID, D.DEPT_NM
FROM TB_USER U
LEFT JOIN TB_DEPT D ON D.DEPT_ID = U.DEPT_ID
<where>
<if test="name != null and name != ''">
AND U.USER_NM LIKE '%' || #{name} || '%'
</if>
<if test="status != null">
AND U.STATUS_CD = #{status}
</if>
<if test="fromDate != null">
AND U.REG_DT >= TO_DATE(#{fromDate}, 'YYYY-MM-DD')
</if>
</where>
) A
)
WHERE RNUM BETWEEN #{offset} + 1 AND #{offset} + #{limit}
</select>
<!-- 동적 UPDATE (변경된 컬럼만) -->
<update id="update">
UPDATE TB_USER
<set>
<if test="name != null">USER_NM = #{name},</if>
<if test="email != null">EMAIL_ADDR = #{email},</if>
UPD_DT = SYSDATE
</set>
WHERE USER_ID = #{id}
</update>
<!-- Oracle Sequence로 PK 채번 -->
<insert id="insert">
<selectKey keyProperty="id" resultType="long" order="BEFORE">
SELECT SEQ_USER.NEXTVAL FROM DUAL
</selectKey>
INSERT INTO TB_USER (USER_ID, USER_NM, EMAIL_ADDR, STATUS_CD, REG_DT)
VALUES (#{id}, #{name}, #{email}, #{status}, SYSDATE)
</insert>
</mapper>
2. Oracle 필수 방언 — 면접 대비
| 패턴 | Oracle 문법 | MySQL 대비 차이 |
|---|---|---|
| 현재 날짜 | SYSDATE | MySQL: NOW() |
| NULL 대체 | NVL(col, '기본값') | MySQL: IFNULL |
| 조건 변환 | DECODE(col, '1', 'Y', 'N') | MySQL: IF/CASE |
| 페이징 | ROW_NUMBER() OVER(ORDER BY col) | MySQL: LIMIT OFFSET |
| 계층쿼리 | CONNECT BY PRIOR | MySQL: 재귀 CTE |
| 문자 합치기 | col1 || col2 | MySQL: CONCAT() |
| 더미 테이블 | SELECT 1 FROM DUAL | MySQL: 불필요 |
3. CONNECT BY — 계층형 쿼리
-- 조직도 계층 조회 (부서 트리)
SELECT
LEVEL,
LPAD(' ', (LEVEL-1) * 4) || DEPT_NM AS DEPT_NM_TREE,
DEPT_ID,
PARENT_DEPT_ID,
SYS_CONNECT_BY_PATH(DEPT_NM, '>') AS FULL_PATH
FROM TB_DEPT
START WITH PARENT_DEPT_ID IS NULL -- 최상위 노드
CONNECT BY PRIOR DEPT_ID = PARENT_DEPT_ID -- 부모→자식 방향
ORDER SIBLINGS BY SORT_ORD;
Redis / 메시지 큐
JD 명시 항목. 캐싱 전략, TTL 관리, Kafka 기본 연동까지 실무 패턴을 코드로 확인합니다.
1. Redis @Cacheable 캐싱 전략
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 캐시별 TTL 다르게 설정
Map<String, RedisCacheConfiguration> configs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofMinutes(5)),
"products", defaultConfig.entryTtl(Duration.ofHours(1)),
"codes", defaultConfig.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configs)
.build();
}
}
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId")
public UserDto findById(Long userId) {
return userMapper.findById(userId).orElseThrow(
() -> new BusinessException("USER_NOT_FOUND"));
}
@CacheEvict(value = "users", key = "#user.id")
public void update(UserDto user) {
userMapper.update(user);
}
// 특정 조건에만 캐싱
@Cacheable(value = "products", key = "#id",
condition = "#id > 0", unless = "#result == null")
public ProductDto getProduct(Long id) { ... }
}
2. Kafka Producer / Consumer
// Producer
@Service
public class OrderEventProducer {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void sendOrderEvent(OrderEvent event) {
kafkaTemplate.send("order-events", event.getOrderId().toString(), event)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Kafka 전송 실패: {}", ex.getMessage());
} else {
log.info("Kafka 전송 성공: partition={}, offset={}",
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
}
});
}
}
// Consumer
@Component
public class OrderEventConsumer {
@KafkaListener(topics = "order-events", groupId = "order-service")
public void consume(ConsumerRecord<String, OrderEvent> record) {
log.info("수신: key={}, value={}", record.key(), record.value());
try {
processOrder(record.value());
} catch (Exception e) {
// DLQ(Dead Letter Queue)로 이동 처리
log.error("처리 실패 → DLQ 이동: {}", e.getMessage());
}
}
}
React 비동기 패턴
JD에서 직접 명시한 "Promise/async-await 이해" 요건. FE-BE 연동 전체 흐름을 패턴으로 숙지합니다.
1. Axios 인스턴스 + 글로벌 인터셉터
// src/lib/api.tsimport axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
export const api = axios.create({
baseURL: '/api', // Vite proxy → localhost:8080
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
})
// 요청 인터셉터 — JWT 토큰 자동 첨부
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 응답 인터셉터 — 토큰 만료 시 자동 갱신
api.interceptors.response.use(
res => res,
async (error: AxiosError) => {
const originalRequest = error.config as any
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const { data } = await api.post('/auth/refresh')
localStorage.setItem('accessToken', data.accessToken)
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
return api(originalRequest)
} catch {
localStorage.clear()
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
2. Custom Hook — 비동기 상태 관리
// src/hooks/useAsync.tsimport { useState, useCallback } from 'react'
interface AsyncState<T> {
data: T | null
loading: boolean
error: Error | null
}
export function useAsync<T>(asyncFn: (...args: any[]) => Promise<T>) {
const [state, setState] = useState<AsyncState<T>>({
data: null, loading: false, error: null
})
const execute = useCallback(async (...args: any[]) => {
setState(s => ({ ...s, loading: true, error: null }))
try {
const data = await asyncFn(...args)
setState({ data, loading: false, error: null })
return data
} catch (err) {
setState(s => ({ ...s, loading: false, error: err as Error }))
throw err
}
}, [asyncFn])
return { ...state, execute }
}
// 사용 예시
const { data: users, loading, error, execute: fetchUsers } = useAsync(
() => api.get('/users').then(r => r.data)
)
3. Promise.all 병렬 호출
// 여러 API를 병렬로 호출하고 모두 완료된 후 처리
const loadDashboardData = async () => {
try {
const [users, products, orders] = await Promise.all([
api.get('/users'),
api.get('/products'),
api.get('/orders?status=pending')
])
setDashboard({ users: users.data, products: products.data, orders: orders.data })
} catch (err) {
// 하나라도 실패하면 catch로 빠짐
console.error('대시보드 로드 실패:', err)
}
}
// 실패해도 계속 진행 — allSettled
const results = await Promise.allSettled([
api.get('/optional-data-1'),
api.get('/optional-data-2')
])
results.forEach(r => {
if (r.status === 'fulfilled') console.log(r.value.data)
else console.warn('부분 실패:', r.reason)
})
개발 워크플로우
기능 개발부터 배포까지 일반적인 계약직 포지션에서의 업무 흐름입니다.
1주차 합류 액션 플랜
일반 기능 개발 프로세스
| 단계 | 내용 | 산출물 |
|---|---|---|
| 요구분석 | 기획서/이슈 확인, 질문 사항 정리 | 질문 리스트, 확인된 스펙 |
| 설계 | API 설계 (URL, 파라미터, 응답), DB 변경 여부 | API 명세 초안 |
| 개발 | feature 브랜치 → 구현 → 로컬 테스트 | 코드 + 단위테스트 |
| 리뷰 | PR 올리기 → 코드리뷰 → 수정 | 리뷰 완료된 PR |
| 배포 | develop merge → QA → main merge → 운영 배포 | 배포 완료 |
꼭 물어봐야 할 것들 (합류 직후)
코드리뷰 / Git 전략
Git flow 기반 브랜치 전략과 좋은 PR을 만드는 방법.
Git Flow 브랜치 전략
| 브랜치 | 역할 | 머지 대상 |
|---|---|---|
| main | 운영 배포 기준 | release 브랜치에서만 |
| develop | 다음 릴리즈 통합 | feature 브랜치에서 |
| feature/xxx | 기능 개발 | develop으로 PR |
| release/x.x.x | 릴리즈 준비 | main + develop |
| hotfix/xxx | 운영 긴급 수정 | main + develop |
커밋 메시지 컨벤션
# 형식: type(scope): 간단한 설명
feat(user): 사용자 목록 페이징 기능 추가
fix(order): 주문 금액 계산 오류 수정
refactor(cache): Redis 캐시 TTL 정책 개선
docs(api): 사용자 API 명세 업데이트
test(user): 사용자 서비스 단위테스트 추가
chore(deps): spring-boot 3.2.1로 업그레이드
# Body: 왜 변경했는지 (선택)
# 기존: NVL 함수 사용 → 성능 이슈 발생
# 변경: COALESCE로 교체하여 인덱스 활용 가능하게 수정
PR 템플릿 (좋은 PR 작성법)
## 변경 내용
사용자 목록 조회 API에 페이징 및 검색 필터 기능 추가
## 변경 이유
데이터 증가로 인해 전체 조회 시 응답시간 5초 이상 발생
## 변경 사항
- UserMapper.xml: ROW_NUMBER() 기반 페이징 쿼리 추가
- UserService: 페이지 파라미터 검증 로직 추가
- Redis 캐시 무효화 처리 (@CacheEvict)
## 테스트
- [x] 로컬 실행 확인
- [x] 단위 테스트 통과
- [ ] QA 환경 배포 (진행 중)
## 스크린샷 (UI 변경이 있는 경우)
트러블슈팅 가이드
자주 만나는 문제 유형별 원인과 해결 패턴입니다.
Spring Boot 자주 나오는 이슈
| 증상 | 원인 | 해결 |
|---|---|---|
| N+1 쿼리 폭발 | MyBatis collection 루프에서 추가 조회 | JOIN 쿼리로 통합 또는 Batch 조회 |
| @Transactional 롤백 안 됨 | checked 예외, 같은 클래스 호출(프록시 우회) | unchecked 예외 사용 또는 self-injection |
| @Async 동작 안 함 | @EnableAsync 누락 또는 같은 클래스 호출 | @EnableAsync 추가, 별도 빈으로 분리 |
| 캐시 무효화 안 됨 | key 표현식 불일치 | key를 동일한 SpEL 표현식으로 통일 |
| CORS 오류 | SecurityConfig에서 CORS 설정 누락 | corsConfigurationSource 빈 등록 확인 |
Oracle / MyBatis 자주 나오는 이슈
| 증상 | 원인 | 해결 |
|---|---|---|
| ORA-01795: 목록이 1000개 초과 | IN 절에 1000개 이상 바인딩 | IN 절을 분할(배치)하거나 임시 테이블 사용 |
| ROWNUM 페이징이 틀림 | ROWNUM을 WHERE에서 직접 사용 | 서브쿼리 감싼 후 RNUM 조건 사용 |
| 한글 깨짐 | JDBC URL characterEncoding 미설정 | NLS_CHARACTERSET 확인, UTF-8 설정 |
| MyBatis resultMap 매핑 안 됨 | column명 대소문자 불일치 | column 속성에 Oracle 컬럼명 그대로(대문자) |
Redis 관련
# 캐시 상태 확인
redis-cli KEYS "users::*" # 특정 캐시 키 목록
redis-cli TTL "users::123" # TTL 남은 시간(초)
redis-cli GET "users::123" # 캐시 값 확인
redis-cli FLUSHDB # 개발환경 전체 캐시 삭제 (주의!)
# 연결 문제
redis-cli ping # PONG 응답 확인
redis-cli INFO server # 서버 정보 확인
기술 면접 Q&A
이 포지션에서 나올 가능성이 높은 질문과 핵심 답변 포인트입니다. 각 항목을 소리 내어 2분 안에 설명할 수 있을 때까지 연습하세요.
핵심 답변: 1번의 쿼리 결과에서 N개 행이 나올 때, 각 행에 대해 추가 쿼리가 N번 실행되는 문제입니다. 예를 들어 부서 목록 1개를 조회하면서 각 부서의 사원 목록을 루프에서 조회하면 1 + N번 실행됩니다.
해결: ① JOIN 쿼리로 한 번에 조회 + resultMap collection으로 매핑 ② 배치 조회(IN 절) ③ 2차 캐시 활용
REQUIRED (기본값): 이미 트랜잭션이 있으면 참여, 없으면 새로 시작. 부모-자식이 같은 트랜잭션 → 어느 쪽 예외든 전체 롤백.
REQUIRES_NEW: 항상 새 트랜잭션 시작, 기존 트랜잭션은 일시 중단. 자식 성공/실패가 부모에 독립적. 로그 기록이나 감사 이력처럼 메인 트랜잭션 실패와 무관하게 반드시 저장해야 할 때 사용.
핵심 답변: useEffect가 다시 실행될 조건을 명시합니다. ① 빈 배열([]) → 마운트 시 1회만 실행 ② 값이 있으면 → 해당 값이 변경될 때마다 실행 ③ 생략하면 → 매 렌더링마다 실행 (보통 의도치 않은 버그 유발).
cleanup 함수: return () => {} 로 이벤트 리스너 제거, API 취소(AbortController) 등 사이드이펙트 정리.
써야 할 때: 읽기 빈도가 높고 변경이 적은 데이터 (공통코드, 상품 목록, 세션). 계산 비용이 큰 집계 쿼리.
쓰면 안 될 때: 실시간 정합성이 반드시 필요한 잔액/재고. 쓰기 빈도가 읽기보다 높은 경우. 캐시 무효화 로직이 복잡해져 버그 유발 가능성이 높을 때.
ROWNUM: 쿼리 결과에 순서대로 붙는 의사 컬럼. ORDER BY 이전에 매겨지므로 페이징 시 반드시 서브쿼리로 감싸야 함.
ROW_NUMBER() OVER(ORDER BY ...): 윈도우 함수로, ORDER BY 후 번호를 매기므로 페이징에 훨씬 안전하고 직관적. 실무에서는 ROW_NUMBER() 사용 권장.
-- ROWNUM 방식 (서브쿼리 2중 필수)
SELECT * FROM (SELECT A.*, ROWNUM RN FROM (SELECT * FROM TB WHERE ...) A)
WHERE RN BETWEEN 1 AND 10;
-- ROW_NUMBER 방식 (권장)
SELECT * FROM (SELECT A.*, ROW_NUMBER() OVER(ORDER BY REG_DT) RN FROM TB A)
WHERE RN BETWEEN 1 AND 10;
Promise.all: 하나라도 reject되면 즉시 catch로 빠짐. 모두 성공해야 하는 경우 (주문 처리, 결제).
Promise.allSettled: 모든 Promise가 완료될 때까지 기다림. 각각 fulfilled/rejected 결과를 배열로 반환. 일부 실패해도 나머지 결과를 처리해야 할 때 (대시보드 위젯별 독립 로딩).