[우아한테크코스 5기] 레벨 3 - 6주차 회고

Glen·2023년 8월 6일
0

회고

목록 보기
21/25

7월 31일 월요일

OAuth2 개발 착수 🔐

이번 주말에도 신나게 놀고 빡세게 공부했다.

금요일이 3차 데모데이 발표일이라, 3차 데모의 핵심 기능이라고 할 수 있는 티켓 예매를 집중적으로 분석하고 설계했다.

저번 주에 티켓 예매를 대비하여 도메인 구조를 다시 설계했는데, 동시성 이슈를 고려해 보니 잘 설계했다는 생각이 들었다.

토요일에는 커피도 마실 겸 잠실에 등교해서 밀린 회고를 작성하고 예매 기능에 대해 고민했다.

우리의 티켓 예매 프로세스는 다음과 같다.

  1. 사용자는 공연의 Id와 티켓의 Type(재학생, 외부인)으로 예매한다.
  2. 서버에서 예매할 수 있는 가장 빠른 시간의 티켓을 발급한다.
  3. 사용자에게 예매 여부를 알려준다.

생각을 조금 해보니, 1번에서 굳이 공연의 Id와 티켓의 Type을 받을 필요는 없었다.

왜냐하면 티켓 자체에 공연의 Id와 티켓의 Type이 있으니까...

따라서 티켓의 Id를 가지고 티켓을 예매할 수 있도록 했다.

2번이 약간 골치가 아픈데, 티켓을 예매할 때 서버에서 가장 빠른 시간의 티켓을 발급해 주므로, 이것을 계산하는 프로세스가 필요하다.

즉, 연산이 발생한다.

티켓 예매의 특성상 많은 인원이 동시에 몰리게 되고, 연산이 있다는 것은 처리에 시간이 더 발생하므로 분명히 동시성 처리에 큰 이슈가 될 것이다.

이에 관해 여러 고민도 조금 해봤는데, 나온 결론은 다음과 같다.

가장 빠른 시간을 조회할 때, 티켓이 가진 입장 가능 시간을 조회한다.

입장 가능 시간은 도메인 로직 상 불변한 값이므로 캐싱이 가능하다.

스프링이 제공하는 캐시를 사용하든.. 우선 캐싱을 한 뒤 DB 조회를 최소화한다면 해당 부분을 어느 정도 해소할 수 있을 것 같다.

그리고 티켓팅하면 동시성 처리를 빼놓을 수 없다.

우선은 간단하게 스프링에서 제공하는 락을 사용하여 이 부분은 해결했다.

충돌이 당연하게 많이 발생할 것이기 때문에 낙관적 락 말고, 비관적 락을 사용했다.

우선 TicketAmount 테이블만 락을 걸고, 조회와 수정 작업이 끝나면 해당 테이블의 락을 풀고 나머지 조회 작업을 수행하려고 코드를 작성했다.

하지만 이게 뭔 일인지 데드락이 발생하는 것이었다. 😂

TicketAmount의 값을 변경하는 부분을 새로운 트랜잭션으로 실행하게 하여, 락이 걸리는 지점을 최소화했는데, 이 부분이 문제였다.

10개의 스레드가 커넥션을 획득하고, TicketAmount에 대한 로직에 접근하여 새로운 트랜잭션이 생기며 커넥션을 가져오려 한다.(hikariCP는 기본 10개의 커넥션 풀을 제공한다.)

하지만 가져올 커넥션이 없으니...

바로 이때 데드락이 발생한다. 😂

결국 티켓 예매 로직 전체에 락을 걸 수 밖에 없었다..

그리고 비관적 락이 걸리면 읽기도 불가능하다고 생각했는데, 테스트를 진행해 보니 읽기가 가능했다.

띠용??

innoDB가 제공하는 기능이라고 하는데... MySQL에 관한 지식이 부족해서 이 부분은 잘 모르겠다. 😂

