본문 바로가기
Spring/Common

[Spring] 차단을 활용한 트랙잭션 경계 설계와 웹훅 결합시 마주하는 장애 분석

by coding_whale 2026. 5. 22.
반응형

1. 도입부 (Introduction)

사용자가 커뮤니티나 SNS 플랫폼에서 다른 사용자를 "차단(Block)"하는 행위는 단순히 관계형 데이터베이스에 차단 이력(Row)을 한 줄 적재하는 가벼운 이벤트가 아니다. 도메인 관점에서 차단은 "두 사용자 사이에 존재하는 모든 상호작용 규칙과 권한 구조를 즉시 파괴하고 재구성하는 중대한 아키텍처적 사건"이다.

많은 주니어 개발자들이 저지르는 실수는 차단 테이블(MemberBlock)에 단순히 데이터를 저장(INSERT)하는 기능만 만들고 비즈니스를 끝내는 것이다. 그러나 차단 이력만 적재하고 기존에 형성되어 있던 양방향 팔로우 관계를 물리적으로 정리하지 않으면, DB 쿼리에 미세한 필터 구멍이 생기는 순간 차단당한 자가 blocker의 비공개 피드를 훔쳐보거나 팔로잉 목록에 여전히 남아 있는 치명적인 보안 누수 장애가 발생한다.

따라서 차단은 트랜잭션 경계 내부에서 팔로우 파괴와 이력 생성이 원자적(Atomic)으로 일어나는 복합 행동이어야 한다. 이번 포스팅에서는 차단 기능을 단순 CRUD가 아닌 '관계 재구성 및 즉시 동기화 정책'으로 전환하기 위한 범용적인 설계 패턴을 파헤쳐 본다.

 

[Spring] 소셜 관계 도메인 : '제약 기반 관계 정책' 설계

1. 도입부 (Introduction)현대 애플리케이션에서 사용자 간의 '관계(Relationship)'를 정의하는 팔로우, 친구 추가, 구독 등의 기능은 매우 흔하게 설계된다. 대다수의 백엔드 개발은 초기 단계에서 단순

myblog01150.tistory.com

 

 

2. 주요 특징 및 핵심 로직 (Main Features & Logic)

안전하고 신뢰할 수 있는 사용자 차단 시스템은 데이터 저장 단계에서 규칙을 확실히 정리하고, 영속화 직후 지연 없이 클라이언트 뷰(View)를 갱신할 수 있도록 설계해야 한다.

라이플 사이클 단계 관련 레포지토리 및 구성 요소 핵심 비즈니스 규칙 및 설계 사상
차단 집행 (Block) UserBlockRepository, FollowRepository 양방향 팔로우의 영구적 즉시 삭제, 멱등성 검증을 통한 중복 가드, 차단자 콘텐츠 ID 수집
차단 해제 (Unblock) UserBlockRepository 차단 이력만 안전하게 삭제. 이전에 존재했던 팔로우 관계를 다시 수동 복구하지 않는 것이 보안 핵심 정책
외부 전파 (Webhook) discordWebhookService 트랜잭션이 최종 확정(Commit)된 이후에만 원격 호출 트리거하여 DB 커넥션 점유 및 롤백 혼선 방지
실시간 숨김 (Hide) contentRepository 차단 완료 응답에 hiddenRecordIds를 배열 형태로 즉시 바인딩하여 앱 UI가 딜레이 없이 피드를 소거하게 지원

 

 

3. 상세 가이드 및 심층 분석 (Detailed Guide)

A) 범용 관계 재구성 비즈니스 서비스 구현

사용자 간의 차단 및 해제를 안전하게 수행하고 부수효과를 비동기로 격리하는 서비스 클래스 코드다.

package com.example.platform.relationship.service;

