Home [Spring] 게시판 기능 설계
Post
Cancel

[Spring] 게시판 기능 설계

제가 참여하고 있는 프로젝트는 커뮤니티 기능이 핵심입니다. 그래서 그만큼 게시판이 중요한 기능이었어요. 이 글에는 게시판 기능을 구현하며 고민했던 경험과 제 나름대로의 해결 방법이 담겨있습니다.

게시판 기능

게시판은 사용자들이 게시물을 조회, 작성, 수정 및 삭제할 수 있는 기본적인 API뿐만 아니라, 댓글, 좋아요, 태그, 신고와 같은 다양한 부가 기능이 구현되어야 합니다. 커뮤니티의 기본적인 기능들입니다.

게시판은 크게 6가지로 나누어 운용하고 있습니다. 소식, 회의록, 학칙은 운영자만 글을 작성할 수 있지만, 모든 사람이 열람할 수 있습니다. 자유, 청원, VOC 게시판은 사용자도 사용할 수 있는 게시판입니다.

정리해본 게시판의 기능들은 아래와 같습니다.

모든 게시판(소식, 회의록, 학칙 + 자유, 청원 포함)

  • 글 CRUD (제목과 본문)
  • 파일을 첨부할 수 있다.
  • 댓글을 작성할 수 있다.
  • 글과 댓글에 좋아요를 누를 수 있다.
  • 글과 댓글은 삭제하면 삭제처리만 하고, 로그에 남습니다.
  • 신고할 수 있다.
  • 태그를 달 수 있다.
  • 관리자에 의한 글 Blind, 삭제
  • 내가 작성한 글, 댓글단 글, 좋아요한 글을 볼 수 있다.
  • 도배 방지 기능이 있다. (30초에 1회)
  • 조회수 기능이 있다. (IP로 구분, 30분당 1회 조회수)

회의록 게시판

  • 글에 회의록이 몇 차인지, 회의 일자가 포함됩니다.

자유 게시판

  • 작성자 이름이 닉네임으로 표시된다.

청원 게시판

  • 청원을 올리고, 동의를 받을 수 있다.
  • 작성자 이름에는 학과명만 표시된다.
  • 150개 이상의 동의를 받으면 총학생회의 답변을 받을 수 있다.
  • 90일이 지나면 만료된다. 만료된 글은 동의 및 답변을 할 수 없다.
  • 동의한 경우 학과별 통계에 누적된다. 학과별 동의 현황을 볼 수 있다.

이번에 특히 게시판 설계가 어려웠던 이유는, 이 6개의 게시판이 각각 특징을 지니고 있었기 때문입니다. 작성자 표시 방법, 글 작성 rule, 특정 부가 기능 사용 여부, 포함하고 있는 데이터 등 디테일한 부분이 모두 조금씩 달라서 설계하기 어려웠습니다. 아래에서는 제가 해결한 방법들을 소개합니다.

도메인 모델 설계

domain-model

먼저 게시판과 관련된 도메인 모델을 설계해봤습니다. 위의 그림은 게시판과 관련된 모델만 표현한 것입니다. 이외에도 User와 연관된 Lecture, Major 등이 있을 수 있습니다.

게시판

Tag, Comment, Like등 연관된 모델들이 Post와 연관관계를 맺습니다. Announce, Conference 등 게시판 모델은 제목, 본문 등과같이 모두 공통된 속성을 가지고 있습니다. 그래서 Post를 상속받는 형태로 표현했습니다.

실제로 제가 구현한 방식은 Post라는 추상 Entity를 만들고, 이를 상속받는 Announce, Conference, Free, Petition, Voc Entity를 만들었습니다. 이렇게 하면 게시판 모델들이 공통된 속성을 가지고 있으면서도, 각각의 특징을 가질 수 있습니다. 아직 도메인 모델 설계이기 때문에 구체적인 구현 방법은 명시하지 않습니다. 그래서 제가 실제 구현한 방식과 달리 하나의 Entity로 표현할 수도 있습니다.