추후 Real MySQL을 읽어보든가 해야겠다.

아무튼 우선은 동시성도 없는 티켓 예매 기능은 만들었으니 우선 이걸 사용하고 추후 성능 개선을 고려해야 할 것 같다.

일요일에는 선릉 캠퍼스로 등교하여, 서버에 HTTPS를 적용했다.

Certbot이 제공하는 기능을 사용했는데, 뭔 HTTPS 적용이 이렇게 쉬운지... 10분 정도 걸려서 적용을 완료했다. 😂

시간이 오래 걸릴 줄 알았는데.. 요즘에는 개발하는 게 정말 쉬운 것 같다.

그리고 크루들과 저녁도 먹고, 방탈출도 하고 주말을 정말 야무지게 보냈다.

이번 월요일에는 근로 활동의 담당 코치님과 점심을 먹기로 했기 때문에 점심시간 전에 등교했다.

간만에 비싸서 못 먹는 연어 덮밥을 맛있게 먹었다. 👍

불쌍한 연어.. 조금 더 맛없게 태어나지 그랬니..

아무튼 그 뒤 조원들과 주말 동안 구상했던 티켓 예매 기능에 관한 얘기를 나눴다.

그리고 일의 우선순위를 나눠 페어로 구현할 기능을 정했다.

  1. OAuth2 인증 구현
  2. 티켓 예매 기능 구현

이번에도 사다리 타기로 진행됐는데, 오리와 내가 OAuth2 인증 구현을 맡았다.

예전에 OAuth2 구현을 인터넷에 있는 자료로 따라만 해본 적이 있어서 이번에는 제대로 배워서 구현해야겠다고 다짐했다.

우리의 OAuth2 인증 프로세스는 다음과 같다.

OAuth2 제공자는 카카오를 사용하기로 했다.

왜냐하면 핸드폰 번호로 인증이 되므로, 가능한 고유할 수 있다고 판단했기 때문이다.

1시간 정도 자료 조사와 어떻게 구현할 것인지 계획을 하고 구현에 들어갔다.

여러 삽질을 하느라 조금 멈칫한 부분은 있었지만, 레퍼런스도 많고, 카카오 API 공식 문서에 설명이 상세히 나와 있어 크게 어려운 부분은 없었다.

대략 절반 정도 기능 구현을 하고, 저녁에 약속이 있어 빠르게 칼퇴를 진행시켰다.

내일까지 최종적으로 인증 기능을 구현하고, 인프라 작업을 빨리해야 할 것 같다.

8월 1일 화요일

OAuth2 최종 완성 🔑

오늘은 오전부터 바로 어제 구현하지 못했던 OAuth2 인증 기능 구현 작업에 들어갔다.

어제 웬만한 기능들은 구현해 놓은 상태라서 빠르게 구현할 수 있었다.

예전에 혼자 OAuth2를 구현했을 때는 왜 이렇게 힘들었는지 모르겠다. 😂

그런데 구현 자체는 얼마 걸리지 않았어도, 테스트하기가 조금 난감했다.

OAuth2 인증은 외부 API와 연결이 필요하다.

따라서 테스트하려면 외부 API에 연결이 필요했다.

하지만 외부 API와 연결되는 테스트는 외부 API의 동작에 영향을 미치게 된다.

즉, 테스트가 외부에 종속적으로 변한다.

이것은 원하는 작업이 아니었기에 어떻게 해야 외부 API의 종속을 없애고 테스트를 할 수 있을지 고민을 많이 했다.

고민 끝에 내린 결론은 외부 API 동작의 응답은 이럴 것이라 가정하고 Mock으로 테스트했다.

스프링에서 제공하는 @RestClientTest를 사용하여 쉽게 외부 API를 Mock으로 만들고 테스트 할 수 있다!

그런데, 로컬 환경이나 개발 환경에서는 굳이 카카오 OAuth2 인증 기능을 사용할 필요가 있을까 싶었다.