import com.example.platform.member.domain.Member;
import com.example.platform.member.repository.MemberRepository;
import com.example.platform.relationship.domain.UserBlock;
import com.example.platform.relationship.dto.BlockActionResponse;
import com.example.platform.relationship.repository.UserBlockRepository;
import com.example.platform.relationship.repository.FollowRepository;
import com.example.platform.relationship.event.UserBlockedEvent; // 차단 비동기 웹훅 발행용 도메인 이벤트
import com.example.platform.content.repository.ContentRepository; // 피드, 게시물 등 범용 콘텐츠 저장소
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.NoSuchElementException;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserBlockService {

    private final MemberRepository memberRepository;
    private final UserBlockRepository userBlockRepository;
    private final FollowRepository followRepository;
    private final ContentRepository contentRepository; // 차단 대상자의 게시글 식별용
    private final ApplicationEventPublisher eventPublisher; // 느슨한 결합을 위한 스프링 이벤트 시스템 주입

    /**
     * [차단 집행 정책] 단순 이력 저장을 넘어 양방향 관계를 완전히 원자적으로 정리하고 정지 처리를 도모한다.
     * @param blockerId 차단을 시도하는 회원의 식별 ID (나)
     * @param blockedUserId 차단당하는 회원의 식별 ID (상대)
     * @return 클라이언트 즉시 마스킹 처리를 위한 콘텐츠 ID 집합이 포함된 응답 객체
     */
    @Transactional
    public BlockActionResponse block(Long blockerId, Long blockedUserId) {
        // 1. 도메인 영속성 상태 가져오기
        Member blocker = memberRepository.findById(blockerId)
                .orElseThrow(() -> new NoSuchElementException("차단 주체를 찾을 수 없습니다."));
        Member blocked = memberRepository.findById(blockedUserId)
                .orElseThrow(() -> new NoSuchElementException("차단 대상을 찾을 수 없습니다."));

        // 2. 가드 룰: 자기 자신 차단 금지 정책
        if (blocker.getId().equals(blocked.getId())) {
            throw new IllegalArgumentException("자기 자신은 차단할 수 없습니다.");
        }

        // 3. 중복 차단 여부 체크 (멱등성 가드 / no-op 패턴)
        // 이미 차단이 완료된 상태에서 네트워크 재시도 요청이 들어올 경우, 트랜잭션을 롤백하거나 에러를 뿜는 대신 
        // 조용히 통과시켜 최종 수신 데이터(`hiddenRecordIds`)를 반환해 화면 복구를 돕는다.
        if (!userBlockRepository.existsByBlockerAndBlocked(blocker, blocked)) {
            // 4. 새로운 차단 이력 영속화
            userBlockRepository.save(new UserBlock(blocker, blocked));

            // 5. 관계 그래프의 원자적 재조직 (양방향 팔로우의 영구 해체)
            // 차단은 강력한 네거티브 제약이므로, 기존에 혹시 맺어져 있던 팔로우가 있다면 
            // Blocker -> Blocked 방향 뿐 아니라, Blocked -> Blocker 방향까지 완벽히 삭제해야 한다.
            followRepository.deleteByFollowerAndFollowing(blocker, blocked);
            followRepository.deleteByFollowerAndFollowing(blocked, blocker);

            // 6. 부수효과 비동기 처리를 위한 도메인 이벤트 발행
            // 외부 원격 API(디스코드 웹훅) 전송의 신뢰성을 위해 물리 DB 쓰기 트랜잭션 관심사에서 분리한다.
            eventPublisher.publishEvent(new UserBlockedEvent(blocker, blocked));
        }

        // 7. 실시간 UI 숨김 피드백용 ID 조회 (범용화된 Content ID 조회)
        // 차단당한 사용자(blocked)가 생성해놓은 모든 게시글/콘텐츠 ID를 확보해 클라이언트에 던진다.
        List<Long> hiddenRecordIds = contentRepository.findIdsByAuthor(blocked);

        return BlockActionResponse.builder()
                .blockedUserId(blocked.getId())
                .blocked(true) // 차단 성공 여부 플래그
                .hiddenRecordIds(hiddenRecordIds) // 클라이언트 인메모리 스토어에서 즉시 삭제할 ID 리스트
                .build();
    }

    /**
     * [차단 해제 정책] 차단 관계를 회복하되, 이전의 팔로우 상태를 '자동 복구'하지 않는다.
     */
    @Transactional
    public BlockActionResponse unblock(Long blockerId, Long blockedUserId) {
        Member blocker = memberRepository.findById(blockerId)
                .orElseThrow(() -> new NoSuchElementException("차단 주체를 찾을 수 없습니다."));
        Member blocked = memberRepository.findById(blockedUserId)
                .orElseThrow(() -> new NoSuchElementException("차단 대상을 찾을 수 없습니다."));

        // 차단 해제는 데이터베이스에서 관계 이력만 파괴한다.
        // 이 시점에 과거의 팔로우를 억지로 되찾으려 시도해서는 안 된다. 팔로우는 사용자의 명시적인 능동적 동의 하에 다시 시작되어야 한다.
        userBlockRepository.deleteByBlockerAndBlocked(blocker, blocked);

        return BlockActionResponse.builder()
                .blockedUserId(blocked.getId())
                .blocked(false) // 차단 해제 상태 각인
                .hiddenRecordIds(List.of()) // 복구는 전역 폴링 혹은 재진입 시 처리되므로 빈 리스트 리턴
                .build();
    }
}

 

 

