@Bean : 개발자가 컨트롤이 불가능한 외부 라이브러리들을 Bean으로 등록하고 싶을때 사용한다.

Ex) QueryDSL을 사용할때 JPAQueryFactory를 사용해야 하는데 QueryDSL을 사용하는 곳마다 주입받는 코드를 작성하기 귀찮다. 그럴때 사용할 수 있다.

 

@Component : 개발자가 직접 만들어 사용하는 Class들의 경우 @Component를 사용한다. 

Ex) API Request를 받기위해 컨트롤러를 만들었을때 그 컨트롤러를 Component로 등록해 사용한다.

 

안녕하세요 이번 시간에는 리팩토링을 해보도록 하겠습니다.

전체 코드는 아래에서 보실 수 있습니다.

https://github.com/sug5806/board-project-rest

 

sug5806/board-project-rest

Contribute to sug5806/board-project-rest development by creating an account on GitHub.

github.com

 

기존에 QueryDSL을 사용하여 검색 유형(게시물 제목으로 검색, 유저 닉네임으로 검색)을 할때 if - else 구문을 통해 코드를 작성하였는데요 이렇게 될경우 코드가 지저분해짐과 동시에 코드를 수정할때 깜빡하고 까먹거나 철자 하나가 틀려 에러남에도 찾기 힘들어 질 수도 있습니다. 그래서 이것을 전략 패턴을 사용하여 바꿔보도록 하겠습니다.

코드 수정 : PostRepositoryImpl

생성 파일 :

  • 정렬 관련 (OrderTypeStrategy, OrderCreate, OrderPopular)
  • 검색 키워드관련 (SearchTypeStrategy, SearchTitle, SearchUser)

 

정렬 관련 코드

- OrderTypeStrategy

public interface OrderTypeStrategy {
    OrderSpecifier<?> getOrder();
}

- OrderCreate

@Component("create")
public class OrderCreate implements OrderTypeStrategy {
    @Override
    public OrderSpecifier<?> getOrder() {
        return post.date.createdAt.desc();
    }
}

- OrderPopular

@Component("popular")
public class OrderPopular implements OrderTypeStrategy {
    @Override
    public OrderSpecifier<?> getOrder() {
        return post.likeCount.desc();
    }
}

 

구현 클래스에 @Component("컴포넌트명") 을 붙여줘 스프링에서 자동으로 bean으로 등록하게끔 설정 해줍니다.

 

검색 키워드관련 코드 

- SearchTypeStrategy

public interface SearchTypeStrategy {
    BooleanExpression search(String query);
}

- SearchTitle

@Component("title")
public class SearchTitle implements SearchTypeStrategy {
    @Override
    public BooleanExpression search(String query) {
        return QPost.post.title.contains(query);
    }
}

- SearchUser

@Component("user")
public class SearchUser implements SearchTypeStrategy {
    @Override
    public BooleanExpression search(String query) {
        return QUser.user.nickname.eq(query);
    }
}

 

구현 클래스에 @Component("컴포넌트명") 을 붙여줘 스프링에서 자동으로 bean으로 등록하게끔 설정 해줍니다.

 

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;
    private final Map<String, OrderTypeStrategy> orderTypeStrategyMap;
    private final Map<String, SearchTypeStrategy> searchTypeStrategyMap;
    
    
    ..............
    
        private BooleanExpression postSearchQuery(SearchDTO searchDTO) {
        SearchType searchType = SearchType.convertToType(searchDTO.getSearchType());

        if (Strings.hasText(searchDTO.getQuery())) {
            SearchTypeStrategy searchTypeStrategy = searchTypeStrategyMap.get(searchType.getType());

            return searchTypeStrategy.search(searchDTO.getQuery());
        }

        return null;

    }
    
    ............
    
        private OrderSpecifier<?> sortingCondition(Sort sort) {
        OrderSpecifier<?> orderBy = null;

        if (!sort.isEmpty()) {
            List<Sort.Order> orders = sort.toList();
            Sort.Order order = orders.get(0);
            OrderType orderType = OrderType.convertToType(order.getProperty());

            OrderTypeStrategy orderTypeStrategy = orderTypeStrategyMap.get(orderType.getType());

            orderBy = orderTypeStrategy.getOrder();
        }

        return orderBy;
    }
    
}

 

Map으로 선언하여 스프링에서 자동으로 아까 지정한 컴포넌트의 이름으로 인터페이스 타입을 구현한 클래스들을 주입시켜 줍니다.

그리하여 검색 타입에따라 SearchTitle, SearchUser를 사용하고 정렬 조건에따라 OrderPopular, OrderCreate를 사용하게됩니다.

안녕하세요 이번시간에는 이미지 업로드를 만들어 보도록 하겠습니다.

전체 코드는 여기 에서 볼 수 있습니다.

 

sug5806/board-project-rest

Contribute to sug5806/board-project-rest development by creating an account on GitHub.

github.com

build.gradle, application.yml 파일을 수정하고

FileUploadDTO, FileController, FileService 을 새로 만들어야 합니다.

 

- build.gradle

// 확장자 구하기용 라이브러리
    compile group: 'commons-io', name: 'commons-io', version: '2.6'

    // aws
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.1.RELEASE'

 

- application.yml

cloud:
  aws:
    s3:
      bucket: hese-board
    region:
      static: ap-northeast-2
    credentials:
      accessKey: ${accessKey}
      secretKey: ${secretKey}
      use-default-aws-credentials-chain: false
      instance-profile: true
    stack:
      auto: false

accessKey와 secretKey에 ${} 이렇게 되어있는데 노출을 피하고자 외부에서 주입하는 것입니다.

이것을 IDE에서 적용하기 위해선

Program arguments에 넣어주시면 읽어서 사용할 수 있습니다.

 

 

- FileUploadDTO

@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FileUploadDTO {
    private String url;
}

 

- FileController

@RestController
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;

    @PostMapping("/image/upload")
    @PreAuthorize("isAuthenticated()")
    public SuccessResponse<FileUploadDTO> imageUpload(@RequestPart MultipartFile file, Principal principal) throws IOException {
        FileUploadDTO fileUploadDTO = fileService.fileUpload(file, principal.getName());
        return SuccessResponse.success(fileUploadDTO);
    }

}

@RequestPart를 통해 Multipart를 받는것을 적용해주고 file이라는 키값으로 파일을 받습니다. 만약 이 이름이 file이 아니라 image라면 파일을 보낼때 키값을 image로 바꿔 보내주면 됩니다.

 

- FileService

@Service
@RequiredArgsConstructor
@Slf4j
public class FileService {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public FileUploadDTO fileUpload(MultipartFile file, String email) throws IOException {

        File uploadFile = convert(file).orElseThrow(() -> new IllegalArgumentException("파일 업로드에 실패하였습니다."));

        String dir = "static/images/".concat(email);

        return upload(uploadFile, dir);
    }

    private FileUploadDTO upload(File uploadFile, String dir) {
        String sourceName = uploadFile.getName();
        String sourceExt = FilenameUtils.getExtension(sourceName).toLowerCase();

        String fileName = dir + "/" + LocalDateTime.now().toString().concat(".").concat(sourceExt);
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);

        return FileUploadDTO.builder()
                .url(uploadImageUrl)
                .build();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private String putS3(File uploadFile, String fileName) {
        try {
            amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (Exception e) {
            log.error("이미지 s3 업로드 실패");
            log.error(e.getMessage());
            removeNewFile(uploadFile);
            throw new RuntimeException();
        }

        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }

            return Optional.of(convertFile);
        }

        return Optional.empty();
    }

}

AWS S3에 이미지를 올릴때는 유저가 올린 이미지를 구분하기 위해 이메일로 경로를 만들어 하였습니다.

해당 코드의 자세한 내용은 https://jojoldu.tistory.com/300여기에 자세히 나와있습니다.

 

안녕하세요 이번 시간에는 게시물 좋아요 기능을 만들어 보도록 하겠습니다.

수정할 파일 : PostController, PostService, PostEntity, UserEntity

생성할 파일 : PostLikeEntity, PostLikeRepository

- PostController

   @PostMapping("/post/{id}/like")
    @PreAuthorize("isAuthenticated()")
    public SuccessResponse<String> postLike(@PathVariable(name = "id") Long postId, Principal principal) {
        postService.postLike(postId, principal.getName());
        return SuccessResponse.success(null);
    }