개발 환경은 그렇다 쳐도.. 로컬 환경에서도 실제 OAuth2 서버를 통해 인증을 해야 하나..?

그런 환경을 대비하여(새로운 OAuth2 클라이언트 추가 대비), OAuth2 클라이언트를 인터페이스로 만들어 설계했다.

코드는 다음과 같다.

public interface OAuth2Client {  
  
    String getAccessToken(String code);  
  
    UserInfo getUserInfo(String accessToken);  
}

public class KakaoOAuth2Client implements OAuth2Client {  
  
    private static final String ACCESS_TOKEN_URL = "https://kauth.kakao.com/oauth/token";  
    private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me";  
  
    private final RestTemplate restTemplate;  
    private final String grantType;  
    private final String clientId;  
    private final String redirectId;  
  
    public KakaoOAuth2Client(  
        @Value("${festago.oauth2.kakao.grant-type}") String grantType,  
        @Value("${festago.oauth2.kakao.client-id}") String clientId,  
        @Value("${festago.oauth2.kakao.redirect-uri}") String redirectUri,  
        RestTemplateBuilder restTemplateBuilder  
    ) {  
        this.grantType = grantType;  
        this.clientId = clientId;  
        this.redirectId = redirectUri;  
        this.restTemplate = restTemplateBuilder.build();  
    }  
  
    public String getAccessToken(String code) {  
        HttpHeaders headers = new HttpHeaders();  
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);  
        headers.add("grant_type", grantType);  
        headers.add("client_id", clientId);  
        headers.add("redirect_uri", redirectId);  
        headers.add("code", code);  
  
        try {  
            KakaoAccessTokenResponse response = restTemplate.postForEntity(  
                ACCESS_TOKEN_URL, headers,  
                KakaoAccessTokenResponse.class).getBody();  
            return response.accessToken();  
        } catch (HttpClientErrorException e) {  
            KakaoOAuth2Error kakaoOAuth2Error = e.getResponseBodyAs(KakaoOAuth2Error.class);  
            if (kakaoOAuth2Error.isErrorCodeKOE320()) {  
                throw new BadRequestException(ErrorCode.OAUTH2_INVALID_CODE);  
            }  
            throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR, e);  
        }  
    }  
  
    public UserInfo getUserInfo(String accessToken) {  
        HttpHeaders headers = new HttpHeaders();  
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);  
        headers.setBearerAuth(accessToken);  
  
        try {  
            KakaoUserInfo kakaoUserInfo = restTemplate.postForEntity(USER_INFO_URL, new HttpEntity<>(headers),  
                    KakaoUserInfo.class)  
                .getBody();  
            return UserInfo.ofKakao(kakaoUserInfo);  
        } catch (HttpClientErrorException e) {  
            throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR, e);  
        }  
    }  
  
    public record KakaoOAuth2Error(  
        String error,  
        @JsonProperty("error_description") String errorDescription,  
        @JsonProperty("error_code") String errorCode  
    ) {  
        public boolean isErrorCodeKOE320() {  
            return errorCode.equals("KOE320");  
        }  
    }  
}

매우 매우 난잡하다.

특히, try-catch 문은 굉장히 추하다. ㅋㅋㅋ

따라서 리팩터링의 필요성을 극히 느꼈다.

또한 사용하는 서비스에서는 다음과 같이 하나의 OAuth2Clinet를 사용한다.

@Service  
public class AuthService {  
  
    private final MemberRepository memberRepository;  
    private final OAuth2Client oAuth2Client;  
    private final AuthProvider authProvider;
    ...
}

이렇게 된다면 여러 클라이언트가 추가되어도 사용하기가 까다롭다. (새로운 OAuth2 클라이언트가 추가되면 기존에 사용하던 클라이언트를 빈에서 제외시킬 것인가??)

따라서 다음과 같이 여러 개의 OAuth2Client를 가지고 있는 객체를 만들었다.