댓글

댓글을 수정하면 Comment Log에 기록합니다. Comment Log는 댓글의 수정 내역을 저장하는 모델입니다. 댓글을 수정하면 Comment Log에 내용이 저장되고, Comment는 바로 update됩니다. 이렇게 하면 댓글을 수정하더라도 운영자는 이전 댓글의 내용을 볼 수 있습니다. 다만 댓글을 삭제한 경우에는 Comment Log에는 삭제된 댓글의 내용이 남아있지만, 삭제 여부 자체가 기록되지는 않습니다.

좋아요

LikeElement는 좋아요를 표현하는 모델입니다. 사용자가 하나의 게시글에 두 번이상 좋아요를 누를 수 없습니다. 그래서 따로 사용자별 좋아요 누른 게시글을 매핑하는 모델이 필요했고, LikeElement가 그 역할을 수행합니다.

태그

여기서 말하는 태그는 다른 커뮤니티에서 주로 사용하는 태그의 의미와는 약간 다릅니다. 이 프로젝트에서 사용하는 태그는 카테고리 성격이 강합니다. 운영자가 미리 태그 목록을 지정합니다. 예를 들어 ‘학교 생활’, ‘시험’, ‘문의’와 같은 태그들이 있을 수 있습니다. 그리고 사용자는 이중 원하는 태그들을 선택해 게시글에 지정할 수 있습니다. 태그는 게시글을 분류하는데 사용됩니다. 예를 들어, ‘학교’ 태그가 달린 게시글은 학교 관련 게시글이라는 것을 알 수 있습니다.

그래서 Tag라는 모델은 운영자가 미리 지정한 태그 목록이고, PostTag라는 모델은 게시글에 달린 태그 목록입니다.

파일

File은 게시글에 포함된 파일(첨부파일)을 의미합니다. 게시글에 포함된 파일은 게시글이 삭제되면 함께 삭제됩니다. 그래서 File은 Post와 OneToMany 관계를 맺습니다. 이 File은 파일이 어떤 위치에 저장되어있는지 링크만 저장합니다. 마찬가지로 파일 저장 구현 방식은 추상적으로 표현했습니다. AWS를 사용한다면 S3가 될 수도, 로컬 파일 시스템을 사용한다면 실제 File이 될 수도 있습니다.

청원 게시판 통계

특정 청원 글에 동의를 받으면, 학과별 통계에 누적됩니다. 학과별 통계는 청원글 마다 동의한 학과 학생의 수를 저장하는 모델입니다. 실제 표시할 때는 상위 4개 학과까지만 표시하고, 나머지는 ‘기타’로 표시합니다. 그래서 User와 Post사이의 Mapping 모델로 존재합니다.

ERD 설계

erd

위의 도메인 모델을 바탕으로 ERD를 설계했습니다. ERD 설계는 도메인 모델 설계와 비슷한 방식으로 진행했습니다. 도메인 모델 설계에서는 추상적으로 표현했던 부분을 구체적으로 표현했습니다.

그림이 조금 복잡해서 보기 힘들지만… 연관 관계는 위의 도메인 모델과 동일합니다. 모든 Entity는 createdAt, updatedAt을 가지고 있지만 그게 중요한 것은 아니어서 위의 ERD에서는 포함하지 않았습니다.

몇 가지 주목할만한 부분을 설명하겠습니다.

게시판 상속 관계

