앱잼이 끝난 지금, 돌아보기

유영재·2024년 7월 29일
2

Morib

목록 보기
1/3
post-thumbnail

34기 NOW SOPT의 서버 파트로서, Morib이라는 서비스의 서버 리드 개발자를 맡았다. (모리비가 최고야 ..)
Nginx, Docker, CI/CD, ACM 등 처음해보거나 익숙하지 않은 것들이 대다수였기 때문에 많은 시행착오를 겪었다.
이 글을 쓰면서 스프린트에서 어떻게 리팩토링할지 천천히 정리해보려고 한다.

1. 발생했던 이슈들

1. Docker CD가 안 돌아가요


CI는 잘 돌아갔는데, CD의 마지막에 넣어둔 Spring Actuator를 이용한 Health Check가 계속해서 실패했다.

그래서 일단 CD를 하지 않고, CI는 됐으니 docker에 띄워둔 이미지를 pull 받아 ec2 환경에서 직접 실행했더니 에러 메시지를 확인할 수 있었다.
에러 메시지가 백업이 안되어있어 직접적인 메시지를 적을 순 없지만,
jdbc, url 관련 오류였기에 CI에 들어간 application.yaml 파일이 문제겠다 예상했다.

내가 생각한 안되는 이유는 다음과 같았다.

  • yaml 파일의 데이터베이스 경로, 또는 DB가 없는 경우
  • Dockerfile
  • yaml 파일 depth
    ..

위의 사항들을 전부 체크했지만, 여전히 오류는 해결되지 않았다.

정말 하다하다 스펠링까지 확인하던 와중에 ? ?? ????

DB 이름 스펠링을 yaml에 잘못 적은 것 ... 🚨🚨
+) application.yaml을 ec2 상에 생성할 때, 디렉토리 경로를 /.mydir 이렇게 해놓은 것 ...

해결 완료.
스펠링과 사소한 오타를 주의하자 .... ^.^


2. image is being used by stopped container 오류

Docker CD에서 오류가 또 .. ^^...

내가 작성한 deploy.sh의 마지막 라인에는 Docker의 dangling image를 삭제하는 코드가 있었다.

docker rmi $(docker images -f "dangling=true" -q)

dangling image ? 💡
돌아가고 있는 컨테이너가 없는 이미지들. 노는 이미지들.

Docker CD에서 오류가 나면 해결해보고, 다시 돌려보고 하는 과정에서 이런 에러메시지가 떴다.

err: Error response from daemon: conflict: unable to delete 59a3a66090ca (must be forced) - image is being used by stopped container e2c35305d0e4
err: Error response from daemon: conflict: unable to delete 5a2c3351679b (must be forced) - image is being used by stopped container 658a0264993b
err: Error response from daemon: conflict: unable to delete 86d8b526de15 (must be forced) - image is being used by stopped container 289b06d02240

대충 살펴보니, 이미지가 컨테이너때문에 삭제되지 못하는 것 같았다.

이 이슈는 실패한 CD에서 컨테이너와 이미지를 만들었는데, 결국 실행이 되지 못해서 노는 컨테이너들이 생긴 것이다.
컨테이너가 놀고 있기 때문에, 그에 종속된 이미지들도 노는 이미지가 된다. (=dangling image로 판단)
하지만, 이미지는 컨테이너에게 종속되어있기 때문에 아무리 노는 이미지여도 엮여있는 컨테이너가 존재한다면 이미지를 삭제할 수 없다.
그래서 오류가 뜬 것이었다 ..

노는 컨테이너들을 전부 docker rm ${container_id} 로 삭제해준 뒤 CD를 다시 실행시켰더니 해결완료!


3. 🔥 CORS 🔥

"이번 앱잼에서 어떤 게 가장 힘들었어?" 한다면 단연코 CORS.

  • CORS 처리 안해봤나? X
  • CORS가 뭔지 잘 모르나? O
  • 억까인가? ⭕️

CORS (Cross-Origin Resource Sharing) ?
기본적으로 브라우저는 동일 출처 정책(Same-Origin Policy) 이라고 하는 정책을 따릅니다. 다른 출처(도메인, 프로토콜, 또는 포트)에서 제공되는 리소스에 대한 요청을 제한합니다.

한 웹 페이지에서 외부 API를 호출하여 데이터를 가져오는 경우,
CORS는 특정 조건 하에 이러한 크로스-도메인 요청을 허용하는 메커니즘을 제공합니다.

CORS는 Spring Boot 코드 단으로 처리하거나, Nginx를 사용한다면 Nginx 설정파일에서 처리할 수 있다.

