안녕하세요. 저는 조수현이라고 합니다.
부산소프트웨어마이스터고등학교를 떠나 취업을 위해 아우름플래닛 백엔드 사전과제를 수행하게 되었습니다.
이번 과제를 진행하면서 사용자 친화적인 기능을 어떻게 구현할 수 있을지에 대해 많은 고민을 해보게 되었던 것 같습니다.
중간에 공개범위 지정에 대한 의문점이 있어서 라이너팀에 질문을 하였는데, 친절하게 답변을 주셔서 정말 감사했습니다.
이렇게 사전과제를 통해 도전하고, 성장하며 발전하는 경험을 쌓는 것은 정말 의미있는 시간이었습니다.
사전과제를 성공적으로 완료하여 아우름플래닛의 일원이 되고자 합니다. 감사합니다.
토글을 펼쳐서 api 문서를 확인해 보세요!
user
-
POST회원가입{ "userId" :12333, "nickname" : "조수현테스트테스트", "username" : "@05tngus99", "password" : "1234" }사용자가 이미 존재할 경우
{ "status": "UNPROCESSABLE_ENTITY", "message": "사용자가 이미 존재합니다." }
정상적인 작동
200 Ok -
POST로그인{ "userId" : 12345, "password" : "1234" }존재하지 않는 userId를 입력한 경우
{ "status": "NOT_FOUND", "message": "사용자가 없습니다." }
잘못된 비밀번호를 입력한 경우
{ "status": "UNAUTHORIZED", "message": "비밀번호가 틀렸습니다." }
정상적인 작동
{ "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjM0NSIsImlhdCI6MTY4OTE3ODI0NywiZXhwIjoxNjg5MTgxODQ3fQ.QV1KvxG2GKFcZ3VhR7PU5NLY16LytJpBIZ7dSyjpbUQ", "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjM0NSIsImlhdCI6MTY4OTE3ODI0NywiZXhwIjoxNjg5MjY0NjQ3fQ.cJ01_yCmky2Y9SsA9_sGqp8okRlodeRyz5ZVjxBaMJg", "userId": 12345, "nickname": "조수현" }
page
-
POST페이지 생성openStatus를PUBLIC또는PRIVATE로 하는 경우{ "userId": 123, "pageUrl": "�google.com", "title": "보이나?", "openStatus": "PUBLIC", "mentionedUserName": null }
openStatus를MENTIONED로 하는 경우{ "userId": 123, "pageUrl": "google.com", "title": "보이나?", "openStatus": "MENTIONED", "mentionedUserName": "@05tngus,@05tngus95,@05tngus959595" }이미 해당 pageUrl를 저장한 페이지가 있을 경우
{ "status": "UNPROCESSABLE_ENTITY", "message": "페이지가 이미 존재합니다." }
mentionedUserName에 존재하지 않는 값이 들어갔을 경우{ "status": "NOT_FOUND", "message": "사용자가 없습니다." }
정상적인 작동
200 Ok -
PATCH페이지 수정openStatus를PUBLIC또는PRIVATE로 하는 경우{ "pageId" : 8, "title" : "테스트페이지", "openStatus" : "PRIVATE", "mentionedUserName" : null }
openStatus를MENTIONED로 하는 경우{ "pageId" : 8, "title" : "테스트페이지", "title": "보이나?", "openStatus": "MENTIONED", "mentionedUserName": "@05tngus,@05tngus95,@05tngus959595" }이미 해당 pageUrl를 저장한 페이지가 있을 경우
{ "status": "UNPROCESSABLE_ENTITY", "message": "페이지가 이미 존재합니다." }
mentionedUserName에 존재하지 않는 값이 들어갔을 경우{ "status": "NOT_FOUND", "message": "사용자가 없습니다." }
정상적인 작동
200 Ok -
GET해당 페이지 조회pageId에 존재하지 않는 값이 들어왔을 경우{ "status": "NOT_FOUND", "message": "페이지가 없습니다." }
정상적인 작동
{ "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 3, "pageUrl": "google.come", "pageTitle": "안보이겠지?", "highlights": [ { "highlightId": 2, "colorHex": "#ffff8d", "text": "dldldlaa" } ] } -
GET내가만든 페이지 조회파라미터로
page: Intsize: Int정상적인 작동
{ "currentPage": 1, "hasMorePage": false, "feedList": [ { "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 4, "pageUrl": "google.comaaae", "pageTitle": "보이나?", "highlights": [ { "highlightId": 4, "colorHex": "#ffff8d", "text": "다른거추가" }, { "highlightId": 3, "colorHex": "#ffff8d", "text": "dldldlaa" } ] }, { "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 3, "pageUrl": "google.come", "pageTitle": "안보이겠지?", "highlights": [ { "highlightId": 2, "colorHex": "#ffff8d", "text": "dldldlaa" } ] }, { "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 1, "pageUrl": "google.com", "pageTitle": "테스트페이지", "highlights": [ { "highlightId": 1, "colorHex": "#ffff8d", "text": "dldldl" } ] } ] } -
GET피드보기파라미터로
userId: Long (토큰으로 사용자 정보를 부를 수 있지만 API명세에 필수값이라 추가)page: Intsize: Int정상적인 작동
{ "currentPage": 1, "hasMorePage": false, "feedList": [ { "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 4, "pageUrl": "google.comaaae", "pageTitle": "보이나?", "highlights": [ { "highlightId": 4, "colorHex": "#ffff8d", "text": "다른거추가" }, { "highlightId": 3, "colorHex": "#ffff8d", "text": "dldldlaa" } ] }, { "nickname": "조수현", "username": "@05tngus", "pageCreateAt": "Jul 12, 2023", "pageId": 1, "pageUrl": "google.com", "pageTitle": "테스트페이지", "highlights": [ { "highlightId": 1, "colorHex": "#ffff8d", "text": "dldldl" } ] } ] }
highlight
-
POST하이라이트 생성{ "pageUrl": "google.comaaae", "colorHex": "#ffff8d", "text": "다른거추가" }해당
pageUrl의 페이지가 저장되어있지 않은 경우{ "status": "NOT_FOUND", "message": "페이지가 없습니다." }
정상적인 작동
200 Ok -
PATCH하이라이트 수정{ "highlightId": 123", "colorHex": "#ffff8d", "text": "다른거추가" }정상적인 작동
200 Ok -
DELETE하이라이트 삭제{ "highlightId": 123 }정상적인 작동
200 Ok
tbl_user는 유저 테이블로 사용자 인증에 사용됩니다.tbl_page는 페이지 테이블로 유저 테이블과 양방향 연관관계를 맺고 하이라이트를 포함하는 페이지를 저장하고 공개범위를 지정할 수 있습니다.tbl_highlight는 하이라이트 테이블로 페이지 테이블과 양방향 연관관계를 맺고 페이지에 생성할 하이라이트를 저장합니다.tbl_mention은 멘션 테이블로 페이지의 공개범위가MENTIONED일 때 해당 페이지에 멘션된 사용자를 저장하기 위해 생성된 테이블입니다. 유저와 페이지 테이블과 양방향 연관관계를 맺습니다.
userId:tbl_user의userId는 다른사용자의 사칭 방지하기 위해서, 그리고 구글 로그인 시 반환되는userId를 저장하기 위하여 추가한 컬럼입니다.mentioned_user_list:tbl_page의mentioned_user_list는 이전에 멘션된 사용자들을 저장하기 위해 추가한 컬럼입니다.
제가 판단한 라이너 사전과제의 요구사항은 아래의 사항입니다!
또한 좀 더 유연한 서비스 흐름을 위해 몇 개의 요구사항은 제가 추가하였습니다.
-
유저
- 유저는 회원가입을 할 수 있다.
- 유저는 로그인을 할 수 있다.
- 유저는 하이라이트를 생성할 수 있다.
-
페이지
- 유저는 페이지를 생성할 수 있다.
- 유저는 페이지의 제목, 공개범위를 수정할 수 있다.
- 유저는 페이지의 공개범위(공개, 비공개, 일부공개)를 지정할 수 있다.
-
하이라이트
- 유저는 하이라이트를 생성할 수 있다.
- 유저는 하이라이트를 추가하면 자동으로 페이지에 추가된다.
-
피드
- 유저는 피드에서 공개 페이지나 일부공개에 포함되어 있는 페이지만 조회할 수 있다.
- 피드는 유저가 해당 페이지에 최초로 하이라이트한 시간을 기준으로 내림차순 된다.
- 피드의 페이지에 조회되는 하이라이트는 최대 3개이다.
- 피드는 페이징처리가 된다.
-
@Query("select p from PageEntity p join fetch p.highlights h where p in (select m.page from Mention m where m.user = :user) or p.openStatus = :openStatus order by h.createdAt desc") fun findPagesWithMentionsOrPublic(user: User, openStatus: OpenStatus, pageable: Pageable): Page<PageEntity>
이 쿼리어노테이션을 sql문으로 변환하면
SELECT p.* FROM tbl_page p JOIN tbl_highlight h ON p.id = h.page_id WHERE p.id IN (SELECT m.page_id FROM tbl_mention m WHERE m.user = :user) OR p.open_status = :openStatus ORDER BY h.created_at DESC;
이렇게 됩니다.
멘션된 사용자와 openStatus가 PUBLIC인 페이지를 조회합니다.
이 때 페이지의 가장 처음 만든 하이라이트를 기준으로 내림차순 정렬합니다.
이렇게 조회한 페이지는GetFeedListResponse( currentPage = pages.number + 1, hasMorePage = (pages.totalPages - 2 > pages.number), feedList = pages.content.map { GetPageResponse( nickname = it.user.nickname, username = it.user.username, pageCreateAt = formatToLocalDateTime(it.createdAt), pageId = it.id, pageUrl = it.url, pageTitle = it.title, highlights = it.highlights.take(3).map { highlight -> //3개만 GetHighlightResponse( highlightId = highlight.id, colorHex = highlight.colorHex, text = highlight.text ) } ) })
해당 형태에 맞춰 리턴됩니다.
fun formatToLocalDateTime(localDateTime: LocalDateTime?): String { val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.ENGLISH) return localDateTime?.format(formatter).toString() }
또한
formatToLocalDateTime함수를 사용하여 LocalDateTime의 형태를 변환하여 가독성을 고려하였습니다. -
공개 범위는
PUBLIC,PRIVATE,MENTIONED이렇게 세 개가 존재합니다.
MENTIONED는 공개할 사용자를 지정할 수 있어서
tbl_mention이라는 테이블을 생성하고 페이지와 멘션된 사용자를 저장하도록 하였습니다.
그리고 위에도 언급했던@Query("select p from PageEntity p join fetch p.highlights h where p in (select m.page from Mention m where m.user = :user) or p.openStatus = :openStatus order by h.createdAt desc") fun findPagesWithMentionsOrPublic(user: User, openStatus: OpenStatus, pageable: Pageable): Page<PageEntity>
를 사용하여 멘션된 사용자만 조회가 가능하도록 구현하였습니다.
또한 공개 범위는 동적으로 변경될 수 있어야 합니다.
그래서 어떻게 하면 쿼리문을 적게 부르면서 공개범위를 변경할 수 있을까 고민해 보았습니다.
그리고 제가 구현한 방법은
UpdatePageService@Transactional fun execute(request: UpdatePageRequest) { val page = pageFacade.findById(request.pageId) //페이지를 MENTIONED으로 변경하려 할 때 if (request.openStatus == OpenStatus.MENTIONED) { if (!request.mentionedUserName.isNullOrEmpty()) { //tbl_page에 저장되어 있던 멘션된 사용자 리스트 val mentionedUserList = page.mentionedUserList?.split(",") //새로 변경하는 멘션된 사용자 리스트 val newMentionedUserList = request.mentionedUserName.split(",") //tbl_page에 저장되어 있던 멘션된 사용자 리스트가 null이 아닐 경우 if (mentionedUserList != null) { //cleanUpMentionMemberService 실행 cleanUpMentionMemberService.execute(mentionedUserList, newMentionedUserList, page) } //tbl_page에 저장되어 있던 멘션된 사용자 리스트가 null일 경우 else { //newMentionedUserList의 사용자를 Mention으로 생성 newMentionedUserList.forEach { createMentionService.execute(it, page) } } } //Mention생성이 다 저장된 후 page를 update(영속성컨텍스트) page.updatePage(request.title, request.openStatus, request.mentionedUserName) } //tbl_page에 저장되어 있던 멘션된 사용자 리스트가 존재하면 else { //tbl_page에 저장되어 있던 멘션된 사용자 리스트가 존재하면 if (page.mentionedUserList != null) { //해당 Mention들 삭제 page.mentionedUserList?.split(",")?.forEach { deleteMentionService.execute(it, page) } } //위의 로직 수행 후 page를 update, mentionedUserList는 null로 입력 page.updatePage(request.title, request.openStatus, null) } }
CleanUpMentionMemberService
@Transactional fun execute(mentionedUserList: List<String>, newMentionedUserList: List<String>, page: PageEntity) { // newMentionedUserList에는 없고 mentionedUserList에는 있는 사용자는 usersToDelete에 저장 val usersToDelete = mentionedUserList.filterNot { newMentionedUserList.contains(it) } // newMentionedUserList에는 있고 mentionedUserList에는 없는 사용자는 usersToInsert에 저장 val usersToInsert = newMentionedUserList.filterNot { mentionedUserList.contains(it) } //Mention 추가(저장) usersToInsert.forEach { userName -> createMentionService.execute(userName, page) } //Mention 삭제 usersToDelete.forEach { userName -> deleteMentionService.execute(userName, page) } }
이렇게 이전
mention된 사용자와 현재mention할 사용자를 비교하여mention을추가/삭제해주고
mention에서public이나private로 변경한다면mention들을삭제해주는 로직을 구현하였습니다. -
jwt 토큰을 �발급하여 토큰으로 로그인을 할 수 있도록 구현하였습니다.
jwt토큰은 redis를 사용하여 저장합니다.
jwt토큰에 User의 userId를 저장하여 토큰인증을 할 때 userId로 인증합니다.
회원가입, 로그인 경로 외엔 토큰 없이는 접근하지 못하도록 구현하였습니다.
AuthDetailsService@Service class AuthDetailsService( val userFacade: UserFacade, ) : UserDetailsService { //token의 userId로 사용자 인증 override fun loadUserByUsername(userId: String): UserDetails { return AuthDetails(userFacade.findUserByUserId(userId.toLong())) } }
JwtTokenProvider
@Component class JwtTokenProvider( val jwtProperties: JwtProperties, val refreshTokenRepository: RefreshTokenRepository ) { //accessToken 발급 fun createAccessToken(userId: String): String { return createToken(userId, jwtProperties.accessTokenValidTime); } //refreshToken 발급 fun createRefreshToken(userId: String): String { val token = createToken(userId, jwtProperties.refreshTokenValidTime) refreshTokenRepository.save( RefreshToken(token = token, email = userId) ) return token } private fun createToken(userId: String, time: Long): String { val claims = Jwts.claims() //userId를 사용하여 claims["userId"] = userId val now = Date() return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(Date(now.time + time)) .signWith(getSigningKey(jwtProperties.secretKey), SignatureAlgorithm.HS256) .compact() } private fun getSigningKey(secretKey: String): Key { val keyBytes = secretKey.toByteArray(Charsets.UTF_8) return Keys.hmacShaKeyFor(keyBytes) } fun getUserId(token: String): String { return extractAllClaims(token) .get("userId", String::class.java) } private fun extractAllClaims(token: String): Claims { try { return Jwts.parserBuilder() .setSigningKey(getSigningKey(jwtProperties.secretKey)) .build() .parseClaimsJws(token).body } catch (e: ExpiredJwtException) { throw ExpiredTokenException.EXCEPTION } catch (e: Exception) { throw InvalidTokenException.EXCEPTION } } fun resolveToken(request: HttpServletRequest): String? { val bearer = request.getHeader(jwtProperties.header) return parseToken(bearer) } private fun parseToken(bearer: String?): String? { if (bearer != null && bearer.startsWith(jwtProperties.prefix)) { return bearer.replace(jwtProperties.prefix, "") } return null } }
SecurityConfig(filterChain)
@Bean @Throws(Exception::class) fun filterChain(http: HttpSecurity): SecurityFilterChain? { http .cors().configurationSource { request -> val cors = CorsConfiguration() cors.allowedOrigins = listOf("http://localhost:3000") cors.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") cors.allowedHeaders = listOf("*") cors } .and() .httpBasic().disable() .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() //로그인, 회원가입 외엔 토큰 없이 접근 못함 .requestMatchers(HttpMethod.POST, "/login").permitAll() .requestMatchers(HttpMethod.POST, "/user").permitAll() .anyRequest().authenticated() .and() .formLogin().disable() http .addFilterBefore( JwtAuthenticationFilter(authDetailsService, jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java ) .addFilterBefore(JwtExceptionFilter(mapper), JwtAuthenticationFilter::class.java) return http.build()