background
aurora10분

프로젝트, 채널 관리 - 추가 및 목록 조회

2025년 8월 20일

Aurora 프로젝트 - 채널 & 프로젝트 관리 시스템 구현 완료

서버 연결 시스템 구현 이후, 채널 추가/관리 기능과 프로젝트 생성 기능을 구현하기 시작했다. 처음에는 간단할 줄 알았지만 실시간 상태 관리, URL 인코딩, Redux 도입 등 여러 복잡한 문제들을 마주하게 되었다.

처음 마주한 문제들

1. 채널 생성 후 목록이 실시간으로 업데이트 안됨

채널 추가 모달을 만들고 채널을 생성했는데, 새로고침을 해야만 채널 목록에 반영되는 문제가 있었다.

typescript
// 문제가 된 코드 - 로컬 상태로만 관리
const [channels, setChannels] = useState<Channel[]>([]);

// 채널 생성 후 다른 컴포넌트에서 상태 공유가 안됨

해결방법: Redux Toolkit으로 전역 상태 관리 도입

typescript
// channelSlice.ts
const channelSlice = createSlice({
  name: 'channels',
  initialState,
  reducers: {
    setChannels: (state, action: PayloadAction<{
      channels: Channel[];
      projectPk: number;
      serverUrl: string;
    }>) => {
      const { channels, projectPk, serverUrl } = action.payload;
      state.channels = channels;
      state.currentProjectPk = projectPk;
      state.currentServerUrl = serverUrl;
      state.loading = false;
    },
    addChannel: (state, action: PayloadAction<Channel>) => {
      state.channels.push(action.payload);
    }
  }
});

2. 한글 채널명이 URL에서 깨짐 (%EC%98%A4%EC%9E%89)

한글로 채널을 만들면 URL에서 %EC%98%A4%EC%9E%89 이런 식으로 인코딩되어 나타나는 문제가 있었다.

typescript
// 문제 상황
URL: /projects/일반/channels/오잉
실제 표시: /projects/일반/channels/%EC%98%A4%EC%9E%89

해결방법: URL 인코딩/디코딩 로직 추가

typescript
// 채널 링크 생성 시 인코딩
const createChannelLink = (channelName: string, isVoice: boolean = false) => {
  const encodedChannelName = encodeURIComponent(channelName);
  const channelType = isVoice ? "voice_channels" : "channels";
  return `/${serverId}/projects/${currentProject?.projectPk}/${channelType}/${encodedChannelName}`;
};

// 채널 이름 표시 시 디코딩
const decodedChannelId = useMemo(() => {
  try {
    return decodeURIComponent(channelId);
  } catch (error) {
    console.warn("채널 ID 디코딩 실패:", channelId, error);
    return channelId;
  }
}, [channelId]);

3. 프로젝트간 채널이 공유되는 문제

프로젝트 A에서 만든 채널이 프로젝트 B에서도 보이는 문제가 있었다.

문제 원인: Redux에서 채널을 전역으로만 관리하고 프로젝트별 구분이 없었음

해결방법: 채널 상태에 프로젝트 정보 추가

typescript
interface ChannelState {
  channels: Channel[];
  currentProjectPk: number | null;  // 추가
  currentServerUrl: string | null;  // 추가
  loading: boolean;
  error: string | null;
}

// 프로젝트별로 채널 관리
const loadChannels = useCallback(async (serverUrl: string, projectPk: number) => {
  const channelList = await getChannelList(serverUrl, projectPk);

  dispatch(setChannels({
    channels: channelList,
    projectPk,        // 어떤 프로젝트의 채널인지 명시
    serverUrl,
  }));
}, [dispatch, getChannelList]);

URL 구조 개선: projectName → projectPk

기존 문제점

typescript
// 기존 URL 구조
/{serverId}/projects/{projectName}/channels/{channelName}

// 문제점들:
// 1. 한글 프로젝트명 URL 인코딩 문제
// 2. API는 projectPk를 사용하는데 URL은 projectName 사용
// 3. 프로젝트 매칭 로직이 복잡함

해결방법

typescript
// 새로운 URL 구조
/{serverId}/projects/{projectPk}/channels/{channelName}

// 장점:
// 1. projectPk는 숫자라 인코딩 문제 없음
// 2. API와 URL 구조 일치
// 3. 정확한 프로젝트 매칭 가능

프로젝트 매칭 로직 개선:

typescript
// 이전: 복잡한 문자열 매칭
const currentProj = projectList.find((p) => p.projectName === projectId) || projectList[0];

// 현재: 간단한 숫자 매칭
const projectPkFromUrl = parseInt(projectId, 10);
if (!isNaN(projectPkFromUrl)) {
  currentProj = projectList.find((p) => p.projectPk === projectPkFromUrl) || null;
}

빈 프로젝트 자동 채널 생성 시스템

문제 상황

