8주차/ Spring Data JPA로 영속성 부여

전진수·2025년 5월 19일

11. ArticleService 도입

리포지터리는 보통 관련 서비스에서만 접근이 가능하고, 그 외에 다른 모듈에서 직접 리포지터리를 다루지 않는게 보통입니다.

수정된 NotProd.java

package com.ll.demo03.global.initData;

import com.ll.demo03.domain.article.article.entity.Article;
import com.ll.demo03.domain.article.article.service.ArticleService;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;

import java.util.List;

//!prod == dev or test
@Profile("!prod")
@Configuration
@RequiredArgsConstructor
public class NotProd {
    @Lazy
    @Autowired
    private NotProd self;
    private final ArticleService articleService;

    @Bean
    public ApplicationRunner initNotprod(){
        return args -> {
            self.work1();
            self.work2();
        };
    }

    @Transactional
    public void work1() {
        if ( articleService.count() > 0 ) return;

        Article article1 = articleService.write("제목 1", "내용 1");
        Article article2 =articleService.write("제목 2", "내용 2");

        article2.setTitle("제목!!");

        articleService.delete(article1);
    }

    @Transactional
    public void work2() {
        //List : 0 ~ N
        //Optional : 0 ~ 1
        Article article = articleService.findById(2L).get();
        List<Article> articles = articleService.findAll();
    }
}

Article.service.java

 package com.ll.demo03.domain.article.article.service;

import com.ll.demo03.domain.article.article.entity.Article;
import com.ll.demo03.domain.article.article.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor

public class ArticleService {
    private final ArticleRepository articleRepository;

    public long count() {
        return articleRepository.count();
    }

    public Article write(String title, String body) {
        Article article = Article
                .builder()
                .title(title)
                .body(body)
                .build();

        return articleRepository.save(article);
    }

    public void delete(Article article) {
        articleRepository.delete(article);
    }

    public Optional<Article> findById(long id) {
        return articleRepository.findById(id);
    }

    public List<Article> findAll() {
        return articleRepository.findAll();
    }
}


select * from article; 해보면 잘 생성된 것을 확인할 수 있다.

12. 엔티티의 생성날짜, 수정날짜 자동기입

자바에서 보통 날짜(시분초 까지 포함한)를 저장할 때 LocalDateTime 을 사용합니다. 그것은 MySQL 의 DATETIME 타입과 호환됩니다.

Article.java에
private LocalDateTime createDate;
private LocalDateTime modifyDate; 추가

ArticleService.java에서
public Article write(String title, String body) {
Article article = Article
.builder()
.createDate(LocalDateTime.now()) <- 수정
.modifyDate(LocalDateTime.now()) <- 수정
.title(title)
.body(body)
.build();
return articleRepository.save(article);
}


잘 만들어진 것을 확인할 수 있다.

Article.java 수정하고,

package com.ll.demo03.domain.article.article.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)

public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // null이 들어갈 수 있다.
    @CreatedDate
    private LocalDateTime createDate;
    @LastModifiedDate
    private LocalDateTime modifyDate;
    private String title;
    @Column(columnDefinition = "TEXT")
    private String body;
}

Demo03Application에 @EnableJpaAuditing를 추가해 자동으로 추가되게 할 수 있다.

13. RsData 도입

RsData를 사용하면 결과 데이터 뿐 아니라 상태코드, 메세지 까지 같이 묶어서 리턴할 수 있습니다.

RsData.java

package com.ll.demo03.global.rsData;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.ll.demo03.standard.dto.Empty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.lang.NonNull;

import static lombok.AccessLevel.PRIVATE;

//Spring Doc + openapi fetch

@AllArgsConstructor(access = PRIVATE)
@NoArgsConstructor(access = PRIVATE)
@Getter
public class RsData<T> {
    public static final RsData<Empty> OK = of("200-1", "성공", new Empty());
    public static final RsData<Empty> FAIL = of("500-1", "실패", new Empty());

    @NonNull
    String resultCode; // 200-1, 200-2
    @NonNull
    int statusCode; // 200, 400, 500
    @NonNull
    String msg; // 메세지
    @NonNull
    T data;

    public static RsData<Empty> of(String msg) {
        return of("200-1", msg, new Empty());
    }

