Stack Overflow 클론 - 1

jungseo·2023년 8월 9일
0

Stack Overflow

목록 보기
1/4

프로젝트 개요

프론트엔드와 백엔드 3:3으로 매칭되어 stack overflow 클론 프로젝트를 시작했다. 깃 칸반 보드와 브랜치 등 깃에서 제공하는 서비스들을 최대한 활용하여 진행할 예정이다.
DB 연결은 Spring date Jpa, DB는 우선적으로 h2를 사용하며 aws RDS 구현 후 mysql을 연결할 예정.

ERD


우선 이런 형태로 엔티티간 관계를 정의했고 이것을 바탕으로 구현 중이다.
주어진 기간이 너무 짧아 기본적인 CRUD 기능에 인증, 인가 OAuth 같은 보안 요소들을 우선적으로 구현하고 회원 별 평판, 게시물 조회수, 추천 수 같은 세부적인 기능들을 구현해볼 계획이다. log 같은 경우 테이블을 하나 따로 만들어서 관리를 하면 유용할 것 같다. 여유가 되면 반드시 해봐야겠다.

사실 프론트엔드 쪽은 흐름이 어떻게 되는지 감을 못잡겠어서 우선 각 도메인별로 분업하여 구현 중이다.

맡은 Question 부분을 간단하게 리뷰해보겠다.

Entity

import com.gujo.stackoverflow.member.entity.Member;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Getter
@Setter
public class Question {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long questionId;

    @ManyToOne // 유저 한명이 여러 질문 작성 가능 -> N:1
    @JoinColumn(name = "MEMBER_ID") // 현재 테이블에서 외래키에 해당하는 컬럼의 이름
    private Member member;

    @Column(nullable = false, length = 50)
    private String title;

    @Column(nullable = false)
    private String content;

    @Column(nullable = false)
    private Long point = 0L; // 초기값 설정

    @Column(nullable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    @Column(nullable = true)
    private LocalDateTime modifiedAt;
}
  • member와 N:1 연관관계 매핑을 위해 @ManyToOne, @JoinColumn 애너테이션을 사용

    질문글 post 요청이 왔을 때 해당 요청을 한 클라이언트를 식별하고 해당 값을 필드에 넣어주는 과정을 어떻게 구현하여야 하는 지 정리가 안된다.....

  • 추천 수를 의미하는 point와 생성 시간 등 초기값이 정해져 있는 값들은 초기화

DTO

import com.gujo.stackoverflow.member.entity.Member;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;

public class QuestionDto {

    @Getter
    public static class PostDto {
        private String title;

        private String content;
    }

    @Getter
    @Setter
    public static class ResponseDto {
        private Long questionId;

        private Member member;

        private String title;

        private String content;

        private Long point;

        private LocalDateTime createdAt;

        private LocalDateTime modifiedAt;
    }

    @Getter
    @Setter
    public static class getQuestionsResponseDto {
        private Long questionId;

        private Member member;

        private String title;

        private LocalDateTime createdAt;

        private LocalDateTime modifiedAt;
    }

    @Getter
    public static class PatchDto {
        private String title;

        private String content;
    }
}
  • post, patch, response와 모든 질문글 조회 요청용 응답 Dto로 구현하였다.
  • QuestionDto 클래스에 Dto 클래스들을 내부 클래스로 정의 하였는데 각 클래스별로 파일을 만드는 것과 큰 차이가 느껴지지 않는다.
    팀원들과 상의 후 통일해야겠다.
  • Getter와 Setter 때문에 한참을 붙잡고 있었다.
    • 내부 클래스가 아닌 QuestionDto 클래스에만 @Getter @Setter를 붙여놓고 동작이 안돼서 한참 헤맸다.
    • 그 다음엔 응답용 DTO들에 Setter를 붙이지 않아서 또 헤맸다.
    • Getter와 Setter를 무의식적으로 둘다 써왔던 것 같은데 어떤 상황에서 필요한지에 대해 한번 더 생각해 볼 수 있어서 유익했다.

Controller

import com.gujo.stackoverflow.question.dto.QuestionDto;
import com.gujo.stackoverflow.question.entity.Question;
import com.gujo.stackoverflow.question.mapper.QuestionMapper;
import com.gujo.stackoverflow.question.service.QuestionService;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;

@RestController
@RequestMapping("/questions")
public class QuestionController {

