프로젝트, 채널 관리 - 추가 및 목록 조회
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에서 채널을 전역으로만 관리하고 프로젝트별 구분이 없었음
해결방법: 채널 상태에 프로젝트 정보 추가
typescriptinterface 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
typescriptconst 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
typescriptconst 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
typescriptimport { 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 커스텀 훅
typescriptexport 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
이제 사용자 경험이 완전히 바뀌었다:
- 채널 생성 → 실시간으로 목록 업데이트 (Redux)
- 한글 채널명 → URL 인코딩/디코딩으로 완벽 지원
- 프로젝트별 채널 분리 → 프로젝트마다 독립적인 채널 관리
- 빈 프로젝트 자동 처리 → "general" 채널 자동 생성
- URL 구조 개선 →
projectPk기반으로 안정적인 라우팅 - 음성/텍스트 채널 구분 → 올바른 페이지로 라우팅
Redux를 처음 도입하면서 상태 관리 패턴을 이해하는 데 시간이 좀 걸렸다. 특히 프로젝트별로 채널을 분리하는 로직을 구현할 때 어떤 데이터를 Redux에 저장할지 고민이 많았다. URL 인코딩 문제도 생각보다 복잡했는데, 한글과 특수문자가 URL에서 어떻게 처리되는지 다시 한번 공부하게 되었다.
이제 실시간 채팅, 음성 채널, 사용자 권한 관리 등 더 복잡한 기능들이 기다리고 있다...