    public static <T> RsData<T> of(T data) {
        return of("200-1", "성공", data);
    }

    public static <T> RsData<T> of(String msg, T data) {
        return of("200-1", msg, data);
    }

    public static <T> RsData<T> of(String resultCode, String msg) {
        return of(resultCode, msg, (T) new Empty());
    }

    public static <T> RsData<T> of(String resultCode, String msg, T data) {
        int statusCode = Integer.parseInt(resultCode.split("-", 2)[0]);

        RsData<T> tRsData = new RsData<>(resultCode, statusCode, msg, data);

        return tRsData;
    }

    @NonNull
    @JsonIgnore
    public boolean isSuccess() {
        return getStatusCode() >= 200 && getStatusCode() < 400;
    }

    @NonNull
    @JsonIgnore
    public boolean isFail() {
        return !isSuccess();
    }

    public <T> RsData<T> newDataOf(T data) {
        return new RsData<>(resultCode, statusCode, msg, data);
    }
}

ArticleService.java

    //리턴
    // - 이번에 생성된 게시물의 번호
    // - 게시물 생성에 대한 결과 메세지
    // - 결과 코드
    public RsData<Article> write(String title, String body) {
        Article article = Article
                .builder()
                .createDate(LocalDateTime.now())
                .modifyDate(LocalDateTime.now())
                .title(title)
                .body(body)
                .build();

        articleRepository.save(article);

        return RsData.of("%d번 게시물이 작성되었습니다.".formatted(article.getId()), article);
    }

NotProd.java

     @Transactional
    public void work1() {
        if ( articleService.count() > 0 ) return;

        Article article1 = articleService.write("제목 1", "내용 1").getData();
        Article article2 =articleService.write("제목 2", "내용 2").getData();

        article2.setTitle("제목!!");

        articleService.delete(article1);
    }

RsData를 사용하기 위해서 ArticleService.java와 NotProd.java를 수정해준다.

14. 샘플 회원 2명 생성

조회 결과가 최대 1개라면 보통 Optional 을 리턴하고, 그 외에는 List 를 리턴하는게 관례입니다.

Member.java

package com.ll.demo03.domain.member.member.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

import static jakarta.persistence.GenerationType.IDENTITY;

@Entity
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Member {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    @CreatedDate
    private LocalDateTime createDate;
    @LastModifiedDate
    private LocalDateTime modifyDate;
    @Column(unique = true)
    private String username;
    private String password;
    private String nickname;
}


member가 생긴 것을 확인할 수 있다.

MemberService.java

package com.ll.demo03.domain.member.member.service;

import com.ll.demo03.domain.member.member.entity.Member;
import com.ll.demo03.domain.member.member.repository.MemberRepository;
import com.ll.demo03.global.rsData.RsData;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public RsData<Member> join(String username, String password, String nickname) {
        boolean present = findByUsername(username).isPresent();

        if (present) {
            return RsData.of("400-1", "이미 존재하는 아이디입니다.", Member.builder().build());
        }

        Member member = Member.builder()
                .username(username)
                .password(password)
                .nickname(nickname)
                .build();

        memberRepository.save(member);

        return RsData.of("회원가입이 완료되었습니다.", member);
    }

    private Optional<Member> findByUsername(String username) {
        return memberRepository.findByUsername(username);
    }
}

NotProd.java 수정

    @Transactional
    public void work1() {
        if ( articleService.count() > 0 ) return;

        Member member1 = memberService.join("user1", "1234", "nickname1").getData();
        Member member2 = memberService.join("user2", "1234", "유저 2").getData();

        RsData<Member> joinRs = memberService.join("user2", "1234", "유저 2");

        System.out.println("joinRs.getMsg() : " + joinRs.getMsg());
        System.out.println("joinRs.getStatusCode() : " + joinRs.getStatusCode());

        Article article1 = articleService.write("제목 1", "내용 1").getData();
        Article article2 =articleService.write("제목 2", "내용 2").getData();

        article2.setTitle("제목!!");

        articleService.delete(article1);
    }

find-> 없으면 insert
세번째 시도에 존재하므로 이미 존재하는 아이디라 뜬다.

15. 오류상태를 리턴말고 예외발생