프로젝트를 새로 만들면 채널이 없어서 빈 화면이 나오고, 어떤 이유로 다른 프로젝트로 자동 이동하는 문제가 있었다.

plain
🔍 [API 로그]
프로젝트 5: 채널 목록 [] (비어있음)
프로젝트 4: 채널 목록 [8개] (갑자기 4번으로 이동함)

해결방법

자동 "general" 채널 생성 로직 추가:

typescript
// useChannels.ts
const loadChannels = useCallback(async (serverUrl: string, projectPk: number) => {
  dispatch(setLoading(true));
  try {
    let channelList = await getChannelList(serverUrl, projectPk);

    // 채널이 없으면 자동으로 "general" 채널 생성
    if (!channelList || channelList.length === 0) {
      console.log("📋 채널이 없어서 기본 'general' 채널 생성 중...");

      const newChannel = await createChannel(serverUrl, projectPk, {
        channelKind: "text",
        isPrivate: false,
        channelRole: "member",
      });

      console.log("✅ 기본 채널 생성 완료:", newChannel);
      channelList = [newChannel];
    }

    dispatch(setChannels({
      channels: channelList,
      projectPk,
      serverUrl,
    }));

    return channelList;
  } catch (error) {
    console.error("❌ 채널 목록 로드 실패:", error);
    throw error;
  }
}, [dispatch, getChannelList, createChannel]);

채널 추가 모달 구현

AddChannelModal.tsx

typescript
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!channelName.trim()) return;

  try {
    console.log("🚀 채널 생성 시작:", channelName);

    const newChannel = await createChannelMutation.mutateAsync({
      serverUrl: channelData.serverUrl,
      projectPk: channelData.projectPk,
      channelData: {
        channelName,
        channelKind,
        isPrivate,
        channelRole: "member",
      },
    });

    console.log("✅ 서버에서 채널 생성 성공:", newChannel);

    // Redux에 채널 추가
    addChannelToState(newChannel);

    // 성공 시 모달 닫기
    handleClose();

    // 새로 생성된 채널로 이동
    const encodedChannelName = encodeURIComponent(channelName);
    const pathParts = window.location.pathname.split("/");
    const serverId = pathParts[pathParts.findIndex(part => part === "projects") - 1];

    if (channelKind === "voice") {
      router.push(`/${serverId}/projects/${channelData.projectPk}/voice_channels/${encodedChannelName}`);
    } else {
      router.push(`/${serverId}/projects/${channelData.projectPk}/channels/${encodedChannelName}`);
    }

  } catch (error) {
    console.error("❌ 채널 생성 실패:", error);
  }
};

채널 타입별 UI

typescript
{/* 채널 타입 선택 */}
<div className="space-y-3">
  {(["notice", "text", "voice"] as const).map((type) => (
    <label key={type} className="flex items-start space-x-3 cursor-pointer group">
      <input
        type="radio"
        name="channelKind"
        value={type}
        checked={channelKind === type}
        onChange={(e) => setChannelKind(e.target.value as "text" | "voice" | "notice")}
        className="mt-1 text-blue-500 focus:ring-blue-500"
      />
      <div className="flex-1">
        <div className="flex items-center">
          <span className="mr-2">{getChannelIcon(type)}</span>
          <span className="font-medium text-white capitalize">
            {type === "text" ? "텍스트" : type === "voice" ? "음성" : "공지사항"}
          </span>
        </div>
        <p className="text-gray-400 text-sm mt-1">
          {getChannelTypeDescription(type)}
        </p>
      </div>
    </label>
  ))}
</div>

프로젝트 추가 기능 구현

AddProjectModal.tsx

typescript
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!projectName.trim()) return;

  try {
    const newProjectData = {
      projectName,
      projectDescription: projectDescription.trim() || undefined,
    };

    console.log("🚀 프로젝트 생성 시작:", newProjectData);

    const newProject = await createProjectMutation.mutateAsync({
      serverUrl: projectData.serverUrl,
      projectData: newProjectData,
    });

    console.log("✅ 서버에서 프로젝트 생성 성공:", newProject);

    // 성공 시 모달 닫기
    handleClose();

    // 현재 URL에서 serverId 추출
    const currentPath = window.location.pathname;
    const pathParts = currentPath.split("/");
    const serverIndex = pathParts.findIndex((part) => part === "projects") - 1;
    const serverId = pathParts[serverIndex];

    // 새 프로젝트의 기본 채널로 이동 (projectPk 사용)
    router.push(`/${serverId}/projects/${newProject.projectPk}/channels/general`);

    // 페이지 새로고침으로 프로젝트 목록 업데이트
    setTimeout(() => {
      window.location.reload();
    }, 100);

  } catch (error) {
    console.error("❌ 프로젝트 생성 실패:", error);
  }
};

Redux로 상태 관리 체계화

store.ts

