본문 바로가기
프로젝트/프로젝트 정리

[백엔드 프로젝트] F!LB - AI가 알려주는 객관적인 감정인식

by 잔디🌿 2024. 8. 30.

    프로젝트 소개

    우리는 사용자의 일기를 ai가 분석해서 감정을 객관적으로 알려주는 서비스를 제작하였다.

    이 프로젝트로 해커톤에서 세종 테크노파크원장상을 수상하였다.

    위와 같이 회원가입을 하고 로그인을 하면

    사용자 별 달력이 뜬다. 달력의 색깔은 해당 날짜에 사용자가 작성한 일기의 감정을 나타낸다. 

     

    사용자가 일기를 쓰고 저장하면 위와 같이 일기의 전반적인 감정상태와 해당 일기에 대한 조언 그리고 통계자료가 나온다.

     

    우리 서비스의 디자인은 정말 최고였다. 진짜 언제 봐도 너무 잘했다. 그리고 이걸 그대로 구현한 프론트도 너무너무 대단하다.

     

    아이디어 도출

    우리의 주제는 현대인의 건강을 위한 서비스였다. 현대인의 건강을 위한 어플이면서, 사업성도 가진 서비스를 생각해내기가 조금 어려웠다. 

    수많은 회의 끝에 현대인이 자신의 감정을 숨기고, 제대로 알아채지 못한다는 문제점을 해결하는 어플을 만들고자 하였고, 그 결과 일기를 통해서 ai가 감정을 분석해주는 어플을 만들자! 라는 결론이 나왔다.

     

    그 동안은 거의 컴퓨터를 전공하는 사람들이랑 이런 회의를 해봤는데 확실히 기획 쪽을 공부해보신 분과 함께하니까 확실히 달랐다. 프로젝트를 기획하는데에 있어서 생각보다 고려해야 할 사항이 많았다. 또한 기획자분들과의 소통이 얼마나 중요한지 깨닫게되었다.

     

    팀원 간 소통 방법

    전체 팀원과의 소통

    우리 팀 기획1, 디자인1, 백엔드2, 프론트 2명으로 이루어져있었다.

    주로 노션과 디스코드를 통해서 소통했다. 다들 노션을 너무너무 잘 쓰신다. 나도 정말 배워야겠다고 생각했다.

    이런 식으로 회의날마다 하나씩 페이지를 만들고 그날그날의 내용을 정리하였다.

     

    또한 따로 정리해야 할 내용도 페이지를 만들어두고 관리했다!

     

    서비스를 기획할 때 기획 분들이 개발자들을 정말 많이 배려해주시는게 느껴져서 감사했다! 덕분에 정말 원활하게 개발을 할 수 있었다.

    정말 다들 최고였다.

     

    백엔드끼리의 소통

    백엔드 팀원분은 나보다 개발을 많이 잘하신다. 그래서 이번 프로젝트를 통해 많은 것을 배울 수 있었다.

    백엔드는 깃허브, 노션, 디스코드를 통해서 소통하였다.

    이렇게 백엔드 공간을 따로 만들었고

    각자 할 일을 이렇게 노션에 정리하여 관리하였다.

     

    깃허브에는 F!lb Organization을 만들고 프론트, 백, 인공지능 코드를 관리하였다.

    우리는 주로 회의를 하면서 위 이슈를 작성했다. 이슈 기능을 잘 몰랐었는데 이번에 배웠다.

    내가 저 기능을 구현하고 PR을 날리면 팀원분이 코드 리뷰를 해주셨다.

     

    코드 리뷰 받을 때마다 느끼는 거지만 내 코드는 아직 고칠 곳이 많다...

    내 코드를 보고 여기저기 불편해하시는 것을 보았다ㅋㅋ 앞으로 객체지향쪽도 많이 공부해봐야겠다.

     

    ERD 설계

    우리의 ERD는 위와 같다.

    회원테이블과 리포트, 1주일 리포트, 한달 리포트, 3개월 리포트 테이블은 각각 일대다로 연결된다.

    또한 감정과 관련된 필드들은 Embedded를 사용하여 따로 관리하도록 하였다.

     

    패키지 구조

     

    패키지 구조는 계층형을 선택했다. 우리 서비스의 규모는 다소 작고, 도메인도 별로 없어서 이러한 선택을 했다.

     

    인증, 인가

    우리는 인증으로 jjwt 라이브러리를 사용하였다.

     

    JwtService

    jwt service 코드에 대해 간단히 설명하자면

    private SecretKey key = Jwts.SIG.HS256.key().build();

    위와 같이 jwt토큰을 생성하기 위한 키를 만들고

    @Value("${token.expiration}")
    private int tokenExpiration;

    토큰의 유효한 시간은 application.yml에서 Value로 가져왔다.

     

    public String createJWT(String email) {
        return Jwts.builder()
                .claim("email", email)
                .signWith(key)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + (tokenExpiration *1000)))
                .compact();
    }

    위 메서드는 jwt토큰을 생성한다. claim 값으로 이메일을 넣어주었다. 키와 유효시간을 넣고 생성된 토큰을 반환한다.

     

        public String getJWT() {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
                    .getRequest();
            String token = request.getHeader("Authorization");
    
            return token.replace("Bearer ", "");
        }

    이 메서드는 요청을 받았을 때 헤더에서 Authorization value값을 가져온 후 Bearer을 제거하여 토큰을 얻는다.

     

    public String getMemberEmail() {
        String accessToken = getJWT();
        checkTokenValidation(accessToken);
        Jws<Claims> jws;
    
        try {
            jws = Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(accessToken);
        } catch (JwtException e) {
            throw new RuntimeException(e);
        }
    
        return jws.getPayload()
                .get("email", String.class);
    }

    여기서는 요청을 받았을 때 해당 요청에서 토큰을 추출하여 해당 토큰에 해당하는 회원의 이메일을 가져온다. 

    위에서 설명한 getJWT() 메서드를 사용한다.

     

    Repository

    내 프로젝트에서 데이터는 JPA로 관리하였다.

    package com.backend.filb.domain.repository;
    
    import com.backend.filb.domain.entity.Member;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.Optional;
    
    public interface MemberRepository extends JpaRepository<Member, Long> {
        boolean existsByEmail(String email);
    
        Optional<Member> findByEmail(String email);
    }
    

     

    package com.backend.filb.domain.repository;
    
    import com.backend.filb.domain.entity.Diary;
    import com.backend.filb.domain.entity.Member;
    import com.backend.filb.dto.response.DiaryMonthlyResponse;
    import com.backend.filb.dto.response.MonthlyEmotionResponse;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.jpa.repository.Query;
    import org.springframework.data.repository.query.Param;
    
    import java.time.LocalDate;
    import java.time.LocalDateTime;
    import java.util.List;
    
    public interface DiaryRepository extends JpaRepository<Diary,Long> {
    
        @Query("SELECT new com.backend.filb.dto.response.DiaryMonthlyResponse(d.diaryId, d.title, d.content, d.createdDate, d.totalEmotion) " +
                "FROM Diary d " +
                "WHERE d.member= :member " +
                "AND MONTH(d.createdDate) = :month")
        List<DiaryMonthlyResponse> findDiariesByMemberAndMonth(Member member, int month);
    
        @Query("SELECT new com.backend.filb.dto.response.MonthlyEmotionResponse(d.createdDate, r.positiveSentencePercent, r.negativeSentencePercent) " +
                "FROM Diary d " +
                "JOIN d.report r " +
                "WHERE d.createdDate BETWEEN :startDate AND :endDate " +
                "AND d.member = :member")
        List<MonthlyEmotionResponse> findReportIdsWithin30Days(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate, @Param("member") Member member);
    }

     

    package com.backend.filb.domain.repository;
    
    import com.backend.filb.domain.entity.Report;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface ReportRepository extends JpaRepository<Report,Long> {
    }

    각 테이블의 Repository는 위와 같다.

     

    DTO

    클라이언트로부터 요청을 받고, 응답을 하기 위해 주로 사용된 DTO는 record를 사용하여 구현하였다.

    getter을 따로 구현할 필요가 없고, 위의 기능을 위해서 DTO를 사용할 때에는 setter을 사용할 필요가 없기 때문이다.

     

    회원 관리

    엔티티

    package com.backend.filb.domain.entity;
    
    import jakarta.persistence.*;
    import lombok.Getter;
    
    import java.util.List;
    import java.util.Objects;
    
    @Entity
    @Getter
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long memberId;
    
        @Column(nullable = false)
        private String email;
    
        @Column(nullable = false)
        private String password;
    
        @Column(nullable = false)
        private String name;
    
        @OneToMany
        private List<Diary> diaryList;
    
        public Member(Long memberId, String email, String password, String name, List<Diary> diaryList) {
            this.memberId = memberId;
            this.email = email;
            this.password = password;
            this.name = name;
            this.diaryList = diaryList;
        }
    
        public Member() {
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Member member = (Member) o;
            return Objects.equals(memberId, member.memberId);
        }
    
        @Override
        public int hashCode() {
            return Objects.hash(memberId);
        }
    
        public boolean checkPassword(String password){
            return this.password.equals(password);
        }
    
        public void validatePassword(String password) {
            if (password.length() < 8) {
                throw new RuntimeException("비밀번호는 8자 이상의 문자여야 합니다.");
            }
        }
    
        public void setDiary(Diary diary) {
            this.getDiaryList().add(diary);
        }
    }

    멤버 엔티티 코드는 위와 같다. Long형의 멤버 고유의 Id와 이메일, 비밀번호, 이름 그리고 일기를 포함하고 있는 DiaryList로 이루어져있다.

     

    멤버 아이디에 대해서 동등성을 부여하기 위해서 equals와 hashCode메서드를 Override했다.

    또한 회원의 비밀번호 일치여부, 비밀번호 유효여부를 판단하는 메서드들도 여기에 포함시켰다.

     

    회원 생성

    회원가입에 대한 api명세는 다음과 같다. 클라이언트로부터 온 위 요청을 MemberController가 받는다.

     

    @RestController
    @RequestMapping("/api/members")
    public class MemberController {
        private MemberService memberService;
    
        public MemberController(MemberService memberService) {
            this.memberService = memberService;
        }
    
        @PostMapping("/join")
        public ResponseEntity<Void> join(
                @RequestBody @Valid MemberRequest memberRequest
        ) {
            memberService.join(memberRequest);
            HttpHeaders headers = new HttpHeaders();
            headers.add("Message", "success");
            return ResponseEntity.ok().headers(headers).body(null);
        }

    클라이언트로부터 오는 정보를 받기 위해서 MemberRequest라는 DTO를 생성했다.

    DTO는 별도의 수정이 필요 없으므로 recode로 만들어주었다.

     

    package com.backend.filb.dto.request;
    
    import lombok.NonNull;
    
    public record MemberRequest(
            @NonNull String email,
            @NonNull String password,
            @NonNull String name
    ) {
    }
    

    Body에 있는 값을 받으니까 @RequestBody 어노테이션을 사용하였고, 유효성 검증을 위해 @Valid 어노테이션을 사용하였다.

     

    로그인

    로그인 api명세는 다음과 같다.

    @PostMapping("/login")
    public ResponseEntity<String> login(
            @RequestBody MemberLoginRequest memberRequest
    ) {
        String jwt = memberService.login(memberRequest);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", jwt);
        headers.add("Message","login success");
        return ResponseEntity.ok().headers(headers).body(null);
    }

    로그인 요청이 들어오면 MemberController의 login에서 받고, MemberLoginRequest DTO에 정보를 넣는다. 

     

    package com.backend.filb.dto.request;
    
    import lombok.NonNull;
    
    public record MemberLoginRequest(
            @NonNull String email,
            @NonNull String password
    ) {
    }
    

    이후 jwt토큰을 받기 위해 memberService 내의 login을 memberRequest를 파라미터로 주며 호출한다.

     

    public String login(MemberLoginRequest memberRequest) {
        Member dbMember = memberRepository.findByEmail(memberRequest.email())
                .orElseThrow(() -> new NoSuchElementException("로그인에 실패했습니다 다시 시도해주세요"));
        if (!dbMember.checkPassword(memberRequest.password())) {
            throw new NoSuchElementException("로그인에 실패하였습니다. 다시 시도해주세요");
        }
        return jwtService.createJWT(memberRequest.email());
    }

    위 메서드는 MemberService내의 login이다. 파라미터 내의 email정보를 사용해서 해당하는 멤버를 찾은 후 그 멤버의 비밀번호와 파라미터 값의 비밀번호를 비교한다. 실패 시 에러를 발생시키고, 성공하면 jwtService를 통해 jwt 토큰을 발급하여 반환한다.

    Controller은 토큰을 받으면 이를 헤더에 넣어 클라이언트에게 응답한다.

     

    일기 관리

    일기 엔티티

    package com.backend.filb.domain.entity;
    
    import jakarta.persistence.*;
    import lombok.Getter;
    
    import java.sql.Date;
    import java.time.LocalDate;
    import java.time.LocalDateTime;
    
    @Entity
    @Getter
    public class Diary {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long diaryId;
    
        @Column(nullable = false)
        private String title;
    
        @Column(nullable = false)
        private LocalDateTime createdDate;
    
        @Column(nullable = false, columnDefinition = "text")
        private String content;
    
        @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
        @JoinColumn(name = "report_id")
        private Report report;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn
        private Member member;
    
        private Integer totalEmotion;
    
        public Diary(String title, LocalDateTime createdDate, String content, Member member) {
            this.title = title;
            this.createdDate = createdDate;
            this.content = content;
            this.member = member;
        }
    
        public Diary() {
        }
    
        public void setReport(Report report) {
            this.report = report;
        }
    
        public void updateTotalEmotion(int totalEmotionIndex) {
            this.totalEmotion = totalEmotionIndex;
        }
    }

    일기 엔티티는 위와 같다. 아이디, 제목, 내용, 생성날짜, 리포트, 멤버, 종합 감정 필드를 가지고있다.

    한 멤버가 많은 일기를 가질 수 있으니까 멤버 필드 위에 @ManyToOne 어노테이션을 적용하였고, 일기 하나 당 리포트는 하나니까 리포트 필드 위에는 @OneToOne 어노테이션을 적용하였다.

     

    일기 저장

    우리 서비스는 사용자가 일기를 쓰고 저장하면 자동으로 리포트를 생성하여 이를 반환한다.

    리포트를 생성하는 부분은 리포트 관리 부분에서 자세히 설명하겠다.

    클라이언트에게 위와 같은 요청을 받으면 

    DiaryController에서 이를 처리한다.

     

    @PostMapping("/save")
    public ResponseEntity<ReportResultResponse> save(
            @RequestHeader("Authorization") String token,
            @RequestBody DiaryRequest diaryRequest
    ) throws JsonProcessingException {
        String jwtEmail = jwtService.getMemberEmail();
        ReportResultResponse reportResultResponse = diaryService.save(diaryRequest,jwtEmail);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Message", "success");
        return ResponseEntity.ok().headers(headers).body(reportResultResponse);
    }

    헤더에서 토큰을 받고, DiaryRequest DTO로 일기 정보를 받는다. 가장 먼저 jwtService를 사용하여 해당 토큰의 멤버 아이디를 추출한다. 그 다음 diaryService에 있는 save 메서드를 호출하여 데이터베이스에 일기를 저장하도록 한다.

     

        @Transactional
        public ReportResultResponse save(DiaryRequest diaryRequest, String jwtId) throws JsonProcessingException {
            Diary diary = mapDiaryRequestToDiary(diaryRequest);
            ResponseEntity<Object> emotionResponse = emotionApi.getEmotionResponse(diaryRequest.content());
            Report report = Report.from(emotionResponse,diaryRequest.content());
            report.setFeedback(getFeedBack(diaryRequest.content()));
            reportRepository.save(report);
            diary.setReport(report);
            diary = diaryRepository.save(diary);
            Member member = memberService.findByEmail(jwtId);
            member.setDiary(diary);
            memberRepository.save(member);
            return mapToReportResult(diary, report);
        }

    save 메서드는 한번에 성공하거나 실패해야하므로 Transactional 어노테이션을 추가하였다. 

    diaryResponse를 diary 객체로 만들고, 이를 diayrRepository에 저장한다. 그 다음 diary 객체를 현재 로그인 중인 멤버 객체 속의 diaryList에 추가하도록 한다. 이 과정의 메서드는 member엔티티 내부에 구현하였다. 

    이 때 주의할 점은 우리가 위에서 memberRepository에서 꺼낸 멤버 객체에 다이어리를 추가했을 때 자동으로 레파지토리에도 반영되는 것은 아니라는 것이다. 따라서 다시 해당 멤버를 memberRepository에 save 해주어야한다. 우리가 member엔티티에다가 이메일에 대한 동등성을 부여했기 때문에 중복된 멤버객체가 들어가지 않고, 이전에 있던 데이터에 덮어쓰기된다. 

     

    월별 일기 조회

    클라이언트로부터 월별 일기를 조회하라는 요청을 받으면 

    @GetMapping("/monthly/{month}")
    public ResponseEntity<List<DiaryMonthlyResponse>> getMonthlyDiaries(
            @RequestHeader("Authorization") String token,
            @PathVariable("month") int month
    ){
        String jwtId = jwtService.getMemberEmail();
        List<DiaryMonthlyResponse> diaryList = diaryService.getMonthlyDiaries(jwtId, month);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Message", "success");
        return ResponseEntity.ok().headers(headers).body(diaryList);
    }

    헤더로 토큰값을 받고, 경로변수로 원하는 달의 정보를 받는다.

    그 다음 이 값을 diaryService의 getMonthlyDiaryies 메서드로 전달하며 호출한다.

    이 과정에서 받은 일기 리스트는 바디에 넣어서 보낸다.

     

        public List<DiaryMonthlyResponse> getMonthlyDiaries(String jwtId, int month) {
            Member member = memberService.findByEmail(jwtId);
            List<DiaryMonthlyResponse> response = diaryRepository.findDiariesByMemberAndMonth(member, month);
            return response.stream()
                    .map(DiaryMonthlyResponse::updatePrefix)
                    .toList();
        }

    getMonthlyDiaryies 에서는 diaryRepository로 현재 로그인중인 멤버와 클라이언트가 원하는 달에 해당하는 다이어리를 찾는다.

    찾은 다이어리에서의 내용은 DiaryMonthlyResponse DTO 내부에 있는 updatePrefix 메서드를 통해서 60자로 줄인다.

     

    public DiaryMonthlyResponse updatePrefix() {
        String updatedContents = this.contents;
        if (this.contents.length() > 60) {
            updatedContents = this.contents.substring(0, 60) + "...";
        }
        return new DiaryMonthlyResponse(diaryId, title, updatedContents, createdDate, totalEmotion);
    }

     

    @Query("SELECT new com.backend.filb.dto.response.DiaryMonthlyResponse(d.diaryId, d.title, d.content, d.createdDate, d.totalEmotion) " +
            "FROM Diary d " +
            "WHERE d.member= :member " +
            "AND MONTH(d.createdDate) = :month")
    List<DiaryMonthlyResponse> findDiariesByMemberAndMonth(Member member, int month);

    updatePrefix 메서드와 findDiariesByMemberAndMonth에 쓰인 쿼리문은 이러하다. 

     

    id로 일기 찾기

    일기id로 일기를 가져오는 기능이다.

    @GetMapping("/{id}")
    public ResponseEntity<DiaryResponse> getById(
            @RequestHeader("Authorization") String token,
            @PathVariable("id") Long id
    ){
        String jwtId = jwtService.getMemberEmail();
        DiaryResponse diaryResponse = diaryService.readById(jwtId, id);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Message", "success");
        return ResponseEntity.ok().headers(headers).body(diaryResponse);
    }

    위 요청을 받으면 DiaryController에서 이를 받는다. 여기서도 id값을 경로변수로 받는다.

    jwt 토큰을 통해서 회원 아이디를 가져온 후 이를 DiaryService의 readById에 전달하며 호출한다.

    이렇게 받은 다이어리 값을 DiaryResponse DTO에 넣어 응답한다.

     

    public DiaryResponse readById(String jwtEmail, Long id) {
        Diary diary = diaryRepository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("일기 정보가 없습니다."));
        return new DiaryResponse(diary.getDiaryId(), diary.getCreatedDate(), diary.getTitle(), diary.getContent(), diary.getTotalEmotion());
    }

    DiaryService의 readById이다.

     

    일기 삭제하기

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(
            @RequestHeader("Authorization") String token,
            @PathVariable("id") Long id
    ){
        String jwtId = jwtService.getMemberEmail();
        diaryService.delete(jwtId,id);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Message", "success");
        return ResponseEntity.ok().headers(headers).body(null);
    }

     

    일기 삭제 요청이 들어오면 DiaryController의 delete가 이를 받는다. 마찬가지로 id는 경로변수로 받는다.

    Controller은 해당 토큰에 해당하는 멤버의 아이디와 id값을 DiaryService의 delete메서드에 넘겨주며 호출한다.

    public void delete(String jwtId, Long id) {
        Diary diary = diaryRepository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("일기 정보가 없습니다."));
        diaryRepository.delete(diary);
    }
    

    delete 메서드는 이러하다.

     

    리포트 관리

    리포트 엔티티

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reportId;
    
    @Column(nullable = false)
    private Integer totalEmotion;
    
    @Column(nullable = false)
    private Integer totalEmotionPercent;
    
    @Column(nullable = false, columnDefinition = "text")
    private String feedback;
    
    @Embedded
    @Column(nullable = false)
    private Emotions emotions;
    
    @Column(nullable = false)
    private Integer negativeSentencePercent;
    
    @Column(nullable = false)
    private Integer positiveSentencePercent;
    
    @Column(nullable = false)
    private Integer totalSentenceCount;
    
    @Column(nullable = false, columnDefinition = "text")
    @Convert(converter = EmotionSentence.EmotionSentenceConverter.class)
    private List<EmotionSentence> emotionSentences = new ArrayList<>();

    필드는 위와 같다. 이 때 각 감정의 정도를 나타내는 emotion은 Embeded를 사용하였다.

     

    @Embeddable
    public class Emotions {
        @Column(nullable = false)
        private Integer happiness;
        @Column(nullable = false)
        private Integer surprised;
        @Column(nullable = false)
        private Integer anxiety;
        @Column(nullable = false)
        private Integer anger;
        @Column(nullable = false)
        private Integer sadness;
        @Column(nullable = false)
        private Integer neutrality;

    Emotions 클래스는 위와 같다.

     

    또한 EmotionSentence는

    위처럼 리포트에서 일기 문장별로 감정을 나타내는 기능을 구현하기 위해 필요하다. 

     

    public class EmotionSentence {
    
        private String sentence;
        private Integer emotionType;
    
        public static class EmotionSentenceConverter extends JsonArrayConverter<EmotionSentence> {
            public EmotionSentenceConverter() {
                super(new TypeReference<>() {}, new ObjectMapper()); // 1
            }
        }
    }

    EmotionSentence의 코드는 위와 같다. 문장과 감정타입 필드가 있고 해당 엔티티를 JSON으로 변환하기 위한 클래스인 EmotionSentenceConverter가 있다. 생성자에서는 부모 생성자를 호출하도록 한다.

     

    엔티티 내의 메서드들은 리포트 생성 부분에서 설명하겠다.

     

    리포트 생성

    리포트 생성은 다이어리 생성 할 때 이루어진다. 

        @Transactional
        public ReportResultResponse save(DiaryRequest diaryRequest, String jwtId) throws JsonProcessingException {
            Member member = memberService.findByEmail(jwtId);
            Diary diary = mapDiaryRequestToDiary(diaryRequest, member);
            ResponseEntity<Object> emotionResponse = emotionApi.getEmotionResponse(diaryRequest.content());
            Report report = Report.of(emotionResponse, diary);
            report.setFeedback(getFeedBack(diaryRequest.content()));
            reportRepository.save(report);
            diary.setReport(report);
            diary = diaryRepository.save(diary);
            member.setDiary(diary);
            memberRepository.save(member);
            List<MonthlyEmotionResponse> monthlyEmotionResponses = getMonthlyEmotion(member, diaryRequest.date());
            return mapToReportResult(diary, report, monthlyEmotionResponses);
        }

    다이어리 서비스의 save로 돌아가보겠다!

    위에 보면 

    ResponseEntity<Object> emotionResponse = emotionApi.getEmotionResponse(diaryRequest.content());

    부분이 있다. 이 코드는 받은 다이어리의 내용을 인공지능 모델을 통해서 감정 분석을 하는 부분이다. 일기의 각 문장별로 감정을 정해서 map형태로 반환한다.

     

    인공지능 모델은 팀원분이 만들어주셨고, 이 모델을 도커에 올려서 restTemplate로 호출하여 응답을 받는 방식으로 구현하였다.

    파이참에다가 받은 파일을 올리고 

    docker build -t my-fastapi-app .
    docker run -p 8000:8000 my-fastapi-app

    명령어를 치면 실행되었다. 이를 postman으로 확인해보면

     

    이런식으로 잘 오는 것을 확인할 수 있다.

     

        private final String analyzeEmotionApiUrl = "http://localhost:8000/predict";
        
        public ResponseEntity<Object> getEmotionResponse(String content) throws JsonProcessingException {
            DiaryRequestToAi diaryRequestToAi = new DiaryRequestToAi(content);
            String jsonBody = objectMapper.writeValueAsString(diaryRequestToAi);
    
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(APPLICATION_JSON);
    
            HttpEntity<String> requestEntity = new HttpEntity<>(jsonBody, headers);
            return restTemplate.exchange(analyzeEmotionApiUrl, HttpMethod.POST, requestEntity, Object.class);
        }

    위 요청을 프로젝트 내에서 보내고 응답을 받기 위해 RestTemplate를 사용하였다.

    보내는 우리의 데이터를 JSON 형태로 변환하고 이를 바에 넣은 후 요청을 보내고, 받은 응답을 리턴한다.

     

    public static Report of(ResponseEntity<Object> responseEntity, Diary diary) throws JsonProcessingException {
        Map<String, Object> map = parseResponse(responseEntity);
    
        Map<String, Integer> predictions = getPredictions(map);
        List<EmotionSentence> sentences = new ArrayList<>();
        for (Map.Entry<String, Integer> entry : predictions.entrySet()) {
            sentences.add(new EmotionSentence((String) entry.getKey(), (Integer) entry.getValue()));
        }
        int[] emotionCounts = countEmotions(predictions);
    
        int totalSentences = calculateTotalSentences(emotionCounts);
        int[] emotionPercentages = calculateEmotionPercentages(emotionCounts, totalSentences);
    
        return createReport(emotionPercentages, totalSentences, diary, sentences);
    }

    위 응답을 받은 후 바로 호출되는 메서드이다. 이 메서드는 받은 ResponseEntity안에 있는 값을 우리가 필요한 값으로 가공한다.

     

    private static Map<String, Object> parseResponse(ResponseEntity<Object> responseEntity) throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper();
        String jsonString = mapper.writeValueAsString(responseEntity.getBody());
        return mapper.readValue(jsonString, new TypeReference<HashMap<String, Object>>() {});
    }
    
    private static Map<String, Integer> getPredictions(Map<String, Object> map) {
        return (Map<String, Integer>) map.get("predictions");
    }

    parseResponse는 받은 응답에서 prediction과 데이터를 분리하고, getPredictions는 prediction 안에 있는 데이터를 map으로 만들어주는 역할을 한다.

     

    private static int[] countEmotions(Map<String, Integer> predictions) {
        int[] emotions = new int[NUMBER_OF_EMOTION];
        for (Integer value : predictions.values()) {
            if (value >= 0 && value < NUMBER_OF_EMOTION) {
                emotions[value]++;
            }
        }
        return emotions;
    }
    

    countEmotions는 일기장에서 각 감정이 등장한 횟수를 측정한다.

     

    private static int calculateTotalSentences(int[] emotionCounts) {
        return Arrays.stream(emotionCounts).sum();
    }

    calculateTotalSentences는 전체 문장 수를 측정한다. 위에서 나온 배열의 총 합을 구하는 방식으로 구현하였다.

     

    private static int[] calculateEmotionPercentages(int[] emotionCounts, int totalSentences) {
        int[] emotionPercentages = new int[NUMBER_OF_EMOTION];
        for (int i = 0; i < NUMBER_OF_EMOTION; i++) {
            emotionPercentages[i] = (int) (((double) emotionCounts[i] / totalSentences) * 100);
        }
        return emotionPercentages;
    }

    각 감정별로 문장에서 등장한 비율을 계산한다.

     

    private static Report createReport(int[] emotionPercentages, int totalSentences, Diary diary, List<EmotionSentence> sentences) throws JsonProcessingException {
        int totalEmotionIndex = findMaxEmotionIndex(emotionPercentages);
        diary.updateTotalEmotion(totalEmotionIndex);
        int totalEmotionPercent = emotionPercentages[totalEmotionIndex];
    
        Emotions emotions = new Emotions(
                emotionPercentages[0], emotionPercentages[1], emotionPercentages[2],
                emotionPercentages[3], emotionPercentages[4], emotionPercentages[5]
        );
    
        int positiveSentencePercent = emotionPercentages[0];
        int negativeSentencePercent = 100 - positiveSentencePercent - emotionPercentages[5];
    
        return new Report(totalEmotionIndex, totalEmotionPercent, emotions, negativeSentencePercent, positiveSentencePercent, totalSentences, sentences);
    }

    최종적으로 리포트를 만드는 메서드이다. 긍정 문장 비율과 부정 문장 비율도 계산하여 리포트 객체를 만든다.

     

    그 다음 일기에 대한 인공지능의 전체적인 피드백을 리포트에 넣어야 한다.

    이 부분은 chetGPT를 사용하였다.

     

    https://ethereal-coder.tistory.com/228

     

    [Spring] RestTemplate로 ChetGPT 연결하기

    이번 해커톤 프로젝트를 하면서 chetGPT를 연동해야 할 일이 생겼다.위와 같이 일기를 쓰면 그에 대한 코멘트를 챗지피티를 통해 보여준다.(일기 마저도 챗 지피티에서 따왔다ㅋㅋ) 팀원분이 지

    ethereal-coder.tistory.com

    이 부분은 따로 정리해두었다.

     

    이렇게 하면 리포트가 만들어지고, 이 리포트가 해당 일기 객체에 들어간다.

     

    느낀점

    이번 프로젝트가 백엔드 파트로 수행한 첫 프로젝트였다. 6주간 카카오테크캠퍼스에서 배운 내용으로 구현해야해서 처음에는 겁을 많이 먹었었는데 막상 구글링하고, 팀원분한테 물어보면서 진행하니까 할만은 했다.

    그리고 기획, 디자인, 프론트 팀원분들과 계속 회의하면서 기능을 수정해나가는 경험이 나에게 굉장히 도움이 되었던 것 같다. 프로젝트가 거의 끝나갈 때 쯤 같이 하는 백엔드 팀원분이 일이 있어서 백엔드 부분은 내가 회의를 주도했던 적이 있었는데, 내가 구현한 것들을 설명하고, 기획분들이 원하는 기능을 어떻게 구현해야하는지, 실현 가능성이 있는지 스스로 생각하고 설명하는 과정이 재미있었다. 또한 회의를 계속 하면서 내 의견을 정리해서 말하는 능력도 많이 길러진 것 같아서 뿌듯하다.

     이번 개발을 하면서 백엔드가 나의 적성과 잘 맞는다는 것을 알게되었다. 기능을 하나하나 구현할 때마다 너무 신기하고, 프로젝트에 생명을 불어넣는 느낌이라 성취감이 컸다. 앞으로도 열심히 공부해서 다른 많은 기능들을 구현해보고싶다.

     

    개선할 점

     원래는 잘 보이지 않았는데 요즘 내 코드를 보면 코드가 깔끔하지 않다는 것이 느껴진다. 그런데 이를 어떤 기준으로 정리해야할지를 잘 모르겠다. 객체지향에 대해서 아직 애매하게 알고 있어서 그런 것 같다. 다른 사람의 코드도 보고, 책도 읽으면서 공부해나가야 할 것 같다.

     이번 프로젝트에서 배포에 어려움이 있었다. 굉장히 급한 상황이었어서 팀원 분이 거의 해주셨다. 그 상황속에서도 설명을 많이 해주셨지만 사실 완전히 이해는 못했다. 그래서 다음에는 내 손으로 끝까지 배포해보고싶다.

     또한 데이터를 설계하고 이를 JPA로 구현할 때 어려움이 있었다. 연관관계 매핑에 대해서 더 많이 공부해야겠다. 그리고 JPA 처음 접했을 때 쿼리문은 이제 별로 안쓰겠구나 했는데 우리가 원하는 데이터만 얻기 위해서는 정말 너무 필요했다. 그래서 쿼리문 짜는 방법에 대해서도 공부를 더 해야겠다.