package com.sooho.projectboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import java.util.Optional;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.of("Sooho"); //TODO: 스프링 스큐리티로 인증 기능을 붙이게 될 때, 수정하자
}
}
이 코드는 스프링 애플리케이션에서 JPA 감사(Auditting)를 활성화하는 데 사용되는 Spring 구성 클래스 ( JpaConfig ) 이다.
목적 :
이코드의 주요 목적은 Spring Data JPA에서 제공하는 감사 기능을 활성화 하는 것이다. 감사 기능을 활성화하면 엔터티의 시간 및 사용자와 같은 정보를 자동으로 기록할 수있다.
참고 : 감사(Auditing) 기능은 소프트웨어에서 데이터의 변경과 관련된 정보를 기록하는 기능입니다. 주로 데이터베이스에서 데이터의 변경 이력을 추적하거나, 사용자의 특정 행동에 대한 로그를 유지하는 데 사용된다
Spring Data JPA에서의 감사 기능은 주로 데이터베이스의 특정 엔티티에 대한 변경 내역을 추적하고, 이를 자동으로 기록하거나 업데이트하는 기능을 말하며 주요 목적은 다음과 같다.
변경 추적: 엔티티(테이블)에 대한 변경(생성, 수정)을 추적하여 해당 변경 이벤트에 대한 정보를 유지한다.
시간 기록: 엔티티의 생성 시간과 마지막 수정 시간을 추적하여 해당 시간 정보를 유지
사용자 추적: 엔티티를 생성하거나 수정한 사용자에 대한 정보를 추적하여 해당 사용자 정보를 유지
Spring Data JPA는 @EnableJpaAuditing 어노테이션을 사용하여 이러한 감사 기능을 활성화할 수 있다 그러면 감사를 위한 추가적인 정보(예: 생성 시간, 수정 시간, 사용자)가 자동으로 엔티티에 기록된다
AuditorAware 인터페이스를 구현하면, 현재 사용자 정보를 제공하는 로직을 정의할 수 있습니다. 이를 통해 감사 기능은 해당 사용자가 누구인지를 파악하여 해당 정보를 엔티티에 저장할 수 있다.
이를 통해 애플리케이션은 데이터 변경에 대한 추적과 관리가 용이해지며, 보안, 무결성, 사용자 추적 등 다양한 측면에서 유용한 기능을 제공할 수 있다.
코드 설명 :
@EnableJpaAuditing : 이 어노테이션은 Spring Data JPA에서 제공하는 감사 기능을 활성화한다.
@Configuration : 이 클래스가 Spring Bean으로 사용될 것임을 나타낸다.
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.of("Sooho"); //TODO: 스프링 스큐리티로 인증 기능을 붙이게 될 때, 수정하자
}
auditorAware() 메서드는 AuditorAware 인터페이스의 구현을 반환한다.
이 인터페이스는 JPA 감사에서 현재 사용자를 가져오는 데 사용된다. Sooho라는 하드코딩된 값을 제공 하고있다.
수정할 부분 :
주석 부분에서는 스프링 시큐리티를 통해 실제 인증된 사용자를 가져오도록 수정해야 함을 나타낸다.
이 부분을 수정하면 감사 기능이 실제로 로그인한 사용자의 정보를 기록하게 된다.
예를 들어 스프링 시큐리티를 사용하여 현재 사용자를 가져오는 방식으로 수정할 수 있다.
@Bean
public AuditorAware<String> auditorAware() {
return () -> {
// 여기에 스프링 시큐리티를 사용하여 실제로 인증된 사용자를 가져오는 로직을 구현
// 가져온 사용자의 이름 또는 식별자를 Optional로 반환
};
}
이제 이 auditorAware() 메서드 내에 스프링 스큐리티를 활용하여 실제로 인증된 사용자를 가져오는 로직을 구현하여야 한다 이렇게 수정하면 JPA 검사 기능이 실제 사용자 정보를 기록하게 된다.
package com.sooho.projectboard.repository;
import com.sooho.projectboard.domain.Article;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleRepository extends JpaRepository<Article, Long> {
}
이 코드는 Spring Data JPA에서 제공하는 기본적인 레포지토리 인터페이스를 확정한 ArticleRepositroy 인터페이스이다.
설명 :
ArticleRepository : 이 인터페이스는 Article 엔티티에 대한 데이터 액세스 작업을 정의한다. 이를 통해 데이터베이스와 상호작용할 수 있는 메서드를 포함하고 있다.
상속 :
JpaRepository<Article, Long> : 이 인터페이스는 Spring Data JPA에서 제공하는 JpaRepository 인터페이스를 상속받는다
Artile : 이 레포지토리는 Article 엔티티와 관련된 작업을 수행한다.
Long : Article 엔티티의 기본 키 타입이 Long 임을 나타낸다.
기능 :
JpaRepository 를 상속받았기 때문에, ArticleRepository 는 기본적인 CRUD 작업을 수행할 수 있는 메서드들을 기본적으로 제공 받는다 이 메서드들로는 :
save() : 엔티티 저장
findById() : ID로 엔티티 조회
findAll() : 모든 엔티티 조회
deleteById() : ID로 엔티티 삭제
등이 있다.
또한 Spring Data JPA는 메서드 이름 규칙에 따라 쿼리를 자동으로 생성해주기 때문에 메서드의 이름만으로도 특정 조건에 맞는 데이터를 조회할 수 있다 . 이를 Query Creation 이라고 한다 . 예를 들어
findByTitle(String title) 메서드를 정의하면 title 필드가 일치하는 모든 엔티티를 조회할 수 있다.
이 ArticleRepository 를 사용하면 데이터베이스와 상호작용을 추상화하여 간단한 메서드 호출을 통해 엔티티에 대한 CRUD 작업을 수행할 수 있다.
package com.sooho.projectboard.repository;
import com.sooho.projectboard.domain.AricleComment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AricleCommentRepository extends JpaRepository<AricleComment, Long> {
}
package com.sooho.projectboard.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
@Getter
@ToString
@Table(indexes = {
@Index(columnList = "title"),
@Index(columnList = "hashtag"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy"),
})
@EntityListeners(AuditingEntityListener.class)
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter @Column(nullable = false) private String title; // 제목
@Setter @Column(nullable = false, length = 10000) private String content; // 본문
@Setter private String hashtag; // 해시태그
@OrderBy("id")
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@ToString.Exclude
private final Set<AricleComment> aricleComments = new LinkedHashSet<>();
@CreatedDate @Column(nullable = false) private LocalDateTime createdAt; //생성일시
@CreatedBy @Column(nullable = false, length = 100) private String createdBy; // 생성자
@LastModifiedDate @Column(nullable = false) private LocalDateTime modifiedAt; // 수정일시
@LastModifiedBy @Column(nullable = false, length = 100) private String modifiedBy; // 수정자
protected Article() {}
private Article(String title, String content, String hashtag) {
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
public static Article of(String title, String content, String hashtag) {
return new Article(title, content, hashtag);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Article article)) return false;
return id != null && id.equals(article.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
Article 엔터티 :
@Entity : JPA 엔티티임을 나타내는 어노테이션이다
@Table : 엔티티가 매핑될 테이블에 대한 정보를 제공하며 여기서는 여러 컬럼에 대한 인덱스를 정의하고있다.
@Table(indexes = {
@Index(columnList = "title"),
@Index(columnList = "hashtag"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy"),
})
@Table(indexes = { ... } ) 부분은 JPA에서 테이블에 인덱스를 추가하는데 사용되는 어노테이션이다.
이 어노테이션을 사용하여 엔티티 클래스와 데이터베이스 테이블 간의 매핑 정보를 제공하고 해당 테이블에 인덱스를 생성할 수 있다.
여기서 @Index 어노테이션은 데잍터베이스 테이블의 특정 칼럼에 인덱스를 추가하는 역활을 한다 각 @Index 어노테이션은 하나의 인덱스를 정의하며 columnList 속성을 사용하여 인덱스를 생성할 칼럼은 지정한다.
예를 들어 주어진 코드에서는 Article 엔티티 클래스와 매핑되는 데이터베이스 테이블에 네 개의 인덱스가 추가되고있다.
이렇게 인덱스를 추가하면 데이터베이스에서 해당 칼럼들을 빠르게 검색 할 수 있게 한다. 인덱스는 특정 칼럼에 대한 데이터의 물리적인 정렬을 만들어서 검색 성능을 향상시킨다 특히 검색이 자주 일어나는 칼럼에 인덱스를 추가하면 데이터베이스의 성능을 최적화할 수 있다.
@EntityListeners : JPA 엔티티 리스너를 등록하는 어노테이션으로 여기서는 AuditingEntityListener 를 사용하여
감사( Auditing ) 기능을 활성화하고 있다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
자동 생성되는 키의 전략( strategy ) 를 지정한다 . GenerrationType.IDENTITY 를 사용하여 데이터베이스에서 키를 자동으로 생성하도록 설정한다.
@OrderBy("id")
@OrderBy("id") 는 JPA 어노테이션으로 연관된 엔티티의 컬렉션을 가져올 때 해당 필드를 기준으로 정렬하는 방식을 지정한다.
여기서 @OrderBy("id") 는 Article 엔티티 클래스 내부의 articleComments 필드에 적용되었다. 이 필드는 ArticleComment 엔티티의 컬렉션을 나타낸다.
@OrderBy("id") arrticcleComments 컬렉션을 id 필드를 기준으로 오름차순으로 정렬하도록 지정한다 즉 Article 엔티티
조회할때 해당 articleComments 컬렉션은 id 에 따라 정렬되어 반환된다.
이렇게 정렬된 컬렉션을 가져오면 예를 들어 특정 글의 댓글 목록을 조회할 때 정해진 순서대로 댓글이 반환될 수 있다.
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@OneToMany 는 일대다 관계를 나타내며 여러개의 ArticleComment 엔티티가 하나의 Article 엔티티에 매핑될 수 있는걸 의미 한다
mappedBy = "article" 이부분은 관계의 주인을 지정한다 여기서 article 은 aticleComment 엔티티 클래스 내에 있는 필드명을 나타낸다 이플디는 atcile 엔티티와 연관되어 있으며 이관계를 기반으로 매핑을 수행한다.
cascade = CascadeType.ALL 은 모든 상태 변화를 전파하는 것으로 Article 엔티티 상태 변화가 발생하면 연관된 ArticleComment 엔티티에도 모든 변경 사항이 전파된다 예를 들어 Article 이 삭제되면 연관된 모든 ArticleComment 엔티티도 함께 삭제된다.
이 관계 설정을 통해 하나의 글 ( 'Article' ) 에 여러 개의 댓글 ( 'ArticleComment' ) 이 연관되며, 글에 대한 변경이 발생할 때 댓글에도 해당 변경이 전파된다는 의미이다.
@ToString.Exclude
Lombok의 기능 중 하나로 해당 필드를 toString() 메서드에서 제외하는 역활을 한다.
일반적으로 Lombok의 @ToString 어노테이션은 클래스의 toString() 메서드를 자동으로 생성해준다 이때 @ToStirng 이 적용된 클래스의 모든 필드는 toString() 결과에 포함된다,
그런데 때로는 특정 필드를 toString() 결과에서 제외하고 싶을 때가 있다 이때 @ToSting.Exclude 를 사용하면 해당 필드를 toString() 결과에서 제외할 수 있다.
이렇게 @ToSting.Exclude 을 사용함으로써 개인정보와 같이 로깅이나 디버깅 목적으로 출력하면 안되는 민감한 정보를 숨길 수 있다.
package com.sooho.projectboard.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import java.time.LocalDateTime;
import java.util.Objects;
@Getter
@ToString
@Table(indexes = {
@Index(columnList = "content"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class AricleComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter @ManyToOne(optional = false) private Article article; //게시글 (ID)
@Setter @Column(nullable = false, length = 500) private String content; // 본문
@CreatedDate @Column(nullable = false) private LocalDateTime createdAt; // 생서일시
@CreatedBy @Column(nullable = false,length = 100) private String createdBy; // 생성자
@LastModifiedDate @Column(nullable = false) private LocalDateTime modifiedAt; //수정일시
@LastModifiedBy @Column(nullable = false,length = 100) private String modifiedBy; // 수정자
protected AricleComment() {}
private AricleComment(Article article, String title) {
this.article = article;
this.content = content;
}
public static AricleComment of(Article article, String content) {
return new AricleComment(article, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AricleComment that)) return false;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
package com.sooho.projectboard.repository;
import com.sooho.projectboard.config.JpaConfig;
import com.sooho.projectboard.domain.Article;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Jpa 연결 테스트")
@Import(JpaConfig.class)
@DataJpaTest
class JpaRepositoryTest {
private final ArticleRepository articleRepository;
private final AricleCommentRepository articleCommentRepository;
public JpaRepositoryTest(
@Autowired ArticleRepository articleRepository,
@Autowired AricleCommentRepository aricleCommentRepository
) {
this.articleRepository = articleRepository;
this.articleCommentRepository = aricleCommentRepository;
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine() {
// Given
// When
List<Article> articles = articleRepository.findAll();
// Then
assertThat(articles)
.isNotNull()
.hasSize(123);
}
@DisplayName("insert 테스트")
@Test
void givenTestData_wheninserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count();
// When
Article savedArticle = articleRepository.save(Article.of("new article","new content", "#spring"));
// Then
assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
}
@DisplayName("Update 테스트")
@Test
void givenTestData_whenUpdateing_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
String updatedHashtag = "springboot";
article.setHashtag(updatedHashtag);
// When
Article savedArticle = articleRepository.saveAndFlush(article);
articleRepository.flush();
// Then
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag);
}
@DisplayName("Delete 테스트")
@Test
void givenTestData_whenDeleteing_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
long previousArticleCount = articleRepository.count();
long previousArticleCommentCount = articleCommentRepository.count();
long deletedCommentsSize = article.getAricleComments().size();
// When
articleRepository.delete(article);
// Then
assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1);
assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize);
}
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine() {
// Given
// When
List<Article> articles = articleRepository.findAll();
// Then
assertThat(articles)
.isNotNull()
.hasSize(123);
}
테스트 목적 :
ArticleRepository 의 findAll() 메서드가 예상한 대로 동작하는지 확인한다.
데이터베이스에서 모든 게시물을 가져온 후 가져온 게시물 목록이 null 이 아니고 123개의 게시물을 포함하는지 검증한다.
테스트 단계 :
When :
List<Artilce> article = articleRepository.findAll(); 에서 ArticleRepository의 findAll() 메서드를 호출하여 모든 게시물을 가져온다.
Than :
assrtThat(article) : 가져온 게시물 목록을 검증하기 위해 AssertJ의 assertThat 을 사용한다
.isNotnull() : 가져온 게시물 목록이 null이 아닌지 확인한다
.hasSize(123) : 가져온 게시물 목록의 크기가 123개인지 확인한다.
의미 :
이 테스트는 ArticleRepository의 findAll() 메서드가 올바르게 작동하는지 확인.
데이터베이스에서 모든 게시물을 가져와서 해당 목록이 null이 아니며 123개의 게시물을 포함하는지를 검증하여, 조회 작업이 예상한 대로 수행되는지 테스트 한다.
@DisplayName("insert 테스트")
@Test
void givenTestData_wheninserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count();
// When
Article savedArticle = articleRepository.save(Article.of("new article","new content", "#spring"));
// Then
assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
}
테스트 목적 :
목적 : ArticleRepository의 save() 메서드를 사용하여 새로운 게시물을 추가할 때, 데이터베이스에 적절히 반영되는지 확인
동작 : 이전 저장된 게시물의 수에 1을 더한 값이 새로운 게시물을 저장한 후의 게시물 수와 일치하는지 검증한다.
테스트 단계 :
Given :
long previousCount = articleRepository.count(); : ArticleRepository 에 저장된 이전 게시물 수를 가져온다
When :
articleRepository.save(Article.of("new article","new content", "#spring")); : 새로운 게시물을 ArticleRepository에 저장
Then :
assertThat(articleRepository.count()).isEqualTo(previousCount + 1); : 새로운 게시물을 저장한 후의 게시물 수가 이전에 저장된 게시물 수에 1을 더한 값과 동일한지를 검증.
의미 :
이 테스트는 ArticleRepository의 save() 메서드를 사용하여 새로운 게시물을 추가했을 때 데이터베이스에 적절히 반영되는지 확인하고 새로운 게시물을 추가하고나서 데이터베이스의 게시물 수가 기존에 저장된 게시물 수에 1을 더한 값과 같은지를 검증하여 새로운 게시물이 데이터베이스에 올바르게 추가되었는지 테스트한다.
@DisplayName("Update 테스트")
@Test
void givenTestData_whenUpdateing_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
String updatedHashtag = "springboot";
article.setHashtag(updatedHashtag);
// When
Article savedArticle = articleRepository.saveAndFlush(article);
articleRepository.flush();
// Then
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag);
}
테스트 목적 :
목적 :
ArticleRepository의 save() 메서드를 사용하여 새로운 게시물을 추가할 때, 데이터베이스에 적절히 반영되는지 확인.
동작 :
이전에 저장된 게시물의 수에 1을 더한 값이 새로운 게시물을 저장한 후의 게시물 수와 일치하는지 검증.
테스트 단계 :
Given (주어진 상황):
Article article = articleRepository.findById(1L).orElseThrow(); : 데이터베이스에서 ID가 1인 게시물을 가져온다
String updatedHashtag = "springboot"; : 새로운 해시태그 값을 변수에 할당
article.setHashtag(updatedHashtag); : 가져온 게시물의 해시태그를 새 값으로 변경
When (실행):
Article savedArticle = articleRepository.saveAndFlush(article); : 변경된 게시물을 저장하고, 플러시하여 즉시 데이터베이스에 반영
articleRepository.flush();: 변경 사항을 데이터베이스에 즉시 반영
Then (단언):
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag); : 저장된 게시물 객체가 새로운 해시태그 값을 가졌는지 검증.
hasFieldOrPropertyWithValue()를 사용하여 savedArticle 객체의 hashtag 필드가 updatedHashtag 값과 일치하는지 확인.
의미:
이 테스트는 ArticleRepository의 saveAndFlush() 메서드를 사용하여 게시물의 해시태그를 변경한 후, 변경된 값이 데이터베이스에 적절히 반영되는지 확인
변경된 해시태그 값을 저장하고 플러시하여 즉시 데이터베이스에 반영한 후 저장된 게시물 객체가 예상한 대로 변경되었는지 검증하여 업데이트 작업이 정상적으로 수행되었는지 테스트한다
@DisplayName("Delete 테스트")
@Test
void givenTestData_whenDeleteing_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
long previousArticleCount = articleRepository.count();
long previousArticleCommentCount = articleCommentRepository.count();
long deletedCommentsSize = article.getAricleComments().size();
// When
articleRepository.delete(article);
// Then
assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1);
assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize);
}
테스트 목적 :
목적 :
Article를 삭제할 때, 그에 연관된 ArticleComment들도 적절히 함께 삭제되는지 확인
동작 :
특정 ID의 Article을 가져온다.
Article과 관련된 ArticleComment 수와 Article 수를 이전에 저장된 값과 비교
Article를 삭제한 후, Article과 관련된 ArticleComment 수가 감소되었는지 확인
테스트 단계 :
Given :
Article article = articleRepository.findById(1L).orElseThrow(); : 데이터베이스에서 ID가 1인 Article을 가져온다.
long previousArticleCount = articleRepository.count(); : ArticleRepository에 저장된 이전 Article 수를 가져온다.
long previousArticleCommentCount = articleCommentRepository.count(); : ArticleCommentRepository에 저장된 이전 ArticleComment 수를 가져온다.
long deletedCommentsSize = article.getAricleComments().size(); : Article과 연관된 ArticleComment 수를 가져온다.
When :
aricleRepositoy.delete(article) : 가져온 Article 을 삭제 한다
Then :
assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1); : Article을 삭제한 후의 Article 수가 이전에 저장된 Article 수보다 1 감소했는지 확인
assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize); : Article을 삭제한 후의 ArticleComment 수가 이전에 저장된 ArticleComment 수에서 Article과 연관된 Comment 수를 뺀 값과 동일한지 확인
의미 :
이 테스트는 Article를 삭제할 때, 해당 Article과 연관된 ArticleComment들도 함께 적절히 삭제되는지 확인
Article을 삭제한 후, 삭제된 Article의 수가 1만큼 감소했으며, 해당 Article과 연관된 ArticleComment의 수도 올바르게 변경되었는지 테스트한다.
'프로젝트 > 게시판 서비스' 카테고리의 다른 글
4. API 테스트 정의 (0) | 2023.12.01 |
---|---|
3. 데이터베이스 접근 로직 구현 (0) | 2023.11.30 |
1-2 프로젝트 기획 (필요한 기술 정리) (0) | 2023.11.24 |
1-1 프로젝트 기획 (환경,목적) (1) | 2023.11.24 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!