ERD에서는 마치 News, Conference 등.. 게시판들이 모두 독립적인 Entity로 존재하는 것 처럼 표현했습니다. 하지만 실제로는 SINGLE_TABLE 전략으로 Post라는 추상 Entity를 상속받는 형태로 구현했습니다. 그래서 각종 field들은 실제 하나의 Post 테이블에 포함됩니다. SINGLE_TABLE 전략을 택한 이유는 아래와 같습니다.

  • JOINED 전략을 사용하면 테이블이 너무 많아집니다.
  • 그리고 테이블을 JOIN해야하는 경우가 많아집니다. 이 프로젝트에서는 Post 전체에 걸쳐 검색하는 로직이 종종 있습니다. 이런 경우 JOINED 전략을 사용하면 매번 모든 테이블을 탐색해야합니다.
  • SINGLE_TABLE 전략을 사용하면 테이블이 하나이기 때문에 JOIN이 필요없습니다. 그래서 검색 속도가 빠릅니다.
  • Post아래 각 테이블마다 독립적인 field가 거의 없습니다. 즉 SINGLE_TABLE로 통합해도 낭비가 심하지 않습니다.

그래서 SINGLE_TABLE전략을 사용했고, 이런 경우가 아니라면 대부분의 경우에는 JOINED 전략이 더 나을 것 같습니다.

status

User, Post에는 status가 포함되어있습니다. User와 Post는 삭제하더라도 실제로 DB에서 제거되지 않습니다. 대신 status를 DELETED로 변경합니다. 이렇게 하면 운영자가 삭제된 게시글/유저를 볼 수 있습니다. 그래서 게시글이나 사용자를 조회할 때 주의해야합니다. User와 Post의 status가 DELETED가 아닌 경우에만 조회할 수 있게 해야합니다.

Data JPA에서는 findAll, findById와 같은 조회 추상 메서드들을 제공합니다. 이런 메서드들은 status가 DELETED인 경우에도 조회할 수 있습니다. 그래서 직접 JPQL을 작성해서 status가 DELETED가 아닌 경우만 조회할 수 있게 해야합니다. findAll이나 findById를 상속받아 JPQL을 커스텀할 수 있습니다. 저는 직접 JPQL을 작성했지만, 동적 쿼리가 필요한 경우에는 QueryDSL등을 사용하면 좋을 것 같습니다.

Post body lob

Post의 body는 대용량 데이터입니다. 그래서 longtext로 필드 타입을 설정했습니다. LongText는 mysql 기준으로 최대 4GB까지 데이터를 담을 수 있습니다. 지금은 본문의 데이터량이 별로 없어서 Post에 함께 본문을 저장하지만, 본문을 별도의 테이블에 저장하고, Post에는 본문의 링크만 저장하는 방식으로 변경할 수 있습니다. 본문이 훨씬 더 방대해지면 sharding등을 통해서 본문의 내용만 다른 DB에 저장하는 방식으로 사용할 수도 있을 것 같습니다. 아직까지는 그 정도는 아니어서 Post에 함께 본문을 저장했습니다.

Category는 Enum타입? 아니면 Entity?

Enum 타입으로 관리하면 장단점이 있습니다.

장점

  • Code단에서 사용 가능한 데이터 값을 제한하고 검증할 수 있다. 이 장점이 가장 크다고 생각합니다. Compile 직전에 알아차릴 수 있어서 개발자의 실수를 줄여줍니다.
  • DB Space를 덜 차지한다. 물론 데이터의 개수가 작으면 큰 의미는 없을 것 같네요.
  • JOIN없이 사용할 수 있으므로 속도가 비교적 빠르다.

단점

  • 변경에 다소 취약합니다. 테이블로 관리하면 그냥 추가/삭제/업데이트가 가능하지만, Enum으로 구성하면 코드를 수정해야합니다. 이건 꽤 큰 문제인데요. 보통 코드수정은 CI/CD와도 관련되어있어서 DB수정에 비해서 과정이 복잡해질 수 있습니다.
  • 유연성이 떨어집니다. 테이블로 관리하면 DB상에서 여러 데이터를 묶고 Entity처럼 관리할 수 있지만, Enum은 JPA상에서 그냥 단일값 취급입니다.
  • 변경이 거의 없고, 비교적 소규모인 데이터에 대해서 Enum이 유용할 것 같습니다.

