금요일

Git Merge Conflict 완전 정복 — 겁먹지 말고 읽어봐라

🔍 검색 키워드: git merge conflict 해결, git 충돌 해결, merge conflict 뜨는 이유, rebase conflict, git pull 충돌, git 머지 에러

왜 merge conflict가 생기는가

두 사람이 같은 파일의 같은 줄을 각자 다르게 수정하면 Git은 어느 쪽을 선택해야 할지 모른다. 그래서 멈추고 사람한테 결정을 넘긴다. 그게 전부다. 무서운 게 없다.

레벨 1 — 기초: 충돌 마커 읽기

충돌이 생기면 Git은 파일 안에 이런 마커를 심어준다.

<<<<<<< HEAD
const greeting = "안녕하세요";  // 내 변경사항
=======
const greeting = "Hello";       // 상대방 변경사항
>>>>>>> feature/english-greeting
  • <<<<<<< HEAD ~ ======= : 현재 브랜치(내 것)
  • ======= ~ >>>>>>> : 병합 대상 브랜치(상대 것)

해결 방법은 단순하다. 둘 중 하나를 고르거나, 둘 다 합치거나, 완전히 새로 쓰거나. 마커 3개(<<<<<<<, =======, >>>>>>>)를 모두 제거하고 원하는 최종 코드만 남기면 된다.

# 충돌 파일 확인
git status

# 수동 편집 후
git add src/greeting.js
git commit

레벨 2 — 실무: 자주 마주치는 상황별 대처

상황 1: git pull 했더니 충돌 폭탄

git pull origin main
# CONFLICT (content): Merge conflict in src/api.js
# Automatic merge failed; fix conflicts and then commit the result.
단계명령어설명
1git status충돌 파일 목록 확인
2에디터에서 파일 열기마커 찾아서 수동 편집
3git add <파일>해결된 파일 스테이징
4git commit머지 커밋 생성 (메시지 자동 입력됨)

상황 2: merge 중 충돌, 그냥 포기하고 싶을 때

git merge --abort

--abort 하면 merge 시작 전 상태로 되돌아간다. 깔끔하게 포기할 수 있다.

상황 3: rebase 중 충돌

git rebase main
# CONFLICT (content): Merge conflict in src/user.js

rebase는 커밋 하나씩 재적용하기 때문에 충돌도 커밋 단위로 난다.

# 각 충돌 해결 후
git add src/user.js
git rebase --continue  # 다음 커밋으로 진행

# 포기할 때
git rebase --abort

상황 4: 특정 파일을 그냥 한 쪽으로 덮어쓰고 싶을 때

# 내 것(HEAD) 으로 덮어쓰기
git checkout --ours src/config.js

# 상대 브랜치 것으로 덮어쓰기
git checkout --theirs src/config.js

git add src/config.js

레벨 3 — 고급: 도구 활용 및 예방

VS Code에서 충돌 해결

VS Code는 충돌 파일에 시각적 버튼을 표시해준다.

  • Accept Current Change → HEAD 것 선택
  • Accept Incoming Change → 병합 대상 것 선택
  • Accept Both Changes → 둘 다 유지
  • Compare Changes → diff 보기

터미널보다 훨씬 빠르다. 파일이 많을 때 특히 유용하다.

git mergetool 사용

# VS Code를 mergetool로 설정
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
git mergetool

Python 프로젝트: requirements.txt 충돌

<<<<<<< HEAD
requests==2.28.0
=======
requests==2.31.0
>>>>>>> feature/update-deps

보통 높은 버전을 선택하되, 호환성 깨지는 메이저 버전 업이면 테스트 먼저.

Node.js: package-lock.json 충돌

package-lock.json 충돌은 직접 해결하려 하지 말고 이게 낫다.

# 충돌난 package-lock.json 삭제 후 재생성
git checkout --theirs package-lock.json
npm install

# 또는 아예 새로 생성
rm package-lock.json
npm install
git add package-lock.json

Java/Spring: application.yml 충돌

