리포지터리는 보통 관련 서비스에서만 접근이 가능하고, 그 외에 다른 모듈에서 직접 리포지터리를 다루지 않는게 보통입니다.
수정된 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; 해보면 잘 생성된 것을 확인할 수 있다.
자바에서 보통 날짜(시분초 까지 포함한)를 저장할 때 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를 추가해 자동으로 추가되게 할 수 있다.
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를 수정해준다.
조회 결과가 최대 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
세번째 시도에 존재하므로 이미 존재하는 아이디라 뜬다.

예외 상활 발생시 빠른 리턴을 사용해도 되지만 예외를 발생시키는게 관례입니다.
예외상황에서 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에서 중간에 한번이라도 오류가 발생하면 모든게 롤백된다.

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


@Transactional 붙은 메서드에서 @Transactional 붙은 메서드를 수행하면 물리 트랜잭션은 가장 바깥쪽 메서드 기준으로 1개만 발동한다. 다만 논리 트랜잭션은 @Transactional 붙은 메서드가 호출될 때 마다 작동한다. 물리 트랜잭션이 중요하다. 다만 논리 트랜잭션안에서 RuntimeException 계열의 예외를 발생시키면 조용한 롤백이 작동한다. try catch 로 예외가 발생하는 메서드를 감싸도 소용없다. @Transactional(noRollbackFor = GlobalException.class) 와 같이 특정 예외에 대해서는 조용한 롤백이 안되도록 할 수 있다.
@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;
각 부분의 공통적인 부분을 상위클래스로 보낸 것이다.
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가 생긴 것을 확인할 수 있다.
서비스의 역할은 비지니스 로직의 처리 입니다. 모든 비지니스 로직은 서비스에 의해서 수행되는게 좋습니다.
수정된 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에 생긴 것을 확인할 수 있다.
프록시 엔티티 객체를 사용하면 쓸데없는 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에서 찾아오는 것을 확인 할 수 있다.