현재 프론트엔드, 백엔드 팀원들과 함께 SPrint라는 게시판 형식의 스터디, 프로젝트 구인 서비스를 미니프로젝트로 진행하고 있다. https://github.com/mottoslo/SPrint-Server  처음 해보는 협업이기 때문에 기획, 설계부터 시작해서 배포까지 처음 겪는 자잘한 문제들이 많아 재밌게 하고있다. 그런 부분들에 대해서는 회고 글로 따로 정리하도록 하고, 이 글에는 내가 구현을 맡은 부분에서 배운 점을 정리하려고 한다.

 

나는 가장 로직이 많을 것이라 예상되는 SprintService 부분의 api를 담당하였다.

스코프가 크지 않아 API 기능 짜는 것은 어렵지 않았는데, 기존에 해봤던 간단한 게시판 CRUD보다는 다룰 부분이 조금 더 많아져서 DB를 어떻게 구성할 것인지에 대한 고민 후에 시작했다.

 

우리 프로젝트에서는 게시글을 하나의 Sprint라고 부르기로 했다.

예시

하나의 Sprint를 보여줄 때는 게시글과 타입(스터디,프로젝트), 좋아요 개수, 모집 포지션을 보여줘야한다.

그런데, 모집 글을 작성할 때 포집 포지션의 이름과 개수, 제한인원을 유저가 자유롭게 설정할 수 있도록 기획하다 보니 Sprint entity에서 모집 필드에 대한 값을 고정적으로 가지고 있을 수 없게 되었다.

따라서, Sprint의 id와 게시글, content 등 고정적으로 들어오는 필드들은 Sprint entity에, 유동적으로 변할 수 있는 필드들은 SprintFieldEntry에 저장하자는 결론에 이르렀다.

설계는 잘 한 것 같은데, 연관관계에 대한 고민이 있었다. 기존 게시판을 만들 때 댓글에 @OneToMany연관관계를 사용해 보았는데, 지연로딩을 사용하면 글을 여러 개 조회할 시 getComment시마다 쿼리가 추가로 나가게 되었고, 즉시로딩을 사용할 시 Comment가 필요하지 않을 때에도 Comment를 가져오게 되어 좋은 설계라고 생각이 들지 않았다.

지연로딩으로 설정 해 두고, 필요할 시만 fetchjoin을 사용하면 된다는 해결책은 검색을 통해 알았으나, "fetchjoin을 사용할거면 어차피 쿼리를 작성한다는 것인데, 그러면 연관관계를 굳이 설정해주지 않아도 쿼리를 통해 해결할 수 있다" 라고 생각했었다. 따라서, 이번 프로젝트에는 FieldEntry가 단방향 @ManyToOne으로 Sprint를 바라보게 한 뒤, Sprint를 조회할 때 FieldEntry를 sprintId로 조회해 필드들을 가져오고, 서비스 로직으로 이들을 묶어주는 방식으로 구현을 하였다.

 

구현은 금방 하였는데, 막상 이런식으로 구현하니 코드의 가독성도 떨어지고 불필요한 작업이 많이 필요했다.

getAllSprint()나 getMySprint() 등 여러개의 sprint를 반환해야 하는 경우, 필요한 Sprint와 SprintField를 모두 가져온 후, SprintFieldList를 보면서 Sprint 별로 나눠 매핑해준 뒤, 이를 다시 responseDto로 변환하여 리스트에 담아야하는 번거로움이 있었다. 불필요한 내부 메서드가 많아지고, 이름을 아무리 상세하게 적어도 나만 알 수 있는 메서드가 되는 것 같았다.

 

만약 Sprint가 @OneToMany로 Entry를 가지고 있었다면, fetchjoin으로 필요한 정보를 가져옴과 동시에 Sprint객체에 Fields가 세팅되기 때문에 피할 수 있는 번거로움이라는 생각이 들었다. 쿼리를 어차피 작성해야 한다면, 더구나 Sprint와 Field같이 함께 사용될 가능성이 매우 높다면, 연관관계를 맺어주고 fetchjoin을 활용한 쿼리를 작성하여 사용하는게 훨씬 효율적이고, 가독성도 향상될 것 같다고 생각했다.

 

*@Query에서 fetchjoin은 join으로 가져온 객체 세팅이 필요할 때, join은 join한 테이블의 조건으로 검색만 하고 해당 컬럼들을 가져올 필요는 없을 때 사용한다.

 