좋아요 할 게시물의 id와 현재 로그인 중인 유저의 email을 Service에 넘겨줍니다.

 

 

- PostService

   public void postLike(Long postId, String email) {
        Post post = getPostInService(postId);
        User user = getUserInService(email);
        Optional<PostLike> byPostAndUser = postLikeRepository.findByPostAndUser(post, user);

        byPostAndUser.ifPresentOrElse(
		// 좋아요 있을경우 삭제
                postLike -> {
                    postLikeRepository.delete(postLike);
                    post.discountLike(postLike);
                },
                // 좋아요가 없을 경우 좋아요 추가
                () -> {
                    PostLike postLike = PostLike.builder().build();

                    postLike.mappingPost(post);
                    postLike.mappingUser(user);
                    post.updateLikeCount();

                    postLikeRepository.save(postLike);
                }
        );
    }

ifPresentOrElse 구문은 게시물과 유저로 찾아 좋아요을 했을 경우와 하지 않았을 경우를 나눠 로직을 작성할수 있습니다.

좋아요가 있을경우 테이블에서 삭제를 한 후 Post에서 LikeCount를 하나 줄여줍니다.

좋아요가 없다면 user와 post를 매핑해 준 후 LikeCount를 업데이트 해줍니다.

 

- PostEntity

@OneToMany(fetch = LAZY, mappedBy = "post", cascade = CascadeType.REMOVE)
    private List<PostLike> postLikeList = new ArrayList<>();
    
    
public void mappingPostLike(PostLike postLike) {
        this.postLikeList.add(postLike);
    }

    public void updateLikeCount() {
        this.likeCount = (long) this.postLikeList.size();
    }

    public void discountLike(PostLike postLike) {
        this.postLikeList.remove(postLike);

    }

 

- UserEntity

 @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<PostLike> postLikeList = new ArrayList<>();
    
  public void mappingPostLike(PostLike postLike) {
        this.postLikeList.add(postLike);
    }

 

- PostLikeEntity

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_PostLike_User"))
    private User user;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id", foreignKey = @ForeignKey(name = "FK_PostLike_Post"))
    private Post post;

    public static boolean isVotedPost(Optional<PostLike> optionalPostLike) {
        return optionalPostLike.isPresent();
    }

    public void mappingUser(User user) {
        this.user = user;
        user.mappingPostLike(this);
    }

    public void mappingPost(Post post) {
        this.post = post;
        post.mappingPostLike(this);
    }

}

Post와 User를 직접적으로 N : M 로 매핑하지 않고 중간 테이블을 만들어 1 : N - M : 1 로 매핑한 이유는 나중에 좋아요을 한 날짜나 다른 정보들이 필요한 경우 이렇게 중간 테이블을 만들어 놓는다면 중간 테이블에 관련된 데이터를 추가 하면 되지만 직접 적으로 연결 할 경우 등록하기 어렵기 때문입니다.

 

- PostLikeRepository

@Repository
public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
    Optional<PostLike> findByPostAndUser(Post post, User user);
}

게시물과 유저로 좋아요를 찾습니다.

안녕하세요 저번 시간에는 게시물의 카테고리로 검색하는 기능을 추가하였습니다.

이번 시간에는 유저 닉네임, 게시물의 제목으로 검색하는 기능을 만들어 보도록 하겠습니다.

코드는 크게 달라진것은 없으며 SearchType이 추가되고, PostRepositoryImpl 만 수정 하면 됩니다.

- SearchType, PostRepositoryImpl

- SearchType

@RequiredArgsConstructor
public enum SearchType {
    TITLE("title", "포스트 제목으로 검색"),
    USER("user", "유저명으로 검색");

    @Getter
    private final String type;

    @Getter
    private final String description;

    public static SearchType convertToType(String stringType) {
        return Arrays.stream(values())
                .filter(searchType -> searchType.type.equals(stringType))
                .findAny()
                .orElse(TITLE);
    }
}

convertToType 메서드는 values()로 SearchType의 enum값을 배열로 가져온뒤 filter로 stringType과 enum의 type이 동일 하면 해당 enum을 리턴하고 일치하는 enum이 없을시 TITLE enum을 리턴하는 메서드 입니다.

 

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Post> postListQueryDSL(SearchDTO searchDTO) {
        BooleanExpression postCategoryQuery = postCategoryQuery(searchDTO.getCategory());
        BooleanExpression postSearchQuery = postSearchQuery(searchDTO);

        return jpaQueryFactory
                .selectFrom(post)
                .where(postCategoryQuery, postSearchQuery)
                .join(post.user, user).fetchJoin()
                .join(post.category, postCategory).fetchJoin()
                .fetch();
    }

    private BooleanExpression postCategoryQuery(String category) {
        if (StringUtils.hasLength(category)) {
            return post.category.eq(getPostCategory(category));
        }

        return null;
    }

    private BooleanExpression postSearchQuery(SearchDTO searchDTO) {
        SearchType searchType = SearchType.convertToType(searchDTO.getSearchType());

        if (searchType == SearchType.USER) {
            return user.nickname.eq(searchDTO.getQuery());
        }
        return post.title.contains(searchDTO.getQuery());

    }

    private PostCategory getPostCategory(String category) {
        return jpaQueryFactory
                .selectFrom(postCategory)
                .where(postCategory.name.eq(category))
                .fetchOne();
    }
}

기존의 코드에서 postSearchQuery가 추가되었습니다. 이 메서드는 searchType에 따라 유저로 검색하는지, 게시물로 검색하는지에 따라where문에 들어갈 쿼리가 달라지게 됩니다.

안녕하세요 이번 시간에는 QueryDSL을 사용하여 게시물 카테고리로 게시물을 검색하는 기능을 만들어 보겠습니다.

QueryDSL을 사용하기위해 build.gradle에 라이브러리를 추가해주도록 하겠습니다.

plugins {
    id 'org.springframework.boot' version '2.4.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'

    //querydsl 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'hose'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // test용 assertj 추가
    testCompile 'org.assertj:assertj-core:3.19.0'

    // spring security 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // jwt
    compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

    // swagger
    compile('io.springfox:springfox-swagger2:2.7.0')
    compile('io.springfox:springfox-swagger-ui:2.7.0')

    //querydsl 추가
    implementation 'com.querydsl:querydsl-jpa'
}

test {
    useJUnitPlatform()
}

// querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}
// querydsl 추가 끝

 

설치를 하고 나서 IntelliJ를 사용하신다면 오른쪽 Gradle에서 cleanQuerydslSourcedDir을 눌러 줍니다.

 

그 후 other에서 compileQuerydsl을 해줍니다 그러고 나면 build > generated > querydsl 디렉토리가 생기며 내부에 Q가 붙은 엔티티가 생깁니다.

 

만약 IntelliJ가 없다면 ./gradlew cleanQuerydslSourcesDir 과 ./gradlew compileQuerydsl 을 명령어를 실행시켜 줍니다.

 

그 다음 PostController, SearchDTO,  PostRepositoryCustom, PostRepositoryImpl 을 만들어주고 PostRepository를 수정해줍니다.

- PostController

@GetMapping("/board/post-list")
    @ResponseStatus(value = HttpStatus.OK)
    @ApiOperation(value = "게시물 목록 조회", notes = "게시물 목록을 조회합니다.")
    public SuccessResponse<List<PostDTO>> getPostList(SearchDTO searchDTO) {
        List<PostDTO> postList = postService.getPostList(searchDTO);

        return SuccessResponse.success(postList);
    }

 

- SearchDTO

@Builder
@Data
public class SearchDTO {
    private String category;
    private String searchType;
    private String query;

    @ConstructorProperties({"category", "search_type", "query"})
    public SearchDTO(String category, String searchType, String query) {
        this.category = category;
        this.searchType = searchType;
        this.query = query;
    }
}

@ConstructorProperties를 넣은 이유는 현재 QueryParameter를 어노테이션으로 받는게 아니라 Object로 받고 있습니다.

왜냐하면 Json은 snake_case를, 자바에서는 cameCase를 쓰고있기 때문에 통일이 되지않기 때문에

필드중 searchType이 실제 QueryParameter를 넘겨줄때도 searchType으로 적어줘야 입력받는 문제점이 있습니다.

