#049. 코프링: 코틀린으로 커뮤니티 신고기능 구현하기
개념
커뮤니티 기능이 있는 앱을 출시하기 위해서는 신고기능이 필요하다. 이번 글에서는 커뮤니티 신고 기능 API를 만들면서 백엔드 API를 어떤 방식으로 작업하는 지 정리해보려고 한다. 생성된 신고의 상태를 바꾸는 API를 보려고 하고 사용한 스택은 springboot, graphql, mysql 이다.
구현
(1) API 작성(스키마) w. 프런트
graphql을 사용하고 있다. schema-first로 작업하고 있어서 스키마를 먼저 작업한다.
신고글 id와 바꾸려고 하는 상태를 입력받고(ChangeReportStatusInput), 변경된 상태를 반영한 신고를 되돌려준다.
스키마를 정하기 위해서는 정책적인 이야기도 필요하다. 이 작업은 프런트와 함께 확인하며 진행한다.
// schema.graphql
type Mutation {
changeReportStatus(input: ChangeReportStatusInput!): Report!
}
input ChangeReportStatusInput {
reportId: ID!
status: String!
}
type Report {
id: ID!
targetId: ID!
targetType: String!
type: String!
status: String!
content: String
reporter: User!
createdAt: String!
updatedAt: String!
}
(2) DB 생성하기
프런트와 이야기가 되고 나면, 이를 저장할 DB를 먼저 생성한다. 여기서는 신고라는 의미의 report 라는 테이블을 생성했다. "게시글 신고" 와 "유저 신고" 두 가지 종류를 표현하기 위해 target_type에는 ENUM으로 POST, USER 두 가지를 받을 수 있고, target_id에는 게시글 또는 유저의 id가 들어올 수 있다. type은 신고 유형이고 ENUM 값을 받도록 했다. content를 통해 구체적인 이유를 설명할 수 있도록 했다. status는 신고 처리 상태로 ENUM 타입으로 REQUESTED(요청됨), ACCEPTED(수리됨), REJECTED(거절됨) 세 가지 상태를 받을 수 있다. 확장 가능성이 열려있어 이후 상태(예를 들면 DELETED 등)가 더 필요해지면 어렵지 않게 수정할 수 있다. CUD메타 정보도 포함하고 있다.
create table report
(
id bigint auto_increment primary key,
reporter_id bigint not null comment '신고자 ID',
target_id bigint not null comment '신고 대상 ID',
target_type varchar(255) not null comment '신고 대상 타입',
type varchar(255) not null comment '신고 유형',
status varchar(255) not null comment '신고 처리 상태',
content text null,
created_at timestamp not null,
updated_at timestamp not null,
deleted_at timestamp null
);
(3) 구현하기(w. QueryDSL)
// Controller
@MutationMapping
fun changeReportStatus(
@Argument input: ChangeReportStatusInput, // <--- GraphqlInput과 DTO를 맞춘다
): Report {
return reportService.changeReportStatus(input.reportId, input.status) // <--- DTO가 아닌 개별 값을 넘긴다
}
// Service
@Transactional // <--- Service 전체는 readonly로 하되, 트랜젝션이 필요한 매서드에만 @Transactional 처리를 한다.
fun changeReportStatus(reportId: Long, reportStatus: ReportStatus): Report {
val report = reportQueryRepository.findReportBy(reportId) ?: throw IllegalArgumentException("존재하지 않는 신고입니다.")
report.changeReportStatus(reportStatus) // <--- status를 바꾸는 책임은 report 도메인이 가져간다
return reportRepository.save(report)
}
// Report.entity(도메인)에 status를 수정하는 매서드가 있다
// create a function that changes the report status
fun changeReportStatus(reportStatus: ReportStatus): Report {
this.status = reportStatus
return this
}
상태를 바꾸기 위해 신고(report)를 조회하는 기능에는 QueryDSL을 사용했다. 사실 JpaRepository를 사용해도 충분하지만 프로젝트 전체에서 QueryDSL을 사용할 예정이기 때문에 간단한 쿼리 연습용으로 적용해봤다.
// QueryDSL Config 설정
@Configuration
class QueryDslConfig {
@Bean
fun jpaQueryFactory(em: EntityManager): JPAQueryFactory {
return JPAQueryFactory(em)
}
}
처음에는 EntityManager를 필드로 받고 적용시켰는데,
@Bean 달린 메서드는 인자로 다른 의존성 주입받을 수 있어요. em을 클래스의 프로퍼티로 선언하는 경우는 해당 프로퍼티의 생명주기가 서버가 종료될 까지 유지되는데 메서드의 인자로 받으면 해당 메서드가 종료될 때까지만 유지되기에 메모리를 덜 쓰게되요.
라는 리뷰를 받고, EntityManager를 @Bean으로 등록된 메서드인 jpaQueryFactory의 인자로 넘겨, 메서드의 생명주기로 사용주기를 맞출 수 있었다.
@Repository의 예외변환기 같은 기능 때문에 @Component를 사용했다.(참고: https://www.inflearn.com/questions/650061/%EC%A7%88%EB%AC%B8%EC%9D%B4-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4, https://namocom.tistory.com/1025)
// Repository
@Component
class ReportQueryRepository @Autowired constructor(
private val query: JPAQueryFactory,
) {
fun findReportBy(reportId: Long): Report? {
return query.select(
report,
).from(report)
.where(report.id.eq(reportId))
.fetchOne()
}
}
(4) 테스트 설정하기 (unit test)
@SpringBootTest, @AutoConfigureGraphQlTester, ninjasquad - springmockk의 @MockkBean, Junit5를 사용했다.
springmockk는 spring boot +kotlin 스택에서 springboot integrationMockito 대신 mockk를 사용할 수 있도록 지원하는 프레임워크로, Spring Boot는 통합 테스트를 위해 Mockito를 사용하여 @MockBean 및 @SpyBean 어노테이션을 제공하는데, 이 프로젝트는 MockK와 동일한 작업을 수행할 수 있는 동등한 어노테이션인 MockkBean과 SpykBean을 제공한다.
자동화된 테스트를 포함하여 스프링 부팅 테스트 라이브러리의 모든 Mockito 관련 클래스가 복제되어 Kotlin으로 번역되고 MockK에 적용되어있다. 표준 Mockito 기반 Spring Boot 모의 빈과 동일한 기능을 제공하는데 예를 들어 JUnit 5를 사용하지만 물론 JUnit 4도 사용할 수 있다. 다시 말해 Mockito가 제공하는 기능들을 kotlin 친화적으로 제공하는 툴이다.
참고로 Mockito(및 표준 MockBean 및 SpyBean 어노테이션)가 사용되지 않도록 하려면 아래와 같이 mockito 종속성을 제외해야한다.
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "mockito-core")
}
테스트 코드 작성:
@Test
@DisplayName("신고글 상태를 바꾼다")
fun changeReportStatus() {
// given
val reportId = 1L
val reportStatusToBe = ReportStatus.ACCEPTED
val report = Report().apply {
id = reportId
type = ReportType.OFFENCE
targetType = ReportTargetType.POST
targetId = 2L
status = reportStatusToBe
}
every { reportService.changeReportStatus(reportId, reportStatusToBe) } returns report
// when
val result = this.graphQlTester.documentName("changeReportStatus")
.variable(
"input", //<--- 테스트를 위한 input을 넣어준다
mapOf(
"reportId" to 1L,
"status" to reportStatusToBe,
),
)
.execute() // <--- graphql을 실행시킨다
.path("changeReportStatus") // <---실행할 쿼리의 위치
.entity(Report::class.java) // <--- 결과물을 report 클래스에 매핑한다.
// then
Assertions.assertThat(result.get().id).isEqualTo(reportId)
Assertions.assertThat(result.get().status).isEqualTo(reportStatusToBe)
}
// Service.test
import io.mockk.every
import io.mockk.mockk
...
private val reportQueryRepository = mockk<ReportQueryRepository>()//<--- 서비스에서는 일반 mockk를 사용한다.
private val reportRepository = mockk<ReportRepository>()
private val redisRepository = mockk<RedisRepository>()
private val userRepository = mockk<UserRepository>()
private val reportService = ReportService(
reportRepository,
reportQueryRepository,
redisRepository,
userRepository,
)
...
@Test
@DisplayName("신고 글 상태 변경")
fun changeReportStatus() {
// given
val reportId = 1L
val report = Report().apply {
id = reportId
type = ReportType.OFFENCE
targetType = ReportTargetType.POST
targetId = 2L
status = ReportStatus.REQUESTED
}
val reportStatus = ReportStatus.ACCEPTED
val expected = report.apply {
this.status = reportStatus
}
every { reportQueryRepository.findReportBy(reportId) } returns report //<--- mockk의 every를 이용한다.
every { reportRepository.save(report) } returns expected
// when
val result = this.reportService.changeReportStatus(reportId, reportStatus)
// then
Assertions.assertThat(result.status).isEqualTo(reportStatus)
}
repository 테스트는 @DataJpaTest를 사용해서 실행했다.
(5) 쿼리 확인 후 인덱스 설정하기
아래와 같은 쿼리를 실행하면 reporter(신고자)로 인해 user를 따로 불러오게 되며 N+1 쿼리를 발생시킨다.
fun findReportBy(reportId: Long): Report? {
return query.select(
report,
).from(report)
.where(report.id.eq(reportId))
.fetchOne()
}
쿼리 결과
Hibernate: select report0_.id as id1_14_, report0_.created_at as created_2_14_, report0_.deleted_at as deleted_3_14_, report0_.updated_at as updated_4_14_, report0_.content as content5_14_, report0_.reporter_id as reporte10_14_, report0_.status as status6_14_, report0_.target_id as target_i7_14_, report0_.target_type as target_t8_14_, report0_.type as type9_14_ from report report0_ where report0_.id=?
Hibernate: select user0_.id as id1_15_0_, user0_.activated as activate2_15_0_, user0_.age_agreement as age_agre3_15_0_, user0_.created_at as created_4_15_0_, user0_.deleted_at as deleted_5_15_0_, user0_.email as email6_15_0_, user0_.name as name7_15_0_, user0_.nickname as nickname8_15_0_, user0_.profile_uri as profile_9_15_0_, user0_.term_agreement as term_ag10_15_0_, user0_.updated_at as updated11_15_0_ from users user0_ where user0_.id=?
신고기능은 거의 항상 유저 정보를 호출해오기 때문에 N+1 문제를 해결하기 위해 아래와 같이 fetchJoin을 해서 하나의 쿼리로 불러올 수 있도록 한다.
fun findReportBy(reportId: Long): Report? {
return query.select(
report,
).from(report)
.join(report.reporter).fetchJoin() //<--- fetchJoin으로 수정한다.
.where(report.id.eq(reportId))
.fetchOne()
}
수정한 쿼리 결과는 아래와 같이 한 번 호출하게 된다.
select * (생략)
from report report0_
inner join users user1_ on report0_.reporter_id = user1_.id
where report0_.id = ?