추가로, @OneToMany를 지양하라는 의견이 많은 이유는 단방향 @OneToMany를 사용 시 외래키의 주인이 아닌 쪽에서 변경이 일어나 추가적인 update문이 나가기 때문인 것으로 보인다. 내가 @OneToMany에 대해 막연하게 안좋다고 생각한 이유(즉시로딩, 지연로딩에 관해 고민해야하는 번거로움)로 지양해야 하는 것은 아닌 것 같았다.

따라서 Sprint와 FieldEntry에 연관관계를 맺어주고, fetchjoin 쿼리를 사용하여 코드를 리팩토링 하기로 결정했다.

 

약간의 삽질이 있었지만, 연관관계에 대한 막연한 거부감을 덜어낼 수 있는 계기가 되었던 것 같아 감사하다.

 

그동안 배운 것들을 적용해 기존 만들었던 CRUD 게시판 처음부터 다시 만들어보기

 

<변경점>

1.Spring Security 적용 (JWT방식 커스텀 필터 사용)

2.Controller에 @Valid 어노테이션 적용 ( 간단해서 보류 )

2.5 기존 Article, Comment 등록/수정/삭제 기능 재구현 (추가)

3.JPA 연관관계 변경 (양방향 -> 단방향)

4.Custom Exception 처리하기

5.ERD, API 작성

 

<패키지 구조>

현재 있는 기능 : 회원기능 / 게시글 / 댓글(대댓글)

Controller // Service // Repository // Exception // Security // Handler // Jwt관련

                                                          =>묶어서 Utils

 

 

<게시글 관련 기능 구현>

 

Controller

게시글 전체목록 조회시에는 게시판의 형태대로 제목, 작성자만 가져오게 했다.

게시글 상세 조회(게시물클릭) 시에는 게시글의 필드를 모두 가져오게 했다.

둘을 구분짓기 위해 ResponseDto를 ListResponseDto // DetailResponseDto로 나누어 받게 함

 

Service

Controller에 맞게 구현했다. Article 조회 시 Comment를 어떻게 가져올지 아직 정하지 못했기 때문에, ArticleDetailResponseDto 작성을 보류했다. Comment에 대댓글 기능을 추가할 생각이기 때문에, 게시글에 달린 댓글을 모두 긁어와서 Article에 List로 나갈 댓글 목록을 만들어줄 생각이다. 자세한건 Comment에서

 

*추가 : 게시글 삭제 요청에 대해 DB에서 삭제하는 것이 아닌, Article의 isDeleted 필드를 true로 만들어주는 방식으로 하기로 했다. 따라서 게시글 긁어오는 부분을 findAllByIsDeletedFalse()로 대체하였다

게시글 상세조회 요청에 대해 가져온 게시글이 isDeleted면 ArticleDeletedException을 throw하도록 바꾸었다.

 

 

<Comment 관련 기능>

Comment 기능에 대댓글 기능을 구현할 생각이다. 구현 방법으로는 여러가지가 있을 것이다.

1. Comment 내부에 Comment필드를 가져 연관관계 맺어주기

- Comment와 Comment의 연관관계를 정의할 수가 없다. (댓글인지, 대댓글인지에 따라 관계가 달라지니까)

 

2. 대댓글 entity를 만들어 관리

- 대댓글이 한 depth로만 달리면 괜찮은 방법일 수 있지만, 대댓글에도 대댓글을 달 수 있는 경우 계속 Entity를 만들어주어야 하기 때문에 좋지 않다고 생각함

 

3. Comment에 commentType 필드를 가져서 게시글에 단 댓글인지 Comment에 단 댓글인지 분류

- Article을 조회했을 때 commentType이 ArticleComment인 댓글을 가져오고, 해당 댓글들에 대해서 commentType이 CommentComment 인 애들을 전부 조회한 뒤 Id를 비교해야 하기 때문에 쿼리가 불필요하게 많이 나가게 될 것 같다.

 

4. 댓글과 대댓글을 구분짓지 않고 Comment 엔티티로 관리하며, nullable한 rootComment 필드를 가지게 한다.

    Article 조회 시, 해당 Article에 달린 댓글 전부를 긁어온다. CommentBuilder 메소드를 하나 만들어 Comment를 구분하       고, 댓글이 대댓글을 포함하도록 하는 로직을 구현한다.

 