그렇기 때문에 @ConstructorProperties에 적어줌으로써 필드 순서에 맞게 매핑이 되는것 같습니다.

 

 

- PostRepositoryCustom

public interface PostRepositoryCustom {
    List<Post> postListQueryDSL(SearchDTO searchDTO);
}

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Post> postListQueryDSL(SearchDTO searchDTO) {
        BooleanExpression postCategoryQuery = getPostCategoryQuery(searchDTO.getCategory());

        List<Post> postList = jpaQueryFactory
                .selectFrom(post)
                .where(postCategoryQuery)
                .join(post.user, user).fetchJoin()
                .join(post.category, postCategory).fetchJoin()
                .fetch();

        return postList;
    }

     private BooleanExpression postCategoryQuery(String category) {
        if (StringUtils.hasLength(category)) {
            return post.category.eq(getPostCategory(category));
        }

        return null;
    }

    private PostCategory getPostCategory(String category) {
        return jpaQueryFactory
                .selectFrom(postCategory)
                .where(postCategory.name.eq(category))
                .fetchOne();
    }
}

여기서 JPAQueryFactory를 주입받고 있는데 Spring boot에서 기본적으로 주입시켜주는게 아니기 때문에 자동으로 주입시켜줄수 있게 작성해야 합니다.

- QueryDslConfig

@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }

}

QueryDslConfig를 만들어 JPAQueryFactory을 자동으로 주입시켜줄 수 있게 Bean으로 등록시켜줍니다.

 

- PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
    @EntityGraph(attributePaths = {"category", "user", "commentList.user"})
    @Override
    Optional<Post> findById(Long postId);

    @EntityGraph(attributePaths = {"user", "category"})
    @Override
    List<Post> findAll(Sort sort);
}

여기서 눈여겨 보셔야 할 점이 PostRepository에 PostRepositoryCustom을 새롭게 상속받았다는 것입니다. 그 다음 PostRepositoryCustom을 구현하는 PostRepositoryImpl 클래스에서 postListQueryDSL 메서드를 구현하고 있는점입니다.

이렇게 하게되면 ServiceLayer에서는 새롭게 PostRepositoryCustom을 추가적으로 의존할 필요없이 기존의 PostRepository를 통해 postListQueryDSL 메서드를 사용 할 수 있습니다.

 

이제 postListQueryDSL 코드를 살펴보도록 하겠습니다.

getPostCategoryQuery(String category) 메서드는 동적 쿼리입니다.

API를 요청할때 Query Parameter에 category를 명시해준다면 좋겠지만 명시하지 않을경우 카테고리와 상관없이 모든글을 가져와야 합니다. 이런 경우를 생각하면 있을 경우와 없을경우, 카테고리가 무엇이냐에 따라 분기문으로 처리해야하는데 그것을 방지하기 위해 동적 쿼리를 사용하였습니다.

QueryDSL에서는 where절에 null이 들어갈경우 자동으로 무시하기 때문에 동적쿼리를 처리하기 좋습니다.

이제 본 메서드의 post, user, postCategory는 아까 gradle로 만들어준 QueryDSL 디렉토리 내부에 있는 Q엔티티 입니다. QueryDSL 에서는 이것을 이용하여 쿼리를 작성합니다.

join을 통해 게시물을 작성한 유저와 카테고리를 한꺼번에 가져오고 fetchJoin()을 통해 N + 1 문제를 해결하였습니다.

 

전체 코드는 여기에서 볼 수 있습니다.

오늘은 게시물에 댓글 기능을 적용해보도록 하겠습니다.

- Controller, Service, Entity, Repository, DTO

- CommentController

@RestController
@RequiredArgsConstructor
public class CommentController {

    private final CommentService commentService;

    @PostMapping("/post/{id}/comment")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "댓글 쓰기", notes = "id에 해당하는 게시글에 댓글을 작성합니다.")
    public SuccessResponse<String> createComment(@PathVariable(name = "id") Long postId,
                                                 @Valid @RequestBody CommentDTO commentDTO,
                                                 Principal principal) {

        CommentDTO comment = commentService.createComment(postId, commentDTO, principal.getName());

        return SuccessResponse.success(null);

    }
}

- CommentService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentService {

    private final CommentRepository commentRepository;
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    @Transactional
    public CommentDTO createComment(Long postId, CommentDTO commentDTO, String email) {
        Optional<Post> byId = postRepository.findById(postId);

        Post post = byId.orElseThrow(() -> new PostNotFound("게시물이 삭제되었거나 존재하지 않습니다."));

        Optional<User> byEmail = userRepository.findByEmail(email);

        User user = byEmail.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다."));

        Comment comment = Comment.builder()
                .contents(commentDTO.getContents())
                .build();

        comment.mappingPostAndUser(post, user);

        Comment saveComment = commentRepository.save(comment);

        return CommentDTO.convertToCommentDto(saveComment);
    }
}

- CommentEntity

@Entity
@Getter
@Builder
@AllArgsConstructor(access = PROTECTED)
@NoArgsConstructor(access = PROTECTED)
public class Comment {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String contents;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id", foreignKey = @ForeignKey(name = "FK_user_comment"))
    private Post post;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_post_comment"))
    private User user;

    public void mappingPostAndUser(Post post, User user) {
        this.post = post;
        this.user = user;

        post.mappingComment(this);
        user.mappingComment(this);
    }
}

- CommentRepository

@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
}

- CommentDTO

@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class CommentDTO {
    private Long id;

    @NotBlank
    @Length(max = 100)
    private String contents;
    private UserDTO user;

    public static CommentDTO convertToCommentDto(Comment comment) {
        return CommentDTO.builder()
                .id(comment.getId())
                .contents(comment.getContents())
                .user(UserDTO.convertToUserDTO(comment.getUser()))
                .build();
    }

    public static List<CommentDTO> convertToCommentDtoList(List<Comment> commentList) {
        Stream<Comment> stream = commentList.stream();

        return stream.map(CommentDTO::convertToCommentDto).collect(Collectors.toList());

    }
}

 

여기 까지가 Comment 관련 코드이고 이제 Comment와 Post, User와 관계를 지정해주어야 합니다.

Comment와 Post는 N : 1 관계, Comment와 User는 N : 1 관계 입니다. 그러므로 PostEntity와 UserEntity에 연관관계및 연관관계 메서드를 설정해줍니다.

- PostEntity

    @OneToMany(fetch = LAZY, mappedBy = "post")
    private List<Comment> commentList = new ArrayList<>();
    
        public void mappingComment(Comment comment) {
        this.commentList.add(comment);
    }

- UserEntity

@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")
    private List<Comment> commentList = new ArrayList<>();
    
    public void mappingComment(Comment comment) {
        commentList.add(comment);
    }

 

그리고 이제 게시물에 댓글을 달고 게시물을 조회하고 나면 쿼리가 무언가 이상합니다. 

1번 게시물에 1번 유저, 2번 유저, 3번 유저가 댓글을 달경우 1번 게시물을 조회할때 1, 2, 3번 유저를 조회하는 쿼리가 나가기 때문입니다.

이를 해결하기 위해 저번에 작업한 PostRepository에 최적화 작업을 하겠습니다.

- PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = {"category", "user", "commentList.user"})
    @Override
    Optional<Post> findById(Long postId);

    @EntityGraph(attributePaths = {"user", "category"})
    @Override
    List<Post> findAll(Sort sort);
}

@EntityGraph에 "commentList.user"를 추가해줌으로써 CommentList와 여기에 댓글을 작성한 유저의 정보까지 한번에 가져오므로 쿼리 1번에 모두 조회가 가능합니다.

전체 코드는 여기에서 보실 수 있습니다.

먼저 코드를 보여드리도록 하겠습니다.

Controller, Service, PostEntity

- PostController

@PostMapping("/post")
    @ResponseStatus(value = HttpStatus.CREATED)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 생성", notes = "게시물을 생성합니다.")
    public SuccessResponse<PostDTO> createPost(@Valid @RequestBody PostDTO postDTO, Principal principal) {
        log.info(principal.getName());
        PostDTO post = postService.createPost(postDTO, principal.getName());

        return SuccessResponse.success(post);
    }

 

