5회차 미션은 1:N 관계와 N:M관계에 대한 새로운 도메인의 추가 및 요구사항이 추가됐습니다.
또한 리소스를 조회하는 로직이 아닌 저장, 수정, 삭제하는 부분에 대해서 사용자의 로그인 및 인증/인가 작업이 필요했습니다.
이제는 익명게시판이 아니라 실명게시판이 됐습니다 ㅎㅎ
설명하지 않은 모든 코드는 다음 PR에 있습니다!
https://github.com/JSCODE-EDU/project-class-HiiWee/pull/12
✅ 추가된 요구사항에 따른 ERD 변화
회원과 댓글, 게시글과 댓글은 모두 1:N의 연관관계를 맺습니다.
1명 이상의 회원은 1개 이상의 게시글을 좋아요할 수 있으므로, 이는 N:M관계가 됩니다.
N:M관계를 표현할 수 도 있지만, 편의를 위해 중간 테이블인 POST_LIKE
테이블을 생성했습니다.
이를 실제 JPA로 매핑해보면 다음과 같습니다.
// MEMBER
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Embedded
private Email email;
@Embedded
private Password password;
@CreatedDate
private LocalDateTime createdAt;
protected Member() {
}
}
// POST
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
@Embedded
private Title title;
@Embedded
private Content content;
@CreatedDate
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
protected Post() {
}
}
// COMMENT
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
private Content content;
@CreatedDate
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
protected Comment() {
}
}
// POST_LIKE
@Entity
public class PostLike {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
protected PostLike() {
}
}
현재는 단순히 단방향 연관관계만 맺어주었습니다. 개인적으로 양방향 연관관계는 실제 로직을 작성하면서 필요하다고 생각될때 그때 추가해주는 편이고, 초기 연관관계를 맺을때는 추가하지 않고 있습니다!
✅ 저장, 수정, 삭제 로직 JWT 인가 작업
저장, 수정, 삭제 로직에서 JWT 인가 작업이 필요한 부분은
게시글 저장, 게시글 수정, 게시글 삭제, 댓글 작성 기능에서 필요합니다.
AuthInterceptor를 수정하여 GET 조회 요청은 통과시키기
현재 GET 메소드를 통해 조회하는 로직을 제외한 나머지 로직들은 전부 인가 작업이 필요하므로 기존에 작성한 AuthInterceptor를 약간 수정하여 이를 적용할 수 있습니다.
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;
public AuthInterceptor(final JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
final Object handler) {
if (CorsUtils.isPreFlightRequest(request) || isGetRequest(request)) {
return true;
}
if (notExistHeader(request)) {
throw new JwtException(String.format("인증을 위한 헤더를 찾을 수 없습니다.:%d", NO_AUTHORIZATION_HEADER.value()));
}
if (JwtTokenExtractor.extractAccessToken(request) == null) {
throw new JwtException(String.format("헤더의 포멧이 일치하지 않습니다.:%d", INVALID_HEADER_FORMAT.value()));
}
jwtTokenProvider.validateToken(JwtTokenExtractor.extractAccessToken(request));
return true;
}
private boolean isGetRequest(final HttpServletRequest request) {
return request.getMethod().equalsIgnoreCase(HttpMethod.GET.name());
}
private boolean notExistHeader(final HttpServletRequest request) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
return Objects.isNull(authorizationHeader);
}
}
isGetRequest()를 추가해 Request Http Method가 GET이라면 그대로 통과시켜주도록 합니다.
이후 WebMvcConfig에서 전체 로직에 대해 해당 인터셉터를 통과하도록 구현하면 됩니다.
@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login")
.excludePathPatterns("/members/signup");
}
로그인, 회원가입 로직에 대해서는 제외를 시켜주어야 합니다.
게시글 저장, 수정, 삭제에 JWT 인가 적용하기
변경되는 로직이 거의 동일하므로, 게시글 저장을 예시로 설명하겠습니다.
// 게시글 저장 - Controller
@PostMapping("/posts")
public ResponseEntity<PostSaveResponse> createPost(@Login final AuthInfo authInfo,
@Valid @RequestBody final PostSaveRequest postSaveRequest) {
PostSaveResponse saveResponse = postService.createPost(authInfo, postSaveRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(saveResponse);
}
이전 시간에 만들어주었던 ArgumentResolver를 이용해 @Login이 붙은 매개인자를 해당 resolver가 resolving 작업을 해주어 Authorization 헤더 payload에서 사용자의 id값을 가져와 AuthInfo 객체를 인자로 념겨줍니다.
이를통해 Request Header에서 수동으로 JWT를 파싱하지 않아도 단순히 애노테이션만으로 간단하게 payload를 파싱해올 수 있습니다.
@Transactional
public PostSaveResponse createPost(final AuthInfo authInfo, final PostSaveRequest postSaveRequest) {
Member member = findMember(authInfo);
Post post = Post.builder()
.title(postSaveRequest.getTitle())
.content(postSaveRequest.getContent())
.member(member)
.build();
Post savedPost = postRepository.save(post);
return PostSaveResponse.createPostSuccess(savedPost.getId());
}
받아온 AuthInfo 객체의 id값을 통해 사용자를 조회합니다. 이때 존재하지 않는 사용자에 대한 검증은 findMember() 메서드에서 진행하고 있습니다.
PostSaveRequest의 값이 정상적으로 검증된다면 Post객체가 생성되는데 이때 Member를 Post객체에 지정해주어 해당 게시글은 특정 Member가 작성했음을 명시합니다.(연관관계)
설명하지 않은 게시글 수정, 삭제의 경우에도 위와 비슷하게 AuthInfo 객체를 통해 진행됩니다!
✅ 인수테스트에서 테스트용 데이터 직접 초기화 하기
기존 인수테스트의 문제
기존 인수테스트는 위와 같이 @DirtiesContext
를 사용하고 있었습니다.
또한 내부 요소로 BEFORE_EACH_TEST_METHOD
를 사용하고 있습니다.
따라서 인수테스트의 각 테스트 직전에 ApplicationContext를 매번 새로운 컨텍스트를 구성한다는 의미가 됩니다. 이 설정으로 인해 각 테스트는 서로 영향을 주지 않는다는 이점이 있지만, 다른 테스트에 비해 속도가 상당히 느리다는 단점이 있습니다.
더하여 JWT 토큰 인증 작업이 생기면서, 테스트 시작전에 미리 사용자를 등록해놓는다면 단순히 로그인 작업을 통해 토큰만 그때 그때 생성하면 되므로 DB에 직접 테스트 데이터를 초기화 놓을 필요가 있었습니다!
따라서 @DirtiesContext를 제거하고, 인수테스트에서 사용되는 DB를 매 테스트마다 직접 초기화해보겠습니다!
DatabaseCleaner
@Component
public class DatabaseCleaner implements InitializingBean { // (1)
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() { // (2)
this.tableNames = entityManager.getMetamodel()
.getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
.collect(Collectors.toList());
}
@Transactional
public void clear() { // (3)
entityManager.flush();
// 제약 조건 무효화 - 데이터를 지울때 외래키, 유일키 등의 제약조건에 영향을 받지 않게 함
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
// 테이블을 돌면서 데이터 TRUNCATE, 컬럼 ID 시작 값을 1로 초기화
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
entityManager.createNativeQuery(
"ALTER TABLE " + tableName + " ALTER COLUMN " + tableName + "_ID RESTART WITH 1").executeUpdate();
}
// 무효화한 제약 조건 다시 TRUE로
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
@Transactional
public void insertInitialData() { // (4)
entityManager.createNativeQuery(
"insert into member(email, password, created_at) values('valid01@mail.com', '!qwer123', CURRENT_TIMESTAMP())")
.executeUpdate();
entityManager.createNativeQuery(
"insert into member(email, password, created_at) values('valid02@mail.com', '!qwer123', CURRENT_TIMESTAMP())")
.executeUpdate();
}
}
- (1): DatabaseCleaner는
InitializingBean
인터페이스를 구현합니다. 이는 모든 빈 팩토리의 초기화 이후 DatabaseCleaner를 동작시키기 위함입니다! - (2): 해당 메소드는
InitializingBean
인터페이스가 제공하는 메소드로 모든 빈 프로퍼티 설정 이후에 해당 메서드가 동작하게 됩니다. 여기서는@Entity
가 붙은 클래스의 이름을 가져와서 DB 규칙에 맞게 TableName -> table_name과 같이 변경하여 DB 조회에서 사용할 수 있도록 합니다. - (3): 현재 DB 스키마에 적용되어 있는 외래키, 유일키와 같은 제약조건을 무효화 합니다. 이후 (2)에서 구한 Table 이름을 가지고 해당 테이블의 모든 튜플들을 TRUNCATE 작업을 하여 최초 테이블이 생성된 상태로 돌립니다. 이후 AUTO_INCREMENT되는 id값의 시작값을 1로 초기화 합니다. 마지막으로 무효화한 제약조건을 다시 TRUE로 변경해 제약조건이 적용되도록 합니다.
- (4): 커스텀하게 사용될 데이터를 초기화 합니다. 여기서는 사용자의 등록을 미리 하기 위해 2명의 사용자를 미리 등록합니다.
미리 등록한 사용자를 통해 토큰 발급 - TokenFixture
public class TokenFixture {
private static final String AUTHORIZATION_PREFIX = "Bearer ";
public static String getMemberToken() {
LoginRequest loginRequest = LoginRequest.builder()
.email("valid01@mail.com")
.password("!qwer123")
.build();
return AUTHORIZATION_PREFIX + httpPost(loginRequest, "/login")
.jsonPath()
.getObject(".", TokenResponse.class)
.getToken();
}
public static String getOtherMemberToken() {
LoginRequest loginRequest = LoginRequest.builder()
.email("valid02@mail.com")
.password("!qwer123")
.build();
return AUTHORIZATION_PREFIX + httpPost(loginRequest, "/login")
.jsonPath()
.getObject(".", TokenResponse.class)
.getToken();
}
}
TokenFixture는 위에서 미리 등록한 사용자의 정보를 이용해 login request 작업을 통해 Token을 발급받고 반환하는 util 클래스입니다. 이를 통해 인수테스트에서 인증/인가가 필요한 부분에서 편리하게 사용할 수 있습니다.
로그인을 한 사용자만 게시글을 작성할 수 있다
@DisplayName("게시글 작성을 할 수 있다.")
@Test
void createPost() {
// given
String token = getMemberToken();
// when
ExtractableResponse<Response> response = httpPostWithAuthorization(postSaveRequest1, "/posts", token);
PostSaveResponse postSaveResponse = response.jsonPath().getObject(".", PostSaveResponse.class);
//then
assertAll(
() -> assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()),
() -> assertThat(postSaveResponse.getSavedId()).isEqualTo(1L),
() -> assertThat(postSaveResponse.getMessage()).isEqualTo("게시글 작성을 완료했습니다.")
);
}
위의 테스트의 흐름을 보면사용자의 로그인
-> 토큰 발급
-> 발급된 토큰을 통해 게시글 작성
과 같은 흐름으로 인수테스트가 진행 됩니다.
리팩토링 결과
- 리팩토링 이전 테스트 완료 시간 (약 13초 ~)
- 리팩토링 이후 테스트 완료 시간 (약 6초 ~)
전체 테스트에서는 큰 차이가 없어보이지만, 반복테스트가 아닌 테스트에서는 거의 10배 정도의 속도차이가 납니다!
❗️트러블 슈팅1 - 인수테스트 ExtractableResponse<Response> 객체에서 json to object가 안되는 현상
문제 상황
// 문제가 되는 코드
private TokenResponse getMemberToken() {
LoginRequest loginRequest = LoginRequest.builder()
.email("valid01@mail.com")
.password("!qwer123")
.build();
return httpPost(loginRequest, "/login")
.jsonPath()
.getObject(".", TokenResponse.class);
}
// 예외 메시지
Cannot construct instance of `com.example.anonymousboard.auth.dto.TokenResponse` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{"token":"eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwiaWF0IjoxNjg1Mjg0OTAzLCJleHAiOjE2ODUyODg1MDN9.FWcppuALNQHevUnS_hauCSYXxSWzON5fANUvEOZxOfHpC_hiI82rfGBPKpariBAPuvSIhI_UkW_Nelx7FGlLDA"}"; line: 1, column: 2]
com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `com.example.anonymousboard.auth.dto.TokenResponse` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{"token":"eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwiaWF0IjoxNjg1Mjg0OTAzLCJleHAiOjE2ODUyODg1MDN9.FWcppuALNQHevUnS_hauCSYXxSWzON5fANUvEOZxOfHpC_hiI82rfGBPKpariBAPuvSIhI_UkW_Nelx7FGlLDA"}"; line: 1, column: 2]
문제 원인
jsonPath().getObject()에서는 json을 Object로 역직렬화하는 코드입니다.
기본적으로 역직렬화시에는 기본생성자가 필요하지만, jackson-module-parameter-nmaes 모듈이 기본생성자가 없어도 다른 생성자로 대체해 역직렬화를 가능케 합니다.
하지만 TokenResponse 객체의 경우 하나의 필드를 가지고 있고, 하나의 필드만을 가지고 있는 경우에는 예외가 발생합니다. 이를 해결하기 위해서는 매개변수 생성자에 @JsonCreator
애노테이션을 붙여주거나 기본생성자를 명시적으로 작성해주면 됩니다!
해결
@Getter
public class TokenResponse {
private String token;
private TokenResponse() { // (1) 기본 생성자를 명시적으로 작성하거나
}
@JsonCreator // (2) 매개변수 생성자에 해당 애노테이션을 붙여준다.
@Builder
private TokenResponse(final String token) {
this.token = token;
}
public static TokenResponse from(final String token) {
return new TokenResponse(token);
}
}
✅ 특정 게시글 조회 - 모든 댓글(댓글 내용, 작성 시간, 작성자 이메일)을 같이 조회
댓글 작성 로직은 간단한 insert이므로 따로 작성하지 않았습니다.
요구사항에서 단건 게시글을 조회하게 되면 해당 게시글에 달린 모든 댓글을 조회해야 합니다. 단, 댓글 작성자의 이메일도 포함되어야 합니다.
기존 단건 게시글 조회 로직은 다음과 같습니다.
public PostResponse findPostById(final Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(PostNotFoundException::new);
return PostResponse.from(post);
}
단일 게시글을 조회해 dto로 변환해 컨트롤러에게 반환합니다. 우리는 여기에 댓글이라는 정보도 포함시켜 주어야 합니다.
우선 댓글 정보를 같이 담아줄 dto를 새로 만들었습니다.
@Getter
public class PostDetailResponse {
private final Long id;
private final String title;
private final String content;
private final LocalDateTime createdAt;
private final List<CommentResponse> comments;
@Builder
private PostDetailResponse(final Long id, final String title, final String content, final LocalDateTime createdAt,
final List<CommentResponse> comments) {
this.id = id;
this.title = title;
this.content = content;
this.createdAt = createdAt;
this.comments = comments;
}
public static PostDetailResponse of(final Post post, final List<Comment> comments) {
List<CommentResponse> commentResponses = comments.stream()
.map(CommentResponse::from)
.collect(Collectors.toList());
return PostDetailResponse.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.createdAt(post.getCreatedAt())
.comments(commentResponses)
.build();
}
}
정적 메서드를 보면 게시글과, 해당 게시글의 모든 댓글을 받아오고 각각 dto로 변환시켜 줍니다.
각 댓글을 dto로 변환시키는 CommentResponse::from
로직을 살펴보면 다음과 같습니다.
@Getter
public class CommentResponse {
private final String content;
private final LocalDateTime createdAt;
private final String email;
@Builder
private CommentResponse(final String content, final LocalDateTime createdAt, final String email) {
this.content = content;
this.createdAt = createdAt;
this.email = email;
}
public static CommentResponse from(final Comment comment) {
return CommentResponse.builder()
.content(comment.getContent())
.createdAt(comment.getCreatedAt())
.email(comment.getWriterEmail())
.build();
}
}
comment.getWriterEmail()
은 Comment
의 Member 객체에서 email값을 반환합니다.
위와 같이 dto를 구성했으니 이제 단건 게시글 조회시 모든 댓글을 조회해보겠습니다!
우선 단건 조회 서비스 로직은 다음과 같습니다.
public PostDetailResponse findPostDetailById(final Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(PostNotFoundException::new);
List<Comment> comments = commentRepository.findCommentsByPost(post);
return PostDetailResponse.of(post, comments);
}
CommentRepository를 통해 모든 댓글을 조회하고, dto에게 게시글과 함께 넘겨주며 응답 객체를 생성합니다.
위 로직에서 한가지 주의해야 할 부분이 있습니다. comments 리스트에는 N개의 댓글 객체가 존재하고 내부의 @ManyToOne으로 맺은 연관관계는 모두 Lazy Loading으로 설정되어 있습니다.
하지만, PostDetailResponse을 생성할때 내부에서 N번의 반복을 통해 Member email을 조회하게 되며 N번의 Member 조회 쿼리가 발생합니다. 즉 N+1 문제
가 발생합니다.
❗️트러블 슈팅2 - 댓글리스트에서 회원 조회시 발생하는 N+1 문제
commentRepository.findCommentsByPost(..)
를 단순 쿼리 메서드로 구성하고 조회하고 응답 dto를 생성하게 되면 다음과 같이 N번의 회원 조회 쿼리가 발생합니다.
2023-06-01 01:24:25.711 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
2023-06-01 01:24:25.713 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
2023-06-01 01:24:25.714 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
2023-06-01 01:24:25.715 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
2023-06-01 01:24:25.717 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
2023-06-01 01:24:25.718 DEBUG 79710 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_1_0_,
member0_.created_at as created_2_1_0_,
member0_.email as email3_1_0_,
member0_.password as password4_1_0_
from
member member0_
where
member0_.member_id=?
이를 해결하기 위한 방식으로는 fetch join + distinct 키워드
를 선택했습니다.
단건 게시글 조회시 조회하는 댓글의 수는 제한이 없으며(페이징x), 단순히 전체를 조회하면 되므로 간단하게 해결할 수 있는 fetch join을 이용하게 됐습니다.
전체 댓글 조회 JPQL
@Query("SELECT distinct c from Comment c left join fetch c.member where c.post = :post")
List<Comment> findCommentsByPost(Post post);
위와 같이 fetch join을 이용하게 되면 N번의 Member.email을 조회하더라도 다음과 같이 1번의 쿼리만 발생합니다.
2023-06-01 01:33:08.863 DEBUG 79751 --- [nio-8080-exec-2] org.hibernate.SQL :
select
post0_.post_id as post_id1_2_0_,
post0_.content as content2_2_0_,
post0_.created_at as created_3_2_0_,
post0_.member_id as member_i5_2_0_,
post0_.title as title4_2_0_
from
post post0_
where
post0_.post_id=?
2023-06-01 01:33:08.931 DEBUG 79751 --- [nio-8080-exec-2] org.hibernate.SQL :
select
distinct comment0_.comment_id as comment_1_0_0_,
member1_.member_id as member_i1_1_1_,
comment0_.content as content2_0_0_,
comment0_.created_at as created_3_0_0_,
comment0_.member_id as member_i4_0_0_,
comment0_.post_id as post_id5_0_0_,
member1_.created_at as created_2_1_1_,
member1_.email as email3_1_1_,
member1_.password as password4_1_1_
from
comment comment0_
left outer join
member member1_
on comment0_.member_id=member1_.member_id
where
comment0_.post_id=?
이를통해 댓글 조회시 발생하는 N+1문제를 해결할 수 있었습니다.
🤔 마치면서
5회차 미션의 내용 자체의 난이도는 높지 않았다고 생각됩니다.
하지만, 인증/인가가 추가되면서 그에따른 테스트에 대한 변동이 정말 많아졌습니다.. 리팩토링과 최적화를하는데 정말 하루종일 고민했던것 같습니다..
의도치 않은 많은 시간을 쓰게되면서 정말 프로젝트의 설계가 중요하다는 생각이 들었습니다.
현재 프로젝트는 어찌보면 3일에 한번씩 요구사항이 변경된다고 볼 수 있습니다. 변경되는 요구사항은 기존 로직들의 변경을 의미했고, 이는 기존에 작성한 테스트 코드들의 일부가 무용지물이 될 수 있다는 의미이기도 합니다.
개인적으로 테스트 코드 또한 유지보수 해야하는 산출물이라고 생각하므로 로직이 변경되면 그때그때 테스트 코드들도 같이 변경해주었습니다. 결론은 요구사항이 계속해서 변경되는것만큼 프로젝트 진행에 차질이 생기는일이 없다는 생각이 듭니다..
만약 프로젝트를 진행하게 된다면, 조금 귀찮고 지루하더라도 프로젝트의 설계 단계를 정말 철저하게 준비하여 요구사항의 변동을 최소화 해야겠다는 생각이 듭니다.
더하여 변동되는 요구사항에 유연하게 대응할 수 있는 코드를 작성하는 능력도 키워야함을 느꼈습니다.
현재 제가 작성하는 코드는 새로운 요구사항이 추가될수록, 유연하게 대응하지 못하는것 같습니다.
위의 로직은 게시글 단건조회와 게시글 수정 이후 단건 조회에 사용되는 2개의 게시글 단건 조회 비즈니스 로직입니다.
사실상 댓글이라는 값의 유무 차이지만, 그로인해 비즈니스 로직이 나뉘게 되고, 또 각각의 비즈니스 로직이 뷰 로직과 강하게 의존되는것 같 보입니다.
만약에 새로운 요구사항이 추가되고, 새롭게 반환해야 한다면 위와 같은 구조에서는 단순히 새로운 뷰 로직을 만들고, 그것에 의존되는 새로운 비즈니스 로직을 만들어야 할 것 같다는 생각입니다.
이런 구조를 조금 더 재사용 가능하고, 유연한 구조로 만들려면 어떻게 해야할지 많은 고민이 들었던 미션이었습니다..!
레퍼런스
jackson을 이용한 data binding 이해하기
@DirtiesContext로 무거워진 인수 테스트 시간을 줄이는 실험을 해봅시다