배경
이번 리팩터링 목적은 코드 내 작업을 비동기적으로 바꾸기 전에 문제(race, 흐름 의존성)를 제거하는 것이다.
그 방법은 기존 코드에 함수형 패러다임을 적용하는 것이다. 상태 변경을 줄이고, 행동을 조합 가능하게 만들 것이다.
0. 기존 코드
아래 코드는 이미지 첨부파일 리스트를 가져와서 NCloud Green Eye를 통해 각 첨부파일에 대해 유해성 판별을 한다.
현재는 이 작업이 .block() 기반 순차 처리라 race가 표면화되진 않지만, 비동기 전환 시 공유 리스트에 대한 동시 접근이 생길 수 있어 구조적으로 위험해진다. 또한, for문 내에서 흐름 제어와 계산 로직이 묶여 테스트가 어려웠다.
참고) 비동기 전환을 고려하는 이유는 외부 API요청에 대해 굳이 쓰레드를 점유하게 하고 싶지 않은 점이다.
본질적으로 WebClient가 non-blocking 사용을 전제로 설계되어 이러한 전환이 바람직해 보인다.
@Override
public List<FeedAttachment> findBadImageListByAI(List<FeedAttachment> feedAttachmentList) {
log.info("feedAttachmentList={}", feedAttachmentList);
WebClient client = WebClient.create(apigwUrl);
List<FeedAttachment> badImageList = new ArrayList<>();
// Green Eye 유해성 판별은 요청 1회당 1개의 이미지만 가능
for (FeedAttachment feedAttachment : feedAttachmentList) {
if (!feedAttachment.isActivated()) continue;
String requestBody = """
{
"version": "V1",
"requestId": "%s",
"timestamp": 0,
"images": [
{
"name": "%s",
"url": "%s"
}
]
}
""".formatted(
feedAttachment.getAttachmentId(),
feedAttachment.getAttachmentId(),
feedAttachment.getFileUrl()
);
try {
JSONObject response = new JSONObject(
client.post()
.header(GREENEYE_SECRET_KEY_HEADER, this.secretKey)
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.onStatus(status -> status.is4xxClientError(), ClientResponse::createException)
.bodyToMono(String.class)
.block()
);
if (checkHarmful(response)) {
badImageList.add(feedAttachment);
}
} catch (Exception e) {
System.err.println("GreenEye 요청이 잘못됨: " + e.getMessage());
}
}
return badImageList;
}
1. 리팩터링 포인트 (문제점)
그러나 요청을 비동기적으로 바꿀 때 미리 검토할 사항이 있었다.
1) badImageList의 공유 mutable 상태
List<FeedAttachment> badImageList = new ArrayList<>();
...
badImageList.add(feedAttachment);
badImageList는 외부에서 관찰 가능한 mutable state이며, continue/try-catch/checkHarmful 분기마다 add 수행 여부가 달라져 변경 지점이 분산되고 테스트가 어려워진다.(로직이 바뀌면 add 시점이 바뀌므로, 디버깅 어려움)
다르게 말하면, 이 리스트가 언제 어떤 값으로 바뀌는지 직접 추론해야한다.
특히 비동기 처리(예: subscribe, flatMap, CompletableFuture 등)로 전환하면, 여러 실행 흐름이 동일 리스트에 접근해 add()를 수행할 수 있어 race condition이 발생할 여지가 커진다.
2) 로직이 하나의 블록으로 뭉쳐 있음
- 외부 API 호출
- requestBody 생성
- 응답 파싱
- harmful 판단
- 결과 수집(상태 변경)
- 활성화 여부 체크
이게 모두 for 문 안에 섞여 있어, 각 단계가 다음 단계에 강하게 의존(조립 불가)한다.
테스트가 어려운 거대한 블록 형태인데, 개별 단계를 테스트 가능한 작은 함수로 나눌 수 있어 보인다.
2. 리팩터링 목표 (방향성)
- 공유 mutable 상태 제거, 상태 변경 최소화
- 테스트 가능한 작은 함수로 각 단계를 분리
참고) Java 코드인 만큼, 모든 코드가 클래스 안에 있어 '함수'개념은 없긴 하다. 사실상 구현 형태는 전부 메서드이기 때문이다.
다만, 여기서 함수로 분리한다는 말은 메서드를 객체 상태에 의존하지 않도록 설계해 함수처럼 사용하는 것을 의미한다.
3. 리팩터링 된 코드 (결과)
함수형 스타일 본문
@Override
public List<FeedAttachment> findBadImageListByAI(List<FeedAttachment> feedAttachmentList) {
WebClient client = WebClient.create(apigwUrl);
return feedAttachmentList.stream()
// 1) 활성화된 이미지만
.filter(FeedAttachment::isActivated)
// 2) 유해 판별 규칙(조합 가능한 Boolean Predicate)
.filter(attachment -> isHarmfulAttachment(client, attachment))
// 3) 한 번에 결과 수집 (중간 상태 변경 없음)
.toList();
}
- for문 내에서 흐름 제어와 계산 로직이 묶여 가변(badImageList)되던 중간 상태를 없앴다.
- 외부 API요청 및 유해성 판단 로직을 캡슐화하여 toList() 전까지의 파이프라인에서는 조합 가능한(filter) Boolean 반환 로직만 드러나게 했다.
- 결과 리스트를 직접 add()하지 않고 toList()에 수집을 위임해서 마지막에만 최종 리스트를 리턴하게 했다.
API 호출 및 유해 여부 판별 분리
private boolean isHarmfulAttachment(WebClient client, FeedAttachment attachment) {
return callGreenEye(client, attachment)
.map(this::checkHarmful)
.orElse(false);
}
private Optional<JSONObject> callGreenEye(WebClient client, FeedAttachment feedAttachment) {
try {
String requestBody = createRequestBody(feedAttachment);
String result = client.post()
.header(GREENEYE_SECRET_KEY_HEADER, this.secretKey)
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(String.class)
.block();
return Optional.of(new JSONObject(result));
} catch (Exception e) {
log.error("GreenEye error attachmentId={} msg={}",
feedAttachment.getAttachmentId(),
e.getMessage()
);
return Optional.empty();
}
}
- 실패가 발생하면 예외나 null 대신
Optional.empty()로 "값이 없다"를 표현한다. - 즉, callGreenEye는 성공/실패에 대한 사실만 전달한다.
- 외부 네트워크·서버 상태 등 외부 변수에 의존하므로 pure function으로 만들지는 못했지만, effect 경계를 분리했으므로 조금 더 예측 가능한 형태가 되었다고 생각한다.
RequestBody 생성 분리
private String createRequestBody(FeedAttachment feedAttachment) {
return """
{
"version": "V1",
"requestId": "%s",
"timestamp": 0,
"images": [
{
"name": "%s",
"url": "%s"
}
]
}
""".formatted(
feedAttachment.getAttachmentId(),
feedAttachment.getAttachmentId(),
feedAttachment.getFileUrl()
);
}
- 이 부분의 의의는 Pure function으로 분리했다는 점이다.
- 생각 비용(?)이 0이다! (같은 입력에 대해 같은 출력)
마무리
- 공유 mutable List를 직접 변경하지 않는다 (
add제거) - 각 FeedAttachment 요소는 결과 리스트를 변경하는 주체가 아니라, filter 조건을 통과할지 여부만 평가되는 값으로 취급된다.
- side effect가 발생하는 로직을 함수형으로 분리하고, pure function만 스트림 단계에 남겨 조합했다. 결과는 마지막에 한 번만 List로 수집해 리턴한다.
이제 아래와 같이 비동기로 작업을 수행할 수 있다.
public Mono<List<FeedAttachment>> findBadImageListByAIAsync(List<FeedAttachment> list) {
WebClient client = WebClient.create(apigwUrl);
return Flux.fromIterable(list)
.filter(FeedAttachment::isActivated)
.filterWhen(att -> callGreenEyeAsync(client, att)
.map(this::checkHarmful)
.onErrorReturn(false)
)
.collectList();
}
비동기 전환 시 race condition이 우려되는 공유 mutable 상태를 구조적으로 제거했다.
상태를 누적하는 방식 대신 조건을 조합하는 방식으로 로직을 재구성함으로써, 기존보다 예측 가능한 코드를 만들었다.