이전 Json으로 로그인하기를 했을때 UserController의 login 메서드에서 email, password를 받아 UsernamePasswordAuthenticationToken을 만들어주고 이것을 UserAuthenticationProvider에 넘겨주었습니다. Provider에서 넘겨준 토큰으로 email과 password를 꺼내 DB에서 유저 정보를 조회해 로그인이 성공하면 UsernamePasswordAuthenticationToken으로 Authentication을 리턴하였습니다. 그로인해 로그인이 Principal를 통해 현재 로그인한 유저의 정보를 가져올 수 있습니다.

- PostService

public PostDTO createPost(PostDTO postDTO, String email) {
        Optional<User> byEmail = userRepository.findByEmail(email);

        User user = byEmail.orElseThrow(() -> new UsernameNotFoundException("게시글 작성 권한이 없습니다."));

        Post post = Post.builder()
                .title(postDTO.getTitle())
                .contents(postDTO.getContents())
                .createAt(LocalDateTime.now())
                .build();

        post.mappingCategory(postCategoryRepository.findByName(postDTO.getCategory()));
        post.mappingUser(user);

        Post savePost = postRepository.save(post);

        return PostDTO.builder()
                .id(savePost.getId())
                .build();
    }

받은 email로 DB에서 유저를 찾은 다음에 post에 매핑시켜 줍니다.

- UserEntity, PostEntity

- UserEntity

@Entity
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String password;

    private String nickname;

    private LocalDateTime createdBy;

    @Builder.Default
    private String role = "ROLE_USER";

    private String authority;

    private boolean enabled = true;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "user")
    private List<Post> postList = new ArrayList<>();

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    public void encryptPassword(String password) {
        this.password = BCrypt.hashpw(password, BCrypt.gensalt());
    }

    public void mappingPost(Post post) {
        postList.add(post);
    }


    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> auth = new HashSet<>();
        auth.add(new SimpleGrantedAuthority(authority));

        return auth;
    }

    @Override
    public String getUsername() {
        return this.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }


}

- PostEntity

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String contents;

    private LocalDateTime createAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_category_id")
    private PostCategory category;

    @Builder.Default
    private Long viewCount = 0L;

    public void addViewCount() {
        this.viewCount += 1;
    }

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

////////////////////////////////////////////////////////////////////////////////////////////////

    public void changeTitle(String title) {
        this.title = title;
    }

    public void changeContents(String contents) {
        this.contents = contents;
    }

    public void mappingCategory(PostCategory postCategory) {
        this.category = postCategory;
        postCategory.mappingPost(this);
    }

    public void mappingUser(User user) {
        this.user = user;
        user.mappingPost(this);
    }
}

유저는 포스트를 여러개 작성 할수 있고 포스트는 유저 1명에게 속해 있어야하니 유저 : 포스트 는 1 : N 관계 입니다. 그러므로 User Entity에는 @ManyToOne 으로 매핑 시켜 주고, 포스트에는 @OneToMany로 매핑 시켜줍니다. N인 Post가 매핑관계의 주인이 되는게 좋으므로 mappedBy를 통해 Post의 user 필드를 지정해 주고 Post Entity에 유저를 매핑시켜줄 연관관계 메서드를 작성해줍니다.

여기 까지 진행한 후 게시물을 만들고 getPost를 통해 게시물을 조회하게 되면 쿼리가 2개가 발생하게 됩니다. Post를 가져올때 한번, Post에 매핑된 User를 가져올때 한번 일어나게 되는데요 이것을 쿼리 한번에 조회하기 위해 PostRepository를 수정해줍니다.

PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = {"category", "user"})
    Optional<Post> findById(Long postId);
}

지난시간 postCategory 쿼리를 최적화 하기위해 @EntityGraph에 category를 지정해줬는데 user를 추가시켜주어 쿼리 1번에 유저까지 가져올수 있도록 합니다.

그 후 게시물 조회를 해보면 유저까지 한번에 가져오는걸 알 수 있습니다.

전체 코드는 여기에서 확인할 수 있습니다.

 

안녕하세요 이번에는 API를 테스트 할 수 있는 Swagger 문서를 추가 해보도록 하겠습니다.

build.gradle 파일에 추가해줍니다.

// swagger
    compile('io.springfox:springfox-swagger2:2.7.0')
    compile('io.springfox:springfox-swagger-ui:2.7.0')

그 다음 SwaggerConfig 파일을 만들어 줍니다.

@Configuration
public class SwaggerConfig {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Spring Boot REST Sample with Swagger")
                .description("API 문서")
                .termsOfServiceUrl("")
                .contact(new Contact("user", "url", "user@example.com"))
                .version("1.0")
                .build();

    }
}

그 다음으로 Controller에 코드를 추가 해줍니다. 저는 PostController에 추가 해주도록 하겠습니다.

@RestController
@RequiredArgsConstructor
@Api(tags = {"게시물 관련 API"})
public class PostController {
    private final PostService postService;

    @GetMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @ApiOperation(value = "게시물 조회", notes = "게시물을 조회합니다.")
    public SuccessResponse<PostDTO> getPost(@PathVariable(name = "id") Long id) {
        PostDTO post = postService.getPost(id);

        return SuccessResponse.success(post);
    }

    @PostMapping("/post")
    @ResponseStatus(value = HttpStatus.CREATED)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 생성", notes = "게시물을 생성합니다.")
    public SuccessResponse<PostDTO> createPost(@Valid @RequestBody PostDTO postDTO) {
        PostDTO post = postService.createPost(postDTO);

        return SuccessResponse.success(post);
    }

    @PutMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 수정", notes = "게시물을 수정합니다.")
    public SuccessResponse<PostDTO> updatePost(@Valid @RequestBody PostDTO postDTO, @PathVariable(name = "id") Long postId) {
        PostDTO postDTOResponse = postService.updatePost(postId, postDTO);

        return SuccessResponse.success(postDTOResponse);
    }

    @DeleteMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 삭제", notes = "게시물을 삭제합니다.")
    public SuccessResponse<String> deletePost(@PathVariable(name = "id") Long postId) {
        postService.deletePost(postId);
        return SuccessResponse.success(null);
    }

    @GetMapping("/post-category")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 카테고리 조회", notes = "게시물 카테고리를 조회합니다.")
    public SuccessResponse<List<PostCategoryDTO>> postCategory() {
        List<PostCategoryDTO> postCategoryDTOS = postService.postCategoryList();

        return SuccessResponse.success(postCategoryDTOS);
    }
}

@Api : 해당 컨트롤러가 어떤 API 그룹인지 적어주세요.

@ApiOperation : value -> api의 제목? 을 적어주세요.

@ApiOperation : notes -> api에 대한 설명을 적어주세요.

그 후 Main 실행 파일에 가셔서 @EnableSwagger2 를 붙여주세요.

@SpringBootApplication
@EnableSwagger2
public class BoardRestApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(BoardRestApiApplication.class, args);
    }

}

 

저 같은 경우에는 Spring Security를 사용하고 있기 때문에 swagger 관련 주소를 제외시켜주어야 합니다. SecurityConfig에 추가시켜줍니다.

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .antMatchers("/webjars/**")
                .antMatchers("/v2/api-docs/**")
                .antMatchers("/configuration/**")
                .antMatchers("/swagger*/**");
    }

 

이제 실행하신다음 http://localhost:8080/swagger-ui.html 에 접속하시면 아래와 같은 화면이 나오며 api를 테스트 할 수 있습니다.

이번시간에는 게시물에 카테고리 적용을 하겠습니다. 

먼저 게시물 카테고리 Entity와 Repository를 만들어줍니다.

- Entitiy

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PostCategory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String name;

    @Column(unique = true)
    private String value;

    @OneToMany(mappedBy = "category")
    private List<Post> postList = new ArrayList<>();

    public void mappingPost(Post post) {
        this.postList.add(post);
    }
}

- Repository

@Repository
public interface PostCategoryRepository extends JpaRepository<PostCategory, Long> {
    PostCategory findByName(String name);
}

 

그 다음에는 Post 엔티티와 PostCategory끼리 연관 관계를 설정해야 합니다.

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String contents;

    private LocalDateTime createAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_category_id")
    private PostCategory category;

    public void changeTitle(String title) {
        this.title = title;
    }

    public void changeContents(String contents) {
        this.contents = contents;
    }

    public void mappingCategory(PostCategory postCategory){
        this.category = postCategory;
        postCategory.mappingPost(this);
    }
}

 