저는 Category를 Entity로 관리하도록 했습니다. 지금은 Tag로 약간 의미는 변질되었지만… 당시 이 프로젝트에서는 Category는 변경이 잦았기 때문에 그렇게 판단했습니다. Enum으로 관리할 경우에는 코드를 수정해야하는 번거로움이 있었습니다. 이 프로젝트에서는 운영자가 Category를 쉽게 변경할 수 있어야 했습니다.

아키텍처

post-architecture

FileUploadService, LikeService 외에 Redis 구현체, NHN File Service등 여러 서비스 들이 더 있었지만, 여기서는 최대한 큰 그림만 간단하게 그려보았습니다.

이 설계에는 몇 가지 포인트가 있습니다.

의존성의 방향

clean-architecture

최대한 위쪽 방향(Repository)으로 의존성이 흘러가도록 설계했습니다. Clean Architecture(계층형 아키텍처)에서는 바깥쪽에서 안쪽으로 의존성이 흘러야 합니다. 아래는 Robert C. Martin이 쓴 Clean Architecture에 나오는 내용입니다.

The Dependency Rule: 소스 코드의 의존성은 반드시 안쪽으로, 고수준의 정책을 향해야 한다. 내부의 원에 속한 요소는 외부의 원에 속한 어떤 것도 알지 못한다.

이건 예전에 Android를 공부하고 Clean Architecture를 접해보면서 공부했던 내용입니다. 그래서 위의 그림에서는 Usecase나 외부 interface와 같은 용어가 적혀있네요. Spring에 적용해보면 Controller, Service, Repository, Entity로 생각할 수 있습니다.

이렇게 계층별로 구분하고, 한 방향으로 흐르게하면 이런 장점들이 있습니다.

  • 의존성이 느슨해집니다. 계층별로 구분하고, 한 방향으로 흐르게 하면, 한 계층의 변경이 다른 계층에 영향을 미치지 않습니다. 예를 들어 Repository 계층을 수정하더라도 Entity 계층에서는 영향을 받지 않습니다.
  • 테스트하기가 쉽습니다. Repository 계층을 테스트하려면 DB가 필요하지만, Entity 계층은 DB가 필요하지 않습니다. 이렇게 계층별로 구분하면 테스트하기가 쉬워집니다.

특히 테스트하기 쉽다는 장점은 매우 유용합니다. Repository나 Service만 계층별로 Unit test하기 좋습니다.

위의 그림에서는 모델 객체(Entity)를 다른 계층에서도 사용할 수 있는 것 처럼 표현했습니다. 의존성의 방향만 맞다면, 그렇게해도 문제는 없겠지만 Entity외에 DTO로 빼는 편이 좋아보입니다. 왜냐하면 Entity는 내부에서만 사용하는 객체이기 때문입니다. 외부에 Entity가 노출되면 API가 변경에 취약할 수 있다는 문제와 내부 매커니즘이 외부에 공개될 수 있다는 문제가 있습니다. 그래서 DTO를 도입해 이런 문제를 최대한 막을 수 있습니다.

DTO는 계층별로 만들어 사용할 수도 있긴 합니다. 이렇게 하면 의존성은 더 낮아지겠지만, 훨씬 더 많은 Mapper가 필요하고 결과적으로 유지보수가 어려울 것이라고 생각을 했습니다. 그래서 DTO는 Service와 Controller 계층에서 함께 사용하도록 했습니다.

상속 관계 배제

전반적으로 상속 관계를 배제하고 구현했습니다. 상속은 코드의 재사용성을 높이지만, 결합도가 높아지고, 불필요한 기능도 포함해야합니다. 그리고 생성자도 상속받아 super()를 호출해야하므로 DI받을 때 오히려 중복된 코드가 많아질 수도 있습니다. 사실 이전에는 주로 상속을 사용하는 방식으로 설계했었습니다. 이렇게 하니까 GenericPostService를 초기화하기 위해서 DI를 사용할 수 없고, 직접 생성자를 초기화해야 했습니다. 꽤 많은 의존이 있는데 그걸 모두 DI로 주입받아 생성자로 넘겨야 했습니다. 결국 오히려 코드가 더 복잡해졌습니다. 그래서 PostService에서는 상속을 최대한 배제하고 구현했습니다.

