🥇 프로그래머스 백엔드 데브코스 3차 실전 프로젝트 13개의 팀 중 1등 (최우수팀 선정)
OurLog는 영화, 도서, 음악 등 다양한 콘텐츠에 대한 감상 경험을 기록하고
이를 다른 사용자들과 공유하며 소통 할 수 있는 웹 서비스입니다.
- Team Repository: Github - OurLog Team Repository
| 이름 | 프로필 | 역할 |
|---|---|---|
| 김주은 | @jueunk617 | • 감상일기 등록/수정/삭제 • 영화·음악 콘텐츠 조회 • 배포 인프라 구축 |
| 남기은 (팀장) | @namgigun | • 감상일기 조회 • 댓글 CRUD • 도서 콘텐츠 조회 |
| 노희철 | @Nohheechul | • 통계 카드 및 그래프 구현 |
| 박영빈 | @yeongbin1999 | • 회원가입/ 로그인 • 프로필 조회 • 무중단 CI/CD |
| 임종현 | @dlawhd | • 타임라인 • 팔로우 / 언팔로우 • 좋아요 기능 |
| 기능 | 설명 |
|---|---|
| 프로필 | 사용자의 감상일기 및 활동 내역 조회 |
| 감상일기 | 콘텐츠 감상 기록 등록·수정·삭제 |
| 소셜 | 팔로우, 좋아요, 댓글을 통한 사용자 간 상호작용 |
| 통계 | 감상 데이터를 기반으로 한 개인화된 통계 제공 |
| 구분 | 사용 기술 |
|---|---|
| Frontend | HTML5 CSS JavaScript TypeScript Next.js |
| Backend | Kotlin Spring Boot Spring Data JPA QueryDSL MySQL Redis |
| Infra | Vercel(front) AWS(back) NGINX Docker |
| DevOps | Github Actions Terraform |
| Version Control | Git Github |
| Collaboration | Notion Slack |
| Performance Test | Apache JMeter |
| Open API | TMDB Spotify 국립중앙도서관 |
수행의도
-
기존 Java 기반 댓글 서비스는
Optional.orElseThrow중심의 런타임 Null 검증- Repository 조회 + 예외 처리 로직의 반복으로 가독성과 유지보수성 저하
-
Kotlin 도입을 통해 컴파일 타임 Null 안정성 확보 및 보일러플레이트 제거를 목표로 함
마이그레이션 전략
의존성이 낮은 계층부터 순차적으로 변환하여, 상위 계층에서 발생할 수 있는 컴파일 오류 최소화
Entity → DTO → Repository → Service → Controller→ Test
- IntelliJ의 Convert Java File to Kotlin File 기능을 활용하여 1차 자동 변환
- 자동 변환 이후, 아래 리팩토링 전략을 기준으로 수동 리팩토링을 진행
- Spring Data JPA에서 Kotlin 확장 함수인
findByIdOrNull을 사용하여, Optional 반환 대신 nullable 객체를 반환하도록 변경 - Java의
Optional기반 예외처리 방식 → Kotlin의 엘비스 연산자(?:)를 활용하여 컴파일 타임 Null 안정성을 확보
변경 전 (Java)
Diary diary = diaryRepository.findById(diaryId)
.orElseThrow(
() -> new CustomException(ErrorCode.DIARY_NOT_FOUND)
);변경 전 (Kotlin)
val diary = diaryRepository.findByIdOrNull(diaryId)
?: throw CustomException(ErrorCode.DIARY_NOT_FOUND)findByIdOrNull이후 반복적으로 발생하는 엘비스 연산자 기반 예외처리는 Service 계층 전반에 보일러 플레이트 코드 유발- 이를 해결하기 위해, findByIdOrNull + 예외 처리를 하나로 묶은 사용자 정의 확장 함수를 도입
확장 함수 정의
/* extensions.kts 안에 사용자 정의 확장 함수를 생성 */
package com.back.ourlog.global.common.extension
fun <T, ID : Any> CrudRepository<T, ID>.findByIdOrThrow(
id: ID,
errorCode: ErrorCode
): T = this.findByIdOrNull(id) ?: throw CustomException(errorCode)변경 전
val diary = diaryRepository.findByIdOrNull(diaryId) ?: throw CustomException(ErrorCode.DIARY_NOT_FOUND)변경 후
val diary = diaryRepository.findByIdOrThrow(diaryId, ErrorCode.DIARY_NOT_FOUND)- Java의 명령형 조건문(
if) 위주의 흐름 제어 → Kotlin의 표현식 기반 함수(takeIf)로 변경하여 조건 검증 로직을 간결하게 표현
변경 전 (Java)
@Transactional(readOnly = true)
public void checkCanUpdate(User user, int commentId) {
...
if(!comment.getUser().equals(user)) {
throw new CustomException(ErrorCode.COMMENT_UPDATE_FORBIDDEN);
}
}변경 후 (Kotlin)
private fun checkCanAccess(
user: User?,
comment: Comment,
errorCode: ErrorCode
) {
....
comment.user.takeIf { it == user } ?: throw CustomException(errorCode)
}- Kotlin의 주 생성자와 프로퍼티 문법을 활용하여 Lombok(
@Getter,@RequiredArgsConstructor등)을 완전히 제거 - Entity의 경우
kotlin-jpa플러그인을 적용하여 기본 생성자가 자동으로 생성
변경 전 (Java)
// Entity
@Entity
@Getter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Comment {
...
private String content;
public Comment(Diary diary, User user, String content) {
this.user = user;
this.content = content;
this.diary = diary;
}
}
// Service
@Service
@RequiredArgsConstructor
public class CommentService {
private final DiaryRepository diaryRepository;
private final CommentRepository commentRepository;
....
}변경 후 (Kotlin)
// Entity
@Entity
@EntityListeners(AuditingEntityListener::class)
class Comment (
var content: String,
) {
...
}
// Service
@Service
class CommentService(
private val diaryRepository: DiaryRepository,
private val commentRepository: CommentRepository,
) {
....
}성과
-
Java의
Optional기반 예외 처리 방식 → Kotlin의nullable 타입과 엘비스 연산자(?:)로 전환, 컴파일 타임 기준의 null 안전성과 코드 간결성 확보 -
Repository 조회 및 예외 처리와 같이 반복되던 로직을 Kotlin 확장 함수로 추상화, 보일러플레이트 코드를 제거
-
Java의 명령형 조건문(
if) 위주의 흐름 제어 → Kotlin의 표현식 기반 함수(takeIf)로 변경, 조건 검증 로직을 간결화 -
Kotlin의 주 생성자와 프로퍼티 문법을 활용,
Getter,RequiredArgsConstructor등 Lombok 제거 -
마이그레이션 후 코드 라인 수 240 → 181로 약 24.6% 감소
구분 Entity Service Controller Total Java 57 105 78 240 Kotlin 42 73 66 181
- 댓글 조회 시, 각 댓글의 작성자(User)를 Lazy Loading으로 조회하면서 댓글 수(N)만큼 추가 쿼리 발생
- Comment → User 다대일 연관관계
- DTO 변환 과정에서 comment.user 접근 시 프록시 초기화 발생
- QueryDSL + Fetch Join을 적용하여 댓글과 작성자를 한 번에 조회
override fun findQByDiaryIdOrderByCreatedAtDesc(diaryId: Int): List<Comment> {
val comment = QComment.comment
val user = QUser.user
return queryFactory
.selectFrom(comment)
// FetchJoin 적용
.join(comment.user, user).fetchJoin()
.where(comment.diary.id.eq(diaryId))
.orderBy(comment.createdAt.desc())
.fetch()
}-
쿼리 수: 1 + N → 1
-
JMeter 100회 동시 요청 기준
- 평균 응답 시간 44ms → 13ms (약 70% 개선)