Post와 PostCategory끼리의 연관 관계는 N : 1 이기 때문에 PostEntitiy에 @ManyToOne 을 붙여줍니다. fetch가 LAZY인 이유는 @ManyToOne의 경우 기본 패치 전략이 EAGER 이며 EAGER의 경우 테이블에 데이터가 많을경우 쿼리가 매우 많아져 이슈가 생겨 실무에서는 사용하지 않는다고 합니다. LAZY같은 경우에는 조인을 해도 바로 관련된 데이터를 가져오지 않고 데이터를 사용할때 가져오기 때문에 쿼리 문제가 당장에는 발생하지 않습니다. 

그리고 N에 해당하는 Post에 연관관계의 주인을 설정하여 게시물 카테고리와 매핑시켜 줍니다.

그럼 이제 Service를 수정하도록 하겠습니다.

- PostDTO

@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PostDTO {
    private Long id;

    @NotBlank(message = "제목을 입력해주세요.")
    private String title;
    @NotBlank(message = "내용을 입력해주세요.")
    private String contents;

    private Long viewCount;

    private String createdAt;

    @NotBlank(message = "카테고리를 선택해주세요.")
    private String category;
}

 

- PostService

 public PostDTO createPost(PostDTO postDTO) {
        Post post = Post.builder()
                .title(postDTO.getTitle())
                .contents(postDTO.getContents())
                .category(postCategoryRepository.findByName(postDTO.getCategory()))
                .createAt(LocalDateTime.now())
                .build();

        Post savePost = postRepository.save(post);

        return PostDTO.builder()
                .id(savePost.getId())
                .build();
    }

 

전체 코드는 여기에서 볼 수 있습니다.

안녕하세요 오늘 작업을 하기위해서는 새로운 라이브러리를 설치해야 합니다.
코드를 먼저 보여드린 후 설명을 하도록 하겠습니다.

 // spring security 추가 implementation 'org.springframework.boot:spring-boot-starter-security' // jwt compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

Spring Security와 로그인 후 JWT 토큰을 주기위해 jjwt가 필요합니다.

1. Controller

 @PostMapping("/login") public ResponseEntity<SuccessResponse<TokenDTO>> login(@RequestBody @Valid UserLoginDTO userLoginDTO) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userLoginDTO.getEmail(), userLoginDTO.getPassword()); Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); String token = tokenProvider.createToken(authentication); HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + token); SuccessResponse<TokenDTO> successResponse = SuccessResponse.success(TokenDTO.builder().token(token).build()); return new ResponseEntity<>(successResponse, httpHeaders, HttpStatus.OK); }

2. LoginDTO, TokenDTO

@Data @AllArgsConstructor @NoArgsConstructor @Builder public class LoginDTO { @Email(message = "이메일 형식이 아닙니다.") @NotBlank(message = "이메일을 입력해주세요") private String email; @NotBlank(message = "비밀번호를 입력해주세요") private String password; } @Builder @AllArgsConstructor @NoArgsConstructor @Data public class TokenDTO { private String token; } 

3. Security Config

@EnableWebSecurity @RequiredArgsConstructor @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { private final UserAuthenticationProvider userAuthenticationProvider; private final TokenProvider tokenProvider; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @Override protected void configure(HttpSecurity http) throws Exception { JwtFilter customFilter = new JwtFilter(tokenProvider); http .authorizeRequests() .antMatchers("/login").permitAll() .antMatchers("/signup").permitAll() .anyRequest().authenticated() .and() .formLogin().disable() .csrf().disable() .exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) ; http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); } @Override protected void configure(AuthenticationManagerBuilder managerBuilder) throws Exception { managerBuilder.authenticationProvider(userAuthenticationProvider); } }

- UserAuthenticationProvider

@Component @RequiredArgsConstructor public class UserAuthenticationProvider implements AuthenticationProvider { private final UserService userService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String email = authentication.getName(); String password = (String) authentication.getCredentials(); UserDetails userDetails = userService.loadUserByUsername(email); if (!checkPassword(password, userDetails.getPassword()) || !userDetails.isEnabled()) { throw new BadCredentialsException(email); } return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } @Override public boolean supports(Class<?> authentication) { return authentication.equals(UsernamePasswordAuthenticationToken.class); } private boolean checkPassword(String loginPassword, String dbPassword) { return BCrypt.checkpw(loginPassword, dbPassword); } }

- service의 loadUserByUsername

@Service @RequiredArgsConstructor @Slf4j public class UserService implements UserDetailsService { private final UserRepository userRepository; public User createUser(UserDTO userDTO) { User user = User.builder() .email(userDTO.getEmail()) .nickname(userDTO.getNickname()) .createdBy(LocalDateTime.now()) .enabled(true) .build(); user.encryptPassword(userDTO.getPassword()); return userRepository.save(user); } @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Optional<User> byEmail = userRepository.findByEmail(email); User user = byEmail.orElseThrow(() -> new UsernameNotFoundException("아이디나 비밀번호가 틀립니다.")); log.debug(String.valueOf(user.isEnabled())); return User.builder() .email(user.getEmail()) .password(user.getPassword()) .nickname(user.getNickname()) .authority(user.getRole()) .enabled(user.isEnabled()) .build(); } } 

- User Entity의 UserDetails

@Entity @Builder @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String password; private String nickname; private LocalDateTime createdBy; @Builder.Default private String role = "ROLE_USER"; private String authority; private boolean enabled = true; public void encryptPassword(String password) { this.password = BCrypt.hashpw(password, BCrypt.gensalt()); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Set<GrantedAuthority> auth = new HashSet<>(); auth.add(new SimpleGrantedAuthority(authority)); return auth; } @Override public String getUsername() { return this.getEmail(); } @Override public boolean isAccountNonExpired() { return false; } @Override public boolean isAccountNonLocked() { return false; } @Override public boolean isCredentialsNonExpired() { return false; } @Override public boolean isEnabled() { return this.enabled; } }

4. JWT 관련 코드

1. JwtFilter

API에 요청이 오게되면 헤더에 토큰이 있나없나 확인을 하기 위해 사용하였습니다.

@RequiredArgsConstructor @Slf4j public class JwtFilter extends GenericFilterBean { public static final String AUTHORIZATION_HEADER = "Authorization"; private final TokenProvider tokenProvider; @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String jwt = resolveToken(httpServletRequest); String requestURI = httpServletRequest.getRequestURI(); if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { Authentication authentication = tokenProvider.getAuthentication(jwt); SecurityContextHolder.getContext().setAuthentication(authentication); log.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI); } else { log.info("유효한 JWT 토큰이 없습니다, uri: {}", requestURI); } filterChain.doFilter(servletRequest, servletResponse); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(AUTHORIZATION_HEADER); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }


2. TokenProvider
jwt 토큰을 생성하고 검증하는 로직입니다.

@Component @Slf4j public class TokenProvider implements InitializingBean { private static final String AUTHORITIES_KEY = "auth"; private final String secret; private final long tokenValidityInMilliseconds; private Key key; public TokenProvider( @Value("${jwt.secret}") String secret, @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) { this.secret = secret; this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000; } @Override public void afterPropertiesSet() { byte[] keyBytes = Decoders.BASE64.decode(secret); this.key = Keys.hmacShaKeyFor(keyBytes); } public String createToken(Authentication authentication) { String authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); long now = (new Date()).getTime(); Date validity = new Date(now + this.tokenValidityInMilliseconds); return Jwts.builder() .setSubject(authentication.getName()) .claim(AUTHORITIES_KEY, authorities) .signWith(key, SignatureAlgorithm.HS512) .setExpiration(validity) .compact(); } public Authentication getAuthentication(String token) { Claims claims = Jwts .parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); User principal = new User(claims.getSubject(), "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { log.info("잘못된 JWT 서명입니다."); } catch (ExpiredJwtException e) { log.info("만료된 JWT 토큰입니다."); } catch (UnsupportedJwtException e) { log.info("지원되지 않는 JWT 토큰입니다."); } catch (IllegalArgumentException e) { log.info("JWT 토큰이 잘못되었습니다."); } return false; } }

3.JwtAccessDeniedHandler

@Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { //필요한 권한이 없이 접근하려 할때 403 response.sendError(HttpServletResponse.SC_FORBIDDEN); } }


4. JwtAuthenticationEntryPoint

@Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } }


설명


