본문 바로가기
Projects/청하-청년을 위한 커뮤니티 서비스

[청하] 4. 게시글 등록 기능 구현 (feat. NCP Object Storage 파일 업로드)

by Lpromotion 2024. 10. 6.

청하의 가장 첫 번째 기능으로 “게시글” 기능을 추가하였다.

“게시글 등록”은 사용자가 제목, 내용, 게시글 유형을 입력하고, 이미지를 선택적으로 업로드할 수 있는 기능이다.

 

1. 프로젝트 구조

src/main/java/com/example/withpeace/
│
├── domain/                 # 도메인 모델 (엔티티)
│   ├── Image.java          # 이미지 엔티티
│   └── Post.java           # 게시글 엔티티
│
├── repository/             # 데이터 접근 계층
│   ├── ImageRepository.java    # 이미지 레포지토리
│   └── PostRepository.java     # 게시글 레포지토리
│
├── dto/                    # 데이터 전송 객체
│   ├── request/
│   │   └── PostRegisterRequestDto.java  # 게시글 등록 요청 DTO
│   └── response/
│       └── PostRegisterResponseDto.java # 게시글 등록 응답 DTO
│
├── controller/             # 컨트롤러 계층
│   └── PostController.java # 게시글 관련 API 엔드포인트
│
├── service/                # 비즈니스 로직 계층
│   └── PostService.java    # 게시글 관련 비즈니스 로직
│
└── type/                   # 열거형 및 상수
    └── ETopic.java         # 게시글 주제 열거형

 

2. 도메인 모델 구현

Post와 Image 엔티티를 정의하여 데이터베이스 구조를 구현했다.

2.1. Post 엔티티

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false, updatable = false, unique = true)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "writer_id", nullable = false)
    private User writer;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "type", nullable = false)
    @Enumerated(EnumType.STRING)
    private ETopic type;

    @Column(name = "create_date", nullable = false)
    private LocalDate createDate;

    @Builder
    public Post(User writer, String title, String content, ETopic type) {
        this.writer = writer;
        this.title = title;
        this.content = content;
        this.type = type;
        this.createDate = LocalDate.now(); // 생성 시 현재 날짜로 설정
    }

}
  • @Entity: 이 클래스가 JPA 엔티티임을 나타내고, 데이터베이스 테이블과 매핑된다.
  • @NoArgsConstructor(access = AccessLevel.PROTECTED): 파라미터가 없는 기본 생성자를 생성한다. protected 접근 제어자를 사용하여 무분별한 객체 생성을 방지한다.
  • @DynamicUpdate: 변경된 필드만 UPDATE 쿼리에 포함시킨다. (작업 최적화)
  • @Table(name = "posts"): 데이터베이스의 "posts" 테이블과 이 엔티티를 매핑한다.
  • @Id: 이 필드가 엔티티의 Primary Key임을 나타낸다.
  • @GeneratedValue(strategy = GenerationType.IDENTITY): Primary Key 생성을 데이터베이스에 위임한다. (auto increment)
  • @ManyToOne: 다대일 관계를 표현한다. 여러 게시글이 한 명의 사용자에 의해 작성될 수 있다. FetchType.LAZY 를 사용해서 필요할 때만 사용자 정보를를 로딩하도록 한다. (지연 로딩)
  • @JoinColumn: 외래 키를 매핑할 때 사용한다. "writer_id" 컬럼을 User 엔티티와의 외래 키로 사용한다.
  • @Enumerated(EnumType.STRING): Enum 타입을 데이터베이스에 저장할 때 문자열로 저장하도록 한다.
  • @Builder: 빌더 패턴을 자동으로 구현해준다.

 

2.2. Image 엔티티

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "images")
public class Image {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false, updatable = false, unique = true)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    @Column(name = "url", nullable = false)
    private String url;

    @Builder
    public Image(Post post, String url) {
        this.post = post;
        this.url = url;
    }
}

@JoinColumn으로 “post_id” 컬럼을 Image 엔티티와의 외래키로 사용한다.

 

3. 레포지토리 구현