server:
  port: 8080         # <-- HEAD vs 8081 충돌 예시
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db

환경별 설정이 섞이는 케이스. application-dev.yml, application-prod.yml로 분리하면 근본 해결된다.

Nginx 설정 파일 충돌

location /api {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Real-IP $remote_addr;  # incoming 것 추가
}

두 변경사항을 합치는 게 맞다. 헤더 추가된 버전에 로컬 주소를 넣거나, 환경변수로 빼거나.

충돌 자주 나는 상황 진단표

상황원인예방법
pull할 때마다 충돌브랜치가 너무 오래됨자주 rebase/merge
같은 파일 반복 충돌역할 분리 안 됨파일 소유권 명확히
package-lock.json 항상 충돌여러 명이 npm installCI에서만 lock 갱신
config 파일 충돌환경 설정 공유환경별 파일 분리
이진 파일(이미지 등) 충돌.gitattributes 없음이진 파일 전략 설정

.gitattributes로 이진 파일 전략 설정

# 이진 파일은 충돌 없이 무조건 덮어쓰기
*.png binary
*.jpg binary
*.pdf binary

# package-lock.json은 theirs 전략
package-lock.json merge=theirs

실수하기 쉬운 것들

1. 마커 제거 안 하고 커밋

# 커밋 전 마커 잔존 여부 확인
grep -r "<<<<<<< " src/

CI에 충돌 마커 검사 스텝 추가해두면 실수 방지된다.

2. rebase 후 force push

rebase 완료 후 원격에 올릴 때는 --force 대신 이걸 써라.

git push --force-with-lease origin feature/my-branch

남이 push한 게 있으면 실패해서 안전하다.

요약

  • 충돌 마커(<<<<<<<, =======, >>>>>>>) 3개 찾아서 제거하고 원하는 코드 남기면 끝
  • 포기하고 싶으면 git merge --abort 또는 git rebase --abort
  • VS Code mergetool 설정해두면 시간 절약
  • package-lock.json은 직접 고치지 말고 재생성
  • 충돌 자주 나면 브랜치 수명을 짧게 가져가는 게 근본 해결책

Docker 포트 충돌 & 컨테이너 연결 에러 완전 정복

🔍 검색 키워드: Docker 포트 충돌, port is already allocated, docker: Error response from daemon, 컨테이너 연결 안됨, bind failed port already in use, docker ps -a, EADDRINUSE 도커

왜 이 에러가 자꾸 나오냐

도커를 쓰다 보면 십중팔구 이 두 가지를 겪는다.

  1. 컨테이너 띄우려는데 포트가 이미 점유됐다고 막힘
  2. 컨테이너는 실행 중인데 앱끼리 통신이 안 됨

둘 다 "왜?"를 이해하면 해결은 5분이다. 모르고 --force나 재시작만 반복하면 하루 날린다.


1. 포트 충돌 에러

에러 메시지 패턴

Error response from daemon: driver failed programming external connectivity on endpoint myapp
(xxx): Bind for 0.0.0.0:8080 failed: port is already allocated
Error starting userland proxy: listen tcp4 0.0.0.0:3306: bind: address already in use

원인 3가지

원인빈도설명
이전 컨테이너가 죽지 않고 포트 점유 중★★★docker stop 안 하고 그냥 터미널 닫은 경우
호스트 프로세스(MySQL, Nginx 등)가 같은 포트 사용★★☆로컬에 MySQL 깔려있는데 3306 쓰려 할 때
이전 컨테이너가 exited 상태로 포트 홀딩★☆☆docker ps엔 안 보이지만 docker ps -a엔 보임

[초보] 단계별 해결법

1단계: 어떤 프로세스가 포트 쓰는지 확인

# macOS / Linux
lsof -i :8080

# Windows (PowerShell)
netstat -ano | findstr :8080

2단계: 도커 컨테이너 확인

# 실행 중인 컨테이너만
docker ps

# 중단된 것 포함 전체
docker ps -a

