본문 바로가기
Spring/Common

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

by coding_whale 2026. 5. 21.
반응형

1. 도입부 (Introduction)

현대 애플리케이션에서 사용자 간의 '관계(Relationship)'를 정의하는 팔로우, 친구 추가, 구독 등의 기능은 매우 흔하게 설계된다. 대다수의 백엔드 개발은 초기 단계에서 단순히 두 사용자 간의 매핑 테이블(예: Follow 테이블)을 생성하고, 행(Row)을 추가하거나 삭제하는 수준의 단순 CRUD API로 접근한다.

하지만 실제 상용 서비스 레벨의 아키텍처에서는 강력한 규제 조건인 '차단(Block)' 정책이 개입하는 순간 이 간단해 보이던 도메인의 복잡도가 급격하게 치솟는다. 쓰기(Write) 시점에 차단 관계를 정상적으로 거르지 못하거나, 데이터베이스 저장에는 성공했으나 정작 목록 조회(Read) 시 차단된 상대방의 프로필이나 활동이 여전히 노출되는 경우 정책 일관성이 완전히 무너진다. 이는 곧 심각한 개인정보 유출과 보안 무력화 문제로 직결된다.

이번 포스팅에서는 특정 서비스 비즈니스에 종속되지 않은 가장 범용적이고 일반적인 '제약 기반 관계 정책(Constraint-Based Relationship Policy)' 아키텍처 템플릿을 분석하고, 이를 설계하기 위한 정형화된 코드와 양식을 제시한다.

 

 

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

안전하고 견고한 관계 시스템을 구축하려면 "제약(Restriction)은 허용(Permission)보다 항상 먼저 작동해야 한다"는 아키텍처 철학을 세워야 한다. 사용자가 전송한 관계 맺기(팔로우 등) 요청이 데이터베이스 테이블에 실제로 영속화되기까지 거쳐야 하는 이상적인 검증 파이프라인은 다음과 같다.

이 모든 흐름이 쓰기(Write)에서 한 번, 읽기(Read)에서 다시 한 번 양방향 대칭 필터로 작동해야 비로소 완벽한 정합성을 달성할 수 있다.

 

 

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

A) 범용 제약 기반 관계 서비스 계층 구조

플랫폼에서 사용 가능한 추상 클래스와 가이드라인을 투영하여 원자적이고 멱등적으로 작성한 서비스 코드다.

package com.example.platform.relationship.service;


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

    private final FollowRepository followRepository;
    private final BlockRepository blockRepository;
    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher eventPublisher; // 느슨한 결합을 위한 스프링 이벤트 발행기

    /**
     * [쓰기 정책] 멱등성과 강력한 제약 조건을 갖춘 관계 형성 로직
     * @param actorId 관계 형성을 시도하는 행위자(로그인 유저) ID
     * @param targetId 대상이 되는 수신자 ID
     */
    @Transactional
    public void follow(Long actorId, Long targetId) {
        // 1. 요청 도메인 객체 검증
        Member actor = memberRepository.findById(actorId)
                .orElseThrow(() -> new NoSuchElementException("요청 주체 회원을 식별할 수 없습니다."));
        Member target = memberRepository.findById(targetId)
                .orElseThrow(() -> new NoSuchElementException("요청 대상 회원을 식별할 수 없습니다."));

        // 2. [가드 로직 1] 동일성 검증: 자기 자신과의 관계 차단
        if (actor.getId().equals(target.getId())) {
            throw new IllegalArgumentException("자기 자신과는 관계를 형성할 수 없습니다.");
        }

        // 3. [가드 로직 2] 차단 제약 조건 검증: 최우선 집행 정책
        // 단방향이 아닌 '양방향' 차단 여부 검사 필수 (existsRelationBetween 메서드 내부 OR 연산 적용)
        if (blockRepository.existsRelationBetween(actor.getId(), target.getId())) {
            throw new IllegalArgumentException("차단 관계에 놓인 사용자 간에는 행위를 수행할 수 없습니다.");
        }

        // 4. [가드 로직 3] 멱등성 검증 (no-op 패턴)
        // 모바일 네트워크 특성상 중복으로 중첩 진입한 요청인 경우, 예외로 처리하여 롤백하지 않고 
        // 성공으로 반환하여 UX를 보호하고 알림 중복 발송을 무력화한다.
        if (followRepository.existsByFollowerAndFollowing(actor, target)) {
            return; // 조용히 종료 (no-op)
        }

        // 5. 비즈니스 상태 영속화 (데이터 커밋)
        followRepository.save(new Follow(actor, target));

        // 6. 도메인 부수 효과 전파 (이벤트 발행 메커니즘)
        // 트랜잭션 외부 연동 장애(알림 서비스 먹통 등)가 원본 트랜잭션을 롤백시키지 않도록 관심사 분리
        eventPublisher.publishEvent(new FollowCreatedEvent(target.getId(), actor.getId(), actor.getNickname()));
    }

    /**
     * [삭제 정책] 멱등성 관계 해제 로직
     */
    @Transactional
    public void unfollow(Long actorId, Long targetId) {
        Member actor = memberRepository.findById(actorId)
                .orElseThrow(() -> new NoSuchElementException("회원이 존재하지 않습니다."));
        Member target = memberRepository.findById(targetId)
                .orElseThrow(() -> new NoSuchElementException("회원이 존재하지 않습니다."));

        // 대상 로우가 없더라도 예외를 발생시키지 않고 안전하게 no-op 삭제 처리하여 멱등성 보장
        followRepository.deleteByFollowerAndFollowing(actor, target);
    }
}

 

