$백엔드 개발자 Rueun의 기술 블로그|Java · Spring · 클린 아키텍처🌱
#Development

Kakao AlimTalk 연동기: Port & Adapter로 외부 API 깔끔하게 감싸기

@2024-12-02·11 min read

담당자가 지원자에게 메시지를 보내면 카카오 알림톡도 함께 발송해야 했다. 처음에는 UseCase 안에 HTTP 호출 코드를 직접 넣었다. "일단 동작하게 만들고 나중에 정리하자"는 말과 함께. 그 "나중"이 올 때까지 테스트 코드는 작성하지 못하고 있었다.

처음 구현: UseCase에 HTTP 호출이 직접

JAVA
// 초기 구현 — UseCase가 외부 API 세부사항을 전부 안다
@Service
@RequiredArgsConstructor
public class SendMessageByRecruiterUseCase {
 
    private final RestTemplate restTemplate;
    private final MsghubProps msghubProps;
    private final ApplicantReader applicantReader;
 
    public void execute(final String applicantId, final String message) {
        // ... 메시지 저장 로직 ...
 
        // 알림톡 발송 — 여기서부터 문제
        final String token = DigestUtils.sha512Hex(
            msghubProps.getUserId() + msghubProps.getTimestamp()
        );
        final HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + token);
        headers.setContentType(MediaType.APPLICATION_JSON);
 
        final ApiSendAlimtalkRequest alimtalkRequest = ApiSendAlimtalkRequest.builder()
            .msg(message)
            .tmpltCode(msghubProps.getTemplateCode())
            .buttons(List.of(
                new Button("결과 확인하기",
                    "https://tajo.sofac.ai/applicant/" + applicantId + "/messages")
            ))
            .build();
 
        restTemplate.exchange(
            msghubProps.getSendUrl(),
            HttpMethod.POST,
            new HttpEntity<>(alimtalkRequest, headers),
            new ParameterizedTypeReference<MsghubApiResponse<List<ApiSendAlimtalkResponse>>>() {}
        );
    }
}

70줄짜리 UseCase에서 절반 이상이 알림톡 관련 코드였다. 메시지를 저장하는 비즈니스 로직과, SHA-512 토큰을 만들고 HTTP 요청을 조립하는 인프라 로직이 뒤섞여 있었다.

문제가 쌓이기 시작했다

결정적으로 한 사건이 있었다. 알림톡 발송 과정에서 RestTemplate 타임아웃이 발생했는데, 그 예외가 UseCase의 트랜잭션을 롤백시켜 메시지 저장까지 실패했다. 알림톡 발송이 실패해도 메시지 저장은 성공해야 하는데, 두 관심사가 결합되어 있어서 한 쪽의 실패가 다른 쪽을 끌어내렸다.

Port 추출: KakaoAlimTalkSender 인터페이스

"비즈니스 로직이 인프라 세부사항을 몰라야 한다"는 클린 아키텍처의 원칙을 적용했다. 먼저 인터페이스를 정의했다.

JAVA
public interface KakaoAlimTalkSender {
 
    void sendAlimtalk(
        String officeName,
        String postingName,
        String phone,
        String applicantId
    );
}

파라미터가 도메인 언어로 되어 있다는 게 중요하다. HttpHeaders, ApiSendAlimtalkRequest 같은 인프라 타입이 아니라, officeName, postingName 같은 비즈니스 개념이다. 이 인터페이스만 보면 "회사명, 공고명, 전화번호를 받아서 알림톡을 보내는구나"가 바로 읽힌다.

💡인터페이스 이름에 'Kakao'가 들어간 이유

NotificationSender처럼 더 추상적으로 만들 수도 있었다. 하지만 현재 비즈니스 요구사항이 명확히 "카카오 알림톡"이고, 다른 메시징 채널(SMS, 푸시 등)과 인터페이스가 다르다. 미래를 과도하게 추상화하지 않고, 필요할 때 확장하기로 했다.

Adapter 구현: MsghubKakaoAlimTalkSender

Msghub REST API를 호출하는 구현체다. SHA-512 토큰 생성, 헤더 조립, 요청 빌드 같은 세부사항이 전부 이 클래스 안에 갇혔다.

JAVA
@Component
@RequiredArgsConstructor
@Slf4j
public class MsghubKakaoAlimTalkSender implements KakaoAlimTalkSender {
 
    private final RestTemplate msghubRestTemplate;
    private final MsghubProps msghubProps;
 
