Home 통합 파일 작성하기
Post
Cancel

통합 파일 작성하기

통합 파일 작성하기

기존 형식의 엑셀 파일(results.xlsx)에 여러 개의 엑셀 파일 내 데이터를

하나의 엑셀 파일에 자동으로 통합하고 수식과 서식을 자동으로 정리하는 프로세스를 구축했다.

*첨부한 이미지들은 이해를 돕기 위해 임의로 작성했다.



주요 단계

1. 기존 엑셀의 데이터를 삭제 (수식은 유지하기위해 첫번째 행 제거x)

2. 최신 엑셀 파일 자동 탐색 및 불러오기

3. 파일 확장자 및 시트명 처리

4. 테이블 생성 및 수식·서식 자동 적용

5. 전체 자동 실행 및 예외 처리



기본

1
2
3
4
5
6
7
from openpyxl import load_workbook

# 엑셀 파일 열기 
wb = load_workbook("test.xlsx") # data_only=True : 계산된 값(수식x)  
wb.sheetnames # 전체 시트 리스트
ws = wb['abc'] # 'abc' 시트
ws['E2'].value # E2 셀 값 읽기 





1. 기존 데이터 제거

엑셀 내의 기존 데이터를 삭제하되 수식이 존재하는 첫 번째 행은 유지했다. (ex. =sum(...))

시트마다 헤더 위치가 달라(1행 또는 2행) 조건문으로 삭제 기준을 다르게 적용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 경고 메시지 
import warnings
warnings.filterwarnings('ignore')

from openpyxl import load_workbook
path = 'excel/'
wb = load_workbook(path + "raw_data.xlsx")    

for i in range(10):
    ws = wb.worksheets[i]
    max_row = ws.max_row
    if i <= 7:
        if max_row >= 4:
            ws.delete_rows(4, amount=max_row - 3)
    else:
        if max_row >= 3:
            ws.delete_rows(3, amount=max_row - 2)
wb.save("results.xlsx")

*숨겨진 시트도 포함되므로 시트 개수(range(10))는 전체 시트 수 기준으로 작성해야한다.




log 설정

1
2
3
4
5
6
7
import logging
logger = logging.getLogger(name = "00. 기존 데이터 제거.py")
logger.setLevel(logging.INFO)
formatter = logging.Formatter('[ %(name)s ] ( %(levelname)s ) %(message)s')
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

파일별 로그명(name)을 다르게 설정해 동일한 로깅 포맷으로 관리했다.






2. 파일 불러오기

각 시트에 들어갈 데이터를 최근에 저장된 파일에서 불러왔다.

파일 하나씩 실행하면서 아래 함수를 실행하므로 가장 최근 파일로만 진행했다.

  • openpyxl → 엑셀 내부 편집 용도

  • pandas → 엑셀 데이터를 분석/가공 용도

    • openpyxl은 엑셀 기준 → 1부터 시작
    • pandas는 파이썬 기준 → 0부터 시작




xls → xlsx로 변환

.xls 파일은 win32com을 통해 Excel을 직접 실행시켜 .xlsx로 변환했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def convert_xls_to_xlsx(file_path):
    if not file_path.lower().endswith(".xls"):
        raise ValueError("Only .xls files can be converted.") # 에러를 일부러 발생시키는 키워드

    abs_path = os.path.abspath(file_path)
    new_path = abs_path + "x"  # .xlsx

    excel = win32.gencache.EnsureDispatch('Excel.Application')
    excel.DisplayAlerts = False

    try:
        wb = excel.Workbooks.Open(abs_path)
        wb.SaveAs(new_path, FileFormat=51)  # .xlsx
        wb.Close()
        print(f"변환 완료: {new_path}")
    except Exception as e:
        print(f"변환 실패: {e}")
        new_path = None
    finally:
        excel.Quit()

    return new_path

win32com : 엑셀을 직접 실행시키고 저장




최신 파일 불러오기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def load_latest_excel(path, skiprows=0):
    excel_files = glob.glob(os.path.join(path, "*.xls*")) + glob.glob(os.path.join(path, "*.csv"))
    if not excel_files:
        logger.error("다운로드 폴더 - Excel 또는 CSV 파일 X")
        return None, None

    latest_file = max(excel_files, key=os.path.getmtime)
    print(f"가장 최근 파일: {os.path.basename(latest_file)}")

    if latest_file.lower().endswith(".csv"):
        df = pd.read_csv(latest_file, sep='\t', encoding='utf-16', skiprows=skiprows)
    elif latest_file.lower().endswith(".xls"):
        try :
            df = pd.read_excel(latest_file, skiprows=skiprows, engine='xlrd')
        except :
            converted = convert_xls_to_xlsx(latest_file)
            if converted:
                    df = pd.read_excel(converted, skiprows=skiprows, engine='openpyxl')
            else:
                raise Exception("변환 실패로 .xls 파일 열 수 없음!")
    elif latest_file.lower().endswith(".xlsx"):
        df = pd.read_excel(latest_file, skiprows=skiprows, engine="openpyxl")
    else:
        raise ValueError("지원하지 않는 파일 형식")