하지만 Repository는 상속으로 관계를 맺어주었습니다. 왜냐하면 자동 구현때문입니다. CRUDRepository를 상속받은 interface를 생성하면 data jpa가 자동으로 구현체를 넣어줍니다. 그래서 repository 인터페이스 내부에 필드를 선언할 수 없습니다. 그리고 인터페이스끼리는 상속을 사용해도 상위 클래스의 생성자를 재정의하지 않아도 됩니다. 그래서 상속을 사용했습니다.

결국 공통된 기능들을 묶어 재사용성을 높이기 위해 위와 같이 설계했습니다. 여기서 상속은 코드를 좀 더 복잡하게 할 여지가 있어서 Service에서는 최대한 배제했습니다.

공통 포스트 서비스

게시판마다 공통적으로 사용하는 기능들을 GenericPostService에 구현했습니다. 이렇게 하면 모든 게시판이 공통적으로 사용하는 기능들을 재사용할 수 있습니다. 예를 들어서 게시판마다 공통적으로 사용하는 기능들은 다음과 같습니다.

  • 게시글 목록 조회 (DTO 변환, 페이징 처리, 삭제된 글 필터링)
  • 게시글 상세 조회 (조회수 추가, 삭제된 글 필터링)
  • 게시글 작성 (파일 첨부, 이미지 썸네일 생성)
  • 게시글 수정
  • 게시글 삭제

특히 조회의 경우에는 다양한 다른 Post Service에서 사용하므로 DTO를 다르게 mapping해주어야 합니다. 이건 내부적으로 mapper functional interface를 사용해서 해결했습니다. DTO들도 모두 결국 상속받아 사용하기 때문에 이런 설계가 가능합니다.

서비스 모듈

서비스들을 최대한 모듈화해서 재사용성을 높였습니다. 예를 들어 FileUploadService, ThumbnailService, LikeService와 같이 기능들을 모듈화해서 서비스로 만들었습니다.

이 프로젝트에서는 게시판마다 기능 지원이 조금씩 달랐습니다. 서비스를 모듈화해서 제공하면 기능을 재사용하기가 쉬워집니다. 예를 들어 자유게시판에 댓글 기능을 넣고 싶다면, CommentService를 사용해 쉽게 확장할 수 있습니다. 그리고 게시판마다 파일 업로드를 지원할 수도 있고, 지원하지 않을 수도 있습니다. 파일 업로드를 지원하는 게시판은 서비스에 FileUploadService를 주입받아 사용하면 됩니다. 이 외에도 좋아요나 청원 게시판의 경우 통계 처리 기능이 필요한데, 쉽게 확장할 수 있습니다.

이렇게 마치 블록 조립하듯 원하는 기능들을 원하는 게시판에서 주입받아 사용할 수 있도록 구성했습니다.

결론

Post 기능을 구현하며 겪은 문제들과 해결한 방법을 정리해봤습니다. 결국 이 모든 설계는 유지보수를 더 쉽게 하기 위한 방향으로 흘러갑니다. 제가 구상했던 초기 Post 아키텍처에 비해 많이 개선되어 뿌듯합니다. 하지만 아직까지도 아쉬운점이 몇 가지 있습니다.

  • GenericPostService가 너무 거대합니다. 이 부분을 좀 더 분리할 수 있으면 좋겠습니다.
  • GenericPostService이 너무 많은 의존성을 가지고 있습니다.

앞으로 게시판 기능은 계속 개선해나갈 생각입니다.

참고

-『Clean Architecture』, Robert C. Martin, 인사이트(2019)

This post is licensed under CC BY 4.0 by the author.

[CS] Network - OSI, TCP/IP 모델

[Spring] 버스 도착정보 수집