==> 4번은 추가적인 로직을 구현해야 해서 복잡할 것 같지만, 이렇게 하는게 맞는 방법 같아서 이렇게 하기로 함.

 

일단, 댓글과 대댓글의 관계를 정리해주는 로직을 제외하고, Comment 등록/삭제/수정 기능을 구현하려고 한다.

또한, 댓글 삭제 시에도 대댓글은 유지하기 위해 댓글 삭제 요청에 대해 DB에서 댓글을 삭제하는 것이 아닌, isDeleted flag를 true로 바꿔주는 방식으로 구현하려고 한다. 이에 따라 CommentResponseDto의 생성자에는 isDeleted를 확인하고, 삭제된 댓글일 시에는 Dto의 content를 "삭제된 댓글"로 바꿔주어야한다.

추가로, 테스트를 위해 Article에서 Comment를 조회하는 부분을 일단 모든 Comment를 조회하도록 구현하려고 한다.

 

(Article상세조회부분)

Article상세조회

(CommentController)

user의 고유ID만 넘겨주면 좋겠지만, 서비스에서 User의 권한을 확인하고 관리자일 시 요청을 처리해주도록 API가 설계되어있기 때문에 User객체 자체를 넘긴다

 

(CommentService)

delete요청에 대해 DB에서 삭제하는 것이 아니라, comment 엔티티의 isDeleted 값을 true로 바꾸는 메서드를 구현했다.

Comment 엔티티의 생성자와 메서드

Comment의 생성자는 Article에 단 Comment인 경우와 대댓글의 경우를 나누어서 오버로딩 해주었다.

기존에는 Comment의 생성자에 CommentRequestDto를 받도록 하였는데, CommentRequest가 바뀔 가능성이 있다는 점을 생각해보면 이에 따라 도메인인 Comment 엔티티의 생성자가 바뀌어야 하는것은 좋지 않다고 생각했다.(의존성문제)

따라서 생성자로 필드값을 받게 한 뒤, 

Dto의 Comment 생성 메서드

requestDto에서 Comment객체를 만드는 메서드를 통해 Comment를 생성하도록 하였다.

이렇게 하면 Comment요청을 받는 방식이 바뀐다 하더라도 도메인인 Comment 엔티티를 바꾸는 것이 아니라 Dto의 메서드만 바꾸면 된다.

 

 

 

(CommentService 내부 메서드 리팩토링)

Comment를 Optional로 불러오는 부분과, 해당 코멘트 삭제/수정 요청에 대해 작성자가 맞는지 혹은 관리자권한이 있는지를 검증하는 부분은 중복되기 때문에 따로 빼주었다.

 

<엔티티 연관관계 변경>

기존에는 한 객체에서 다른 객체를 조회할 일이 있으면 연관이 있다고 생각하여 @OneToMany나 @ManyToOne을 사용하여 연관관계를 맺었다. 전체적인 설계를 먼저 하고, 그에 맞게 연관관계를 맺어주는 것이 더 좋은 것 같다.

 

1. User와 Article의 관계 : 단방향 @ManyToOne

- 기존에는 User가 @OneToMany로 Article을, Article이 @ManyToOne으로 User를 바로보게 하였다.

   이렇게 했을 때 장점은 user.getArticles로 작성 글 목록을 한번에 가져올 수 있다는 것이 있다.

   회원 Entity가 모든 요청에 조회되는데 비해 비중이 굉장히 낮은 이점일 뿐더러, 이런식으로 설정 했을 때 fetch되어야하     는 시점이 언제인지, Article의 연관관계는 어떻게 되는지 등 생각할 것이 많아진다. 따라서 이번엔 

   Article이 User를 단방향 @ManyToOne으로 바라보는 관계로 설정하였다. 게시글이 조회될 땐 거의 무조건 작성자를  알    아야 하기 때문이다.

 

*Article의 User필드는 user_id가 아닌 User 객체로 한 이유는 Article을 조회할 때 username(작성자 닉네임)을 알아야하는데, Article의 필드에 가지고 있는 외래키는 고유id이다. 따라서 Article을 가지고 올 때 User객체를 같이 가져와야 작성자명을 찾기 위한 추가 쿼리가 나가지 않게 된다.

 