# 특정 포트 쓰는 컨테이너 찾기
docker ps --filter "publish=8080"

3단계: 점유 중인 컨테이너 정리

# 특정 컨테이너 중지
docker stop <container_id>

# 중지 + 삭제
docker rm -f <container_id>

# exited 상태 컨테이너 일괄 정리
docker container prune

[중급] 포트 매핑 전략

같은 포트를 써야 하는 서비스가 여러 개라면, 호스트 포트를 다르게 매핑한다.

# 호스트 8081 → 컨테이너 내부 8080
docker run -p 8081:8080 myapp

# 여러 포트 동시 매핑
docker run -p 8080:8080 -p 443:443 myapp

docker-compose.yml에서는:

services:
  app:
    image: myapp
    ports:
      - "8081:8080"  # 호스트:컨테이너
  db:
    image: mysql:8
    ports:
      - "3307:3306"  # 로컬 MySQL과 충돌 방지

[고급] 동적 포트 할당

테스트 환경에서 포트 충돌을 원천 차단하는 방법 — 호스트 포트를 도커가 알아서 비어있는 거 잡게 한다.

# 호스트 포트 미지정 → 임의 할당
docker run -p 0:8080 myapp

# 할당된 포트 확인
docker port <container_id> 8080
# 출력 예: 0.0.0.0:49152

Node.js에서 환경변수로 포트 받아 쓰는 패턴:

// app.js
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2. 컨테이너 간 통신 안 되는 에러

에러 메시지 패턴

Error: connect ECONNREFUSED 127.0.0.1:3306
getaddrinfo ENOTFOUND db

컨테이너 A에서 컨테이너 B의 localhost로 접속하려 해서 생기는 문제다. 컨테이너끼리 localhost는 공유하지 않는다. 각자 독립된 네트워크 네임스페이스를 가진다.

원인과 해결법 체크리스트

상황잘못된 접근올바른 접근
컨테이너 A → B 접속localhost:3306컨테이너명 또는 서비스명
docker run 단독 실행네트워크 미지정--network 플래그로 같은 네트워크 사용
docker-compose 사용별도 network 정의같은 compose 파일 내면 자동 연결

[초보] docker-compose로 컨테이너 연결

docker-compose를 쓰면 같은 파일 안의 서비스끼리는 서비스명으로 바로 통신된다. 네트워크 설정 따로 안 해도 된다.

# docker-compose.yml
services:
  app:
    image: node:20
    environment:
      DB_HOST: db        # "localhost" 아니고 서비스명 "db"
      DB_PORT: 3306
    depends_on:
      - db

  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: mydb

Node.js에서 DB 연결:

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'db',  // 서비스명
  port: process.env.DB_PORT || 3306,
  user: 'root',
  password: 'secret',
  database: 'mydb'
});

Python (SQLAlchemy):

import os
from sqlalchemy import create_engine

DATABASE_URL = (
    f"mysql+pymysql://root:secret@"
    f"{os.getenv('DB_HOST', 'db')}:"
    f"{os.getenv('DB_PORT', '3306')}/mydb"
)

engine = create_engine(DATABASE_URL)

[중급] docker run으로 수동 네트워크 연결

docker-compose 없이 컨테이너 여러 개 연결할 때:

# 1. 공용 네트워크 생성
docker network create mynet

# 2. DB 컨테이너를 해당 네트워크에 붙여 실행
docker run -d \
  --name mydb \
  --network mynet \
  -e MYSQL_ROOT_PASSWORD=secret \
  mysql:8

# 3. 앱 컨테이너도 같은 네트워크로 실행
docker run -d \
  --name myapp \
  --network mynet \
  -e DB_HOST=mydb \
  -p 8080:8080 \
  myapp:latest

Java (Spring Boot) application.yml:

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:mydb}:${DB_PORT:3306}/mydb
    username: root
    password: secret

[고급] 네트워크 디버깅

# 컨테이너가 어떤 네트워크에 붙어있나
docker inspect myapp | grep -A 20 "Networks"