typescript
import { configureStore } from '@reduxjs/toolkit';
import channelReducer from '../(servers)/store/channelSlice';

export const store = configureStore({
  reducer: {
    channels: channelReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

useChannels 커스텀 훅

typescript
export const useChannels = () => {
  const dispatch = useDispatch<AppDispatch>();
  const channelState = useSelector((state: RootState) => state.channels);
  const { getChannelList, createChannel } = useServerApi();

  // 채널 목록 로드
  const loadChannels = useCallback(async (serverUrl: string, projectPk: number) => {
    // ... 로딩 로직
  }, [dispatch, getChannelList, createChannel]);

  // 새 채널 추가
  const addChannelToState = useCallback((channel: Channel) => {
    dispatch(addChannelAction(channel));
  }, [dispatch]);

  // 채널 타입별 필터링
  const textChannels = useMemo(() =>
    channelState.channels.filter(channel => channel.channelKind === 'text'),
    [channelState.channels]
  );

  const voiceChannels = useMemo(() =>
    channelState.channels.filter(channel => channel.channelKind === 'voice'),
    [channelState.channels]
  );

  const noticeChannels = useMemo(() =>
    channelState.channels.filter(channel => channel.channelKind === 'notice'),
    [channelState.channels]
  );

  return {
    channels: channelState.channels,
    loading: channelState.loading,
    error: channelState.error,
    currentProjectPk: channelState.currentProjectPk,
    loadChannels,
    addChannelToState,
    textChannels,
    voiceChannels,
    noticeChannels,
  };
};

음성 채널과 텍스트 채널 경로 분리

문제 상황

채널 링크 수정 과정에서 음성 채널과 텍스트 채널을 구분하는 로직이 사라져서, 음성 채널을 클릭해도 텍스트 채널 페이지가 렌더링되는 문제가 발생했다.

해결방법

typescript
// createChannelLink 함수 개선
const createChannelLink = (channelName: string, isVoice: boolean = false) => {
  const encodedChannelName = encodeURIComponent(channelName);
  const channelType = isVoice ? "voice_channels" : "channels";
  return `/${serverId}/projects/${currentProject?.projectPk}/${channelType}/${encodedChannelName}`;
};

// 사용 시 채널 타입 구분
// 텍스트 채널
<Link href={createChannelLink(channel.channelName)}>

// 음성 채널
<Link href={createChannelLink(channel.channelName, true)}>

useEffect 무한 루프 지옥 해결

문제 원인

typescript
// 문제가 된 코드
useEffect(() => {
  if (serverInfo?.serverUrl && currentProject?.projectPk && isProjectSelected) {
    loadChannels(serverInfo.serverUrl, currentProject.projectPk);
  }
}, [
  currentProject?.projectPk,
  serverInfo?.serverUrl,
  isProjectSelected,
  loadChannels,  // 이 함수가 매번 새로 생성됨!
]);

해결방법: useCallback 의존성 배열 정리

typescript
// useChannels.ts에서 함수 메모화
const loadChannels = useCallback(async (serverUrl: string, projectPk: number) => {
  // ... 로직
}, [dispatch, getChannelList, createChannel]); // 안정적인 의존성만

// ProjectSidebar.tsx에서 사용
useEffect(() => {
  if (serverInfo?.serverUrl && projectId && isProjectSelected && currentProject?.projectPk) {
    loadChannels(serverInfo.serverUrl, currentProject.projectPk);
  }
}, [
  projectId, // URL에서 직접 가져온 값 변경 감지
  serverInfo?.serverUrl,
  isProjectSelected,
  currentProject?.projectPk,
  loadChannels, // 이제 안정적으로 메모화됨
]);

🎉 최종 결과

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

이제 사용자 경험이 완전히 바뀌었다:

  1. 채널 생성 → 실시간으로 목록 업데이트 (Redux)
  2. 한글 채널명 → URL 인코딩/디코딩으로 완벽 지원
  3. 프로젝트별 채널 분리 → 프로젝트마다 독립적인 채널 관리
  4. 빈 프로젝트 자동 처리 → "general" 채널 자동 생성
  5. URL 구조 개선projectPk 기반으로 안정적인 라우팅
  6. 음성/텍스트 채널 구분 → 올바른 페이지로 라우팅

Redux를 처음 도입하면서 상태 관리 패턴을 이해하는 데 시간이 좀 걸렸다. 특히 프로젝트별로 채널을 분리하는 로직을 구현할 때 어떤 데이터를 Redux에 저장할지 고민이 많았다. URL 인코딩 문제도 생각보다 복잡했는데, 한글과 특수문자가 URL에서 어떻게 처리되는지 다시 한번 공부하게 되었다.

이제 실시간 채팅, 음성 채널, 사용자 권한 관리 등 더 복잡한 기능들이 기다리고 있다...

태그

#aurora#Next.js