이를 처리하기 전에, 알아야 할 개념들이 있다.

웹 브라우저는 특정 경우에 OPTIONS 메소드로 Preflight 요청을 보낸다.

When?

  • 요청 메서드가 GET, POST, HEAD가 아닌 경우 (PUT, DELETE 등)
  • 비표준 헤더를 포함하는 경우
  • Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain이 아닌 경우

서버는 이 Preflight 요청에 대한 응답으로 CORS 정책을 결정하는 헤더를 포함하여 응답한다.
그럼, CORS 관련 헤더는 어떤 것들이 있을까 ?

  • Access-Control-Allow-Origin : 요청을 허용할 출처를 지정. *로 모든 출처를 허용할 수 있음.
  • Access-Control-Allow-Methods: 서버가 허용하는 HTTP 메서드들을 지정
  • Access-Control-Allow-Headers: 실제 요청에서 사용할 수 있는 HTTP 헤더들을 지정
  • Access-Control-Allow-Credentials: 자격 증명(쿠키, 인증 헤더 등)을 요청에 포함할 수 있는지 여부를 지정

굵은 글씨가 보이는가 ? CORS에서 가장 중요한 부분이다.

💥 만약 CORS 문제를 해결하지 못하고 있는 서버 개발자라면, 당장 요청하고 있는 브라우저의 개발자 도구를 켜서 Response Header를 확인해보길 바란다.


문제 상황 🚨

  • Access-Control-Allow-Origin이 아예 오지 않는다.
  • Vary : ~ 는 HTTP 응답에서 클라이언트와 프록시 캐시에게 이 리소스가 다른 조건에 따라 다르게 반환될 수 있음을 알려주는 역할이다. CORS와 관련 없다.

해결된 상황 😇

  • Response Header (응답 헤더)의 Access-Control-Allow-Origin : * 이 보이는가 ??!!!
  • CORS가 잘 해결되었다!

어떻게 해결했을까? 프로젝트 구조는 이러하다.

1. Spring Boot 코드 단으로 해결하기

어노테이션으로 하는 방법도 있지만, WebSecurityConfig 파일로도 가능하다.

@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS")
                .maxAge(3000);
    }
}

2. Nginx 설정파일에서 해결하기

Nginx를 사용한다면 /etc/nginx/sites-available/default 파일에 리버스 프록시 설정을 했을 것이다.

해당 파일에 CORS 처리를 해주는 코드를 넣어주면 된다.

location / {
    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, PUT' always; 
    add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type' always;
    
    # OPTIONS (Preflight)
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, PUT, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type';
        add_header 'Content-Length' '0';
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Access-Control-Max-Age' 1728000;
        return 204;
    }
}

Preflight 요청을 위한 OPTIONS 메소드 처리도 같이 넣어주면 된다.

단, 주의할 것은 📍
Nginx 설정과 Spring Boot 코드 단에서 설정을 둘 다 해버리면 충돌이 날 수 있으므로 둘 중에 하나만 선택해야 한다 💥

레전드 버그 발생
하지만 .. 이렇게 순조롭게 끝났다면 깔끔했을 것이다.
둘 다 각각 설정을 해봐도 CORS가 잡히지 않았고, 구글링과 주변 지인들을 모두 동원했지만 CORS가 도저히 잡히지 않았다.
최후의 수단으로 Nginx 자체를 빼버린 뒤 코드 단으로 CORS 처리를 해줬더니? 이게 왠걸 ? 해결되었다.

근데, Spring Boot에 추가되어있는 actuator로 헬스체크를 해보니?

503이 뜨는 것이다 .. ?
그래서 다른 API도 테스트 해봤더니,

....??

심지어, EC2에서 Nginx를 삭제했는데도 포트포워딩이 되는 상황이 발생했다 .. (유령 포트포워딩?)
정말 열심히 이슈를 해결하려 노력했지만, 아직은 해답을 못 찾은 상태이다.
해당 이슈의 원인을 여러가지 생각해봤는데,

  1. Redis 문제 .. Redis를 한번 다 뜯어보고 수정해봐야할 것 같다.
  2. 예전에 소켓 서버를 8082 포트에서 돌렸었는데, 그 소켓 때문에 꼬였다.
  3. 억까다.

해답을 찾아서 꼭 이 블로그 글에 링크를 달도록 하겠다 ....

해결한 링크 : ( 아직 해결 못 함 .. )


2. 지나간 고민의 흔적

1. Redis, Docker