예외 상활 발생시 빠른 리턴을 사용해도 되지만 예외를 발생시키는게 관례입니다.
예외상황에서 if 조건 return 으로 끝내도 되고, 예외를 발생시켜도 된다. IllegalArgumentException 도 좋지만 추후 더 세밀한 예외 핸들링을 위해서 GlobalException 을 추가한다. @Transactional 이 붙은 메서드에서 RuntimeException 계열 예외를 발생시킨다. 그러면 해당 로직을 포함한 물리 트랜잭션(가장 바깥쪽 @Transactional 붙은 메서드)의 모든 쿼리가 취소된다. GlobalException 은 getRsData() 메서드를 통해서 오류 상태에 대한 정보를 받을 수 있다.

GlobalException.java

package com.ll.demo03.global.exceptions;

import com.ll.demo03.global.rsData.RsData;
import com.ll.demo03.standard.dto.Empty;
import lombok.Getter;

@Getter
public class GlobalException extends RuntimeException {
    private final RsData<Empty> rsData;

    public GlobalException() {
        this("400-0", "에러");
    }

    public GlobalException(String msg) {
        this("400-0", msg);
    }

    public GlobalException(String resultCode, String msg) {
        super("resultCode=" + resultCode + ",msg=" + msg);
        this.rsData = RsData.of(resultCode, msg);
    }

    public static class E404 extends GlobalException {
        public E404() {
            super("404-0", "데이터를 찾을 수 없습니다.");
        }
    }
}

MemberService.java 수정

findByUsername(username).ifPresent(ignored -> {
            throw new GlobalException("400-1", "%s(은)는 이미 존재하는 아이디입니다.".formatted(username));
        });

Transaction에서 중간에 한번이라도 오류가 발생하면 모든게 롤백된다.

16. 서비스에 @Transactional 적용

서비스의 클래스에는 보통 @Transactional(readOnly = true) 를 붙이는게 관례입니다.
서비스의 모든 public 메서드에는 @Transactional 을 붙여야 한다.
그 중 오직 조회(SELECT)로만 구성된 메서드는 @Transactional(readOnly = True) 를 붙여야 한다.

@Transactional 붙은 메서드에서 @Transactional 붙은 메서드를 수행하면 물리 트랜잭션은 가장 바깥쪽 메서드 기준으로 1개만 발동한다. 다만 논리 트랜잭션은 @Transactional 붙은 메서드가 호출될 때 마다 작동한다. 물리 트랜잭션이 중요하다. 다만 논리 트랜잭션안에서 RuntimeException 계열의 예외를 발생시키면 조용한 롤백이 작동한다. try catch 로 예외가 발생하는 메서드를 감싸도 소용없다. @Transactional(noRollbackFor = GlobalException.class) 와 같이 특정 예외에 대해서는 조용한 롤백이 안되도록 할 수 있다.

17. @MappedSuperclass

@EqualsAndHashCode 의 효과로 인해서 member1.equals(member2); 와 같은 코드에서 객체 전체비교가 아닌 id 비교만으로 끝납니다.

BaseEntity.java

package com.ll.demo03.global.jpa.entity;

import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import static jakarta.persistence.GenerationType.IDENTITY;

@MappedSuperclass
@Getter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    @EqualsAndHashCode.Include
    private Long id;

    public String getModelName() {
        String simpleName = this.getClass().getSimpleName();
        return Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1);
    }
}

BaseTime.java <- 얘만 상속받으면 된다.