public class OAuth2Clients {  
  
    private final Map<SocialType, Supplier<OAuth2Client>> oAuth2ClientMap;  
  
    private OAuth2Clients(Map<SocialType, Supplier<OAuth2Client>> oAuth2ClientMap) {  
        this.oAuth2ClientMap = oAuth2ClientMap;  
    }  
  
    public static OAuth2ClientsBuilder builder() {  
        return new OAuth2ClientsBuilder();  
    }  
  
    public OAuth2Client getClient(SocialType socialType) {  
        return oAuth2ClientMap.getOrDefault(socialType, () -> {  
            throw new BadRequestException(ErrorCode.OAUTH2_NOT_SUPPORTED_SOCIAL_TYPE);  
        }).get();  
    }  
  
    public static class OAuth2ClientsBuilder {  
  
        private final Map<SocialType, Supplier<OAuth2Client>> oAuth2ClientMap = new EnumMap<>(SocialType.class);  
  
        private OAuth2ClientsBuilder() {  
        }  
        public OAuth2ClientsBuilder addAll(List<OAuth2Client> oAuth2Clients) {  
            for (OAuth2Client oAuth2Client : oAuth2Clients) {  
                add(oAuth2Client);  
            }  
            return this;  
        }  
  
        public OAuth2ClientsBuilder add(OAuth2Client oAuth2Client) {  
            SocialType socialType = oAuth2Client.getSocialType();  
            if (oAuth2ClientMap.containsKey(socialType)) {  
                throw new InternalServerException(ErrorCode.DUPLICATE_SOCIAL_TYPE);  
            }  
            oAuth2ClientMap.put(socialType, () -> oAuth2Client);  
            return this;        }  
  
        public OAuth2Clients build() {  
            return new OAuth2Clients(oAuth2ClientMap);  
        }  
    }  
}

Map을 포장하는 일급 컬렉션 객체이다.

OAuth2Clients는 SocialType으로 Client를 반환하는 단순한 하나의 메서드만 제공한다.

또한 빌더 패턴을 사용해서 강제로 EnumMap을 Map 구현체로 사용하게 했고, 중복된 클라이언트 타입에 대해 검증 기능을 추가했다.

Map의 Value로 Supplier를 사용한 이유는 getClient() 메서드의 예외 처리를 좀 더 간결하게 하기 위해 사용했다.

Map의 Key로 SocialType을 받기 때문에 OAuth2ClientgetSocialType()이라는 메소드도 추가되었다.

public interface OAuth2Client {  
  
    String getAccessToken(String code);  
  
    UserInfo getUserInfo(String accessToken);  
  
    SocialType getSocialType();  
}

아무튼 이제 서비스는 OAuth2Clients를 주입받고, 반환된 클라이언트를 가지고 지지고 볶으면 된다.

이제 서비스는 여러 타입의 클라이언트를 사용할 수 있으니, 우리만의 OAuth2Client를 만들었다.

@Component  
@Profile("!prod")  
public class FestagoOAuth2Client implements OAuth2Client {  
  
    private static final String PROFILE_IMAGE = "https://placehold.co/150x150";  
  
    private final Map<String, Supplier<UserInfo>> userInfoMap = new HashMap<>();  
  
    public FestagoOAuth2Client() {  
        userInfoMap.put("1", () -> new UserInfo("1", getSocialType(), "member1", PROFILE_IMAGE));  
        userInfoMap.put("2", () -> new UserInfo("2", getSocialType(), "member2", PROFILE_IMAGE));  
        userInfoMap.put("3", () -> new UserInfo("3", getSocialType(), "member3", PROFILE_IMAGE));  
    }  
  
    @Override  
    public String getAccessToken(String code) {  
        return code;  
    }  
  
    @Override  
    public UserInfo getUserInfo(String accessToken) {  
        return userInfoMap.getOrDefault(accessToken, () -> {  
            throw new BadRequestException(ErrorCode.OAUTH2_INVALID_CODE);  
        }).get();  
    }  
  