먼저 이메일과 비밀번호를 입력하여 로그인 요청을 하게되면 등록한 JwtFilter가 작동하여 헤더에 토큰이 있는지 없는지 확인을 합니다.
그러나 최초로 로그인 api를 요청하면 최초 로그인이기 때문에 헤더에 토큰이 없는게 당연합니다. 그러므로 진행하게 되고 그 다음 Controller에서

Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
코드를 실행하게 되는데 이 코드는 위에서 만든 UserAuthenticationProvider의 authenticate 메서드를 실행하게 됩니다.
authenticate 메서드에서는 이메일과 비밀번호를 받아 userService의 loadUserByUsername으로 유저를 DB에서 찾아와 유저정보를 만들고 다시 Controller로 돌아와 SecurityContextHolder에 유저정보를 저장하게 됩니다. 그후 그 정보를 기반으로 JWT 토큰을 생성하고ResponseBody에 토큰을 넣어 리턴해 줍니다. 그 이후 다른 API를 요청할때 헤더에 토큰을 넣어 보내면 됩니다.

전체 코드는 여기에서 볼 수 있습니다.

 

1. 유저 생성 API - Controller, Service, Repository, Entitiy, UserDTO

- controller

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @PostMapping("/user")
    @ResponseStatus(value = HttpStatus.CREATED)
    public SuccessResponse<UserDTO> createUser(@RequestBody @Valid UserDTO userDTO) {
        User user = userService.createUser(userDTO);

        return SuccessResponse.success(UserDTO.builder()
                .id(user.getId())
                .build());
    }
}

 

- service

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    public User createUser(UserDTO userDTO) {
        User user = User.builder()
                .email(userDTO.getEmail())
                .password(userDTO.getPassword())
                .nickname(userDTO.getNickname())
                .createdBy(LocalDateTime.now())
                .build();

        return userRepository.save(user);
    }
}

 

- repository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

 

- entity

@Entity
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String password;

    private String nickname;

    private LocalDateTime createdBy;
}

 

- userDTO

@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDTO {
    private Long id;

    @Email(message = "이메일 양식이 아닙니다.")
    @NotBlank(message = "이메일은 필수값 입니다.")
    private String email;

    @NotBlank(message = "비밀번호는 필수값 입니다.")
    private String password;

    @NotBlank(message = "닉네임은 필수값 입니다.")
    private String nickname;

    private LocalDateTime createdBy;
}

 

2. 유저 생성 테스트코드 작성 - ControllerTest, ServiceTest

- controllerTest

@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserControllerTest {
    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserService userService;

    public UserDTO initUserDTO() {
        return UserDTO.builder()
                .email("email@email.com")
                .password("password")
                .nickname("nickname")
                .build();
    }

    @Test
    public void 유저_생성() throws Exception {
        // given
        UserDTO userDTO = initUserDTO();

        // when
        ResultActions perform = mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userDTO)));

        // then
        perform
                .andExpect(status().isCreated())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("success"))
                .andExpect(jsonPath("data").hasJsonPath())
                .andExpect(jsonPath("data.id").exists())
                .andExpect(jsonPath("data.id").value(1L))
                .andDo(print());
    }

    @Test
    public void 유저_생성_이메일_양식아님() throws Exception {
        // given
        UserDTO userDTO = initUserDTO();
        userDTO.setEmail("email");

        // when
        ResultActions perform = mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("bad_request"))
                .andExpect(jsonPath("errors").hasJsonPath())
                .andExpect(jsonPath("errors", hasSize(1)))
                .andExpect(jsonPath("errors[*].field", containsInAnyOrder("email")))
                .andExpect(jsonPath("errors[*].message", containsInAnyOrder("이메일 양식이 아닙니다.")))
                .andExpect(status().isBadRequest())
                .andDo(print());
    }

    @Test
    public void 유저_생성_이메일_없음() throws Exception {
        // given
        UserDTO userDTO = initUserDTO();
        userDTO.setEmail("");

        // when
        ResultActions perform = mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("bad_request"))
                .andExpect(jsonPath("errors").hasJsonPath())
                .andExpect(jsonPath("errors", hasSize(1)))
                .andExpect(jsonPath("errors[*].field", containsInAnyOrder("email")))
                .andExpect(jsonPath("errors[*].message", containsInAnyOrder("이메일은 필수값 입니다.")))
                .andExpect(status().isBadRequest())
                .andDo(print());
    }

    @Test
    public void 유저_생성_패스워드_없음() throws Exception {
        // given
        UserDTO userDTO = initUserDTO();
        userDTO.setPassword("");

        // when
        ResultActions perform = mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("bad_request"))
                .andExpect(jsonPath("errors").hasJsonPath())
                .andExpect(jsonPath("errors", hasSize(1)))
                .andExpect(jsonPath("errors[*].field", containsInAnyOrder("password")))
                .andExpect(jsonPath("errors[*].message", containsInAnyOrder("비밀번호는 필수값 입니다.")))
                .andExpect(status().isBadRequest())
                .andDo(print());
    }

    @Test
    public void 유저_생성_닉네임_없음() throws Exception {
        // given
        UserDTO userDTO = initUserDTO();
        userDTO.setNickname("");

        // when
        ResultActions perform = mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("bad_request"))
                .andExpect(jsonPath("errors").hasJsonPath())
                .andExpect(jsonPath("errors", hasSize(1)))
                .andExpect(jsonPath("errors[*].field", containsInAnyOrder("nickname")))
                .andExpect(jsonPath("errors[*].message", containsInAnyOrder("닉네임은 필수값 입니다.")))
                .andExpect(status().isBadRequest())
                .andDo(print());
    }
}

 

- serviceTest

기존에는 Mock을 활용하여 유닛테스트를 하였으나 과연 실제 상황에서 잘 작동하는가에 대해 의문이 들어 통합테스트로 변경하였습니다.

@SpringBootTest
@EnableAutoConfiguration
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PostServiceTotalTest {
    @Autowired
    private PostService postService;

    @Autowired
    private PostRepository postRepository;

    public PostDTO initPostDTO() {
        return PostDTO.builder()
                .title("title")
                .contents("contents")
                .build();
    }

    public Post initPost(PostDTO postDTO) {
        return Post.builder()
                .title(postDTO.getTitle())
                .contents(postDTO.getContents())
                .createAt(LocalDateTime.now())
                .build();
    }

    @Test
    @DisplayName("게시물 등록하기 테스트")
    @Order(1)
    public void createPost() throws Exception {
        //given
        PostDTO postDTO = initPostDTO();

        // when
        PostDTO post1 = postService.createPost(postDTO);

        // then
        assertThat(post1.getId()).isEqualTo(1L);
    }

    @Test
    @Order(2)
    public void 게시물_조회() throws Exception {
        // when
        PostDTO post = postService.getPost(1L);

        // then
        assertThat(post.getId()).isEqualTo(1L);
        assertThat(post.getTitle()).isEqualTo("title");
        assertThat(post.getContents()).isEqualTo("contents");
    }

    @Test
    @Order(3)
    @Transactional
    public void 게시물_제목_업데이트() throws Exception {
        Post findPost = postRepository.findById(1L).get();

        findPost.changeTitle("title update");

        Post updatePost = postRepository.save(findPost);

        assertThat(updatePost.getId()).isEqualTo(findPost.getId());
        assertThat(updatePost.getTitle()).isEqualTo("title update");
        assertThat(updatePost.getContents()).isEqualTo("contents");
    }

    @Test
    @Order(4)
    @Transactional
    public void 게시물_내용_업데이트() throws Exception {
        Post findPost = postRepository.findById(1L).get();

        findPost.changeContents("contents update");

        Post updatePost = postRepository.save(findPost);

        assertThat(updatePost.getId()).isEqualTo(findPost.getId());
        assertThat(updatePost.getTitle()).isEqualTo("title");
        assertThat(updatePost.getContents()).isEqualTo("contents update");
    }

    @Test
    @Transactional
    public void 게시물_삭제_성공() throws Exception {
        Post post = postRepository.findById(1L).get();

        postRepository.delete(post);

        assertThatThrownBy(() -> {
            postService.getPost(1L);
        }).isInstanceOf(PostNotFound.class)
                .hasMessage("해당 포스트가 존재하지 않습니다.");
    }
}

수정 테스트 코드 