Spring Data JPA 를 사용하여 데이터 접근 계층을 구현했다. 레포지토리를 통해 SQL 쿼리를 직접 작성하지 않아도 데이터를 조작할 수 있다.

 

3.1. PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

}

3.2. ImageRepository

epository
public interface ImageRepository extends JpaRepository<Image, Long> {

}

 

JpaRepository 사용 이유

JpaRepository<T, ID>를 상속받아 사용하는 이유는

  • 기본 CRUD 연산을 자동으로 제공한다. (ex: save, findById, findAll, delete, count)
  • 쉽게 페이징과 정렬을 구현할 수 있다. (ex: findAll(Pageable))
  • 메서드 이름 규칙을 통해 메서드를 선언하면 자동으로 쿼리를 생성한다.
  • @Query 어노테이션을 통해 직접 쿼리를 정의할 수 있다.

 

4. DTO 구현

게시글 등록의 요청과 응답을 위한 데이터 전송 객체를 정의했다.

DTO를 사용하면 엔티티의 모든 필드를 외부에 노출하지 않고, 필요한 데이터만 전송할 수 있다.

4.1. PostRegisterRequestDto

게시글 등록 시 필요한 정보를 담는다.

public record PostRegisterRequestDto(
        @NotBlank @JsonProperty("title") String title,
        @NotBlank @JsonProperty("content") String content,
        @NotNull @JsonProperty("type") ETopic type,
        @Nullable @JsonProperty("imageFiles") List<MultipartFile> imageFiles){
}

record 클래스를 사용하여 불변 데이터 객체를 쉽게 생성할 수 있게 해준다.

  • @NotBlank: 문자열 필드가 null이 아니고, 최소한 한 개의 공백이 아닌 문자를 포함해야한다.
  • @NotNull: 필드가 null이 아니어야 한다.
  • @Nullable: 필드가 null일 수 있음을 나타낸다.
  • @JsonProperty: JSON 직렬화/역직렬화 시 사용할 속성 이름을 지정한다.

title과 content는 반드시 입력받아야 하는 값이므로 @NotBlank를 사용했다.

type은 @NotNull을 사용해서 반드시 유효한 게시글 유형을 입력해야 한다는 것을 나타냈다.

imageFiles는 필수 요청값이 아니기 때문에 @Nullable로 설정했다.

 

4.2. PostRegisterResponseDto

게시글 등록 완료 후 반환할 정보를 담는다.

public record PostRegisterResponseDto(Long postId) {
}

게시글 등록 후 생성된 게시글의 ID 값을 반환한다.

 

5. 컨트롤러 구현

API 엔드포인트를 정의하고, 서비스 계층과 연결해 요청을 처리하고, 응답을 반환한다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/posts")
public class PostController {

    private final PostService postService;

    // 게시글 등록
    @PostMapping("/register")
    public ResponseDto<PostRegisterResponseDto> registerPost(@UserId Long userId, @RequestBody PostRegisterRequestDto postRegisterRequestDto) {
        Long postId = postService.registerPost(userId, postRegisterRequestDto);
        return ResponseDto.ok(new PostRegisterResponseDto(postId));
    }
}
  • @RestController: 이 클래스가 RESTful 웹 서비스의 컨트롤러임을 나타낸다. @Controller@ResponseBody를 합친 어노테이션이다.
  • @RequiredArgsConstructor: Lombok 어노테이션으로, final 필드나 @NonNull 필드에 대한 생성자를 자동으로 생성한다. 여기서는 PostService에 대한 생성자 주입을 위해 사용된다.
  • @UserId: 커스텀 어노테이션이다. 현재 인증된 사용자의 ID를 주입받는 데 사용된다.
  • @RequestBody: HTTP 요청의 본문(body)을 자바 객체로 변환한다. 여기서는 PostRegisterRequestDto 객체로 변환된다.

 

registerPost 메서드

registerPost 메서드에서 게시글 등록 요청을 처리한다. 인증된 사용자의 ID와 클라이언트가 전송한 게시글 정보를 받아, postService.registerPost를 호출하여 게시글 등록 로직을 수행한다. 등록된 게시글의 ID 를 받아 PostRegisterResponseDto 객체를 생성하고, 반환한다.

 