    @Override  
    public SocialType getSocialType() {  
        return SocialType.FESTAGO;  
    }  
}

정말 단순하다.

프로덕션 환경에서는 해당 클라이언트 사용을 막기 위해, @Profile(!prod)를 사용했다.

이제 사용자 인증을 위해 굳이 로컬이나 개발 환경에서 실제 외부 API를 사용할 필요가 없다!

하지만 여전히 지저분하고 추한 try-catch문이 남아있다.

우리는 스프링을 사용하여 웹 개발을 할 때, 굳이 try-catch문을 사용하지 않는다.

왜?

@ControllerAdvice가 있으니까.

OAuth2Client 구현체에서 사용하는 RestTemplate에서도 발생하는 예외를 상위에서 처리해 주는 @ControllerAdvice가 존재한다.

바로 ResponseErrorHandler이다.

다음과 같이 RestTemplateBuilderErrorHandler를 추가하면 된다.

public KakaoOAuth2UserInfoClient(RestTemplateBuilder restTemplateBuilder) { 
    this.restTemplate = restTemplateBuilder  
        .errorHandler(new KakaoOAuth2UserInfoErrorHandler())  
        .build();  
}

ErrorHandler를 사용하면 다음과 같이 지저분하고 추했던 코드가

public String getAccessToken(String code) {  
    HttpHeaders headers = new HttpHeaders();  
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);  
    headers.add("grant_type", grantType);  
    headers.add("client_id", clientId);  
    headers.add("redirect_uri", redirectId);  
    headers.add("code", code);  
  
    try {  
        KakaoAccessTokenResponse response = restTemplate.postForEntity(  
            ACCESS_TOKEN_URL, headers,  
            KakaoAccessTokenResponse.class).getBody();  
        return response.accessToken();  
    } catch (HttpClientErrorException e) {  
        KakaoOAuth2Error kakaoOAuth2Error = e.getResponseBodyAs(KakaoOAuth2Error.class);  
        if (kakaoOAuth2Error.isErrorCodeKOE320()) {  
            throw new BadRequestException(ErrorCode.OAUTH2_INVALID_CODE);  
        }  
        throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR, e);  
    }  
}

이렇게 바뀐다.

public String getAccessToken(String code) {  
    HttpHeaders headers = getAccessTokenHeaders(code);  
    return requestAccessToken(headers);  
}  
  
private HttpHeaders getAccessTokenHeaders(String code) {  
    HttpHeaders headers = new HttpHeaders();  
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);  
    headers.set("grant_type", grantType);  
    headers.set("client_id", clientId);  
    headers.set("redirect_uri", redirectUri);  
    headers.set("code", code);  
    return headers;  
}  
  
private String requestAccessToken(HttpHeaders headers) {  
    KakaoAccessTokenResponse response = restTemplate.postForEntity(URL, headers,  
        KakaoAccessTokenResponse.class).getBody();  
    return response.accessToken();  
}

물론 메서드 분리를 통해, 더 깔끔해진 부분도 있겠지만, try-catch문이 사라짐으로, 해당 객체가 하는 역할이 무엇인지 더 명확하게 드러나게 된다.

아무튼.. 이에 대한 자세한 글은 추후 작성을 해봐야겠다.

결론은?

SOLID 원칙을 지키며, 객체 지향적으로 설계했다~

단순히 자바로 구현했다면 SOLID 원칙을 지키기 어려웠을 것이다.

하지만 스프링의 의존성 주입을 통한 기가 막힌 도움으로 SOLID 원칙을 지키기 매우 쉽다.

간만에 기획에 관한 부분은 잊고, 코드 설계에 집중하니 재미있다.

레벨 1, 2때 미션들이 그립다. 😭

내일은 이제 인프라 관련 작업에 집중해야겠다.