    @Override
    public void sendAlimtalk(
        final String officeName,
        final String postingName,
        final String phone,
        final String applicantId
    ) {
        try {
            final String token = generateToken();
            final HttpHeaders headers = createHeaders(token);
 
            final String message = buildMessage(officeName, postingName);
            final ApiSendAlimtalkRequest request = ApiSendAlimtalkRequest.builder()
                .msg(message)
                .tmpltCode(msghubProps.getTemplateCode())
                .buttons(List.of(
                    new Button("결과 확인하기",
                        "https://tajo.sofac.ai/applicant/" + applicantId + "/messages")
                ))
                .recvInfoLst(List.of(
                    RecvInfo.of(phone, officeName, postingName)
                ))
                .build();
 
            final ResponseEntity<MsghubApiResponse<List<ApiSendAlimtalkResponse>>> result =
                msghubRestTemplate.exchange(
                    msghubProps.getSendUrl(),
                    HttpMethod.POST,
                    new HttpEntity<>(request, headers),
                    new ParameterizedTypeReference<>() {}
                );
 
            log.info("AlimTalk sent successfully: applicantId={}, status={}",
                applicantId, result.getStatusCode());
 
        } catch (final Exception e) {
            log.error("Failed to send AlimTalk: applicantId={}", applicantId, e);
        }
    }
 
    private String generateToken() {
        final String raw = msghubProps.getUserId() + msghubProps.getTimestamp();
        return DigestUtils.sha512Hex(raw);
    }
 
    private String buildMessage(final String officeName, final String postingName) {
        return String.format(
            "[%s] %s 채용 관련 새로운 메시지가 도착했습니다.",
            officeName, postingName
        );
    }
}

Adapter 내부에서 예외를 catch하고 로그만 남긴다는 점도 중요하다. 알림톡 발송 실패가 비즈니스 로직에 영향을 주면 안 된다. 이전에 타임아웃이 트랜잭션을 롤백시킨 그 문제를 구조적으로 방지한 것이다.

이벤트 기반으로 완전 분리

사실 Port & Adapter만으로는 부족했다. UseCase 안에서 kakaoAlimTalkSender.sendAlimtalk()을 직접 호출하면, 동기 호출이라 발송이 느릴 때 API 응답 시간이 늘어났다. 이벤트로 완전히 분리했다.

AlimTalk Sending Flow

JAVA
@Component
@RequiredArgsConstructor
public class NotificationEventListener {
 
    private final SendAlimTalkUseCase sendAlimTalkUseCase;
 
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendAlimTalk(final MessageByRecruiterSentEvent event) {
        if (!event.isSendAlimTalk()) {
            return;
        }
        try {
            sendAlimTalkUseCase.sendAlimTalk(event.getApplicantId());
        } catch (final Exception e) {
            log.error("Failed to send AlimTalk: applicantId={}",
                event.getApplicantId(), e);
        }
    }
}

@Async + AFTER_COMMIT 조합이 핵심이다. 메시지 저장 트랜잭션이 커밋된 후에, 별도 스레드에서 알림톡을 발송한다. 메시지 저장과 알림톡 발송이 완전히 독립적으로 동작한다.

테스트가 달라졌다

Port를 인터페이스로 분리한 가장 큰 체감 효과는 테스트였다. 이전에는 WireMock을 세팅하거나 실제 API를 호출해야 했는데, 이제는 인터페이스를 mock하면 된다.

JAVA
@ExtendWith(MockitoExtension.class)
class SendAlimTalkUseCaseTest {
 
    @Mock
    private KakaoAlimTalkSender kakaoAlimTalkSender;
    @Mock
    private ApplicantService applicantService;
    @Mock
    private PostingService postingService;
    @Mock
    private OfficeService officeService;
 
    @InjectMocks
    private SendAlimTalkUseCase useCase;
 
    @Test
    void sendAlimTalk() {
        // given
        given(applicantService.getById("app-1")).willReturn(applicantDto);
        given(postingService.getById("post-1")).willReturn(postingDto);
        given(officeService.getById("office-1")).willReturn(officeDto);
 
        // when
        useCase.sendAlimTalk("app-1");
 
        // then
        verify(kakaoAlimTalkSender).sendAlimtalk(
            "소팩오피스", "백엔드 개발자", "010-1234-5678", "app-1"
        );
    }
}
ℹ️테스트 실행 시간도 줄었다

WireMock 기반 통합 테스트는 한 건에 2-3초가 걸렸는데, mock 기반 단위 테스트는 50ms도 안 걸린다. 테스트를 자주 돌릴 수 있게 되면서 코드 품질이 자연스럽게 올라갔다.

패키지 구조

Notification Architecture

돌이켜보면 "일단 동작하게 만들고 나중에 정리하자"에서 가장 위험한 건 "나중"이 언제인지 모른다는 것이었다. 타임아웃 장애가 나서야 정리했는데, 처음부터 인터페이스를 정의하는 데 30분이면 충분했다. Adapter 구현은 기존 코드를 옮기기만 하면 됐으니까. 장애 대응에 쓴 3시간을 생각하면, 30분의 설계 투자가 훨씬 저렴했다.

§ 목차