2. User와 Comment의 관계 : 단방향 @ManyToOne

- 1과 마찬가지의 이유로 Comment가 @ManyToOne으로 User를 바라보게 하였다.

 

3. Comment와 Article의 관계 : 연관관계없이 필드값으로 id 가지고있기

-  Article의 경우 전체 글 목록을 보여줄 때는 Comment를 알 필요가 없다. 따라서 게시글 상세보기 시에만 Comment 엔티티에서 Article의 id로 검색하여 댓글을 가져오면 된다.

Comment에서 Article에 대해 알아야할 정보는 Article의 고유번호밖에 없다. Comment를 통해서 해당 댓글이 달린 Article의 내용이나 작성자를 조회할 일이 없기 때문이다. 따라서 따로 연관관계를 맺어주지 않고, Article의 고유id를 하나의 필드article_id로 가지게 하였다. Article에서 해당 글의 Comment를 가지고 올 때는 findby article_id로 쿼리를 날리면 된다.

저장할 때는 어차피 요청 쿼리스트링에서 해당 게시글의 id를 가지고 들어오기 때문에 Article 객체를 찾는 추가적인 쿼리가 나갈일이 없다.

 

 

서비스가 커지고 문제가 복잡해질수록, 이를 처리하기 위한 소프트웨어의 아키텍쳐적인 부분이 중요해진다.

 

내가 진행했던 토이프로젝트의 서버는 크게 세 가지 일을 했다.

 

-새로운 데이터를 처리

-서비스로직을 처리(시간표그려주기 등)

-기존의 데이터를 이용 (db 활용)

 

Spring 에서는 해당 부분들이 각각의 레이어로 나누어져있다.

 

-Presentation layer : 사용자와 상호작용하는 처리 계층. CLI, HTTP요청, HTML처리 등을 담당 (Model, View, Controller..)

   ==> 특정 url에 요청을 보내면 해당 url에 묶인 메서드가 호출되었던 부분과 비슷.

 Spring에서 @Controller로 표현

 

-Domain(business / service) layer : 서비스나 시스템의 핵심 로직. 유효성 검사, 계산 등 어플리케이션의 도메인과 관련된 작업들을 담당함. 프로그램이 복잡해지면, 비지니스 로직을 수행하기 위한 별도의 계층이 필요하다. 그게 Domain layer

 Spring에서 @Service로 표현

 

-Data Access(persistence) Layer (DAO 계층) : Database / Message Queue / 외부 API와의 통신 등을 처리한다.

DB 혹은 데이터 소스가 서버 외부에 별개로 존재하는 경우가 매우 많기 때문에, 데이터 소스와의 소통계층이 필요함.

Spring에서 @Repository로 표현

 

레스토랑에 비유하면

@Controller : 웨이터

@Service : 쉐프

@Repository : 주방보조 (재료가져다줌)

 

<Database>

DBMS : Database Management System

RDBMS : Relational Database Magagement System

==RDBMS==============

-테이블이라는 최소  단위로 구성되고, 테이블은 열과 행으로 이루어져있다.

-MySQL, PostgreSQL, Oracle 등

======================

 

H2 : In-memory DB의 대표주자. 인메모리DB란 서버가 작동하는 동안에만 내용을 저장하고, 서버를 닫으면 데이터가 모두 삭제  ==> 연습용으로 좋다.

 

<SQL> : Structured Query Language  :  RDBMS에서 사용하는 언어

 

=====DDL=====  : Data Definition Language   : 테이블이나 관계의 구조를 생성하는데 사용

CREATE DATABASE 데이터베이스이름;

CREATE TABLE 테이블이름

(

필드이름1 필드타입1,

필드이름2 필드타입2,

...

)

ALTER TABLE 테이블이름 ADD 필드이름 필드타입;

ALTER TABLE 테이블이름 DROP 필드이름;

ALTER TABLE 테이블이름 MODIFY COLUMN 필드이름 필드타입;

 

DROP DATABASE 데이터베이스이름;

DROP TABLE 테이블이름;

TRUNCATE DATABASE 데이터베이스이름;

TRUNCATE TABLE 테이블이름;

 

=====DCL=====  : Data Control Language   :  데이터의 사용 권한을 관리하는데 사용

GRANT SELECT , INSERT          : 권한 부여

ON mp

TO scott WITH GRANT OPTION;

 