특히 프로덕션 서버는 아예 손을 대지 않아서, 작업이 매우 절실히 필요하다.

8월 2일 수요일

빡셌던 Git Submodule 적용 😡

어제 OAuth2 마무리를 마치고, 오늘은 인프라 작업을 했다.

지금은 개발 서버에도 H2 데이터베이스를 사용하고 있어서, 서버를 재시작할 때 데이터가 초기화되는 문제가 있었다.

따라서 개발 서버에도 MySQL을 사용하여 데이터를 영구 저장해야 한다.

하지만 MySQL을 연결하려면 URL, 계정 정보가 필요한데 이것을 깃허브와 같은 공개 저장소에 올리는 것은...

아무튼 그래서 설정 정보를 우리만 볼 수 있는 저장소에 올리거나, 아예 따로 관리해야 할 필요가 있었다.

예전에 프로젝트를 하며 사용했던 방법은 민감한 정보가 있는 설정 파일을 .gitignore에 등록하여 수동으로 배포하는 방법을 사용했다.

그때 당시 느꼈던 점은 관리해야 할 포인트가 하나 늘어나기 때문에 당연히 귀찮은 작업의 추가는 물론이고, 팀원마다 해당 파일을 수정하기가 까다롭다.

따라서, 이 방법은 1초의 고민도 없이 패스했다.

두 번째로 고민한 방법은 JASYPT 라이브러리를 사용하여, 암호화된 설정 파일을 공개 저장소에 올리는 방법이다.

물론 암호화의 신뢰도는 보장할 수 있지만, 중요한 정보가 저장소에 올라간다는 것 자체가 부담스러웠고, 설정 파일의 값이 암호화되어 있어 값을 한눈에 보기 힘들다는 점이 있었다.

세 번째로 고민한 방법은 Git Submodule을 사용하는 방법이다.

서브모듈을 사용하면 위의 단점을 모두 해결할 수 있다.

물론 암호화를 해주는 것은 아니고, 개인 저장소에 설정 파일만 올리고 팀원들에게 접근 권한을 주는 것이다.

둘의 단점을 완벽하게 극복할 수 있어서 당장 도입을 서둘렀다.

하지만 그때는 몰랐다.

이것이 악몽이 될 줄은... ㅋㅋㅋ

서브모듈에 관해 검색을 해보면 레퍼런스가 많다.

따라서 적용이 쉬울것이라 생각했다.

물론 적용은 쉬웠다.

git submodule add ... 해당 명령어만 치면 끝이니까.

그리고 서버에 서브모듈이 반영된 프로젝트를 받아오려고 하는데..

띠용??

뜬금없이 깃허브의 계정과 비밀번호를 요구하는 것이었다.

그래서 로그인 과정을 거치고 나니, 또 정책이 바뀌었다며 토큰을 달란다.

그래서 토큰을 등록하려고 하는데, 해당 개인 레포는 계정에다 만든 것이 아닌, Organization을 만들어 생성한 것이라, classic token 사용이 불가했고, Fine-grained Token을 만들어 사용해야 했다.

Fine-grained Token은 최대 1년까지 기간을 발급할 수 있어 다른 방법을 물색하다 SSH를 사용하여 개인 레포에 접근할 수 있는 방법을 찾았다.

그저 ssh-keygen 명령어를 치고, 공개키와 개인키를 생성 후 해당 공개 키의 내용을 레포 설정에 있는 Deploy Keys에 등록을 해주면 끝이다.

그리고 ㅋㅋ 별거 아니네 생각했다가 다시 서브모듈을 받아오려고 하는데..

또 깃허브 계정과 비밀번호를 요구한다. 😂

공개키 다시 만들고, 공개키를 개인 계정에 등록도 해보고 별 뻘짓을 하다가 뒤늦게 깨달았다.

[submodule "backend/src/main/resources/festago-config"]  
 path = backend/src/main/resources/festago-config  
 url = https://github.com/festago/festago-config.git