# 특정 네트워크에 연결된 컨테이너 목록
docker network inspect mynet

# 컨테이너 안에서 직접 핑 테스트
docker exec -it myapp ping mydb

# 컨테이너 안에서 포트 열려있나 확인
docker exec -it myapp nc -zv mydb 3306

# 임시 debug 컨테이너로 네트워크 진단
docker run --rm --network mynet nicolaka/netshoot nmap -p 3306 mydb

Nginx 설정에서 upstream을 컨테이너명으로:

# nginx.conf
upstream backend {
    server app:8080;  # 컨테이너/서비스명
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

트러블슈팅 체크리스트

포트 충돌 발생 시

체크 항목명령어
실행 중인 컨테이너 확인docker ps
중단 포함 전체 확인docker ps -a
호스트 포트 점유 확인lsof -i :PORT (mac/linux)
문제 컨테이너 강제 삭제docker rm -f CONTAINER_ID
불필요한 컨테이너 일괄 정리docker container prune
포트 다르게 재매핑docker run -p HOST:CONTAINER

컨테이너 연결 안 될 때

체크 항목명령어
같은 네트워크인지 확인docker network inspect NETWORK
컨테이너명/서비스명으로 접속하는지 확인localhost → 서비스명
컨테이너 내부에서 핑 테스트docker exec -it APP ping DB
포트 열려있나 확인docker exec -it APP nc -zv DB PORT
컨테이너 로그 확인docker logs CONTAINER_ID
네트워크 재생성 후 재연결docker network create + --network

자주 하는 실수 요약

1. docker stop 대신 터미널만 닫는다
→ 컨테이너는 살아서 포트 계속 점유. docker stop 또는 docker-compose down 습관화.

2. 컨테이너 안에서 localhost로 다른 컨테이너 접근
→ 각 컨테이너는 독립 네트워크. 서비스명이나 컨테이너명으로 접근.

3. exited 컨테이너가 포트 잡고 있는 줄 모름
→ docker ps -a로 exited 포함해서 항상 확인.

4. depends_on만 믿고 DB 준비됐다고 착각
→ depends_on은 컨테이너 시작 순서만 보장. DB가 실제로 ready 상태인지는 별개. healthcheck나 wait 로직 필요.

# healthcheck로 DB 준비 확인
services:
  db:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 10s
      retries: 5

  app:
    depends_on:
      db:
        condition: service_healthy  # DB healthy 확인 후 시작

도커 관련 에러는 대부분 이 두 가지에서 온다. 포트 충돌이면 docker ps -a부터, 연결 안 되면 네트워크와 호스트명부터 확인하면 길을 잃지 않는다.

JWT 에러 완전 정복: TokenExpiredError, invalid signature 원인과 해결법

🔍 검색 키워드: JWT 에러, jwt expired 해결, invalid signature jwt, TokenExpiredError, JWT 트러블슈팅, jwt 토큰 에러, Spring JWT 에러, Node.js jwt 에러, jwt 인증 오류

JWT를 처음 붙이든, 실무에서 수년째 쓰든 어느 날 갑자기 TokenExpiredError 또는 invalid signature가 튀어나온다. 원인은 뻔한데 처음 보면 당황스럽다. 이 글에서 JWT 에러의 종류별 원인과 해결법을 레벨별로 정리한다.


JWT 에러 종류 한눈에 보기

에러 메시지원인긴급도
TokenExpiredError: jwt expired토큰 유효기간 만료보통 (정상 흐름)
JsonWebTokenError: invalid signature키 불일치 또는 토큰 변조높음
JsonWebTokenError: jwt malformed토큰 형식 자체가 잘못됨높음
NotBeforeError: jwt not activenbf 클레임 이전에 사용낮음
JsonWebTokenError: invalid algorithm알고리즘 불일치높음

LEVEL 1 초보자: 기본 개념부터

JWT는 세 파트로 구성된다: Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    ← Header (알고리즘, 타입)
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjox...  ← Payload (클레임: sub, exp, iat 등)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_...   ← Signature (서명)

Signature는 HMAC-SHA256(base64(header) + "." + base64(payload), secretKey)로 만든다.
토큰이 변조되면 서명 검증에서 실패한다. 이것이 JWT 보안의 핵심.

가장 흔한 실수 — 토큰을 Authorization 헤더에 제대로 안 보냄

# 잘못된 예
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# 올바른 예
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

LEVEL 2 중급자: 에러별 원인과 해결

TokenExpiredError: jwt expired

토큰 발급 시 설정한 exp(만료시간)가 지났을 때 발생한다. 정상적인 흐름이다.

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({ code: 'TOKEN_EXPIRED' });
  }
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({ code: 'INVALID_TOKEN' });
  }
}

