본문 바로가기
우아한테크코스/Level2

[우아한테크코스] 로그인에서 감자로 살아남기 🥔

by kriorsen 2024. 5. 13.

📌  어떻게 웹 개발이 페어 프로그래밍


레벨2 페어가 데일리 조 단위로 매칭되지 않는다는 사실은 정말 예상 밖의 일이었고, 경우의 수가 늘어난 탓에 계속 불안한 마음이 들었다. 

 

왜 안 좋은 예감은 틀리질 않을까 🥲

과거 위키에서 랜덤으로 다른 크루들 문서 보다가 공포에 휩싸인 적이 있는데

충격적인 크루의 망언 목록

 

저 크루랑 페어를 하게 되면 정말 매일 베개를 눈물로 적시겠다는 생각이 들었다. 코치님이 페어를 랜덤으로 돌리던 당시에도 설마설마했는데, 진짜 79명 중 딱 내가 무서워하던 크루랑 페어가 됐다. 점심에 육회가 입으로 들어가는지 코로 들어가는지도 모르는 채로 있다가 근심 가득 안고 페어와 마주했다.

 

그런데 대화를 좀 해보니 망언 대부분은 오해와 날조였다. 처음 만난 날 커피도 주고, 페어 중에 단축키도 짱 많이 알려주는 착한 사람이었다. (사실 단축키는 수요 없는 공급이었다)


+) 나중에는 서로 이상한 별명 지어줄 정도로 친해졌다

 

무뚝뚝한 페어 리액션 봇으로 만들기 🤖

"칭찬 안 해주면 나 코딩 안 해."

 

레벨2 페어 프로그래밍 기간 동안 내 페어들에게 자주 하던 협박인데 세뇌가 된 건지 나중엔 살짝 째려보기만 해도 알아서 박수 치면서 잘했다고 해 줬다. 맞춰 줘서 고마워~~

 

리뷰어와 첫 DM 🚨

리뷰어의 피드백을 반영하는 것에 어려움을 느낄 때, 주변 크루들에게 도움을 요청하면 돌아오는 답변은 십중팔구 DM으로 물어보라는 말이었다. 문제에 대해 충분히 고민했음에도 결론을 내릴 수 없을 땐 리뷰어에게 물어보는 것이 가장 좋은 방법이라는 것을 머리로는 알았다. 그러나 조금 더 고민해 보면 스스로 좋은 답을 내릴 수 있을 거라는 실낱같은 희망, 그리고 리뷰어에게 내가 겪고 있는 문제를 명확하게 설명하지 못할 거라는 불안감이 발목을 잡았다.

 

방탈출 예약 미션에서는 고민에 고민을 거듭해 수십 번 문장을 고치고, 겨우 용기를 내어 전송 버튼을 눌렀다. 당연하게도 내가 걱정했던 상황은 일어나지 않았고, 리뷰어와 합의점을 찾아가면서 코드를 좋은 방향으로 수정할 수 있었다.

 

📌  이렇게갑자기로그인을구현하라고하시면제가


네알겠습니다.

 