@Test
    public void 게시물_제목_변경() throws Exception {
        // given
        Long targetPostId = 1L;
        PostDTO post = postService.getPost(targetPostId);

        PostDTO postDTO = PostDTO.builder()
                .title("title update")
                .contents(post.getContents())
                .build();

        // when
        ResultActions perform = mockMvc.perform(put("/post/{id}", targetPostId)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(postDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("success"))
                .andExpect(status().isOk())
                .andDo(print());

    }

    @Test
    public void 게시물_내용_변경() throws Exception {
        // given
        Long targetPostId = 1L;
        PostDTO post = postService.getPost(targetPostId);

        PostDTO postDTO = PostDTO.builder()
                .title(post.getTitle())
                .contents("contents update")
                .build();


        // when
        ResultActions perform = mockMvc.perform(put("/post/{id}", targetPostId)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(postDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("success"))
                .andExpect(status().isOk())
                .andDo(print());
    }

    @Test
    public void 게시물_제목_내_변경() throws Exception {
        // given
        Long targetPostId = 1L;
        PostDTO post = postService.getPost(targetPostId);

        PostDTO postDTO = PostDTO.builder()
                .title(post.getTitle())
                .title("title update")
                .contents("contents update")
                .build();


        // when
        ResultActions perform = mockMvc.perform(put("/post/{id}", targetPostId)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(postDTO)));

        // then
        perform
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("success"))
                .andExpect(status().isOk())
                .andDo(print());
    }

제목만 수정하였을경우, 내용만 수정하였을경우, 둘다 수정하였을경우의 케이스를 모두 테스트 하였습니다.


삭제 테스트 코드

@Test
    public void 게시물_삭제() throws Exception {
        // given
        Long targetPostId = 1L;

        // when
        ResultActions perform = mockMvc.perform(delete("/post/{id}", targetPostId));

        // then
        perform.andExpect(status().isOk())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("message").value("success"))
                .andDo(print());
    }

 

1. 수정, 삭제 - Controller

@PutMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    public SuccessResponse<PostDTO> updatePost(@Valid @RequestBody PostDTO postDTO, @PathVariable(name = "id") Long postId) {
        PostDTO postDTOResponse = postService.updatePost(postId, postDTO);

        return SuccessResponse.success(postDTOResponse);
    }

    @DeleteMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    public SuccessResponse<String> deletePost(@PathVariable(name = "id") Long postId) {
        postService.deletePost(postId);
        return SuccessResponse.success(null);
    }

2. 수정, 삭제 - Service

public PostDTO updatePost(Long postId, PostDTO postDTO) {
        Optional<Post> byId = postRepository.findById(postId);
        Post post = byId.orElseThrow(() -> new PostNotFound("해당 포스트가 존재하지 않습니다."));

        post.changeTitle(postDTO.getTitle());
        post.changeContents(postDTO.getContents());

        return PostDTO.builder()
                .id(post.getId())
                .build();
    }

    public void deletePost(Long postId) {
        Optional<Post> byId = postRepository.findById(postId);
        Post post = byId.orElseThrow(() -> new PostNotFound("해당 포스트가 존재하지 않습니다."));

        postRepository.delete(post);
    }
}

전체 코드는 여기서 볼 수 있습니다.

안녕하세요. 오늘부터는 스프링 부트를 활용하여 블로그 API를 만들어 보도록 하겠습니다.

개발 환경은 아래와 같습니다.

  • 스프링부트 2.4.5
  • spring-boot-starter-web
  • spring-data-jap, h2, mysql
  • validation, lombok
  • junit5

1. application.yml 파일에 database 정보 입력하기

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/board-rest?autoReconnect=true&useUnicode=true&serverTimezone=UTC&characterEncoding=UTF8
    username: root
    password: ghd9413
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true
  jackson:
    property-naming-strategy: SNAKE_CASE

저는 mysql로 진행하였습니다. 

property-naming-strategy는 기본값이 CamelCase인데 저는 SnakeCase를 선호하여 변경하였습니다.

 

2. 게시물 가져오기 API - Controller, Service, Repository, Entity, PostDTO

- controller

@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;

    @GetMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    public SuccessResponse getPost(@PathVariable(name = "id") Long id) {
        PostDTO post = postService.getPost(id);

        return SuccessResponse.success(post);
    }
}

 

- service

@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    public PostDTO getPost(Long postId) {
        Optional<Post> byId = postRepository.findById(postId);

        Post findPost = byId.orElseThrow(() -> new PostNotFound("해당 포스트가 존재하지 않습니다."));

        return PostDTO.builder()
                .id(findPost.getId())
                .title(findPost.getTitle())
                .contents(findPost.getContents())
                .createdAt(findPost.getCreateAt().toString())
                .build();
    }
 }

 

- repository

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

 

- entity

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String contents;

    private LocalDateTime createAt;
}

 

- postDTO

@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class PostDTO {
    @NotBlank
    private String title;
    @NotBlank
    private String contents;

    private Long viewCount;

    private String createdAt;
}

API를 만들기 앞서 클라이언트와 통신할때 사용할 PostDTO를 만들도록 하겠습니다.

Client - Controller - Service 간에는 PostDTO로 작업 하였습니다.

@NoArgsConstructor와 @AllArgsConstructor에 PROTECTED를 한 이유는 PostDTO를 만들 때 Builder를 사용할 것이기 때문에 생성자를 통한 생성은 막기 위함입니다.

title과 contents에 @NotBlank를 한 이유는 validation을 통해 값을 필수로 받기 위함입니다.

3. 게시물 등록 API - Controller, Service

-controller

 @PostMapping("/post")
    @ResponseStatus(value = HttpStatus.CREATED)
    public SuccessResponse createPost(@Valid @RequestBody PostDTO postDTO) {
        PostDTO post = postService.createPost(postDTO);

        return SuccessResponse.success(post);
    }

 

- service

public PostDTO createPost(PostDTO postDTO) {
        Post post = Post.builder()
                .title(postDTO.getTitle())
                .contents(postDTO.getContents())
                .createAt(LocalDateTime.now())
                .build();

        Post save = postRepository.save(post);

        PostDTO postDTOResponse = PostDTO.builder()
                .id(save.getId())
                .build();


        return postDTOResponse;
    }

 

4. Response 양식 만들기

- SuccessResponse

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SuccessResponse<T> {
    private String message;
    private T data;

    public static <T> SuccessResponse success(T data) {

        SuccessResponse responseUtil = SuccessResponse.builder()
                .message("success")
                .data(data)
                .build();

        return responseUtil;

    }
}

- ErrorResponse

@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
    private String message;
    @Builder.Default
    private List<CustomError> errors = new ArrayList<>();

    public List<CustomError> addError(CustomError error) {
        this.errors.add(error);
        return this.errors;
    }
}

- CustomError

@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CustomError {
    private String field;
    private String message;
}

Response할때 PostDTO를 바로 return해도 되지만 클라이언트와 통신을 생각하면 고정된 Response 양식이 필요하다고 생각하여 만들었습니다.

@JsonInclude(JsonInclude.Include.NON_NULL)은 필드중 null값이 있을경우 해당 필드는 제외하고 리턴합니다.

SuccessResponse의 경우 message에는 success가, data에는 결과 데이터가 들어가있으며

ErrorResponse의 경우 message에는 error가, erorrs에는 에러가 들어있습니다! 

errors를 Array로 한 이유는 게시글 등록처럼 @Valid가 달린경우 제목과 내용을 둘다 적지 않으면 에러가 2개이기 때문에 한번에 보여주고자 하기 위함입니다.

다음 시간에는 테스트 코드를 작성하여 확인해보도록 하겠습니다.

진행 코드는 아래에서 확인할 수 있습니다.

sug5806/board-project-rest (github.com)

Spring boot에서는 기본적으로 에러페이지를 지원해주고 있는데요. 배포해놓은 서비스에 기본적인 에러페이지가 뜨게되면 좋지않다고 생각되어 변경해보도록 하겠습니다.

먼저 templates 디렉토리 아레에 error 디렉토리를 만든 후 에러코드별 페이지를 하나씩 만들어 줍니다.

이렇게 에러코드별로 만들어놓은 HTML은 해당 HTTP status code가 발생 하였을 경우 status code에 맞는 html을 보여주게 됩니다.

그러나 서버에서 보내주는 에러의경우 매우 다양한 에러가 있는데요 

  • 해당 유저를 찾을 수 없어서 500에러
  • 해당 포스트를 찾을 수 없어서 500에러
  • 글쓰다가 등록실패로 500에러 등이 있을수 있습니다.