.submodule의 url이 SSH 주소로 되어있는게 아니라, https 주소로 되어 있는 것이었다. 😂

따라서 다음과 같이 설정을 바꿔주어야 한다.

[submodule "backend/src/main/resources/festago-config"]  
 path = backend/src/main/resources/festago-config  
 url = git@github.com:festago/festago-config.git

그리고 이제 해치웠나 라고 생각한 순간

어림도 없이 다른 에러가 발생했다.

Permission denied(publickey) 해당 메시지만 나올 뿐, 서브모듈을 여전히 받을 수 없었다. 😂

분명히 공개키를 등록도 하고 할 거 다 했는데.. 대체 왜 😂😂😂

1시간 넘는 삽질끝에 얻은 해결 방법은 바로 ssh-keygen으로 만든 개인키를 ssh-agent에 등록을 시켜줘야 한다고 한다.

그리고 마침내.. 성공적으로 서브모듈을 받아올 수 있게 되었다.

차라리 Jasypt를 적용할 걸 그랬나 후회가 막심하게 밀려온다.

그래도 추후 Github Actions를 활용한 CD를 구축하게 된다면 서브모듈의 이점을 더 볼 수 있을 것 같다.

언제 CD를 구축하게 될지 모르겠지만...

8월 3일 목요일

3차 데모데이 발표 준비 🏗

바로 내일이 드디어 3차 데모데이 발표일이다.

즉, 이제 레벨3의 남은 기간이 2주밖에 남지 않았다는 뜻이다.

시간이 왜 이렇게 금방 지나가는지 모르겠다.

당장 1주차에 기획할 때 빨리 개발하고 싶다고 생각한 게 저번 주 같다.

그리고 당장 내일이 발표일인데, 발표자는 정하지도 않았다. 😂

워낙 기능 구현에 치여 살다 보니...

거의 대부분은 칼퇴를 해서 그런 건가..

아무튼 발표자는 사다리 뽑기로 정했다.

이미 푸우와 애쉬는 발표했기에, 나와 오리 중 한 명이 발표해야 했다.

그리고 대망의 결과

내가 당첨됐다. 😂

그래도 하고자 했던 기능 구현은 완료했기에, 오늘은 발표 자료 제작에 집중했다.

고민하고 해결하고 적용했던 것은 많지만, 당장 발표에 무엇을 적어야 할지, 말해야 할지 고르는 것이 일이었다.

그래도 제일 고민했고, 큰 결심을 했던 티켓 도메인의 재설계를 말하는 게 좋을 것 같아 먼저 해당 내용을 말하기로 했다.

그 뒤 로깅 전략과 설정 파일을 어떻게 분리했는지 등..

적는거야 쉽게 한 것 같지만 어떻게 설명해야 할지 PPT에 녹여야 했기에, 실제 작업은 거의 18시 넘게 계속됐다. 😂

OAuth2에 관한 내용도 포함하려 했지만, 안드로이드 측에서 해당 작업이 완료되지 않아서 작업 내역에는 포함하지 않았다.

그리고 팀원들도 모두 도와주느라 다 같이 22시 30분에 퇴실했다.

같이 고생한 팀원들이 정말 정말 고맙다 🥹

발표 데모를 한 두 번밖에 하지 않아서 내일 제대로 발표할 수 있을지 모르겠다. 😂

8월 4일 금요일

3차 데모데이 발표 3️⃣

드디어 3차 데모데이 발표일이 밝았다.

어제 집에서 새벽 3시까지 연습을 하느라, 잠을 제대로 못 자서 컨디션이 굉장히 좋지 않았다. 😂

그렇다고 연습하지 않을 수 없으니...

우리 조는 참관이 필수여서, 따로 캠퍼스에서 연습할 시간도 없었다.

게다가 참관하면서 마실 커피를 쏟아버려 기분도 좋지 않았다. 😭