Refresh Token을 관리하는 인메모리로 Redis가 적당하다고 생각했다.
그 이유는,

  • TTL(Time To Live)을 설정할 수 있기 때문에 유효 기간이 정해진 Refresh Token을 관리하는 데 적합하다.
  • RDB보다 간결하고 매우 빠른 읽기 및 쓰기 성능을 가지기 때문에 많은 변경과 조회가 이루어지는 Refresh Token에 적합하다.

이러한 생각들로 Redis를 채택했는데, 그 때부터 고민이 시작됐다.
우리 프로젝트는 Docker에 EC2를 띄워서 실행하고 있었는데, Redis를 어떻게 배포 환경에서 사용할 것인가가 고민이었다.
선택지는 여러 가지였다.

  1. Docker로 Redis image를 사용해 돌린다 -> Docker Network를 만져줘야 한다.
  2. Docker-compose로 묶어준다 -> Redis만을 위해 compose를 도입해야 한다.
  3. ec2에 직접 설치한다 -> WAS와 Subnet 문제 때문에 연동이 어려울 수 있다.

이들 중 나의 선택은 1번이였다.
그 이유를 나름대로 말해보자면,

  1. Docker Network를 만져주는 것은 생각보다 어렵지 않다. 네트워크를 생성하고, 같은 네트워크에서 Redis가 돌아가기만 하면 된다.
  2. Redis만을 위해서 Compose를 쓰는 건 오버 리소스다.
  3. Subnet에 대해 잘 몰라서 .....,....

deploy.sh를 다음과 같이 수정했다!

create network ${network_name}
docker run --network ${network_name} ~ ${user_name}/${server_name}
docker run --network ${network_name} ~ redis

끗. 간단하다. (간단하지만 쌩초보에겐 매우 고민되는 문제였다 ㅋㅎ)


2. 개발 서버도 배포할까?

어찌 저찌 배포에 성공했다.
로컬에서 테스트하고 배포한 다음 테스트까지 해야 기능 1개, 아니 수정사항이 제대로 반영이 됐구나 할 수 있었다.
로컬에선 돌아가지만, 배포 환경에서는 안 돌아가는 경우도 있었다.

만약 개발 환경이 로컬이 아니라 배포 환경과 똑같다면 테스트를 2번씩 안 해도 되지 않을까 ?

예전에는 개발 서버를 배포하는 이유에 대해서 알지 못했었는데, 이제서야 조금은 이해가 됐다. 이런 이유 때문이지 않았을까 ?

하지만 결과적으로, 프로젝트 기간에는 하지 않았다.

Why ?
1. 개발 서버를 배포하는 데 리소스가 많이 든다.
2. 남은 시간은 2주. 이 시간동안 기능을 구현하고 에러를 핸들링하는 게 더 가치 있다고 판단했다.

하지만, 앱잼이 끝난 지금! 이제 할 예정이다 우하하


3. 백엔드 서버와 프론트 서버의 분리

지금까지 프로젝트를 하면서 프론트가 배포를 한 적이 없었어서, 서버 분리에 대한 생각이 없었다.
이번에는 Vercel로 프론트에서 배포를 한다고 하길래 이 구조를 확실히 알고 가고 싶었다.
예를 들어서 설명해보겠다.

프론트 서버가 www.example.com 이라면 뷰를 보여주는 용도로 사용하고
백엔드 서버가 api.example.com 이라면 프론트가 백엔드 서버에 요청하는 링크로 사용하게 된다.

간단한 Flow

  1. 클라이언트가 서버로 요청을 보낼 때, https://api.example.com 으로 요청을 보낸다.
  2. 서버는 요청을 받고 응답한다.
  3. 응답받은 클라이언트는 https://www.example.com 에 뷰를 띄운다.

프론트 서버가 배포되면, 우리는 Route53에 프론트 레코드를 등록해주면 된다!


3. Ending

아키텍처도 그렇고, 여러가지 설정이 급한 탓에 너무 많이 꼬였다.
"왜 저런 고민을 했지?" 라는 생각도 한편으로 들지만, 저런 사소한 고민들과 이슈가 있었기에 더욱 성장한 거라 생각도 든다.
차근차근 리팩토링하면서 다져나가야겠다. 화이팅! 모립 화이팅!
CORS는 내가 어떻게든 파헤쳐서 해결한다 쒸익 쒸익

profile
계속해서 의심하고, 고민하고, 질문하며 성장하는

1개의 댓글

comment-user-thumbnail
2024년 7월 29일

쓲껄

답글 달기