Java (Spring Boot + jjwt)

public Claims parseToken(String token) {
    try {
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new CustomException(ErrorCode.TOKEN_EXPIRED);
    } catch (SignatureException e) {
        throw new CustomException(ErrorCode.INVALID_SIGNATURE);
    }
}

Python (PyJWT)

import jwt
try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    raise HTTPException(status_code=401, detail="토큰이 만료되었습니다.")
except jwt.InvalidSignatureError:
    raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.")

JsonWebTokenError: invalid signature

가장 많은 삽질을 유발하는 에러다. 원인은 크게 세 가지.

원인 1 — 서버 재시작 후 환경변수가 바뀜

echo $JWT_SECRET   # 양쪽 서버에서 값이 같은지 확인

원인 2 — HS256 vs RS256 알고리즘 불일치

// 발급 시 RS256 사용
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// 검증 시 반드시 publicKey + 알고리즘 명시
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // ✅
HS256: 대칭키 (같은 secret으로 발급·검증)
RS256: 비대칭키 (privateKey로 발급, publicKey로 검증)

원인 3 — URL 전달 시 토큰이 잘림

const url = `/verify?token=${encodeURIComponent(token)}`;
const token = decodeURIComponent(req.query.token);

JsonWebTokenError: jwt malformed

const authHeader = req.headers.authorization; // "Bearer eyJ..."
const token = authHeader.split(' ')[1]; // Bearer 제거 후 verify
jwt.verify(token, secret);

LEVEL 3 고급자: 실무 패턴

Access Token + Refresh Token 구조

function issueTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { accessToken, refreshToken };
}

Spring Security + JWT 필터 체인

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            String token = bearer.substring(7);
            try {
                Claims claims = jwtProvider.parseToken(token);
                SecurityContextHolder.getContext().setAuthentication(
                    jwtProvider.getAuthentication(claims));
            } catch (ExpiredJwtException e) {
                response.setStatus(401);
                response.getWriter().write("{"code":"TOKEN_EXPIRED"}");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
}

상황별 체크리스트

상황확인 항목
invalid signature양쪽 환경의 JWT_SECRET 값이 동일한가?
invalid signature알고리즘이 HS256/RS256으로 일치하는가?
invalid signatureRS256이라면 검증 측에 publicKey를 쓰고 있는가?
jwt malformedBearer prefix를 제거하고 verify 하는가?
jwt malformedURL 전달 시 encodeURIComponent 처리 했는가?
토큰이 금방 만료됨expiresIn 단위: '1h'(1시간) vs 3600(초)
서버 재시작 후 전체 만료환경변수 기반 secret인가?

디버깅 도구

jwt.io — 토큰을 붙여넣으면 header, payload, signature를 즉시 디코딩해준다.

echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig" \
  | cut -d'.' -f2 | base64 --decode 2>/dev/null
🗂 정리

JWT 에러의 90%는 세 가지다: 키 불일치, 알고리즘 불일치, 토큰 만료.
invalid signature가 나오면 키와 알고리즘을 먼저 의심하고, 환경변수가 양쪽에서 같은지 확인하는 것이 제일 빠른 경로다.
TokenExpiredError는 에러가 아니라 정상 흐름이니 클라이언트 리프레시 로직을 갖춰두면 된다.