`
    return df, latest_file
  • glob.glob() 으로 모든 엑셀 파일을 불러오고

  • os.path.getmtime() 으로 가장 최신 파일을 선택

  • 확장자에 따라 다르게 처리(.csv, .xls, .xlsx 파일 구분 처리)





3. 파일 통합 및 저장

확장자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def process_excel(sheet_name, start_row=2, skiprows=0, header_row = 2):
    path = r"C:\Users\Downloads"
    df, latest_file = load_latest_excel(path, skiprows=skiprows)
    if df is None:
        logger.info(f"{sheet_name} 파일이 없음!")
        return

   # 파일명 시트 이름으로 변경
    ext = os.path.splitext(latest_file)[1].lower() # 확장자
    new_filename = f"{sheet_name}{ext}"
    new_filepath = os.path.join(path, new_filename)

    if latest_file != new_filepath:
        os.rename(latest_file, new_filepath)
        logger.info(f"파일 이름 변경: {os.path.basename(latest_file)}{new_filename}")

    exist_file = "results.xlsx"
    wb = load_workbook(exist_file)
   
    if sheet_name in wb.sheetnames:
        ws = wb[sheet_name]
    else:
        ws = wb.create_sheet(sheet_name)

엑셀 파일의 확장자가 .xlsx뿐 아니라 .xls와 .csv도 존재한다.

splitext : 파일명을 입력으로 받아 파일명과 확장자를 분리해서 반환한다.

최신 파일명을 시트명으로 통일해 저장했다.(이름 변경)




헤더 및 데이터 붙여넣기

1
2
3
4
5
6
7
8
9
10
11
# 데이터 중에 info 와 같이 필요없는 데이터가 있는 경우가 있어 제거
df = df[~df.iloc[:, 0].astype(str).str.contains("info")] # 여러 행 첫번째 열

# header 붙여넣기
for j, col in enumerate(df.columns, start=1):
    ws.cell(row=header_row, column=j, value=col)

# 데이터 붙여넣기 (2행부터 수식 열 전까지)
for i, row in df.iterrows(): # 0 or 1부터 시작 
    for j, value in enumerate(row, start=1): # 1부터 시작
        ws.cell(row=i + start_row, column=j, value=value)

각 시트마다 헤더 위치(header_row)가 다르기 때문에 인자로 받았다.

기본값은 대부분의 시트에 맞춰 header_row=2로 설정했다.


i는 “info”가 있는 엑셀 파일은 “info” 행을 제거해서 1부터 시작하고

“info”가 없는 엑셀 파일은 0부터 시작해서 i가 0 or 1이다.




테이블 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 테이블 범위
table_start_row = header_row
table_end_row = start_row + len(df) - (1 if header_row == 1 else 0)
end_col_letter = get_column_letter(df.shape[1] + 3)
table_range = f"A{table_start_row}:{end_col_letter}{table_end_row}"

# 기존 테이블 삭제
for tbl_name in list(ws.tables.keys()):
    del ws.tables[tbl_name]

# 테이블 생성
table = Table(displayName=sheet_name, ref=table_range)
table.tableStyleInfo = TableStyleInfo(
    name="TableStyleMedium2",
    showFirstColumn=False,
    showLastColumn=False,
    showRowStripes=True,
    showColumnStripes=False
)
ws.add_table(table)
  • get_column_letter : 열의 인덱스 정보를 입력받고 해당 열에 대한 문자를 반환

    *행은 숫자, 열은 문자




수식 및 서식 자동 적용

끝에서 3개의 열(수식 포함)에 기존 행의 서식을 복사 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
def apply_formatting(ws, table_start_row, table_end_row, columns_to_format):
    for col in range(1, ws.max_column+1) :
        first_row_cell = ws.cell(row=table_start_row+1, column=col)

        if col in columns_to_format: # 끝에서 3번째까지 열
            for row in range(table_start_row + 1, table_end_row + 1):
                cell = ws.cell(row=row, column=col)
                cell.value = first_row_cell.value
                cell.number_format = first_row_cell.number_format
        else :
            for row in range(table_start_row+1, table_end_row+1) :
                cell = ws.cell(row=row, column=col)
                cell.number_format = first_row_cell.number_format

적용

1
2
3
4
5
6
# 서식 자동 적용 (끝에서 3개 열만 수식 적용)
columns_to_format = [df.shape[1]+1, df.shape[1]+2, df.shape[1]+3]  # 끝에서 3개 열
apply_formatting(ws, table_start_row, table_end_row, columns_to_format)

wb.save(exist_file)
print(f"{sheet_name} 완료")




호출

1
2
3
4
5
6
7
8
9
10
11
12
x = '자동화/01. data/'

subprocess.run(["python", x + "00. 기존 데이터 제거.py"])
time.sleep(2)

data = subprocess.run(["python", x + "01. data_1.py"], capture_output=True, text=True)
output = data.stdout
if "데이터 확인" in output :  
    logger.info("데이터 존재 x")
else :
    process_excel("data_1")
time.sleep(2)
  • STDOUT (Standard Output, 1, 출력을 위한 스트림)
    표준 출력은 프로그램이 출력 데이터를 기록하는 스트림으로 줄여서 stdout 으로 표현

    • capture_output = True : 표준 출력(stdout)과 표준 에러(stderr)가 캡처
    • text=True : 문자열

01. data_1.py에서 조건문에 print("데이터 확인")를 추가하여

원하는 조건이 실행 되었을 때 print문이 출력되고

해당 파일이 끝이나면 해당 값을 output에 저장하여

값 유무에 따라 process_excel("data_1")를 실행 여부를 결정





문제 해결 과정 정리

날짜 형식 문제

데이터 붙여넣기 시 날짜가 “YYYY-MM-DD HH:MM:SS” 형태로 변환되던 문제 해결

1
2
if (j==1) & ("data_1" in sheet_name):
    value = value.strftime("%Y-%m-%d")

이후 셀 서식 자동 적용 기능이 완성되면서 제거했다.



수식 자동 반영 문제

끝 3열에 존재하는 수식이 신규 행에 자동 복사되지 않는 문제 해결

첫 번째 데이터 행의 수식을 기준으로 아래 행에 복사

이후 일반 셀에도 서식 전체 적용(본문 내용 참고 - apply_formatting())





REFERENCE

This post is licensed under CC BY 4.0 by the author.