B) 읽기-쓰기 대칭성을 보장하는 실시간 필터링 기법

데이터베이스 물리 영속성에 제대로 검증하여 차단된 객체를 막았더라도, 목록 출력 단계에서 이를 대칭적으로 빼주지 않으면 UX 가 고장 난다.

/**
 * [읽기 정책] 행위자 기준 양방향 블랙리스트(차단/피차단 대상 ID 목록)를 원스톱 빌드한다.
 * @param memberId 마스킹 처리를 하려는 중심 사용자 ID
 * @return 차단 관계에 놓인 모든 사용자 ID 집합 (O(1) 해시 검색 최적화용)
 */
public Set<Long> getExcludedMemberIds(Long memberId) {
    Set<Long> excluded = new HashSet<>();
    
    // 1. 내가 능동적으로 차단(Block)한 유저들의 ID 리스트 로드
    excluded.addAll(blockRepository.findBlockedIdsByBlockerId(memberId));
    
    // 2. 나를 수동적으로 차단(피차단)한 유저들의 ID 리스트 로드 (나의 정보가 상대에게 노출되지 않기 위함)
    excluded.addAll(blockRepository.findBlockerIdsByBlockedId(memberId));
    
    return excluded;
}

/**
 * 조회 시점에 블랙리스트 해시 셋을 활용하여 차단 대상자들을 메모리 상에서 완벽히 여과(마스킹)한다.
 */
public List<FollowListResponseDto> getFollowingsWithFilter(Long memberId) {
    // 1. 대상 유저의 팔로잉 전체 관계 로드
    List<Follow> rawList = followRepository.findAllByFollowerId(memberId);
    
    // 2. 동적 블랙리스트 해시 집합 확보
    Set<Long> blockedIds = getExcludedMemberIds(memberId);
    
    // 3. O(1) 시간 복잡도를 가진 Set.contains() 연산을 활용한 대칭 마스킹 집행
    return rawList.stream()
            .filter(f -> !blockedIds.contains(f.getFollowing().getId()))
            .map(FollowListResponseDto::from)
            .toList();
}

 

4. 실무 팁 및 주의사항 (Tips & Notes)