이러한 에러를 모두 500.html 페이지 하나로 관리하려면 어떤 에러가 발생했는지 관리하기 힘들어집니다. 

이것을 해결하기위해 커스텀 에러를 정의하고 @ControllerAdvice에러 커스텀에러를 처리하도록 하겠습니다.

@Slf4j
public class PostNotFoundException extends RuntimeException {

    public PostNotFoundException(Long id) {
        super("해당 포스트를 찾을 수 없습니다.");
        log.error(id + " 번 포스트를 찾을 수 없습니다!");
    }

    public PostNotFoundException() {
        super();
    }
}

 

 

먼저 커스텀에러를 정의해 줍니다 저는 해당 포스트를 찾지 못하였을경우 에러를 뱉도록 하였습니다.

ServiceLayer에서 해당 포스트를 찾지 못할경우 방금 정의한 PostNotFoundException을 던져줍니다.

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends RuntimeException {

    @ExceptionHandler(PostNotFoundException.class)
    public String PostNotFoundException(PostNotFoundException e) {
        log.error(e.getMessage());
        return "error/post_not_found.html";
    }
}

그후 @ControllerAdvice를 이용하여 전체 Controller에서 발생하는 에러를 여기서 처리해주는데요

@ExceptionHandler를 사용하여 PostNotFoundException이 발생하였을 경우 처리를 해줍니다.

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends RuntimeException {

    @ExceptionHandler(PostNotFoundException.class)
    public String PostNotFoundException(PostNotFoundException e) {
        return "error/post_not_found.html";
    }
}

에러 메시지를 로그로 출력하고 template/error/post_not_found.html 파일로 에러화면을 보여줍니다.

Spring Boot : 2.4.4 버전 입니다.

먼저 CKeditor와 AWS S3 버킷은 만들었다고 가정하겠습니다.

우선 기본적으로 CDN을 통해 CK editor를 붙이면 드래그&드롭기능은 있지만 이미지 업로드가 되지 않을텐데요. 먼저 이미지 업로드부터 활성화 시키겠습니다.

 

 

imageUploadUrl 이라는 키워드를 통해 드래그&드롭으로 이미지를 올릴 시 어떤 API로 보낼지 정할 수 있습니다. 저의 경우 /image/upload 입니다.

그 다음은 컨트롤러 부분을 보겠습니다.

 

 

CK Editor를 사용했다면서 왜 RestController있냐고 생각하실수도 있으신 분들을 위해 설명하겠습니다.

이미지 업로드를 하고 나면 정해진 형태의 json 양식으로 response를 보내주어야 CK Editor가 인식 할 수 있습니다.

 

 

response의 양식을 위와 같습니다.

그 다음은 Service 부분입니다.

코드가 길어 이미지 대신 코드블럭으로 대신하였습니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class FileService {

    private final AmazonS3Client amazonS3Client;
    
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public FileUploadDTO fileUpload(MultipartFile upload, Principal principal) throws IOException {
        File uploadFile = convert(upload).orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환실패."));

        return upload(uploadFile, "static");
    }

    private FileUploadDTO upload(File uploadFile, String dir) {
        String sourceName = uploadFile.getName();
        String sourceExt = FilenameUtils.getExtension(sourceName).toLowerCase();

        String fileName = dir + "/" + LocalDateTime.now().toString().concat(".").concat(sourceExt);
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);

        return FileUploadDTO.builder()
                .uploaded(true)
                .fileName(fileName)
                .url(uploadImageUrl)
                .build();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private String putS3(File uploadFile, String fileName) {
        try {
            amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (Exception e) {
            log.error("이미지 s3 업로드 실패");
            log.error(e.getMessage());
            removeNewFile(uploadFile);
            throw new RuntimeException();
        }

        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }

            return Optional.of(convertFile);
        }

        return Optional.empty();
    }
}

코드에 대해 자세한 설명이 필요하신 분들은 https://jojoldu.tistory.com/300 이 글을 보시면 자세히 알 수 있습니다.

저는 위의 글과는 조금 다르게 환경설정을 했습니다. 위의 글에서는 aws.yml 파일을 따로 만들어서 accessKey와 secretKey를 등록을 해줬는데요 저같은 경우에는 로컬, develop 서버, product 서버의 환경이 다르다보니 각 서버별로 RDS를 연결시켜주어야 했습니다. 그러기 때문에 yml파일을 환경별로 세팅하여서 위의 글과는 다른 점이 있습니다. 물론 하나의 yml 파일에 ---로 구분지어 환경을 나눌수 있다고 하는데 아직 해보지 않아서 모르겠습니다. 추후 해본다음에 포스트하도록 하겠습니다.

다른점은 yml파일과 main 함수 부분입니다.

 

 

spring:
  config:
    activate:
      on-profile: dev

  mvc:
    hiddenmethod:
      filter:
        enabled: false

  datasource:
    url: jdbc:mysql://${rds}:3306/board?autoReconnect=true&useUnicode=true&serverTimezone=UTC&characterEncoding=UTF8
    username: ${username}
    password: ${password}
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true

  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
    cache: false

  loggin.level:
    org.hibernate.SQL: debug

server:
  port: 8888

cloud:
  aws:
    s3:
      bucket: hese-board
    region:
      static: ap-northeast-2
    credentials:
      accessKey: ${accessKey}
      secretKey: ${secretKey}
      use-default-aws-credentials-chain: false
      instance-profile: true
    stack:
      auto: false

accessKey와 secretKey 부분에 ${} 이렇게 되어있는걸 볼 수 있는데요 이렇게 해줌으로써 jar 파일을 실행할때 변수를 넘겨주어 acessKey와 secretKey를 설정해줄 수 있습니다.

제가 사용하는 명령어를 적어놓겠습니다.

java -Dspring.profiles.active=dev -jar /home/ubuntu/application/deploy/*.jar --rds=rds주소 --username=계정명 --password=비밀번호 --accessKey=액세스키 --secretKey=시크릿키

-Dspring.profiles.active로 사용할 application.yml 파일을 정해주고 뒤에 추가 변수로 전부 매핑시켜 줍니다.

사용한 스프링부트의 버전은 2.4.4 입니다.

 

저의 경우 환경별로 AWS RDS가 있기때문에 환경별로 따로 설정을 해주어야 합니다.

우선 환경별로 사용할 application.yml 파일을 준비합니다.

저는 dev, prd에서 사용할것이기 때문에 2개를 준비하였습니다.

파일의 위치는 resources아래에 위치해 있습니다.

 

application-dev.yml

spring:
  config:
    activate:
      on-profile: dev

  mvc:
    hiddenmethod:
      filter:
        enabled: false

  datasource:
    url: jdbc:mysql://board-project-dev.cg2hxamogcbp.ap-northeast-2.rds.amazonaws.com:3306/board?autoReconnect=true&useUnicode=true&serverTimezone=UTC&characterEncoding=UTF8
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true

  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
    cache: false

  loggin.level:
    org.hibernate.SQL: debug

server:
  port: 9090

application-prd.yml

spring:
  config:
    activate:
      on-profile: prd

  mvc:
    hiddenmethod:
      filter:
        enabled: false

  datasource:
    url: jdbc:mysql://board-project-prd.cg2hxamogcbp.ap-northeast-2.rds.amazonaws.com:3306/board?autoReconnect=true&useUnicode=true&serverTimezone=UTC&characterEncoding=UTF8
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true

  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
    cache: false

  loggin.level:
    org.hibernate.SQL: debug

server:
  port: 9090

 

그 다음 Dockerfile을 작성해 줍니다.

Dockerfile의 위치는 루트 바로 밑에 있습니다.

 

그 다음 Dockerfile 안에 아래와 같이 작성해 줍니다.

작성한 Dockerfile로 이미지를 만들어 줍니다. 아래와 같은 명령어로 입력해주세요

docker build --build-arg ENVIRONMENT=dev -t 이미지이름 .

Dockerfile에서 선언해놓은 ENVIRONMENT로 dev값을 주고 docker run을 하게되면 위에서 설정해놓은 application-dev.yml 파일의 설정으로 동작하게되고 만약 ENVIRONMENT의 값을 prd로 주면 application-prd.yml 파일의 설정으로 동작하게 됩니다.

+ Recent posts