그 뒤 다른 조들의 발표를 듣는 중, 옆에 있던 오리가 갑자기 DB 서버에 연결이 안 된다고 했다.

뭐지? 네트워크 오류인가? 싶기도 했고, 발표가 진행 중이라 자세히 살펴보지 않았다.

하지만 그때는 몰랐다.. 마치 공포영화의 시작처럼..

그리고 점심을 먹고, 남은 시간 동안 연습하려고 하는데 시연용 데이터가 잘 들어갔나 확인을 하려고 서버에 접속하려고 하는데...

Permission denied(publickey) 오류 메시지만 발생하고, 서버에 접속하지 못했다.

뭐지?? 내 환경의 문제인가 싶어 다른 조원의 컴퓨터로 접속했지만, 똑같이 에러가 발생했다.

그래서 개발 서버 말고, 프로덕션 서버에 접속했는데 또 프로덕션 서버는 접속이 잘 되었다.

당장 발표가 2시간 전인데...

빠르게 사태 파악을 한 결과 서버의 SSH 키가 유실되었다고 판단하여, 해결 방법을 찾아 봤다.

우선 EC2 스토리지를 분리 후, 접속이 가능한 다른 서버에 스토리지를 연결하여, SSH 키를 재등록 후 다시 원본 서버에 스토리지를 연결하는 방법이 있었다.

하지만 스토리지를 분리, 연결하는 권한이 없어 해당 방법은 사용하지 못했다.

그렇다면 남은 방법은..?

바로 서버 삭제 후, 다시 만들기였다. 😂

서버를 복구하느라, 발표를 듣지도 못하고 노트북만 빠르게 두들겼다. 😂

자바 설치, MySQL 설치, Nginx 설치, HTTPS 적용...

내가 인프라를 거의 담당했기에 1시간도 안 돼서 복구할 수 있었다.

물론 DB에 관한 형상 관리를 하지 않아 이 부분은 조금 애를 먹었다. 😂

이 부분은 추후 플라이웨이를 적용하는데, 계기를 주었다.

그리고 서버를 복구하며 느낀 것이, 만약 내가 이 자리에 없었다면(코로나, 개인 사정) 팀원들이 과연 서버를 복구할 수 있었을까 싶다.

이번 발표는 발표뿐 아니라, 시연도 포함되었기 때문에 특히나 시연하지 못하는 것은 치명적이었다.

팀원들과 정보 공유가 충분히 있었다고 생각했는데 전혀 아니었던 것 같다.

그리고 우리 차례가 와서 무사히 발표를 끝냈다.

남은 시간 동안 회고를 진행하고, 회식을 추진하려고 했다.

그리고 코치님과 궁금했던 점에 관한 얘기를 하고 있는데, 저 멀리 크루들이 가방을 싸고 나오고 있었다.

뭐지? 싶었는데 최근 칼부림 사건 때문에 조기 귀가 조치가 내려진 것이었다...

따라서 회고와 회식은 물 건너가고, 다음 주로 기약되었다.

커피 쏟은 것도 그렇고.. 서버 날아간 것도 그렇고..

오늘은 뭔가 되는 일이 없었다. 😂

일주일 동안 정말 바쁘게 개발만 했다.

개발도 개발이지만, 데모데이 준비가 좀 신경이 쓰인 것 같다.

당장 2주 뒤가 레벨3 마지막인 게 실감이 안 난다.

프로젝트에 치이다 보니, 블로그에 글을 쓰는 것도 뜸한 것 같다.

팀 블로그를 해보자고 의견은 내놓았지만, 다들 바쁘고 할 일이 추가로 생기는지라 흐지부지되었다.

나도 그렇지만 다른 크루들도 프로젝트에 치여 힘들게 지내고 있는 것 같다.

이제 우테코 기간도 얼마 남지 않았다.

남은 기간 동안 역시 하던 대로 열심히 해야겠다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글