게시판 하나 만드는 건 쉽다. boards, articles, comments 테이블 3개면 된다.

근데 프로덕션에 올리면 터진다. 좋아요가 꼬이고, 조회수가 밀리고, 대댓글 구조가 깨지고, 게시판 추가할 때마다 배포해야 한다.

이 글은 커뮤니티 DB 설계에서 자주 반복되는 5가지 실수와, 실무에서 실제로 쓰는 구조를 정리한다.


먼저, 기본 구조

올바른 커뮤니티 스키마의 뼈대:

users
  id, nickname, email

boards
  id, code, name, is_active

articles
  id, board_id (FK), user_id (FK), title, body, view_count

comments
  id, article_id (FK), user_id (FK), parent_id (FK, self), body

reactions
  id, user_id (FK), reactable_type, reactable_id, type
  UNIQUE(user_id, reactable_type, reactable_id)

이게 끝이다. 테이블 5개. 여기서 시작해서 실수 하나씩 짚어보자.


실수 1: 좋아요를 숫자 컬럼으로 저장

// ❌ articles 테이블에 like_count 하나만
Schema::create('articles', function (Blueprint $table) {
    $table->id();
    $table->unsignedInteger('like_count')->default(0);
    // ...
});

// 좋아요 누를 때
Article::find($id)->increment('like_count');

3가지가 동시에 터진다:

  1. 한 유저가 100번 누를 수 있다 — 누가 눌렀는지 모르니 중복 방지 불가
  2. 좋아요 취소가 안 된다 — 누른 사람을 모르니 되돌릴 수 없다
  3. 동시 요청 시 카운트가 꼬인다 — 10명이 동시에 누르면 race condition

올바른 구조

// ✅ reactions 테이블 분리
Schema::create('reactions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->morphs('reactable'); // reactable_type + reactable_id
    $table->string('type')->default('like');
    $table->timestamps();

    $table->unique(['user_id', 'reactable_type', 'reactable_id']);
});
// Eloquent에서 사용
class Article extends Model
{
    public function reactions(): MorphMany
    {
        return $this->morphMany(Reaction::class, 'reactable');
    }

    public function likeCount(): int
    {
        return $this->reactions()->where('type', 'like')->count();
    }
}

UNIQUE 제약이 중복을 DB 레벨에서 막는다. 애플리케이션 코드가 아니라 DB가 보장하니까 race condition에도 안전하다.

“그러면 like_count 컬럼은 아예 없애나요?”

아니다. 목록 조회 시 매번 COUNT를 치면 느리다. like_count를 캐시 컬럼으로 유지하되, 원천 데이터는 reactions 테이블이다:

// 좋아요 토글 시 캐시 컬럼 동기화
$article->reactions()->create([...]);
$article->update(['like_count' => $article->reactions()->count()]);

실수 2: 대댓글을 별도 테이블로 만들기

// ❌ 깊이마다 테이블 추가
Schema::create('comments', ...);
Schema::create('replies', ...);       // 대댓글
Schema::create('reply_replies', ...); // 대대댓글??

3단 댓글이면 테이블 3개, 5단이면 5개. 쿼리도 JOIN이 깊이만큼 늘어난다. 구조적으로 확장 불가.

올바른 구조: 자기참조 FK

// ✅ parent_id로 자기참조
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('article_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('parent_id')->nullable()->constrained('comments');
    $table->text('body');
    $table->timestamps();
});
class Comment extends Model
{
    // 이 댓글의 대댓글들
    public function children(): HasMany
    {
        return $this->hasMany(Comment::class, 'parent_id');
    }

    // 이 댓글의 부모
    public function parent(): BelongsTo
    {
        return $this->belongsTo(Comment::class, 'parent_id');
    }

    // 최상위 댓글인가?
    public function isRoot(): bool
    {
        return is_null($this->parent_id);
    }
}

parent_idNULL이면 최상위 댓글, 값이 있으면 대댓글. 테이블 하나로 무한 깊이를 처리한다.

실무 팁: 대부분의 커뮤니티는 2단(댓글 → 대댓글)까지만 허용한다. UI가 복잡해지기 때문이다. 그래도 스키마는 자기참조로 만들어두고, 깊이 제한은 애플리케이션 레벨에서 건다:

// 대댓글까지만 허용 (depth = 1)
if ($parentComment->parent_id !== null) {
    abort(422, '대댓글에는 답글을 달 수 없습니다.');
}

실수 3: 게시판을 코드에 하드코딩

// ❌ 게시판마다 라우트 등록
Route::get('/free', [BoardController::class, 'free']);
Route::get('/humor', [BoardController::class, 'humor']);
Route::get('/car', [BoardController::class, 'car']);
// 게시판 추가 = 코드 수정 + 배포

게시판이 3개면 괜찮다. 24개면? 게시판 하나 추가할 때마다 코드 수정하고 배포해야 한다. 운영자가 게시판을 직접 만들 수도 없다.

올바른 구조: DB 기반 동적 라우트

// boards 마이그레이션
Schema::create('boards', function (Blueprint $table) {
    $table->id();
    $table->string('code')->unique();  // URL용: "free", "humor"
    $table->string('name');            // 표시용: "자유게시판", "유머"
    $table->text('description')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});