프리코스를 하는 동안에는 자바에 익숙해서 몰랐는데, 레벨2 웹 프로그래밍을 하면서 정말 우테코가 야생이라는 것을 실감하고 있다. 살아남기 위해 LMS와 학습 테스트를 와다다 소화하고, Interceptor와 ArgumentResolover를 활용해 로그인을 구현했다.

 

 🤔 완전히 잊힌 레벨1

    private Optional<String> extractTokenFromRequestCookie(final NativeWebRequest webRequest) {
        final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        if (request.getCookies() == null) {
            return Optional.empty();
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> TOKEN_FIELD.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findFirst();
    }

 

위와 같이 요청 메시지 쿠키에서 토큰 값을 추출하는 로직이 Interceptor와 ArgumentResolover에서 완전히 중복되고 있었는데 분리를 하지 않고 PR을 보냈었다. 추가적으로 토큰을 쿠키에 저장하는 것 또한 세부 구현 사항이라는 피드백을 받아서 인터페이스를 도임하고, 쿠키를 활용해 토큰을 저장하고 추출하는 구현체를 만들었다.

@Component
public class CookieTokenManager implements TokenManager {

    private static final String TOKEN_FIELD = "token";

    @Override
    public Optional<String> extract(final HttpServletRequest request) {
        if (request.getCookies() == null) {
            return Optional.empty();
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> TOKEN_FIELD.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findFirst();
    }

    @Override
    public void setToken(final HttpServletResponse response, final TokenResponse token) {
        final Cookie cookie = new Cookie(TOKEN_FIELD, token.accessToken());
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
}

 

📌 @ConfigurationProperties

@Component
public class JwtTokenProvider {

    @Value("${security.jwt.token.secret-key}")
    private String secretKey;
    
    public String createToken(final Member member) {
        // method body
    }

    public Long getMemberId(final String token) {
        // method body
    }
}

 

JwtToken을 생성에 필요한 시크릿 키를 application.yml에서 불러오기 위해 @Value를 사용했었다. 만약 JwtTokenProvider에 적용해야 할 필드가 많아진다면 하나하나 설정해 줘야 하는데 @ConfigurationProperties를 적용해 해당 문제를 해결했다.

@ConfigurationProperties(prefix = "token-provider")
public class JwtTokenProvider {

    private String secretKey;
    
    public String createToken(final Member member) {
        // method body
    }

    public Long getMemberId(final String token) {
        // method body
    }
    
    public void setSecretKey(final String secretKey) {
        this.secretKey = secretKey;
    }
}

 

보통은 클래스에 직접 지정하는 것보다 @Configuration을 적용한 별도의 클래스를 도출하는 방식이 많이 사용된다.

@Configuration
public class ConfigProperties {

    @Bean
    @ConfigurationProperties(prefix = "token-provider")
    public TokenProvider TokenProvider() {
        return new JwtTokenProvider();
    }
}

 

🍠  Repository에 복잡한 쿼리문 사용

리뷰어와 DM을 주고받았던 주제가 바로 Repository에 복잡한 쿼리문 사용에 대한 것이었다. 

	// ReservationTimeService.java
        final List<ReservationTime> reservationTimes = reservationTimeRepository.findAll();
        final List<Reservation> reservations = reservationRepository.findByDateAndThemeId(reservationTimeBookedRequest.date(), reservationTimeBookedRequest.themeId());

        return reservationTimes.stream()
                .map(reservationTime -> createReservationTimeBooked(reservationTime, reservations))
                .toList();

 

이렇게 Service에서 조회 쿼리를 두 번 실행하는 방식이 첫 번째 선택지이고

// ReservationTimeRepository.java
public List<ReservationTimeBookedResponse> findTimesWithBooked(final LocalDate date, final Long themeId) {
    final String sql = """
            SELECT
                rt.start_at,
                rt.id,
                r.id is not null AS already_booked
            FROM reservation_times AS rt
            LEFT JOIN
                (SELECT id, time_id
                FROM reservations
                WHERE date = ? AND theme_id = ?) AS r
                ON rt.id = r.time_id
            ORDER BY start_at
            """;

    return jdbcTemplate.query(sql, TIME_WITH_BOOKED_ROW_MAPPER, date, themeId);
}

 

위와 같이 ReserationTimeRepository에서 서브 쿼리가 포함된 복잡한 쿼리문을 한 번만 요청하는 것이 두 번째 선택지였다.

복잡한 쿼리는 관계형 데이터베이스의 장점을 잘 활용한다는 장점이 있지만 다음과 같은 이유들로 첫 번째 방식을 선택했다.

  • findTimesWithBooked 메서드는 매우 특정한 상황에 초점을 맞춘 동작을 수행하여 코드의 재사용성이 제한된다.
  • Repository가 관계형 데이터베이스를 사용하지 않는 구현체로 변경되었을 때 reservation_time에서 reservation을 참조하고 있어서 구현이 어렵다.
  • 예약 여부는 ReservationTime의 필드로 포함된 정보가 아니기 때문에, 이를 판단하는 것을 Repository가 아닌 Service의 역할로 볼 수 있다.

그리고 현재 방식은 이중 for-loop와 findAll()의 호출로 인해 장애를 발생시킬 요소로 작용할 소지가 너무 커 보인다는 피드백을 받았다. 추가적으로 서브쿼리를 활용하는 방식으로 수정하거나 요구 사항을 잘 해결하기 위한 새로운 데이터 구조 설게를 도입해 볼 것을 제안받았다. 해당 리뷰를 받고 다음과 같은 세 가지 해결 방안을 떠올렸다.

 

1. ReservationTimeService 수정

final Set<Long> bookedReservationTimeIds = reservationRepository.findByDateAndThemeId(reservationTimeBookedRequest.date(), reservationTimeBookedRequest.themeId())
                .stream()
                .map(Reservation::getTimeId)
                .collect(Collectors.toSet());

 

다른 변경 사항 없이 List<Reservation>과 List<ReservationTime>을 이중 for-loop로 판별하던 부분을 Set<Long>으로 변경해서 성능 개선을 할 수 있다. 서비스 계층에서 reservation_time 테이블의 데이터 중복 저장을 막고 있기 때문에 최대로 저장될 있는 데이터는 60 * 60개이므로, findAll 인한 장애 발생 가능성이 크지 않다고 판단할 수 있다.

 

2. 서브 쿼리를 통해 한번에 데이터 조회

SELECT rt.start_at,
       rt.id,
       r.id is not null AS already_booked
FROM reservation_times AS rt
LEFT JOIN
       (SELECT id, time_id
       FROM reservations
       WHERE date = ? AND theme_id = ?) AS r
ON rt.id = r.time_id
ORDER BY start_at;

 

Repository와 Dao를 구분지어서 봤을 때 Repository는 영속성 계층보다는 도메인 계층에 가깝다. Dao는 데이터베이스 연산을 직접적으로 수행하는 역할을 하고, Repository는 Dao보다 한 단계 더 추상화된 개념이기 때문에 데이터베이스를 고려하기보다는 일급 컬렉션처럼 바라볼 수 있다.

 

List<ReservationTime>에서 Reservation 대한 정보를 가지고 있는 것은 논리적으로 불가능하다. 같은 맥락으로 ReseravtionRepository에서도 현재 예약이 존재하지 않는 ReservationTime 대해 없기 때문에 각각의 Repository 대해 별도로 쿼리하는 방향으로 코드를 작성했다.

 

그러나 현재 코드에서는 Repository와 Dao를 명확히 구분해서 사용하고 있지 않고, Repository의 역할은 데이터 영속성과 관련되도록 설계되었기 때문에, 해당 책임을 보았을 때 조회성 책임에서 필요한 데이터를 조합해서 반환하는 것 또한 자연스럽다. 한방 쿼리가 성능 상 이슈가 없다는 가정 하에 조회성 데이터를 구성하기 위해 쿼리 호출 횟수를 줄이는 것은 애플리케이션 성능에도 이점을 가져온다.

 

이번 미션 기간 동안 진행된 수업에서 사용자를 고려하면서 미션을 진행해 보라는 코치님의 조언이 있었고, 사용자에게 가치를 제공하는 애플리케이션에서는 성능을 고려하는 것이 높은 우선순위를 가진다는 점을 인지하자 이 방식도 충분히 고려될 수 있다는 것을 깨달았다. 앞으로는 비즈니스 영역에서 필요한 도메인 모델이 데이터 영속성 모델로 대응하여 바라보는 시각을 경계하면서 코드를 작성하는 연습이 필요할 것 같다.

3.  데이터베이스 View 사용

CREATE VIEW reservation_times_with_booked AS
SELECT
    rt.start_at,
    rt.id AS reservation_time_id,
    CASE WHEN r.id IS NOT NULL THEN TRUE ELSE FALSE END AS already_booked,
    r.date AS reservation_date,
    r.theme_id AS reservation_theme_id
FROM reservation_times AS rt
LEFT JOIN reservations AS r ON rt.id = r.time_id;

 

reservation_time reservation 테이블 정보를 합친 데이터베이스 뷰를 생성하고 새로운 Repository 도입하는 방식 또한 고려했었다. 그러나 date theme_id 대한 조건 처리가 선행되는 가지 방식과 다르게 뷰는 마지막에 조건문 처리를 해야 해서 데이터베이스 부하가 크다는 단점이 있다. 또한 특정 요구사항을 해결하기 위해 데이터 구조를 개선하고 싶다면 view를 생성하기보다는 새로운 테이블을 생성하는 것이 유지보수가 수월하다.

 

 👀  미션 아직 두 개 남았다


레벨2 절반은 왔는데 아직도 스프링 감이 안 잡혀서 버퍼 기간이랑 방학 동안 열심히 보충해야 될 것 같은 예감이 벌써 든다 🫨

그래도 새로운 데일리 조도 만나고 모르는 크루랑 페어도 해보면서 새로운 사람들과 친해질 수 있어서 좋았다.