B) 도메인 이벤트 핸들러를 통한 웹훅 전송 격리

원래 트랜잭션이 완벽하게 디스크에 기록(Commit)된 이후에만 비동기로 외부 원격 API를 호출하게 만드는 영리한 핸들러 구현체다.

package com.example.platform.relationship.event;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class UserBlockEventHandler {

    private final DiscordUserBlockWebhookService webhookService;

    /**
     * 비즈니스 핵심 트랜잭션 커밋 완료 후 동작하도록 이벤트 시점을 분리한 핸들러다.
     * @Async 어노테이션을 통해 완전히 별개의 독자 스레드에서 원격 호출을 태움으로써 메인 서버 부하를 무력화한다.
     */
    @Async("webhookTaskExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleUserBlocked(UserBlockedEvent event) {
        try {
            log.info("차단 영속화 커밋 완료 감지. 외부 디스코드 웹훅 전송 준비. Blocker: {}, Blocked: {}", 
                     event.getBlocker().getId(), event.getBlocked().getId());
            
            // 실제 외부 타사 서버와 HTTP 연결 수립 (타임아웃 장애의 위협으로부터 자유로운 안전 패스)
            webhookService.sendUserBlocked(event.getBlocker(), event.getBlocked());
            
        } catch (Exception e) {
            // 외부 플랫폼의 장애가 발생하더라도 메인 트랜잭션은 커밋 완료되었으므로 데이터 롤백 혼선이 발생하지 않는다.
            log.error("차단 알림 웹훅 발송 오류 발생. 비즈니스 정합성에는 무방함. 사유: ", e);
        }
    }
}

 

 

C) REST API 진입점 (Controller) 및 통신 DTO 구성

비즈니스 응답으로 내려보낼 hiddenRecordIds DTO 및 컨트롤러 계층을 구성한다.

package com.example.platform.relationship.dto;

import lombok.Builder;
import lombok.Getter;
import java.util.List;

@Getter
@Builder
public class BlockActionResponse {
    private final Long blockedUserId;      // 규제 대상이 된 피차단 유저의 고유 식별값
    private final boolean blocked;          // 현재 차단 처리 여부 상태 (true: 차단 완료, false: 차단 해제)
    private final List<Long> hiddenRecordIds; // 화면(DOM)에서 즉각 소거해야 할 피차단자의 모든 작성물 ID 리스트
}
package com.example.platform.relationship.controller;