6. 서비스 구현

게시글 등록과 이미지 업로드 로직을 작성한다.

@Service
@RequiredArgsConstructor
public class PostService {

    private final UserRepository userRepository;
    private final PostRepository postRepository;
    private final ImageRepository imageRepository;
    private final AmazonS3 amazonS3; // NCP Object Storage 클라이언트
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;
    @Value("${cloud.aws.s3.endpoint}")
    private String endpoint;

    @Transactional
    public Long registerPost(Long userId, PostRegisterRequestDto postRegisterRequestDto) {
        User user =
                userRepository.findById(userId).orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER));

        Post post = postRepository.saveAndFlush(Post.builder()
                .writer(user)
                .title(postRegisterRequestDto.title())
                .content(postRegisterRequestDto.content())
                .type(postRegisterRequestDto.type())
                .build());
        // imageFiles가 비어있지 않은 경우에만 uploadImages 메소드를 호출합니다.
        if (postRegisterRequestDto.imageFiles() != null && !postRegisterRequestDto.imageFiles().isEmpty()) {
            uploadImages(post.getId(), postRegisterRequestDto.imageFiles());
        }

        return post.getId();
    }

    @Transactional
    private void uploadImages(Long postId, List<MultipartFile> imageFiles) {
        Post post = postRepository.findById(postId).orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_POST));

        int idx = 0;
        for (MultipartFile file : imageFiles) {
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentType(file.getContentType());
            metadata.setContentLength(file.getSize());

            String fileName = idx + "_" + file.getOriginalFilename();
            String fileUrl = endpoint + "/" + bucket + "/postImage/" + postId + "/" + fileName;
            try {
                amazonS3.putObject(bucket, "postImage/" + postId + "/" + fileName, file.getInputStream(), metadata);
                Image image = imageRepository.save(Image.builder()
                        .post(post)
                        .url(fileUrl)
                        .build());
            } catch (Exception e) {
                throw new CommonException(ErrorCode.POST_FILE_UPLOAD_ERROR);
            }

            idx++;
        }

    }
}
  • @Service: 이 클래스가 서비스 계층의 컴포넌트임을 나타낸다. 스프링의 컴포넌트 스캔 대상이 된다.
  • @Value: 설정 파일(application.yml)에서 값을 읽어와 필드에 주입한다.

 

registerPost 메서드

@Transaction 을 통해 메서드 실행을 하나의 트랜잭션으로 묶어, 실행 중 예외가 발생하면 모든 데이터베이스 변경사항이 롤백되도록 한다.

주어진 userId로 사용자를 조회하고, 존재하지 않으면 예외를 발생시킨다.

Post 엔티티를 생성하고 데이터베이스에 저장하여 게시글을 생성하고 저장한다.

요청받은 이미지 파일이 있으면 uploadImages 메서드를 호출하여 업로드한다.

 

uploadImages 메서드

주어진 postId로 게시글을 조회하고, 존재하지 않으면 예외를 발생시킨다.

ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());

String fileName = idx + "_" + file.getOriginalFilename();
String fileUrl = endpoint + "/" + bucket + "/postImage/" + postId + "/" + fileName;

파일 타입과 파일 크기 정보를 메타데이터로 설정한다. 파일 이름의 중복 방지를 위해 임의의 인덱스값을 사용하여 파일명을 만들고, URL을 설정한다.

각 이미지 파일에 대해 NCP Object Storage에 파일을 업로드하고, 업로드된 이미지의 url을 Image 엔티티로 저장한다.

 

7. Enum 구현

게시글의 유형을 나타내기 위해 Enum 클래스를 사용한다.

package com.example.withpeace.type;

public enum ETopic {
    FREEDOM, 
    INFORMATION, 
    QUESTION, 
    LIVING, 
    HOBBY, 
    ECONOMY
}

Enum 을 사용하면 잘못된 값이 할당되는 것을 컴파일 시점에 방지할 수 있고, 데이터베이스에 저장될 때 일관된 값을 보장한다.

반응형

댓글