package com.ll.demo03.global.jpa.entity;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@MappedSuperclass
@Getter
@Setter(AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime extends BaseEntity {
    @CreatedDate
    private LocalDateTime createDate;
    @LastModifiedDate
    private LocalDateTime modifyDate;

    public void setModified() {
        setModifyDate(LocalDateTime.now());
    }
}

Member.java

package com.ll.demo03.domain.member.member.entity;

import com.ll.demo03.global.jpa.entity.BaseTime;
import jakarta.persistence.Entity;
import lombok.*;
import static lombok.AccessLevel.PROTECTED;

@Entity
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
@Builder
@Getter
@Setter
public class Member extends BaseTime {
        private String username;
        private String password;
        private String nickname;
}

Article.java

package com.ll.demo03.domain.article.article.entity;

import com.ll.demo03.global.jpa.entity.BaseTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import lombok.*;
import static lombok.AccessLevel.PROTECTED;

@Entity
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
@Builder
@Getter
@Setter
public class Article extends BaseTime {
    private String title;
    @Column(columnDefinition = "TEXT")
    private String body;

각 부분의 공통적인 부분을 상위클래스로 보낸 것이다.

18. Article 에 Member 추가

DB 테이블의 특정 필드에 객체를 저장할 수 없지만, 그의 짝인 엔티티 클래스에서는 필드에 객체 타입으로 선언하는게 관례입니다.
Article 클래스에 해당 게시물을 누가 작성했는지에 대한 정보를 표시하기 위해 필드를 추가해야 한다.
private String authorUsername; 도 가능하고, private Long authorId; 도 가능하지만 @ManyToOne(Member 하나에 Article이 많다) private Member auhor; 로 하는게 JPA 에서의 관례 이다.

Article.java


author_id가 자동으로 생긴 것을 확인할 수 있다.

ArticleService.java에서 author를 추가해주면

@Transactional
    public RsData<Article> write(Member author, String title, String body) {
        Article article = Article
                .builder()
                .author(author)
                .title(title)
                .body(body)
                .build();

        articleRepository.save(article);

        return RsData.of("%d번 게시물이 작성되었습니다.".formatted(article.getId()), article);
    }

NotProd.java에서 추가할 수 있다.

    @Transactional
    public void work1() {
        if ( articleService.count() > 0 ) return;

        Member member1 = memberService.join("user1", "1234", "nickname1").getData();
        Member member2 = memberService.join("user2", "1234", "유저 2").getData();

        Article article1 = articleService.write(member1, "제목 1", "내용 1").getData();
        Article article2 =articleService.write(member1, "제목 2", "내용 2").getData();

        Article article3 = articleService.write(member2, "제목 1", "내용 1").getData();
        Article article4 =articleService.write(member2,"제목 2", "내용 2").getData();


    }


author_id가 생긴 것을 확인할 수 있다.

19. Surl 엔티티화

서비스의 역할은 비지니스 로직의 처리 입니다. 모든 비지니스 로직은 서비스에 의해서 수행되는게 좋습니다.

수정된 SurlController.java

package com.ll.demo03.domain.surl.surl.controller;

import com.ll.demo03.domain.surl.surl.entity.Surl;
import com.ll.demo03.domain.surl.surl.repository.SurlService;
import com.ll.demo03.global.exceptions.GlobalException;
import com.ll.demo03.global.rsData.RsData;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class SurlController {
    private final SurlService surlService;

    @GetMapping("/all")
    @ResponseBody
    public List<Surl> getAll() {
        return surlService.findAll();
    }

    @GetMapping("/add")
    @ResponseBody
    public RsData<Surl> add(String body, String url) {
        return surlService.add(body, url);
    }

    @GetMapping("/s/{body}/**")
    @ResponseBody
    public RsData<Surl> add(@PathVariable String body, HttpServletRequest req) {
        String url = req.getRequestURI();

        if(req.getQueryString() != null) {
            url = url + "?" + req.getQueryString();
        }

        String[] urlBits = url.split("/", 4);

        url = urlBits[3];

        return surlService.add(body, url);
    }

    @GetMapping("/g/{id}")
    public String go(@PathVariable long id) {
        Surl surl = surlService.findById(id).orElseThrow(GlobalException.E404::new);
        surlService.increaseCount(surl);
        return "redirect:" + surl.getUrl();
    }
}

SurlService.java

package com.ll.demo03.domain.surl.surl.repository;

import com.ll.demo03.domain.surl.surl.entity.Surl;
import com.ll.demo03.global.rsData.RsData;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SurlService {
    private final SurlRepository surlRepository;

    public List<Surl> findAll() {
        return surlRepository.findAll();
    }

    @Transactional
    public RsData<Surl> add(String body, String url) {
        Surl surl = Surl.builder()
                .body(body)
                .url(url)
                .build();

        surlRepository.save(surl);

        return RsData.of("%d번 URL이 생성되었습니다.".formatted(surl.getId()), surl);
    }

    public Optional<Surl> findById(long id) {
        return surlRepository.findById(id);
    }

    @Transactional
    public void increaseCount(Surl surl) {
        surl.increaseCount();
    }
}

SurlRepository.java

package com.ll.demo03.domain.surl.surl.repository;

import com.ll.demo03.domain.surl.surl.entity.Surl;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SurlRepository extends JpaRepository<Surl, Long> {

}

http://localhost:8070/add?body=%EA%B5%AC%EA%B8%80&url=https://google.com 실행해서 추가

http://localhost:8070/s/%ED%85%8C%ED%82%B7/https://techit.education 실행해서 추가

http://localhost:8070/g/100하면 오류로 인한 예외가 뜬다.

http://localhost:8070/g/1->google로 이동
http://localhost:8070/g/2->techit.education으로 이동

surl에 생긴 것을 확인할 수 있다.

20. Surl에 author 필드 추가

프록시 엔티티 객체를 사용하면 쓸데없는 SELECT 쿼리의 빈도를 낮출 수 있어서 사용했습니다.
프록시 객체는 사용자에게 진짜 객체처럼 보이지만 평소에는 텅 비어있다(SQL 작동하지 않음) 그러나 사용자가 접근하면 일이 제대로 처리 되기 전에 진짜 SQL을 가져와서 채운다.(위임 방식)

Rq.java

package com.ll.demo03.global.rq;

import com.ll.demo03.domain.member.member.entity.Member;
import com.ll.demo03.domain.member.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.RequestScope;

@Component
@RequestScope
@RequiredArgsConstructor
public class Rq {
    private final MemberService memberService;

    public Member getMember() {
        return memberService.getReferenceById(1L);
    }
}

수정된 SurlService.java

@Transactional
    public RsData<Surl> add(Member author, String body, String url) {
        Surl surl = Surl.builder()
                .author(author)
                .body(body)
                .url(url)
                .build();

        surlRepository.save(surl);

        return RsData.of("%d번 URL이 생성되었습니다.".formatted(surl.getId()), surl);
    }

수정된 Surl.java

@Entity
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PROTECTED)
@Builder
@Getter
@Setter
public class Surl extends BaseTime {
    @ManyToOne
    @JsonIgnore
    private Member author;
    private String body;
    private String url;
    @Setter(AccessLevel.NONE)
    private long count;

    public void increaseCount() {
        count++;
    }
}

수정된 SurlController.java

package com.ll.demo03.domain.surl.surl.controller;

import com.ll.demo03.domain.member.member.entity.Member;
import com.ll.demo03.domain.surl.surl.entity.Surl;
import com.ll.demo03.domain.surl.surl.service.SurlService;
import com.ll.demo03.global.exceptions.GlobalException;
import com.ll.demo03.global.rq.Rq;
import com.ll.demo03.global.rsData.RsData;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class SurlController {
    private final Rq rq;
    private final SurlService surlService;

    @GetMapping("/all")
    @ResponseBody
    public List<Surl> getAll() {
        return surlService.findAll();
    }

    @GetMapping("/add")
    @ResponseBody
    public RsData<Surl> add(String body, String url) {
        Member member = rq.getMember(); // 현재 브라우저로 로그인한 회원

        System.out.println("before get id");
        member.getId();
        System.out.println("after get id");

        System.out.println("before get username");
        member.getUsername();
        System.out.println("after get username");

        return surlService.add(member, body, url);
    }

    @GetMapping("/s/{body}/**")
    @ResponseBody
    public RsData<Surl> add(@PathVariable String body, HttpServletRequest req) {
        Member member = rq.getMember();

        String url = req.getRequestURI();

        if(req.getQueryString() != null) {
            url = url + "?" + req.getQueryString();
        }

        String[] urlBits = url.split("/", 4);

        url = urlBits[3];

        return surlService.add(member, body, url);
    }

    @GetMapping("/g/{id}")
    public String go(@PathVariable long id) {
        Surl surl = surlService.findById(id).orElseThrow(GlobalException.E404::new);
        surlService.increaseCount(surl);
        return "redirect:" + surl.getUrl();
    }
}

MemberService.java에 추가

   public Member getReferenceById(long id) {
        return memberRepository.getReferenceById(id);
    }

before get username에서 넘어가자마자 sql에서 찾아오는 것을 확인 할 수 있다.

0개의 댓글