JUnit5, Mockito, 단위/통합 테스트 설계와 디버거, 로깅, 예외 추적으로 코드 품질을 검증한다.
테스트와 디버깅 전략
입문편에서 예외 처리 문법(try-catch)을 익혔다면, 이제는 그 한 단계 위인 "어떻게 하면 예외가 발생하기 전에 버그를 잡고, 발생했을 때 빠르게 추적하는가"를 다룰 차례입니다. 테스트 코드는 리팩터링에 대한 신뢰를 부여하고, 디버거와 구조적 로깅은 프로덕션 장애 원인을 좁히는 핵심 도구입니다. 이 레슨에서는 JUnit 5와 Mockito를 중심으로 테스트 설계 전략을 정립하고, IDE 디버거와 로깅 프레임워크를 통해 코드를 깊이 들여다보는 실무 기법을 배웁니다.
학습 목표
- JUnit 5 생명주기 애너테이션과 파라미터화 테스트로 체계적인 단위 테스트를 작성할 수 있다.
- Mockito로 의존 객체를 모킹하고, 호출 행위를 검증하는 테스트를 설계할 수 있다.
- 의존성 주입과 경계 분리를 통해 테스트하기 좋은 코드를 작성할 수 있다.
- 코드 커버리지 수치의 의미와 한계를 이해하고 올바르게 해석할 수 있다.
- 디버거 중단점, 조건부 중단점, 스택 트레이스 분석으로 런타임 오류를 정확하게 추적할 수 있다.
- SLF4J/Logback 구조적 로깅과 MDC로 분산 환경에서의 예외를 진단할 수 있다.
JUnit 5 단위 테스트: 생명주기와 파라미터화
JUnit 5(Jupiter)는 JUnit 4와 아키텍처가 완전히 다릅니다. 테스트 클래스를 public으로 선언할 필요가 없고, 생명주기 제어가 애너테이션 기반으로 세분화되어 있습니다.
생명주기 애너테이션
import org.junit.jupiter.api.*;
class OrderServiceTest {
private static DatabaseConnection db; // 클래스 수준 공유 자원
private OrderService sut; // System Under Test
@BeforeAll
static void initDb() {
// 테스트 클래스 전체에서 딱 한 번 실행
// ✅ 비용이 큰 자원(DB 연결, 임베디드 서버) 초기화에 사용
db = DatabaseConnection.createInMemory();
}
@AfterAll
static void closeDb() {
db.close();
}
@BeforeEach
void setUp() {
// 각 테스트 메서드 실행 전 호출 → 테스트 격리 보장
sut = new OrderService(db);
}
@AfterEach
void tearDown() {
db.clearAll(); // 각 테스트 후 데이터 초기화
}
@Test
@DisplayName("재고가 충분하면 주문이 성공한다")
void placeOrder_whenStockSufficient_succeeds() {
// given
Product product = new Product("P001", 10);
db.save(product);
// when
Order order = sut.placeOrder("P001", 3);
// then
Assertions.assertEquals(OrderStatus.CONFIRMED, order.getStatus());
Assertions.assertEquals(7, db.findProduct("P001").getStock());
}
@Test
@DisplayName("재고가 부족하면 InsufficientStockException이 발생한다")
void placeOrder_whenStockInsufficient_throwsException() {
db.save(new Product("P001", 2));
Assertions.assertThrows(
InsufficientStockException.class,
() -> sut.placeOrder("P001", 5)
);
}
}
💡 TIP
@DisplayName을 사용하면 테스트 결과 리포트에 가독성 높은 설명이 표시됩니다. "메서드명_조건_기대결과" 패턴(Given-When-Then)으로 작성하면 테스트 자체가 명세 문서가 됩니다.
파라미터화 테스트
동일한 로직을 다양한 입력값으로 검증해야 할 때 테스트 메서드를 중복 작성하는 것은 유지보수 지옥을 만듭니다. @ParameterizedTest가 해법입니다.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
class DiscountCalculatorTest {
private final DiscountCalculator calculator = new DiscountCalculator();
// ✅ CsvSource: 간단한 입출력 쌍
@ParameterizedTest(name = "등급={0}, 금액={1} → 할인율={2}%")
@CsvSource({
"GOLD, 100000, 10",
"SILVER, 100000, 5",
"BRONZE, 100000, 0",
"GOLD, 5000, 0", // 최소 주문금액 미달 → 할인 없음
})
void discount_byGradeAndAmount(String grade, int amount, int expectedRate) {
int actual = calculator.calculateDiscountRate(MemberGrade.valueOf(grade), amount);
Assertions.assertEquals(expectedRate, actual);
}
// ✅ MethodSource: 복잡한 객체 공급이 필요할 때
static Stream<Arguments> invalidOrderCases() {
return Stream.of(
Arguments.of(null, 1, "상품이 null"),
Arguments.of("P001", 0, "수량이 0"),
Arguments.of("P001", -1, "수량이 음수")
);
}
@ParameterizedTest(name = "[{2}] 케이스에서 예외 발생")
@MethodSource("invalidOrderCases")
void placeOrder_withInvalidInput_throwsIllegalArgument(
String productId, int quantity, String description) {
Assertions.assertThrows(
IllegalArgumentException.class,
() -> new Order(productId, quantity)
);
}
}
| 소스 애너테이션 | 사용 시점 |
|---|---|
@ValueSource | 단일 타입 단순 값 목록 |
@CsvSource | 여러 컬럼의 간단한 문자열 페어 |
@CsvFileSource | CSV 파일로 대용량 케이스 분리 |
@MethodSource | 복잡한 객체나 동적 생성이 필요할 때 |
@EnumSource | Enum 상수 전체 또는 일부 순회 |
Mockito: 의존성 모킹과 행위 검증
단위 테스트의 핵심 원칙은 테스트 대상(SUT)을 외부 의존성으로부터 격리하는 것입니다. 데이터베이스, 외부 API, 메시지 큐에 실제로 연결하는 테스트는 느리고 불안정합니다. Mockito는 Java에서 가장 널리 쓰이는 모킹 프레임워크입니다.
기본 모킹과 스텁
import org.mockito.Mockito;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(MockitoExtension.class) // JUnit 5 연동
class PaymentServiceTest {
@Mock
private PaymentGateway paymentGateway; // 자동 목 생성
@Mock
private OrderRepository orderRepository;
@InjectMocks
private PaymentService sut; // 위 Mock들이 자동 주입
@Test
@DisplayName("결제 성공 시 주문 상태가 PAID로 변경된다")
void processPayment_onSuccess_changesStatusToPaid() {
// given — 스텁: 특정 호출에 대한 반환값 정의
Order order = new Order("ORD001", 50000);
Mockito.when(orderRepository.findById("ORD001"))
.thenReturn(Optional.of(order));
Mockito.when(paymentGateway.charge("ORD001", 50000))
.thenReturn(PaymentResult.SUCCESS);
// when
sut.processPayment("ORD001");
// then — 행위 검증: 특정 메서드가 올바른 인자로 호출됐는지 확인
Mockito.verify(orderRepository).save(
Mockito.argThat(o -> o.getStatus() == OrderStatus.PAID)
);
Mockito.verify(paymentGateway, Mockito.times(1)).charge("ORD001", 50000);
}
@Test
@DisplayName("결제 실패 시 예외를 던지고 주문 상태를 변경하지 않는다")
void processPayment_onFailure_throwsAndDoesNotSave() {
Order order = new Order("ORD001", 50000);
Mockito.when(orderRepository.findById("ORD001"))
.thenReturn(Optional.of(order));
Mockito.when(paymentGateway.charge("ORD001", 50000))
.thenThrow(new PaymentException("카드 한도 초과"));
Assertions.assertThrows(PaymentException.class,
() -> sut.processPayment("ORD001"));
// ✅ 예외 발생 후 save가 호출되지 않았는지 확인
Mockito.verify(orderRepository, Mockito.never()).save(Mockito.any());
}
}
ArgumentCaptor: 전달된 인자 깊이 검증
verify에서 argThat으로 부족할 때, ArgumentCaptor로 실제 전달된 객체를 캡처해 세밀하게 검증할 수 있습니다.
@Test
@DisplayName("이메일 발송 시 올바른 수신자와 제목으로 호출된다")
void sendConfirmation_usesCorrectEmailContent() {
// given
ArgumentCaptor<EmailMessage> captor = ArgumentCaptor.forClass(EmailMessage.class);
Order order = new Order("ORD001", "user@example.com", 50000);
// when
sut.sendConfirmationEmail(order);
// then
Mockito.verify(emailSender).send(captor.capture());
EmailMessage captured = captor.getValue();
Assertions.assertEquals("user@example.com", captured.getTo());
Assertions.assertTrue(captured.getSubject().contains("ORD001"));
Assertions.assertEquals("text/html", captured.getContentType());
}
⚠️ 주의
Mockito.verify는 행위(메서드 호출 여부, 호출 횟수, 인자)를 검증합니다. 상태 검증(객체의 값이 바뀌었는지)과 혼용하면 테스트가 지나치게 구현 세부사항에 묶이게 됩니다. 가능한 한 상태 검증을 우선하고, 행위 검증은 꼭 필요한 경우에만 사용하세요.
Spy: 실제 객체의 일부만 교체
Mock은 모든 메서드가 기본값(null, 0, false)을 반환하는 완전한 가짜 객체입니다. Spy는 실제 객체를 감싸되 특정 메서드만 스텁으로 교체합니다.
@Test
void spy_overridesOnlySpecificMethod() {
List<String> realList = new ArrayList<>();
List<String> spyList = Mockito.spy(realList);
// 특정 메서드만 스텁 — 나머지는 실제 동작
Mockito.doReturn(100).when(spyList).size();
spyList.add("hello"); // ✅ 실제 add 호출
spyList.add("world");
Assertions.assertEquals(100, spyList.size()); // 스텁된 size
Assertions.assertEquals("hello", spyList.get(0)); // 실제 데이터
}
⚠️ 주의
Mockito.when(spy.method()).thenReturn(...)대신Mockito.doReturn(...).when(spy).method()형태를 사용해야 합니다. 전자는 실제 메서드를 먼저 호출한 뒤 스텁을 설정하므로 부작용이 생길 수 있습니다.
테스트하기 좋은 코드 설계: 의존성 주입과 경계 분리
아무리 JUnit과 Mockito를 잘 알아도, 설계가 잘못된 코드는 테스트하기가 극히 어렵습니다. 테스트 작성이 고통스럽다면 그것은 설계 냄새(Design Smell)입니다.
의존성 주입(DI)으로 결합도 낮추기
// ❌ 테스트 불가 — new로 의존성을 직접 생성
public class ReportService {
private final EmailClient emailClient = new SmtpEmailClient(); // 하드코딩
public void generateAndSend(String reportId) {
Report report = buildReport(reportId);
emailClient.send(report.toEmail()); // SMTP 서버가 없으면 테스트 불가
}
}
// ✅ 생성자 주입 — EmailClient 인터페이스로 의존
public class ReportService {
private final EmailClient emailClient;
public ReportService(EmailClient emailClient) { // 외부에서 주입
this.emailClient = emailClient;
}
public void generateAndSend(String reportId) {
Report report = buildReport(reportId);
emailClient.send(report.toEmail());
}
}
// 테스트에서는 Mock 주입
@Test
void generateAndSend_callsEmailClientWithCorrectReport() {
EmailClient mockClient = Mockito.mock(EmailClient.class);
ReportService sut = new ReportService(mockClient);
sut.generateAndSend("RPT001");
Mockito.verify(mockClient).send(Mockito.any(EmailMessage.class));
}
경계 분리: 순수 로직과 I/O를 분리하라
테스트가 어려운 가장 흔한 원인은 비즈니스 로직과 I/O(DB, 파일, 네트워크)가 뒤섞인 것입니다. 순수 로직은 외부 의존 없이 빠르게 단위 테스트할 수 있어야 합니다.
// ❌ 로직과 I/O 혼재
public class InvoiceProcessor {
public void process(String invoiceId) {
// DB 조회, 비즈니스 로직, 파일 저장이 한 메서드에
Invoice invoice = jdbcTemplate.queryForObject(
"SELECT * FROM invoices WHERE id = ?", Invoice.class, invoiceId);
if (invoice.getAmount() > 1_000_000) {
invoice.applyTax(0.1);
}
Files.write(Path.of("/invoices/" + invoiceId + ".pdf"), invoice.toPdf());
}
}
// ✅ 순수 로직 분리 — 이 메서드만 단위 테스트
public class InvoiceCalculator {
public Invoice applyTaxIfRequired(Invoice invoice) {
if (invoice.getAmount() > 1_000_000) {
return invoice.withTax(0.1);
}
return invoice;
}
}
// InvoiceProcessor는 통합 테스트 또는 E2E 테스트에서 검증
순수 함수(같은 입력 → 항상 같은 출력, 부작용 없음)는 @BeforeEach, Mock 설정 없이 단 몇 줄로 완전하게 테스트할 수 있습니다.
코드 커버리지: 해석과 한계
IntelliJ IDEA 또는 Maven/Gradle의 JaCoCo 플러그인으로 코드 커버리지를 측정할 수 있습니다.
<!-- pom.xml — JaCoCo 플러그인 설정 -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<limits>
<!-- 브랜치 커버리지 70% 미달 시 빌드 실패 -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
커버리지 종류와 해석
| 커버리지 종류 | 의미 | 실무 기준 |
|---|---|---|
| 라인(Line) | 실행된 코드 줄 비율 | 참고용; 가장 쉽게 높일 수 있음 |
| 브랜치(Branch) | if/switch/삼항의 분기 비율 | 핵심 지표; 70~80% 권장 |
| 명령(Instruction) | 바이트코드 명령 단위 | JaCoCo 기본 지표 |
| 메서드/클래스 | 최소 한 번 실행된 메서드/클래스 비율 | 데드코드 탐지에 유용 |
커버리지가 높다고 테스트가 좋은 것은 아닙니다. 아래 예시를 보면 100% 커버리지를 달성하면서도 버그를 잡지 못합니다.
public int divide(int a, int b) {
return a / b; // b == 0 이면 ArithmeticException
}
@Test
void divide_test() {
Assertions.assertEquals(2, divide(10, 5)); // 커버리지 100%지만 b=0 케이스 미검증
}
⚠️ 주의 커버리지 임계값을 맞추기 위해
Assertions없는 테스트를 작성하는 것은 금물입니다. "어떤 버그를 잡으려 하는가"를 먼저 생각하고, 그 경로를 명확한 어서션으로 검증하세요. 커버리지는 누락된 영역을 발견하는 지표일 뿐, 목적이 아닙니다.
디버거 활용: 중단점과 스택 추적
IDE 디버거는 런타임 상태를 직접 들여다볼 수 있는 강력한 도구입니다. System.out.println 디버깅은 이제 그만하고, 구조적으로 원인을 추적하는 방법을 익혀야 합니다.
중단점 종류와 활용
IntelliJ IDEA 기준으로 다음 세 가지 중단점을 적극 활용합니다.
1. 라인 중단점 (Line Breakpoint)
소스 라인 번호 옆을 클릭해 설정합니다. 해당 라인에 도달하면 실행이 일시 정지되고 변수 값을 확인할 수 있습니다.
2. 조건부 중단점 (Conditional Breakpoint)
루프나 반복 호출에서 특정 조건일 때만 정지하고 싶을 때 사용합니다. 중단점 위에서 우클릭 → "Edit Breakpoint" → Condition 입력.
// 수천 건의 루프 중 특정 항목만 멈추고 싶을 때
for (Order order : orders) {
processOrder(order); // 여기에 조건부 중단점
// Condition: order.getId().equals("ORD_99999") && order.getAmount() > 100000
}
3. 예외 중단점 (Exception Breakpoint)
NullPointerException이 어디서 터지는지 찾기 어려울 때 유용합니다. Run → View Breakpoints → "+" → Java Exception Breakpoints → 예외 클래스 입력. 이후 해당 예외가 생성(throw)되는 순간 자동으로 정지합니다.
스택 프레임 읽기
디버거의 "Frames" 패널은 현재 호출 스택 전체를 보여줍니다.
processPayment:42, PaymentService (com.example.service)
placeOrder:87, OrderController (com.example.controller)
invoke:-1, Method (java.lang.reflect)
...
위에서 아래로 내려갈수록 더 오래된 호출입니다. 프레임을 클릭하면 해당 메서드의 지역 변수 상태를 그 시점에서 확인할 수 있습니다. NullPointerException을 만났다면 스택을 위에서부터 내려가며 어느 프레임에서 null이 처음 들어왔는지 추적합니다.
스택 트레이스 분석
운영 환경에서는 디버거를 붙일 수 없습니다. 로그에 남은 스택 트레이스가 유일한 단서입니다.
java.lang.NullPointerException: Cannot invoke "String.length()" because "this.userName" is null
at com.example.service.UserService.validate(UserService.java:34) ← 예외 발생 지점
at com.example.service.UserService.createUser(UserService.java:18) ← 호출한 곳
at com.example.controller.UserController.register(UserController.java:52)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
분석 순서:
- 첫 번째 줄: 예외 타입과 메시지로 현상 파악.
at com.example...줄 중 내 코드가 등장하는 가장 위 줄부터 확인. (java.,sun.,org.springframework.등 라이브러리 줄은 일단 건너뜀.)- 해당 파일과 줄 번호로 이동해 어떤 값이 null인지 파악.
- 호출 스택을 따라 내려가며 null이 어디서 시작됐는지 역추적.
💡 TIP Java 17+에서는 JEP 358 "Helpful NullPointerExceptions" 덕분에 NPE 메시지가
"Cannot invoke 'String.length()' because 'this.userName' is null"처럼 어떤 변수가 null인지 명확히 알려줍니다. 이 기능은 Java 14에서 도입되었는데, 당시에는 기본 비활성화여서 JVM 옵션-XX:+ShowCodeDetailsInExceptionMessages로 직접 켜야 했습니다. Java 15부터는 기본 활성화되어 별도 옵션 없이 동작합니다.
구조적 로깅과 예외 진단
System.out.println은 언제 어디서 찍힌 로그인지 알 수 없고, 운영 환경에서는 보이지도 않습니다. 실무에서는 SLF4J + Logback 조합을 표준으로 사용합니다.
SLF4J 기본 사용과 레벨 선택
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
// ✅ 클래스마다 Logger 선언 — 클래스 정보가 로그에 포함됨
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public Order placeOrder(String productId, int quantity) {
log.debug("주문 요청: productId={}, quantity={}", productId, quantity);
Product product;
try {
product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
} catch (ProductNotFoundException e) {
// ✅ 예외 객체를 마지막 인자로 넘기면 스택 트레이스가 함께 기록됨
log.error("상품 조회 실패: productId={}", productId, e);
throw e;
}
if (product.getStock() < quantity) {
// ❌ 예외 정보 없이 메시지만 남기면 원인 파악이 어려움
log.warn("재고 부족: stock={}, requested={}", product.getStock(), quantity);
throw new InsufficientStockException(productId, product.getStock(), quantity);
}
Order order = Order.create(product, quantity);
orderRepository.save(order);
log.info("주문 완료: orderId={}, amount={}", order.getId(), order.getAmount());
return order;
}
}
로그 레벨 선택 기준
| 레벨 | 사용 시점 | 예시 |
|---|---|---|
ERROR | 즉각 대응이 필요한 오류, 예외와 항상 함께 | DB 연결 실패, 결제 API 오류 |
WARN | 정상 흐름을 벗어났지만 서비스 중단은 아닐 때 | 재고 부족, 비밀번호 틀림 |
INFO | 주요 비즈니스 이벤트 (운영 모니터링 기준) | 주문 완료, 회원 가입 |
DEBUG | 개발/디버깅 시 상세 흐름 | 메서드 진입/인자 값 |
TRACE | 아주 상세한 내부 상태 (거의 사용 안 함) | 루프 내 변수 매 순회 |
MDC: 분산 환경에서 요청 추적
마이크로서비스나 멀티스레드 환경에서는 여러 요청의 로그가 뒤섞입니다. MDC(Mapped Diagnostic Context)를 사용하면 요청별 식별자를 로그에 자동으로 포함시킬 수 있습니다.
import org.slf4j.MDC;
// Spring 필터 또는 인터셉터에서 설정
public class RequestTraceFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String traceId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("traceId", traceId);
MDC.put("userId", extractUserId(request));
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // ✅ 반드시 정리 — 스레드 풀 재사용 시 이전 값 오염 방지
}
}
}
<!-- logback-spring.xml — MDC 값을 패턴에 포함 -->
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{userId}] %-5level %logger{36} - %msg%n</pattern>
이제 [a3f8e1c2] 같은 traceId로 로그를 필터링하면 해당 요청의 전체 흐름을 추적할 수 있습니다.
10:35:22.413 [http-nio-8080-exec-3] [a3f8e1c2] [user_42] INFO OrderService - 주문 완료: orderId=ORD_8801
10:35:22.421 [http-nio-8080-exec-3] [a3f8e1c2] [user_42] INFO EmailService - 확인 메일 발송: to=user@example.com
예외 래핑과 원인 체인 보존
예외를 다른 예외로 감쌀 때 원인(cause)을 반드시 전달해야 합니다. 그렇지 않으면 스택 트레이스에서 진짜 원인이 사라집니다.
// ❌ 원인 소실 — 원본 SQLException이 사라짐
try {
orderRepository.save(order);
} catch (SQLException e) {
throw new OrderPersistenceException("주문 저장 실패");
}
// ✅ 원인 보존
try {
orderRepository.save(order);
} catch (SQLException e) {
throw new OrderPersistenceException("주문 저장 실패", e); // cause 전달
}
로그에서 Caused by: 체인을 따라가면 최초 원인까지 도달할 수 있습니다.
com.example.exception.OrderPersistenceException: 주문 저장 실패
at com.example.service.OrderService.save(OrderService.java:67)
...
Caused by: java.sql.SQLException: Deadlock found when trying to get lock
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(...)
...
요약
- JUnit 5 생명주기(
@BeforeAll,@BeforeEach등)로 테스트 자원을 체계적으로 관리하고,@ParameterizedTest로 여러 입력 케이스를 중복 없이 검증한다. - Mockito의
@Mock/@InjectMocks으로 의존성을 격리하고,verify와ArgumentCaptor로 행위를 검증한다. 단, 상태 검증을 우선하고 행위 검증은 꼭 필요할 때만 사용한다. - 의존성 주입과 순수 로직/I/O 분리가 테스트 가능한 코드의 기반이다. 테스트 작성이 고통스럽다면 설계를 먼저 의심하라.
- 코드 커버리지는 테스트의 목적이 아니라 빈틈을 발견하는 지표다. 브랜치 커버리지를 중심으로 해석하되, 커버리지 수치보다 어서션의 질이 더 중요하다.
- 디버거의 조건부 중단점과 예외 중단점으로 원인을 빠르게 좁힌다. 스택 트레이스는 내 코드가 등장하는 가장 위 줄부터 역추적한다.
- SLF4J 구조적 로깅에서 예외는 항상 마지막 인자로 전달해 스택 트레이스를 남기고, MDC로 요청별 추적 컨텍스트를 관리한다. 예외 래핑 시
cause를 반드시 보존한다.
연습문제
-
다음
PriceCalculator클래스에 대해@ParameterizedTest와@CsvSource를 사용하여 단위 테스트를 작성하세요. 회원 등급(BASIC, VIP)과 구매 금액에 따른 최종 가격이 올바른지 검증해야 하며, VIP는 10% 할인, BASIC은 할인 없음, 그리고 어느 등급이든 구매 금액이 0 이하이면IllegalArgumentException이 발생해야 합니다.public class PriceCalculator { public int calculate(MemberGrade grade, int originalPrice) { if (originalPrice <= 0) throw new IllegalArgumentException("가격은 0보다 커야 합니다"); if (grade == MemberGrade.VIP) return (int)(originalPrice * 0.9); return originalPrice; } }힌트 정상 케이스는
@CsvSource로, 예외 케이스는@ValueSource와assertThrows를 조합하세요. -
아래
NotificationService는UserRepository와PushClient에 의존합니다. Mockito를 사용해 두 의존성을 목으로 만들고, 사용자를 찾지 못했을 때PushClient.send가 호출되지 않는지, 그리고 사용자를 찾았을 때 올바른 기기 토큰으로send가 호출되는지를 검증하는 테스트를 작성하세요.public class NotificationService { private final UserRepository userRepository; private final PushClient pushClient; public NotificationService(UserRepository userRepository, PushClient pushClient) { this.userRepository = userRepository; this.pushClient = pushClient; } public void notify(long userId, String message) { Optional<User> user = userRepository.findById(userId); if (user.isEmpty()) return; pushClient.send(user.get().getDeviceToken(), message); } }힌트
Mockito.verify(pushClient, Mockito.never()).send(...)패턴과ArgumentCaptor를 활용하세요. -
현재 팀에서 사용하는 로깅 코드에 두 가지 문제가 있습니다. 아래 코드에서 문제를 찾아 올바르게 수정하고, 왜 문제인지 설명하세요.
public class InventoryService { private static final Logger log = LoggerFactory.getLogger(InventoryService.class); public void reduceStock(String itemId, int qty) { try { int remaining = itemRepository.reduceStock(itemId, qty); log.info("재고 감소 완료 - 아이템: " + itemId + ", 잔여: " + remaining); } catch (Exception e) { log.error("재고 감소 중 오류 발생: " + e.getMessage()); throw new InventoryException("재고 처리 실패"); } } }힌트 로그 메시지 구성 방식과 예외 처리의 두 가지 측면을 살펴보세요.
-
다음 스택 트레이스를 분석하고, (1) 예외가 최초로 발생한 클래스와 줄 번호, (2) 이 예외를 유발한 내 코드의 호출 흐름(라이브러리 프레임 제외), (3) 근본 원인을 추론하여 설명하세요.
org.springframework.dao.DataIntegrityViolationException: could not execute statement at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:276) at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233) at com.example.service.MemberService.register(MemberService.java:45) at com.example.controller.MemberController.signUp(MemberController.java:28) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:59) Caused by: java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'user@example.com' for key 'members.UK_email' at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:117)힌트
Caused by:체인을 아래까지 따라가면 진짜 원인이 드러납니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Java 심화” 강좌에 대한 댓글입니다.