아키텍처 설계 수립 및 디버깅 가이드라인

  • 단방향 차단과 양방향 차단 구분의 디테일: 상호 작용을 막을 때는 관계 리포지토리 조회 쿼리에서 반드시 양방향 조건으로 검색해야 한다. 아래 SQL 구조처럼 두 조건이 OR로 단단히 연결되어 있는지 체크해야 비로소 한 유저의 수동적 피차단 상태까지 완벽하게 보호할 수 있다.
  • 알림 이벤트의 발행 위치는 물리 영속화 이후: 이벤트를 발행하여 모바일 푸시 알림을 트리거하는 행위(eventPublisher.publishEvent)는 반드시 JPA 트랜잭션이 성공적으로 데이터베이스 세션에 반영된 이후(Commit 완료)에 실행되거나 스프링의 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 장착해야 한다. 그렇지 않으면 DB 에러로 인해 팔로우 저장이 실패(Rollback)했음에도 이미 가동된 외부 알림 로직으로 인해 '유령 알림'이 상대방에게 송출되는 심각한 비정상 사용자 경험 결함을 낳게 된다.

 

 

5. 마무리 (Conclusion)

관계 도메인 설계의 진정한 성패는 단지 테이블에 Row를 잘 추가하느냐에 달린 것이 아니다. 도메인이 지탱하는 '프라이버시 수호 규칙과 제한 장벽'을 얼마나 아키텍처적으로 무결하게 보장하느냐가 본질이다. 쓰기 작업에 제약 파이프라인 수문장을 배치하고, 읽기 작업에 블랙리스트 메모리 해시 마스킹을 결합하는 것만으로 소셜 도메인은 그 어떤 비정상적인 우회 시도 속에서도 안전하게 동작하게 될 것이다.

 

 

6. 복습 질문과 해답 (Review Q&A)

Q1. 차단 상태 확인 메서드(existsRelationBetween)를 중복 가입 체크 메서드보다 상단에 두는 핵심적인 설계 이유는 무엇인가?

A1. 제한 정책의 적용 범위를 엄격하게 사수하기 위해서다. 만약 중복 체크를 상위에 두면 이미 과거에 맺어놓은 팔로우 관계 상태에서 추후 상대가 나를 차단했을 때, 클라이언트의 중복 팔로우 요청이 들어오면 중복 필터에 걸려 조기 리턴(no-op)되어 버린다. 결국 그 하위에 위치한 '차단 예외 던지기' 가드에 도달하지 못해 정지되어야 할 부정한 비즈니스 흐름이 버젓이 성공하게 되는 모순이 발생하기 때문이다.

Q2. 멱등적(Idempotent) 해제 흐름 구현 시 테이블 데이터가 이미 삭제되어 부재할 때 예외를 발생시키지 않고 통과하는 장점은 무엇인가?

A2. 예외 처리를 무분별하게 던지면 클라이언트(프론트엔드/모바일) 단에서 에러 응답 코드를 파싱한 뒤 화면에 에러 다이얼로그나 경고 메시지를 불필요하게 팝업하게 된다. 모바일의 불안정한 수신 대역 환경에서 클라이언트가 API 해제를 재시도할 때, 이미 삭제된 경우에도 최종적인 의미론적 상태는 "unfollow 완료" 상태가 유지되는 것이 맞으므로 조용히 성공으로 처리해 주는 것(no-op)이 설계 무결성과 시스템 안정성(UX)을 모두 살리는 길이다.

Q3. 읽기 영역에서의 마스킹 처리를 메모리(Set.contains)에서 하는 기법이 데이터베이스 다중 조인 쿼리보다 나은 점은 무엇인가?

A3. DB 인프라에 걸리는 조인 복잡성 연산량의 비용을 기하급수적으로 줄일 수 있다. 관계 목록 조회는 사용자가 앱을 켤 때마다 가장 빈번하게 타는 핫-패스(Hot Path)다. 여기에 매번 다중 조인 쿼리를 실행해 차단을 발라내면 인덱스 탐색 범위가 비대해져 성능 병목이 온다. 행위자의 가벼운 차단 유저 해시 셋 데이터(보통 100건 이하)를 한 차례 가볍게 조회한 뒤, 메모리에서 해시 lookup $O(1)$ 연산으로 걷어내는 설계가 자원 최적화 관점에서 압도적으로 뛰어나다.

반응형