import com.example.platform.relationship.service.UserBlockService;
import com.example.platform.relationship.dto.BlockActionResponse;
import com.example.platform.member.domain.Member; // 현재 로그인 멤버
import com.example.platform.common.api.ApiResponse; // 공통 통신 봉투(Wrapper) DTO
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/relationship")
@RequiredArgsConstructor
public class UserBlockController {

    private final UserBlockService userBlockService;

    /**
     * 특정 사용자 차단 API 진입점
     * @param blockedUserId 차단하고 싶은 사용자의 식별 ID
     */
    @PostMapping("/block/{blockedUserId}")
    public ApiResponse<BlockActionResponse> blockUser(
            @CurrentUser Member currentMember, // 현재 로그인하여 보안 필터를 통과한 blocker 객체
            @PathVariable Long blockedUserId) {
            
        BlockActionResponse response = userBlockService.block(currentMember.getId(), blockedUserId);
        return ApiResponse.ok(response);
    }

    /**
     * 특정 사용자 차단 해제 API 진입점
     */
    @DeleteMapping("/unblock/{blockedUserId}")
    public ApiResponse<BlockActionResponse> unblockUser(
            @CurrentUser Member currentMember,
            @PathVariable Long blockedUserId) {
            
        BlockActionResponse response = userBlockService.unblock(currentMember.getId(), blockedUserId);
        return ApiResponse.ok(response);
    }
}

 

 

4. 트랜잭션 경계와 실패 시나리오 (Transaction Boundaries & Failures)

데이터베이스 롤백과 네트워크 비동기 부수효과가 충돌하는 아키텍처 한계를 면밀히 분석한 도식화 장치다.

실무 디버깅 포인트 및 고급 동시성 장애 방어법

  • 두 사용자가 서로를 정확히 동시에 차단할 때 생기는 데드락(Deadlock) 이슈:
    사용자 A가 B를 차단하는 요청과 사용자 B가 A를 차단하는 요청이 나노초 단위로 겹칠 때, 데이터베이스 내부의 유니크 제약(Unique Key)이나 관계 테이블의 특정 인덱스 행 락(Row Lock)이 서로 맞물리며 교착 상태(Deadlock)가 발생할 수 있다.
    이 경우 자바 레벨에서 정렬된 상태로 PK 조회를 시도하거나, userBlockRepository 적재 시점에 actor_id와 target_id를 소팅한 인덱스 기반 정적 잠금 방식을 고려하여 DB 수준의 락 전이(Lock Escalation)를 미연에 제어해야 한다.

 

  • 차단 대상 유저가 이미 대량의 콘텐츠(작성 글 수천 개 이상)를 지닌 인플루언서일 때 발생하는 GC 및 지연 속도 병목:
    차단 대상 유저가 수만 개의 글을 쓴 대형 창작자라면, contentRepository.findIdsByAuthor(blocked) 쿼리를 호출하는 순간 힙 메모리에 수만 개의 Long ID 객체들이 적재되며 가비지 컬렉터(GC) 폭주와 함께 1초 이상의 응답 지연을 야기한다.
    이런 대규모 대량 데이터의 병목 상황을 해결하는 실무 솔루션은 "백엔드에서의 마스킹 필터링 격리"다. 응답으로 hiddenRecordIds에 수만 건을 한 번에 내려주는 어리석은 방식 대신, 차단 목록 셋(Set<BlockedUserId>) 자체를 메모리에 캐싱(Redis 캐시 스토어 활용)해두고, 클라이언트가 피드 목록 조회 API를 당길 때마다 백엔드가 해당 셋을 조회하여 차단 유저의 콘텐츠를 서브 쿼리 WHERE author_id NOT IN (:blockedIds)로 인덱스 스캔하는 쿼리 필터 방식(In-Database Isolation)을 채택하는 것이 메모리를 훨씬 아낄 수 있는 세련된 대안이다.

 

 

5. 마무리 (Conclusion)

