[Java] BiConsumer: 두 개의 매개변수를 처리하는 함수형 인터페이스
안녕하세요 개발자 stark입니다! 오늘은 BiConsumer에 대해서 소개드리고자 합니다.
어느 날 제 사수님께서 2개의 List를 이중 stream을 돌리며 filter로 특정 조건을 매칭시키면서 값을 세팅하는 방식에서 BiConsumer를 사용하여 값을 세팅하는 방식으로 변경하신 다음 코드의 가독성과 전체적인 성능이 개선된 것을 리뷰해 주셨습니다. 그래서 저도 이 지식을 흡수하고 로직에서 사용해 보고자 BiConsumer에 대한 자료들을 찾아보며 이해하기 위해 많은 노력을 했습니다. 그리고 이제는 어느 정도 이해가 되었다고 느껴져서 조금이지만 정리해 본 것을 공유드리고자 합니다.
항상 새로운 것을 제게 가르쳐주시는 제 사수님께 감사한 마음을 가지며 포스팅을 작성합니다.
시작해 봅시다. Let's go!!
시작하며
상황을 간단하게 설명드리자면 기존 로직에서는 stream의 filter를 사용하여 특정 조건에 맞으면 리스트에서 값을 꺼내서 다른 리스트에 값을 세팅하고 있었습니다. 제 생각에는 대부분의 개발자분들이 이 방법을 사용하실 것 같은데요(아닐 수도 있습니다). 굉장히 흔한 로직 작성 방식입니다. 근데 이 로직 구성을 Java의 BiConsumer 방식으로 바꾸면서 복잡하고 길었던 stream로직이 BiConsumer의 accept 메서드 호출만으로 실행되도록 변경되었습니다.
물론 BiConsumer를 사용한다고 해서 아예 비즈니스 반복문 로직이 사라지지는 않습니다. 그렇기에 기존과 동일하게 어딘가에는 긴 반복문 로직이 존재합니다. 그러나 이 반복문 로직이 외부의 어떤 클래스(BiConsumer 전용 클래스)에 선언되기에 필요시 이 클래스를 주입받고 accept메서드에 필요한 인자만 잘 넣어서 호출하기만 하면 됩니다. 그렇다 보니 이전에 모든 로직이 한 클래스에 줄줄이 작성되어 있던 것과 달리 메인 비즈니스 로직의 가독성이 향상되고 매우 깔끔해졌습니다.
이렇게 말했지만 제가 처음 BiConsumer를 봤을 때 저는 이것을 사용해야 할 이유를 납득하지 못했습니다. 제게는 이 로직이 오히려 가독성이 나빠지는 것 같아 보였고 사용방식이 쉬워 보이지만 어느 정도 진입장벽도 느껴졌습니다. 그래서 한동안 "모두가 아는 방식으로 작성해서 가독성을 중시하는 게 유지보수에 좋지 않을까?" 이런 생각을 계속하며 고민을 했었습니다.
그러던 어느 날 이제 고민은 그만하고 BiConsumer에 대해서 직접 이해하고 싶어 졌습니다. 그래서 직접 사용해 봤더니 확실히 각 코드의 역할이 분리되는 것이 눈에 보이고 함수형 프로그래밍 방식이 은근히 편하다는 것도 느껴지고 전체적인 코드 구성도 깔끔해진다는 것을 알게 되었습니다. 역시 우리 선배님들의 결정은 다 이유가 있는 것 같습니다. 어쩌면 저는 BiConsumer를 사용하여 로직을 구성하는 방식이 익숙지 않고 생소하다 보니 이것을 사용하는 것에 거부감이 들었던 것 같습니다.
이제 BiConsumer가 무엇이고 어떻게 사용하는지 알아봅시다.
BiConsumer란 무엇인가?
BiConsumer는 Java 8에서 도입된 함수형 인터페이스로, 두 개의 입력 매개변수를 받아 특정 작업을 수행하며, 결괏값을 반환하지 않는 인터페이스입니다. Consumer와 유사하지만, Consumer가 단일 매개변수를 처리하는 데 반해, BiConsumer는 두 개의 매개변수를 처리할 수 있는 점이 특징입니다. 이를 활용하면 두 객체를 동시에 처리해야 하는 다양한 시나리오를 깔끔하고 효율적으로 처리할 수 있습니다.
주요 특징은 다음과 같습니다.
- 두 개의 입력 매개변수: BiConsumer는 T와 U라는 두 가지 타입의 입력을 받습니다.
- 반환값없음: 작업의 결과를 반환하지 않고, 주로 입력 객체를 활용해 작업을 수행하거나 상태를 변경합니다.
- 람다식과의 궁합: 함수형 인터페이스이기 때문에 람다식 또는 메서드 참조로 쉽게 구현할 수 있습니다.
- 체이닝 가능: andThen 메서드를 통해 여러 작업을 연속적으로 실행할 수 있습니다.
BiConsumer의 장점은 다음과 같습니다.
- 간결한 코드: 익명 클래스를 작성하는 번거로움을 줄이고, 람다식으로 간단하게 처리 로직을 정의할 수 있습니다.
- 모듈화 된 처리 로직: 두 객체를 함께 처리하는 로직을 별도의 메서드나 클래스 없이 캡슐화하여 관리할 수 있습니다.
- 유연한 활용: 다양한 입력 타입과 상황에 맞게 쉽게 구현 가능하며, 재사용성이 높습니다.
BiConsumer가 왜 필요한가?
두 개의 객체를 동시에 처리해야 하는 경우, 기존 방식대로라면 메서드 파라미터를 여러 개 받아 처리하거나 클래스 내부에서 주입받은 필드를 사용해야 할 때가 많았습니다. BiConsumer를 사용하면 “두 개의 입력”이라는 점이 인터페이스 자체에 명확히 나타나므로, 코드 구조가 깔끔해지고 의도를 더욱 분명히 표현할 수 있습니다.
BiConsumer의 기본 구조와 andThen
다음은 BiConsumer의 기본 구조입니다.
- accpet 메서드와 andThen 메서드를 가지고 있는 interface입니다.
@FunctionalInterface
public interface BiConsumer<T, U> {
void accept(T t, U u);
default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
Objects.requireNonNull(after);
return (l, r) -> {
accept(l, r);
after.accept(l, r);
};
}
}
- T: 첫 번째 입력 매개변수의 타입입니다.
- U: 두 번째 입력 매개변수의 타입입니다.
- accept(T t, U u): 두 개의 입력을 받아 작업을 수행하는 추상 메서드
andThen 메서드는 default로 인터페이스에 구현이 되어있습니다.
andThen 메서드는 BiConsumer가 제공하는 디폴트 메서드로, 하나의 BiConsumer 작업이 끝난 뒤에 다른 BiConsumer 작업을 연속적으로 실행할 수 있게 합니다. 이를 통해 작업의 체이닝(chaining)을 간단히 구현할 수 있습니다.
- 매개변수 after: 첫 번째 작업 후에 실행할 추가 BiConsumer.
- 반환값: 두 작업을 연결한 새로운 BiConsumer.
- 예외: after가 null이면 NullPointerException을 던집니다.
andThen 메서드 사용 시 주의점
andThen으로 체이닝 할 때, 두 번째 작업(BiConsumer)이 null이 아니어야 하며, 예외 발생 시 앞선 작업의 결과가 올바르게 롤백되지 않을 수 있음을 유의해야 합니다. 필요하다면 예외 처리를 내부에 직접 두거나, 안전하게 감싼 유틸 메서드를 통해 체이닝 하는 방안을 고려해 볼 수 있습니다.
간단한 예제를 통해 살펴봅시다.
먼저 accpet만 사용하는 예제를 살펴봅시다.
import java.util.function.BiConsumer;
public class BiConsumerExample {
public static void main(String[] args) {
// 두 개의 정수를 더하는 BiConsumer
BiConsumer<Integer, Integer> adder = (a, b) -> System.out.println("Sum: " + (a + b));
// 두 개의 문자열을 합치는 BiConsumer
BiConsumer<String, String> concatenator = (s1, s2) -> System.out.println("Concatenated: " + s1 + s2);
// 실행
adder.accept(5, 10); // 출력: Sum: 15
concatenator.accept("Hello, ", "World!"); // 출력: Concatenated: Hello, World!
}
}
- 위 예제에서는 BiConsumer를 통해 두 개의 매개변수(a, b)를 받아 작업을 수행하는 로직이 간결하게 작성된 것을 확인할 수 있습니다. (사실상 익명함수와 동일한 구조입니다.)
다음으로 andThen 메서드 사용 예시를 살펴봅시다.
- 예제 1) 로그 기록 후 데이터 처리
import java.util.function.BiConsumer;
public class BiConsumerAndThenExample {
public static void main(String[] args) {
// 첫 번째 작업: 로그 기록
BiConsumer<String, Integer> logAction = (name, score) ->
System.out.println("Logging: " + name + " scored " + score);
// 두 번째 작업: 점수 판정
BiConsumer<String, Integer> scoreEvaluator = (name, score) -> {
if (score >= 80) {
System.out.println(name + " passed with excellent score!");
} else {
System.out.println(name + " needs improvement.");
}
};
// andThen으로 두 작업 연결
BiConsumer<String, Integer> fullProcess = logAction.andThen(scoreEvaluator);
// 실행
fullProcess.accept("Alice", 90); // 로그 기록 후 점수 판정 실행
fullProcess.accept("Bob", 70); // 로그 기록 후 점수 판정 실행
}
}
- 출력 결과는 다음과 같습니다.
Logging: Alice scored 90
Alice passed with excellent score!
Logging: Bob scored 70
Bob needs improvement.
- 예제 2) 주문 처리 시스템에서 상태 업데이트와 알림 발송
import java.util.function.BiConsumer;
public class OrderProcessor {
public static void main(String[] args) {
// 첫 번째 작업: 상태 업데이트
BiConsumer<String, String> updateOrderStatus = (orderId, status) ->
System.out.println("Order " + orderId + " status updated to: " + status);
// 두 번째 작업: 알림 발송
BiConsumer<String, String> sendNotification = (orderId, status) ->
System.out.println("Notification sent for Order " + orderId + " with status: " + status);
// andThen으로 두 작업 연결
BiConsumer<String, String> processOrder = updateOrderStatus.andThen(sendNotification);
// 실행
processOrder.accept("12345", "SHIPPED");
}
}
- 출력 결과는 다음과 같습니다.
Order 12345 status updated to: SHIPPED
Notification sent for Order 12345 with status: SHIPPED
주문 처리 시스템으로 이해해 봅시다.
1. 주문 상태 업데이트 처리
- BiConsumer를 활용하여 주문 처리 시스템의 상태를 업데이트하고 검증하는 예제를 만들어보겠습니다. 이 예제는 두 개의 도메인 객체를 리스트로 처리하여 매칭 작업을 수행합니다.
@AllArgsConstructor
@Data // Lombok 사용
public class Order {
private Long id;
private String status;
private LocalDateTime updatedAt;
public void changeStatus(String newStatus) {
this.status = newStatus;
this.updatedAt = LocalDateTime.now();
}
}
@Data // Lombok 사용
@AllArgsConstructor
public class OrderHistory {
private Long orderId;
private String previousStatus;
private String newStatus;
}
- 상태 처리 및 매칭을 진행하는 로직입니다.
- 각 리스트를 선언하고 2중 for문을 돌면서 각 인자를 넣어주면서 비즈니스 로직을 처리합니다.
public class OrderProcessor {
public static void main(String[] args) {
// 주문 리스트와 히스토리 리스트
List<Order> orders = new ArrayList<>();
orders.add(new Order(1L, "PENDING", null));
orders.add(new Order(2L, "PENDING", null));
List<OrderHistory> histories = new ArrayList<>();
histories.add(new OrderHistory(1L, "PENDING", "CONFIRMED"));
histories.add(new OrderHistory(2L, "PENDING", "SHIPPED"));
// BiConsumer로 상태 업데이트 처리
BiConsumer<Order, OrderHistory> updateStatus = (order, history) -> {
if (order.getId().equals(history.getOrderId())) {
order.changeStatus(history.getNewStatus());
System.out.println("Order ID: " + order.getId() + " updated to status: " + order.getStatus());
}
};
// 리스트 매칭 처리
for (Order order : orders) {
for (OrderHistory history : histories) {
updateStatus.accept(order, history);
}
}
// 결과 출력
orders.forEach(order -> System.out.println(order));
}
}
- 실행결과는 다음과 같습니다.
Order ID: 1 updated to status: CONFIRMED
Order ID: 2 updated to status: SHIPPED
Order(id=1, status=CONFIRMED, updatedAt=2024-12-27T20:45:40.309749)
Order(id=2, status=SHIPPED, updatedAt=2024-12-27T20:45:40.314556)
외부에서 BiConsumer에 함수를 전달하여 사용하는 방법
1. 책임 분리와 확장성
BiConsumer를 외부에서 주입받으면, “로직을 동적으로 교체할 수 있다”는 장점이 있습니다. 예컨대, 테스트 환경에서는 ‘테스트용 BiConsumer’를 주입해 로그만 찍게 하고, 운영 환경에서는 ‘실제 DB나 외부 API를 호출하는 BiConsumer’를 주입할 수도 있습니다. 이는 객체 지향 설계 원칙(개방-폐쇄 원칙, DIP 등)과도 잘 부합합니다.
2. BiConsumer를 외부에서 전달받아 사용하는 방식
- BiConsumer를 외부에서 전달받아 사용하는 가장 기본적인 방법은, 특정 로직을 포함하지 않은 상태로 BiConsumer를 선언한 뒤, 필요할 때 외부에서 설정하거나 전달받아 사용하는 것입니다.
public class BiConsumerManager {
private BiConsumer<String, Integer> biConsumer;
// BiConsumer를 설정하는 메서드
public void changeBiConsumer(BiConsumer<String, Integer> biConsumer) {
this.biConsumer = biConsumer;
}
// BiConsumer를 호출하는 메서드
public void execute(String input1, Integer input2) {
if (biConsumer != null) {
biConsumer.accept(input1, input2);
} else {
System.out.println("No BiConsumer is set.");
}
}
}
- 외부에서 BiConsumer를 설정하여 호출해 봅시다.
public class BiConsumerExample {
public static void main(String[] args) {
// BiConsumerManager 인스턴스 생성
BiConsumerManager manager = new BiConsumerManager();
// BiConsumer 설정: 문자열과 숫자를 받아 출력
BiConsumer<String, Integer> stringIntegerBiConsumer = (str, num) -> {
System.out.println("String: " + str + ", Number: " + num);
};
// BiConsumer 설정
manager.changeBiConsumer(stringIntegerBiConsumer);
// BiConsumer 실행
manager.execute("Hello", 42);
}
}
- 실행 결과는 다음과 같습니다.
String: Hello, Number: 42
3. BiConsumer를 메서드의 인자로 전달
- 아래와 같이 BiConsumer를 메서드의 인자로 전달하여 특정 로직을 동적으로 처리할 수도 있습니다.
public class BiConsumerService {
// BiConsumer를 사용하여 주문 처리
public void processOrder(String orderId,
Double amount,
BiConsumer<String, Double> consumer) {
// 전달받은 BiConsumer 실행
consumer.accept(orderId, amount);
}
}
- 호출 코드 예제를 살펴봅시다.
public class BiConsumerUsage {
public static void main(String[] args) {
// BiConsumerService 객체 생성
BiConsumerService service = new BiConsumerService();
// 주문 ID와 금액 출력 BiConsumer 설정
BiConsumer<String, Double> stringDoubleBiConsumer = (id, amt) -> {
System.out.println("Order ID: " + id + ", Amount: " + amt);
};
// 주문 처리 메서드 호출
service.processOrder("ORD123", 99.99, stringDoubleBiConsumer);
// 주문 ID와 금액 출력 및 10% 할인 적용 BiConsumer 설정
BiConsumer<String, Double> stringDoubleBiConsumer1 = (id, amt) -> {
System.out.println("Order ID: " + id + ", Discounted Amount: " + (amt * 0.9));
};
// 주문 처리 메서드 호출
service.processOrder("ORD124", 200.00, stringDoubleBiConsumer1);
}
}
- 실행 결과는 다음과 같습니다.
Order ID: ORD123, Amount: 99.99
Order ID: ORD124, Discounted Amount: 180.0
BiConsumer 활용이 효과적인 상황
1. 상태 변경 처리
- BiConsumer는 객체의 상태를 변경하고 추가 작업을 결합해야 할 때 유용합니다.
- 예시: 주문 상태를 변경한 후 알림을 발송하는 로직.
// BiConsumer를 선언합니다.
BiConsumer<Order, String> updateStatusAndNotify = (order, newStatus) -> {
order.changeStatus(newStatus);
System.out.println("Status updated to: " + newStatus);
// 추가 작업: 알림 발송
System.out.println("Notification sent for order: " + order.getId());
};
Order order = new Order(1L, "PENDING");
// accept 메서드로 BiConsumer의 메서드 로직을 실행합니다.
updateStatusAndNotify.accept(order, "CONFIRMED");
2. 데이터 검증
- 입력 데이터와 결과를 동시에 처리해야 하는 경우에 적합합니다.
- 여러 검증 규칙을 개별적으로 정의하고 재사용 가능하게 설계할 수 있습니다.
// BiConsumer 로직을 선언합니다.
BiConsumer<Order, ValidationResult> validateOrder = (order, result) -> {
if (order.getAmount() <= 0) {
result.addError("Order amount must be greater than zero.");
}
if (order.getShippingAddress() == null || order.getShippingAddress().isEmpty()) {
result.addError("Shipping address is required.");
}
};
// 인스턴스를 생성하고 BiConsumer의 accept 메서드를 통해 로직을 실행합니다.
Order order = new Order(1L, "PENDING", 0, "");
ValidationResult result = new ValidationResult();
validateOrder.accept(order, result);
// 로직이 실행되어 result에 에러가 쌓인것을 소비합니다.
result.getErrors().forEach(System.out::println);
3. 이벤트 처리
- 이벤트 소스와 데이터를 조합하여 로직을 구현해야 할 때 활용도가 높습니다.
- UI 이벤트 처리나 데이터 업데이트에 적합합니다.
// BiConsumer 로직을 선언합니다.
BiConsumer<String, String> handleEvent = (event, data) -> {
System.out.println("Event: " + event + ", Data: " + data);
// 추가 로직 처리
};
// accept 메서드로 로직을 실행합니다.
handleEvent.accept("ButtonClick", "SubmitForm");
주의사항: BiConsumer: 책임 분리와 예외 처리의 중요성
책임 분리
- BiConsumer는 단일 책임 원칙(Single Responsibility Principle)을 준수해야 합니다. 한 BiConsumer 내에서 너무 많은 작업을 처리하려고 하면 코드의 가독성과 유지보수성이 떨어질 수 있습니다. 따라서 각 BiConsumer는 명확한 목적을 가져야 합니다.
잘못된 예: 여러 작업을 처리하는 BiConsumer
BiConsumer<String, String> overloadedConsumer = (key, value) -> {
// 1. 처리를 한다.
System.out.println("Processing key: " + key);
// 2. 검증을 한다.
System.out.println("Validating value: " + value);
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("Value cannot be null or empty");
}
// 3. 저장을 한다.
System.out.println("Storing data for key: " + key);
};
- 이 로직에서는 실제 비스니스를 생략하고 프린트문으로 행동을 작성하였는데 로직을 살펴보면 여러 작업(처리, 검증, 저장)이 한 BiConsumer 로직 안에 혼재되어 있어 각 책임이 불명확합니다.
- 여기서는 검증부터 저장까지 한 곳에서 처리하기 때문에, 단일 책임 원칙이 무너지고 재사용성이 떨어집니다.
올바른 예: 작업을 분리한 BiConsumer를 andThen으로 체이닝
// 1. 처리를 한다.
BiConsumer<String, String> processKey = (key, value) -> {
System.out.println("Processing key: " + key);
};
// 2. 검증을 한다.
BiConsumer<String, String> validateValue = (key, value) -> {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("Value cannot be null or empty");
}
System.out.println("Validated value: " + value);
};
// 3. 저장을 한다.
BiConsumer<String, String> storeData = (key, value) -> {
System.out.println("Storing data for key: " + key);
};
// 4. 체이닝으로 연결한다.
BiConsumer<String, String> pipeline = processKey
.andThen(validateValue)
.andThen(storeData);
// 5. accept 메서드로 로직을 실행한다.
pipeline.accept("someKey", "someValue");
- 이렇게 작업을 분리하여 각각 BiConsumer를 선언하게 되면 필요에 따라 각각의 BiConsumer를 조합하거나 독립적으로 사용할 수 있어 유연성이 높아집니다. (단일 책임 원칙은 어디서든 중요하다는 것을 배울 수 있습니다.)
예외 처리
- BiConsumer 내부에서 발생할 수 있는 예외를 적절히 핸들링하지 않으면 프로그램이 중단될 수 있습니다. SafeBiConsumer.wrap 같은 메서드를 두면, 예외를 중앙에서 일괄 처리할 수 있어서 편리합니다. 우선 wrap이 없는 상황을 살펴봅시다.
// BiConsumer를 선언한다.
BiConsumer<String, String> safeConsumer = (key, value) -> {
try {
System.out.println("Processing key: " + key + ", value: " + value);
if (value.equals("error")) {
throw new RuntimeException("Simulated exception");
}
System.out.println("Successfully processed key and value.");
} catch (Exception e) {
System.err.println("Error processing key: " + key + ", value: " + value + " - " + e.getMessage());
}
};
// accept 메서드로 로직을 실행한다.
safeConsumer.accept("Key1", "ValidValue");
safeConsumer.accept("Key2", "error");
- 실행 결과는 다음과 같습니다.
Processing key: Key1, value: ValidValue
Successfully processed key and value.
Processing key: Key2, value: error
Error processing key: Key2, value: error - Simulated exception
공통 예외 처리 로직 추출
- 중복되는 예외 처리 로직은 별도의 메서드로 분리하여 재사용할 수 있습니다.
public class SafeBiConsumer {
// BiConsumer를 안전하게 실행하는 메서드 (예외 처리 추가)
public static <T, U> BiConsumer<T, U> wrap(BiConsumer<T, U> consumer) {
return (t, u) -> {
try {
consumer.accept(t, u);
} catch (Exception e) {
System.err.println("Error occurred: " + e.getMessage());
}
};
}
// BiConsumer를 안전하게 실행하는 예제 메서드
public static void main(String[] args) {
// 안전하지 않은 BiConsumer
BiConsumer<String, String> unsafeConsumer = (key, value) -> {
if (value.equals("error")) {
throw new RuntimeException("Simulated exception");
}
System.out.println("Processed key: " + key + ", value: " + value);
};
// 안전한 BiConsumer로 변환
BiConsumer<String, String> safeConsumer = wrap(unsafeConsumer);
// 안전한 BiConsumer 실행
safeConsumer.accept("Key1", "ValidValue");
safeConsumer.accept("Key2", "error");
}
}
- 실행 결과는 다음과 같습니다.
Processed key: Key1, value: ValidValue
Error occurred: Simulated exception
마무리하며
이렇듯 BiConsumer는 상태 변경, 데이터 검증, 이벤트 처리 등 다양한 상황에서 활용도가 높은 함수형 인터페이스입니다. 이를 적절히 활용하면 코드의 모듈성과 재사용성을 높이고, 유지보수하기 쉬운 구조를 만들 수 있습니다.
이 기능은 사실 Java8부터 생긴 기능입니다. 지금 와서 생각해 보면 Java에는 정말 많은 기능이 존재하고 추가되고 있지만 직접 살펴보고자 하지 않았기 때문에 이런 다양하고 편리한 기능이 있다는 것을 몰랐던 것 같습니다.
요즘 저는 '보이지 않는 것을 보는 힘'을 기르고자 노력하고 있는데 기술적인 것 또한 제게는 보이지 않는 것 중 하나였던 것 같습니다. 최대한 넓은 시야를 가지고자 오픈소스도 직접 만들어보고 있고 Kafka 코드도 분석하고 있습니다. 그래도 아직은 시야가 많이 좁은 것 같습니다.
"내가 시대에 뒤처진 것일까? 아니면 노력하지 않아서 그런 것일까? 아니면 사용되지 않는 기술인 걸까?" 이런 생각도 하게 되었는데요. 앞으로는 조금 더 Java의 기본적인 기능들을 공부하고 알아봐야겠다는 생각이 들었습니다. 정말 갈길이 머네요..
필요시 아래의 JavaDoc의 공식 자료를 통해 BiConsumer를 더 자세히 알아볼 수 있습니다.
BiConsumer (Java Platform SE 8 )
andThen default BiConsumer andThen(BiConsumer after) Returns a composed BiConsumer that performs, in sequence, this operation followed by the after operation. If performing either operation throws an exception, it is relayed to the caller of the compo
docs.oracle.com
지금까지 긴 글 읽어주셔서 감사합니다 :)