// ✅ 라우트 하나로 전체 처리
Route::get('/boards/{boardCode}', [ArticleController::class, 'index']);
Route::get('/boards/{boardCode}/{article}', [ArticleController::class, 'show']);
// 컨트롤러
class ArticleController extends Controller
{
    public function index(string $boardCode)
    {
        $board = Board::where('code', $boardCode)
                      ->where('is_active', true)
                      ->firstOrFail();

        $articles = $board->articles()
                          ->latest()
                          ->paginate(20);

        return view('board.index', compact('board', 'articles'));
    }
}

게시판 추가는 DB에 한 줄 INSERT면 끝이다. 배포 필요 없다.


실수 4: 조회수를 매 요청마다 UPDATE

// ❌ 글 조회할 때마다 DB 직접 UPDATE
public function show(Article $article)
{
    $article->increment('view_count');
    return view('article.show', compact('article'));
}

1,000명이 동시에 같은 글을 보면 1,000번의 UPDATE 쿼리가 날아간다. 행(row) 단위 쓰기 잠금이 걸리면서 응답이 밀린다. 게시글 하나 때문에 DB 전체가 느려질 수 있다.

올바른 구조: Redis 버퍼 + 배치 동기화

// ✅ 조회 시 Redis에 누적
public function show(Article $article)
{
    Redis::hincrby('article_views', $article->id, 1);
    return view('article.show', compact('article'));
}
// ✅ 스케줄러로 5분마다 DB 동기화
// app/Console/Kernel.php
$schedule->call(function () {
    $views = Redis::hgetall('article_views');

    foreach ($views as $articleId => $count) {
        Article::where('id', $articleId)
               ->increment('view_count', (int) $count);
    }

    Redis::del('article_views');
})->everyFiveMinutes();

Redis HINCRBY는 메모리 연산이라 동시 1,000건이 와도 문제없다. DB에는 5분에 한 번만 쓴다.

트레이드오프: 조회수가 최대 5분 지연된다. 대부분의 커뮤니티에서 이건 문제가 안 된다 — 조회수가 실시간이어야 하는 서비스는 거의 없다.


실수 5: FK가 있는 데이터를 hard delete

// ❌ 유저 탈퇴 시 hard delete
User::find($id)->delete();
// → articles.user_id FK 제약 위반 → 에러
// 또는 CASCADE 설정 → 유저의 모든 글과 댓글이 삭제됨

유저가 탈퇴했다고 글이 사라지면 안 된다. 다른 사람이 단 댓글, 받은 좋아요 — 전부 연쇄 삭제된다.

올바른 구조: Soft Delete

// 마이그레이션에 softDeletes 추가
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('nickname');
    $table->softDeletes(); // deleted_at 컬럼
    $table->timestamps();
});
// 모델에 SoftDeletes 트레이트
class User extends Model
{
    use SoftDeletes;
}

// 탈퇴 처리
User::find($id)->delete();
// → deleted_at = 2026-04-13 14:30:00
// → 글, 댓글 전부 그대로 유지
// 뷰에서 탈퇴 유저 처리
{{ $article->user?->nickname ?? '탈퇴한 사용자' }}

모든 테이블에 soft delete가 필요한 건 아니다. 판단 기준:

테이블삭제 전략이유
userssoft delete글, 댓글이 참조 중
articlessoft delete댓글, 좋아요가 참조 중
commentssoft delete대댓글이 참조 가능
reactionshard delete다른 테이블이 참조 안 함
boardssoft delete게시판 글이 참조 중

“이 row가 사라지면 다른 row가 깨지는가?” — 깨지면 soft delete, 안 깨지면 hard delete.


보너스: 규모가 커지면 뭘 바꾸나

유저 10만 명, 하루 글 5,000건이 되면:

1. 인덱스 전략

// 게시판별 최신글 목록 — 가장 빈번한 쿼리
$table->index(['board_id', 'created_at']); // 복합 인덱스

WHERE board_id = ? ORDER BY created_at DESC — 이 쿼리를 인덱스만으로 해결한다.

2. 인기글 정렬

// ❌ 매 요청마다 계산
Article::withCount('reactions')->orderByDesc('reactions_count');
// → 10만 건 JOIN + COUNT + ORDER = 느림
// ✅ 스코어링 컬럼 + 스케줄러
$table->decimal('popularity_score')->default(0)->index();

// 1시간마다 계산
$schedule->call(function () {
    Article::where('created_at', '>', now()->subDays(7))
        ->each(function ($article) {
            $article->update([
                'popularity_score' =>
                    $article->like_count * 3
                    + $article->comments()->count() * 2
                    + log1p($article->view_count)
            ]);
        });
})->hourly();

사전 계산된 스코어로 정렬하면 ORDER BY popularity_score DESC가 인덱스를 타서 빠르다.

3. N+1 방지

// ❌ 글 목록에서 작성자, 댓글수, 좋아요수를 각각 조회
$articles = Article::all();
// 뷰에서 $article->user->name → N+1

// ✅ eager loading으로 한 번에
$articles = Article::with(['user', 'board'])
    ->withCount(['comments', 'reactions'])
    ->where('board_id', $board->id)
    ->latest()
    ->paginate(20);

정리

실수올바른 구조
좋아요를 숫자 컬럼에reactions 테이블 분리 + UNIQUE 제약
대댓글을 별도 테이블로자기참조 parent_id
게시판을 코드에 하드코딩boards 테이블 + 동적 라우트
조회수를 매번 UPDATERedis 버퍼 + 배치 동기화
FK 있는 데이터를 hard deletesoft delete + deleted_at

5가지 다 “처음에는 됐는데 커지니까 터졌다” 유형이다. 처음부터 이렇게 설계하면 나중에 뜯어고칠 일이 없다.