결국 '차단'이라는 기능의 성공적인 구현은 단순히 테이블 간 관계를 저장하고 멈추는 CRUD 수준의 소극적 사고를 탈피하는 데서 출발한다. 기존에 유지되던 데이터 간 관계를 트랜잭션 안에서 완전히 파괴하여 원자적으로 재구성하고, 변경으로 오염된 클라이언트 뷰의 잔상을 hiddenRecordIds 통신 프로토콜을 통해 말끔히 지워주며, 외부 시스템 연동 부수효과는 트랜잭션 바깥으로 격리해야만 흔들림 없는 완벽한 관계 정책을 구축할 수 있다.

 

 

6. 복습 질문과 해답

Q1. 차단(Block) 요청이 통과되는 즉시, 기존에 형성되어 있던 팔로우 관계를 DB에서 확실하게 삭제(delete) 해야만 하는 아키텍처 설계적 이유는 무엇인가?

A1. 팔로우 관계를 끊지 않고 남겨두면, 복잡한 인메모리 비즈니스 쿼리나 통계용 뱃지 카운팅, 추천 알고리즘 쿼리 등에서 차단 테이블(MemberBlock)을 누락하는 미세한 휴먼 에러가 단 한 군데라도 생겼을 때, 차단된 사용자에게 내 활동과 개인 피드가 버젓이 노출되는 치명적인 보안 결함이 발생한다. 따라서 테이블을 Join해서 걸러내는 식의 임시방편이 아닌, 물리적인 관계 데이터 자체를 트랜잭션 레벨에서 완전히 지워서 원천 폐기하는 것이 정합성 유지 비용과 아키텍처 단순성 측면에서 훨씬 안전하다.

 

Q2. 차단 완료 응답 DTO 필드로 피차단 사용자가 작성한 모든 글의 ID 목록(hiddenRecordIds)을 내려주면 클라이언트(App/Web)에 어떤 이점을 제공할 수 있는가?

A2. 네트워크 전송 속도와 별개로 '체감 딜레이가 전혀 없는(Zero-Delay) 반응형 사용자 경험(UX)'을 제공한다. 서버가 차단 성공 여부만 내려주면 클라이언트는 화면에 떠 있는 피드 목록을 갱신하기 위해 전체 페이지를 새로고침(Fetch API 호출)하는 비효율적인 연쇄 호출을 거쳐야 한다. 하지만 숨김 대상 ID 목록을 한 몸으로 넘겨주면, 앱의 프론트 메모리 내부 스토어(Redux, Zustand 등)에서 즉시 해당 게시글 데이터들만 화면에서 제거(DOM removal)하여 사용자는 즉각적인 차단 반응성을 시각적으로 느끼게 된다.

 

Q3. 차단이나 차단 해제 트랜잭션 안에서 디스코드/슬랙 같은 운영 서버 웹훅(Webhook) API를 동기적으로 호출해 동기화할 때, 아키텍처 수준에서 어떤 장애가 유발될 수 있는가?

A3. DB 커넥션 풀 고갈로 인한 전체 시스템 마비 장애가 터진다. RDB 트랜잭션은 메서드가 끝날 때까지 활성화된 DB Connection을 계속해서 묶어두는 상태가 된다. 이 트랜잭션 내부에서 네트워크 속도를 신뢰할 수 없는 타사 웹 서버를 향해 동기적으로 HTTP 요청을 보내는 경우, 외부 서버의 응답이 10초간 밀리면 자바의 해당 스레드는 DB 목줄(Connection)을 잡은 채로 대기 상태에 빠진다. 이 요청들이 여러 개 쌓이면 서버의 모든 커넥션이 고갈되어 차단과 아예 무관한 회원가입, 로그인, 마이페이지 조회 등 다른 평범한 API들까지 모두 먹통이 되는 연쇄 장애로 이어진다. 따라서 트랜잭션 커밋 완료 이후 시점에 비동기로 동작하는 @TransactionalEventListener로 무조건 위임해야 한다.

반응형