REVOKE SELECT, INSERT         :  권한 회수

ON emp

FROM scott

[CASCASE CONSTRAINTS];

 

=====DML=====  : Data Manipulation Language   :  테이블에서 데이터를 검색 / 삽입 / 수정 / 삭제

INSERT INTO 테이블이름 (필드이름1,필드이름2,필드이름3...) VALUES(데이터값1, 데이터2, 데이터3....)

INSERT INTO 테이블이름 VALUES (데이터1,데이터2,데이터3....)

 

SELECT 필드이름 FROM 테이블이름 WHERE[조건];

 

UPDATE 테이블이름 SET 필드이름1 = 데이터값1, 필드이름2 = 데이터값2...... , WHERE 필드이름 = 데이터값;

 

DELETE FROM 테이블이름 WHERE 필드이름 = 데이터값;

 

 

<SQL Cheatsheet>

=====CREATE 제약조건들=====

AUTO_INCREMENT  :  컬럼의 값이 중복되지 않게 1씩 자동으로 증가하게 해줘 고유번호를 생성

NOT NULL : 해당필드는 NULL값을 저장할 수 없게됨

UNIQUE : 해당필드는 서로 다른값을 가져야만 함

PRIMARY KEY : 해당필드가 NOT NULL과 UNIQUE 특징을 모두 가지게 됨

     ==> 테이블에 유일하게 존재하는 값의 조합을 설정해서 중복된 데이터가 테이블에 삽입되는 것을 방지하는 제약조건

     ==> 데이터의 무결성을 위해서 사용, 데이터를 빠르게 검색할 수 있게 됨

FOREIGN KEY : 두개의 테이블을 연결하는 다리 역할을 하는 KEY 

     ==> FOREIGN KEY (필드이름) REFERENCES 테이블이름(필드이름)

     ==> FOREIGN KEY 역시 데이터의 무결성을 보장. FK를 적용하려는 컬럼이 참조하는 테이블의 컬럼은 PK, UNIQUE

 

 

어플리케이션이 데이터베이스를 직접 다룰 때의 문제점

- 훨씬 번거롭다 : 테이블 만들고, 쿼리 작성하고, 쿼리를 jdbc api통해 실행, 결과 객체도 직접 만들어줘야함

- SQL의존적이다: SQL에 컬럼을 추가하면, 관련된 객체, 쿼리문 등등 모든 부분을 수정해야함 => 비지니스로직보다 SQL↑

- 패러다임 불일치

https://knoc-story.tistory.com/m/90

==> ORM, JPA 기술의 등장

ORM : object relation Mapping

 

<JPA>  : Java Persistence API

=>자바 ORM 기술에 대한 표준 명세

1. 쿼리를 자동으로 만들어줌

2. 어플리케이션 계층에서 sql 의존성을 줄임 (번거로운 반복작업 ↓)

3. 패러다임의 불일치 해결

4. 방언을 지원하기 때문에, h2 DB, mySQL, oracle 등 SQL표준을 준수한 DB무엇을 붙여도 같은 코드로 쓸 수 있다.

 

@Entity     annotation을 통해 해당 클래스가 DB의 테이블 역할을 한다는 것을 알려준다.

@OneToMany  // @ManyToOne // @OneToOne // @ManyToMany   : Entity들의 연관관계 형성

 

eg) Food 엔티티의 food // Orders 엔티티의 food_id가 매핑되어야 한다고 할 때,

Food 클래스 안에서:

@OneToMany(mappedBy = "food", fetch = FetchType.EAGER)

private List<Orders> orders = new ArrayList<>();

 

Orders 클래스 안에서:

@ManyToOne

@JoinColumn(name = "food")

private Food food;                      ==> 생성자까지 추가해줘야함

 

repository 패키지에 각 entity별로 FoodRepository와 같이 생성

public interface FoodRepository extends JpaRepository<Food, Long>{}

foodRepository.saveAll(List<Food>)와 같이 사용 가능

메서드들 : save, saveAll, findAll, findById 등등

웬만한 기능들은 JpaRepository에 구현 되어있기 때문에, Repository 구현체에서 override 해주면 된다.

만들 수 있는 것들이 추천됨

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods  에서 query method 들을 확인가능

 

<추가 공부>

-영속성 컨텍스트, 1차 캐시

 

 

 

+ Recent posts