    private final QuestionService service;

    private final QuestionMapper mapper;

    public QuestionController(QuestionService questionService, QuestionMapper mapper) {
        this.service = questionService;
        this.mapper = mapper;
    }

    @PostMapping
    public ResponseEntity postQuestion(@RequestBody QuestionDto.PostDto postDto) {
        Question question = mapper.questionPostDtoToQuestion(postDto);
        Question created = service.createQuestion(question);

        QuestionDto.ResponseDto responseDto = mapper.questionToQuestionResponseDto(created);
        return new ResponseEntity(responseDto, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity getQuestions(Pageable pageable) {
        List<Question> questions = service.getQuestions(pageable);

//        전체 질문 조회 시 게시물의 제목 이름 등 표시 -> 내용 미포함
//        List<Question>으로 질문들을 받아오고 반복자를 활용해 전체질문 조회 요청용 응답 DTO로 변환
        List<QuestionDto.getQuestionsResponseDto> result = new ArrayList<>();
        for (Question question : questions) {
            result.add(mapper.questionToGetQuestionsResponseDto(question));
        }

        return new ResponseEntity(result, HttpStatus.OK);
    }

    @GetMapping("/{question-id}")
    public ResponseEntity getQuestion(@PathVariable("question-id") Long questionId) {
        Question question = service.getQuestion(questionId);

        QuestionDto.ResponseDto responseDto = mapper.questionToQuestionResponseDto(question);
        return new ResponseEntity(responseDto, HttpStatus.OK);
    }

    @PatchMapping("/{question-id}")
    public ResponseEntity patchQuestion(@PathVariable("question-id") Long questionId,
                                        @RequestBody QuestionDto.PatchDto patchDto) {
        Question question = mapper.questionPatchDtoToQuestion(patchDto);
        Question updated = service.updateQuestion(questionId, question);

        QuestionDto.ResponseDto responseDto = mapper.questionToQuestionResponseDto(updated);
        return new ResponseEntity(responseDto, HttpStatus.OK);
    }

    @DeleteMapping("/{question-id}")
    public ResponseEntity deleteQuestion(@PathVariable("question-id") Long questionId) {
        service.deleteQuestion(questionId);

        return new ResponseEntity<>(HttpStatus.OK);
    }
}
  • 모든 요청에 대한 응답을 엔티티 자체가 아닌 별도의 ResponseDto를 만들어 반환했다.
    • 현재로썬 엔티티를 그대로 반환하는 것과 차이가 없는 것 같지만 추후 어떻게 될지 몰라 우선 Dto로 반환하였다.
  • 모든 질문글을 조회하는 요청의 경우 페이지네이션을 구현 하였고 응답으로 질문글의 제목과 작성자, 작성 시간 등 질문 내용을 제외한 부분만 포함하여 Dto를 구현하였다.
    • 최신순, 답변순 등 필터도 적용해볼 수 있을 것 같다.

Service

import com.gujo.stackoverflow.question.entity.Question;
import com.gujo.stackoverflow.question.repository.QuestionRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Service
public class QuestionService {
    private final QuestionRepository repository;

    public QuestionService(QuestionRepository repository) {
        this.repository = repository;
    }

    public Question createQuestion(Question question) {
        return repository.save(question);
    }

    public List<Question> getQuestions(Pageable pageable) {
        return repository.findAll(pageable).getContent();
    }

    public Question getQuestion(Long questionId) {
        return repository.findById(questionId).orElseThrow();
    }

    @Transactional // repository.save 하지 않아도 DB 반영됨
    public Question updateQuestion(Long questionId, Question question) {
        Question findQuestion = repository.findById(questionId).orElseThrow();

//        patchDto에 title, content 각각 항목에 값이 null이 아닐경우 수정사항 반영
//        if(question.getTitle() != null) {
//            findQuestion.setTitle(question.getTitle());
//        }
//        if (question.getContent() != null) {
//            findQuestion.setContent(question.getContent());
//        }
        Optional.ofNullable(question.getTitle()).ifPresent(findQuestion::setTitle);
        Optional.ofNullable(question.getContent()).ifPresent(findQuestion::setContent);

//        수정시간 반영
        findQuestion.setModifiedAt(LocalDateTime.now());

        return findQuestion;
    }

    public void deleteQuestion(Long questionId) {
        repository.deleteById(questionId);
    }
}
  • updateQuestion() 부분에서 한참을 헤맸던 것 같다.
    • title이나 content가 null이 아닐 경우 DB에서 조회한 값을 요청으로 받은 값으로 수정하는 로직이다.
    • 처음 if문으로 작성했었는데 Optional.ofNullable().ifPresent() 방식으로 같은 로직을 처리하던 실습이 떠올라 조금 수정해보았다.
    • 로직을 수행하고 수정된 시간도 반영해주고 해당 question 객체를 반환한다.
  • update 부분에 @Transaction을 사용하였는데 spring date jpa 기술과 이 어노테이션을 함께 사용할 경우 메서드 내에서 엔티티 변경이 감지되면 자동으로 Flush가 발생해 DB에 변경 내용이 반영된다고 한다.

Mapper

import com.gujo.stackoverflow.question.dto.QuestionDto;
import com.gujo.stackoverflow.question.entity.Question;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface QuestionMapper {
    Question questionPostDtoToQuestion(QuestionDto.PostDto postDto);

    QuestionDto.ResponseDto questionToQuestionResponseDto(Question question);

    QuestionDto.getQuestionsResponseDto questionToGetQuestionsResponseDto(Question question);

    Question questionPatchDtoToQuestion(QuestionDto.PatchDto patchDto);
}

Repository

import com.gujo.stackoverflow.question.entity.Question;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Long> {
}
  • JpaRepository를 확장하는 인터페이스를 정의하였다.
  • JpaRepository와 JpaRepository가 상속받는 인터페이스들을 살펴봐도 @Component 어노테이션이 없는데 어떻게 빈으로 등록되고 관리되나 궁금했다.
    • 이유는 스프링 부트 자동 설정 기능이 특정 라이브러리들을 자동으로 빈으로 등록하는데 JpaRepository를 상속받는 인터페이스들의 구현체가 포함된다고 한다.
    • 자동으로 해준다는게 편리한데 이해하기엔 어려운 것 같다.

회고

첫 프로젝트여서 아무것도 모르겠다. 근데 옆에 사람들도 그래보인다. 일단 팀장을 맡아 뭐라도 더 해보려고 노력중이다. 이번 기회에 aws IAM 계정도 생성해서 팀원들과 사용해봐야겠다.
프론트엔드 팀원들과 어떻게 협업해야하는 지는 정말 모르겠다. 얘기를 많이 나눠봐야 할 것 같은데 무엇을 물어봐야하는지가 제일 어려운 것 같다.
혼자서 무언가 끄적이는 것보다 함께 하는 사람들이 있다는게 다른 사람들을 보며 동기부여 받고 스스로 조금더 채찍질할 수 있게 해주는거 같아서 좋다.
첫 프로젝트이니 목표한 부분까지는 어떻게든 완성하고 배포해보고싶다.
아직 많이 진행하진 않았지만 팀원들이 잘 따라주고 말도 잘 통하는 것 같아 너무 감사하다..

깃허브 레포지토리 링크

1개의 댓글

comment-user-thumbnail
2023년 8월 9일

많은 것을 배웠습니다, 감사합니다.

답글 달기