FireDrago

[치즈픽] Redis vs 애플리케이션 비즈니스 로직은 어디에 둬야 할까? 본문

프로젝트

[치즈픽] Redis vs 애플리케이션 비즈니스 로직은 어디에 둬야 할까?

화이용 2026. 5. 27. 15:27

프로젝트를 진행하며 방송 제목 변경, 카테고리 변경과 같은 스트림 메타데이터 변경을 추적하는 기능을 구현했다.

초기에는 Redis Lua 스크립트 내부에서 상태 비교와 변경 감지까지 모두 처리하도록 구성했다.

하지만 기능이 점점 추가되면서 비즈니스 로직이 계속 쌓이기 시작했다.
그 과정에서 자연스럽게 이런 고민이 들었다.

비즈니스 로직은 Redis가 담당하는 게 맞을까?
Redis와 애플리케이션 중 어디까지 책임을 가져가야 할까?

 

이번 글에서는 Redis Lua 스크립트에 비대해진 비즈니스 로직을 어떻게 바라봤는지,
그리고 Redis와 애플리케이션 사이에서 트레이드 오프를 고민하며 리팩토링했는지 정리해보려 한다.

문제 : 비대해진 Lua 스크립트

-- [AS-IS Lua 스크립트 중 일부] JSON 디코딩 및 상태 비교를 Redis에서 직접 수행
for i = count + 2, #ARGV, 2 do
    local stream_id = ARGV[i]
    local new_json_str = ARGV[i + 1]
    local old_json_str = redis.call('HGET', info_key, stream_id)

    if old_json_str then
        local old_data = cjson.decode(old_json_str) -- 여기서 JSON 파싱!
        local new_data = cjson.decode(new_json_str)

        -- 비즈니스 로직 (제목, 카테고리 변경 감지)
        if old_data.liveTitle ~= new_data.liveTitle or old_data.categoryName ~= new_data.categoryName then
            -- 변경분 저장 로직...
        end
    end
end

Lua 스크립트의 일부코드이다. 

 

  1. JSON 데이터 파싱
  2. 방송 시작/종료 감지
  3. JSON 상태 비교를 통한 방송제목, 카테고리 변경 감지
  4. 변경된 방송 정보 업데이트

 

크게 4가지 작업을 한 스크립트 내에서 처리하고 있었다.
방송 수집단에서 비지니스 로직이 추가될 경우 Lua 스크립트가 점점 비대해지는 구조였다.

 

Redis(Lua) vs 애플리케이션 트레이드 오프

현재 프로젝트는 데이터가 단순하고(JSON 100개 + 채널 ID)

수집단 특성상 고동시성 환경도 아니라, Lua의 네트워크/원자성 이점이 크지 않다.

유지보수성과 테스트 용이성을 위해 비즈니스 로직을 앱 레이어로 이동한다.

 

반면, 재고 차감, 포인트 적립처럼 원자성이 핵심이고 실행 시간이 짧은 도메인에서는 Lua가 여전히 유효할 수 있다.

 

리팩토링

@Component
public class StreamUpdateAnalyzer {
    
    public StreamUpdateResults analyze(List<StreamTarget> current, Set<String> activeIds, List<StreamTarget> old) {
        Set<StreamTarget> newStreams = filterNewStreams(current, activeIds);
        Set<String> closedStreamIds = filterClosedStreams(current, activeIds);
        Set<ChangedStream> changedStreams = detectChangedStreams(current, old);

        return new StreamUpdateResults(newStreams, closedStreamIds, changedStreams);
    }

    private Set<ChangedStream> detectChangedStreams(List<StreamTarget> current, List<StreamTarget> old) {
        // ... (중략) ... 
        // Java 객체 레벨에서 안전하게 상태 비교
        if (isMetadataChanged(oldTarget, newTarget)) {
            changedStreams.add(createChangedStream(oldTarget, newTarget));
        }
    }
}

lua 스크립트에서 실행하는 각종 비교 로직을 도메인 서비스로 분리했다.

`StreamUpdateAnalyzer` 컴포넌트가 기존 데이터와 새 데이터를 비교하여 신규, 종료, 변경된 방송을 분류한다.

이 로직은 어떤 외부 의존성도 없이 쉽게 단위테스트를 작성 할 수 있게 되었다.

 

-- [TO-BE Lua 스크립트] 비즈니스 로직 제거, 순수 상태 동기화만 수행

local closed_count = tonumber(ARGV[1])

-- 1. 종료된 방송 일괄 삭제
if closed_count > 0 then
  -- ... (closed_ids 파싱) ...
  redis.call('HDEL', KEYS[2], unpack(closed_ids))
  redis.call('SREM', KEYS[3], unpack(closed_ids))
end

-- 2. 활성 방송 상태 덮어쓰기
-- ... (active_ids, hash_data 파싱) ...
redis.call('DEL', KEYS[1])

if #active_ids > 0 then
    redis.call('SADD', KEYS[1], unpack(active_ids))
    redis.call('SADD', KEYS[3], unpack(active_ids))
end
if #hash_data > 0 then
    redis.call('HSET', KEYS[2], unpack(hash_data))
end

return {}

Lua 스크립트는 단순히 넘어온 데이터를 삭제 (`HDEL`, `SREM`)하고 덮어쓰는 (`SADD`,`HSET`)역할만 수행하도록 줄였다.
동시에 리포지토리 역시 단순 조회와 json 파싱으로 위 도메인 객체에 데이터를 넘겨주는 역할만 하게 된다.

 

결론

물론 애플리케이션에서 Redis의 기존 상태를 읽어오는 과정(multiGet)이 추가되어 네트워크 I/O는 소폭 증가했다.

또한 수집단이 분산서버로 스케일 아웃 된다면, (그럴일은 없지만,) 동시성 문제가 발생할 위험이 생겼다.

 

하지만 그 대가로 우리가 짠 코드를 빠르고 쉽게 단위 테스트할 수 있게 되었고,

추후 변경 사항이 생겨도 Java 코드만 수정하면 되니 유지보수성은 비교할 수 없을 만큼 좋아졌다.